anima-core 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -2,8 +2,9 @@
2
2
 
3
3
  module TUI
4
4
  module Decorators
5
- # Renders write tool calls and responses.
6
- # Calls show the file path with a memo icon.
5
+ # Renders write_file tool calls and responses.
6
+ # Calls show the file path with a memo icon in the unified tool color.
7
+ # Responses use the CRUD Create color (light_green) to signal new content.
7
8
  class WriteDecorator < BaseDecorator
8
9
  ICON = "\u{1F4DD}" # memo
9
10
 
@@ -11,8 +12,8 @@ module TUI
11
12
  ICON
12
13
  end
13
14
 
14
- def color
15
- "yellow"
15
+ def response_color
16
+ "light_green"
16
17
  end
17
18
  end
18
19
  end
data/lib/tui/flash.rb CHANGED
@@ -22,7 +22,7 @@ module TUI
22
22
  # @example Dismissing
23
23
  # flash.dismiss!
24
24
  class Flash
25
- AUTO_DISMISS_SECONDS = 5.0
25
+ AUTO_DISMISS_SECONDS = 20.0
26
26
 
27
27
  # Flash area occupies at most 1/3 of the chat pane height.
28
28
  MAX_HEIGHT_FRACTION = 3
@@ -16,6 +16,28 @@ module TUI
16
16
  "[#{label} tok]"
17
17
  end
18
18
 
19
+ # Returns a semantic color for token count display.
20
+ # Visually flags expensive messages so runaway tool calls or bloated
21
+ # responses jump out immediately in debug mode.
22
+ #
23
+ # Thresholds (empirically tuned from real agent sessions):
24
+ # < 1k → dark_gray (routine, ignorable)
25
+ # < 3k → white (normal)
26
+ # < 10k → yellow (notable)
27
+ # < 20k → 208/orange (expensive)
28
+ # ≥ 20k → red (alarm — likely runaway)
29
+ #
30
+ # @param tokens [Integer] token count
31
+ # @return [String, Integer] named color or 256-color index
32
+ def token_count_color(tokens)
33
+ return "dark_gray" if tokens < 1_000
34
+ return "white" if tokens < 3_000
35
+ return "yellow" if tokens < 10_000
36
+ return 208 if tokens < 20_000 # orange (256-color)
37
+
38
+ "red"
39
+ end
40
+
19
41
  # Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
20
42
  # @param ns [Integer, nil] nanosecond timestamp
21
43
  # @return [String] formatted time, or "--:--:--" when nil
@@ -5,25 +5,25 @@ module TUI
5
5
  # Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
6
6
  # TUI, with no dependency on Rails or the Events module.
7
7
  #
8
- # Accepts Action Cable event payloads and stores typed entries:
9
- # - `{type: :rendered, data:, event_type:, id:}` for events with structured decorator output
8
+ # Accepts Action Cable message payloads and stores typed entries:
9
+ # - `{type: :rendered, data:, message_type:, id:}` for messages with structured decorator output
10
10
  # - `{type: :message, role:, content:, id:}` for user/agent messages (fallback)
11
11
  # - `{type: :tool_counter, calls:, responses:}` for tool activity
12
12
  #
13
- # Structured data takes priority when available. Events with nil
14
- # rendered content fall back to existing behavior: tool events aggregate
15
- # into counters, messages store role and content.
13
+ # Structured data takes priority when available. Messages with nil
14
+ # rendered content fall back to existing behavior: tool messages aggregate
15
+ # into counters, conversation messages store role and content.
16
16
  #
17
- # Entries with event IDs are maintained in ID order (ascending)
17
+ # Entries with message IDs are maintained in ID order (ascending)
18
18
  # regardless of arrival order, preventing misordering from race
19
19
  # conditions between live broadcasts and viewport replays.
20
20
  # Duplicate IDs are deduplicated by updating the existing entry.
21
21
  #
22
22
  # Tool counters aggregate per agent turn: a new counter starts when a
23
- # tool_call arrives after a message entry. Consecutive tool events
24
- # increment the same counter until the next message breaks the chain.
23
+ # tool_call arrives after a conversation entry. Consecutive tool messages
24
+ # increment the same counter until the next conversation message breaks the chain.
25
25
  #
26
- # When an event arrives with `"action" => "update"` and a known `"id"`,
26
+ # When a message arrives with `"action" => "update"` and a known `"id"`,
27
27
  # the existing entry is replaced in-place, preserving display order.
28
28
  class MessageStore
29
29
  MESSAGE_TYPES = %w[user_message agent_message].freeze
@@ -36,6 +36,8 @@ module TUI
36
36
  def initialize
37
37
  @entries = []
38
38
  @entries_by_id = {}
39
+ @pending_entries = []
40
+ @pending_by_id = {}
39
41
  @mutex = Mutex.new
40
42
  @version = 0
41
43
  end
@@ -48,14 +50,14 @@ module TUI
48
50
  @mutex.synchronize { @version }
49
51
  end
50
52
 
51
- # @return [Array<Hash>] thread-safe copy of stored entries
53
+ # @return [Array<Hash>] thread-safe copy of stored entries (pending messages at the end)
52
54
  def messages
53
- @mutex.synchronize { @entries.dup }
55
+ @mutex.synchronize { @entries.dup + @pending_entries.dup }
54
56
  end
55
57
 
56
- # @return [Integer] number of stored entries (no array copy)
58
+ # @return [Integer] number of stored entries including pending (no array copy)
57
59
  def size
58
- @mutex.synchronize { @entries.size }
60
+ @mutex.synchronize { @entries.size + @pending_entries.size }
59
61
  end
60
62
 
61
63
  # Processes a raw event payload from the WebSocket channel.
@@ -69,16 +71,16 @@ module TUI
69
71
  # and optionally "rendered" (hash of mode => lines), "id", "action"
70
72
  # @return [Boolean] true if the event type was recognized and handled
71
73
  def process_event(event_data)
72
- event_id = event_data["id"]
74
+ message_id = event_data["id"]
73
75
 
74
- if event_data["action"] == "update" && event_id
75
- return update_existing(event_data, event_id)
76
+ if event_data["action"] == "update" && message_id
77
+ return update_existing(event_data, message_id)
76
78
  end
77
79
 
78
80
  rendered = extract_rendered(event_data)
79
81
 
80
82
  if rendered
81
- record_rendered(rendered, event_type: event_data["type"], id: event_id)
83
+ record_rendered(rendered, message_type: event_data["type"], id: message_id)
82
84
  else
83
85
  case event_data["type"]
84
86
  when "tool_call" then record_tool_call
@@ -90,44 +92,75 @@ module TUI
90
92
  end
91
93
 
92
94
  # Removes all entries. Called on view mode change and session switch
93
- # to prepare for re-decorated viewport events from the server.
95
+ # to prepare for re-decorated viewport messages from the server.
94
96
  # @return [void]
95
97
  def clear
96
98
  @mutex.synchronize do
97
99
  @entries = []
98
100
  @entries_by_id = {}
101
+ @pending_entries = []
102
+ @pending_by_id = {}
99
103
  @version += 1
100
104
  end
101
105
  end
102
106
 
107
+ # Adds a pending message to the separate pending list.
108
+ # Pending messages always render after real messages.
109
+ #
110
+ # @param pending_message_id [Integer] PendingMessage database ID
111
+ # @param content [String] message text
112
+ # @return [void]
113
+ def add_pending(pending_message_id, content)
114
+ @mutex.synchronize do
115
+ entry = {
116
+ type: :rendered,
117
+ data: {"role" => "user", "content" => content, "status" => "pending"},
118
+ message_type: "user_message",
119
+ pending_message_id: pending_message_id
120
+ }
121
+ old = @pending_by_id[pending_message_id]
122
+ @pending_entries.delete(old) if old
123
+ @pending_entries << entry
124
+ @pending_by_id[pending_message_id] = entry
125
+ @version += 1
126
+ end
127
+ end
128
+
129
+ # Removes a pending message by its PendingMessage ID.
130
+ #
131
+ # @param pending_message_id [Integer] PendingMessage database ID
132
+ # @return [Boolean] true if found and removed
133
+ def remove_pending(pending_message_id)
134
+ @mutex.synchronize do
135
+ entry = @pending_by_id.delete(pending_message_id)
136
+ return false unless entry
137
+
138
+ @pending_entries.delete(entry)
139
+ @version += 1
140
+ true
141
+ end
142
+ end
143
+
103
144
  # Returns the last pending user message for recall editing.
104
- # Walks entries backwards and returns the first pending user_message found.
105
145
  #
106
- # @return [Hash, nil] `{id: Integer, content: String}` or nil if none pending
146
+ # @return [Hash, nil] `{pending_message_id: Integer, content: String}` or nil
107
147
  def last_pending_user_message
108
148
  @mutex.synchronize do
109
- @entries.reverse_each do |entry|
110
- next unless entry[:event_type] == "user_message"
149
+ entry = @pending_entries.last
150
+ return nil unless entry
111
151
 
112
- if entry[:type] == :rendered && entry.dig(:data, "status") == "pending"
113
- return {id: entry[:id], content: entry.dig(:data, "content")}
114
- end
115
-
116
- # Only check the most recent user message
117
- break
118
- end
119
- nil
152
+ {pending_message_id: entry[:pending_message_id], content: entry.dig(:data, "content")}
120
153
  end
121
154
  end
122
155
 
123
- # Removes an entry by its event ID. Used when a pending message is
156
+ # Removes an entry by its message ID. Used when a pending message is
124
157
  # recalled for editing or deleted by another client.
125
158
  #
126
- # @param event_id [Integer] database ID of the event to remove
159
+ # @param message_id [Integer] database ID of the message to remove
127
160
  # @return [Boolean] true if the entry was found and removed
128
- def remove_by_id(event_id)
161
+ def remove_by_id(message_id)
129
162
  @mutex.synchronize do
130
- entry = @entries_by_id.delete(event_id)
163
+ entry = @entries_by_id.delete(message_id)
131
164
  return false unless entry
132
165
 
133
166
  @entries.delete(entry)
@@ -136,17 +169,17 @@ module TUI
136
169
  end
137
170
  end
138
171
 
139
- # Removes entries by their event IDs. Used when the brain reports
140
- # that events have left the LLM's viewport (context window eviction).
172
+ # Removes entries by their message IDs. Used when the brain reports
173
+ # that messages have left the LLM's viewport (context window eviction).
141
174
  # Acquires the mutex once for the entire batch.
142
175
  #
143
- # @param event_ids [Array<Integer>] database IDs of events to remove
176
+ # @param message_ids [Array<Integer>] database IDs of messages to remove
144
177
  # @return [Integer] count of entries actually removed
145
- def remove_by_ids(event_ids)
178
+ def remove_by_ids(message_ids)
146
179
  @mutex.synchronize do
147
180
  removed = 0
148
- event_ids.each do |event_id|
149
- entry = @entries_by_id.delete(event_id)
181
+ message_ids.each do |message_id|
182
+ entry = @entries_by_id.delete(message_id)
150
183
  next unless entry
151
184
 
152
185
  @entries.delete(entry)
@@ -159,17 +192,17 @@ module TUI
159
192
 
160
193
  private
161
194
 
162
- # Replaces data on an existing entry matched by event ID.
195
+ # Replaces data on an existing entry matched by message ID.
163
196
  # Only updates rendered entries — tool counters and plain messages
164
197
  # are not individually addressable by ID.
165
198
  #
166
199
  # @return [Boolean] true if the entry was found and updated
167
- def update_existing(event_data, event_id)
200
+ def update_existing(event_data, message_id)
168
201
  rendered = extract_rendered(event_data)
169
202
  return false unless rendered
170
203
 
171
204
  @mutex.synchronize do
172
- entry = @entries_by_id[event_id]
205
+ entry = @entries_by_id[message_id]
173
206
  return false unless entry
174
207
 
175
208
  entry[:data] = rendered
@@ -190,28 +223,25 @@ module TUI
190
223
 
191
224
  # Inserts a rendered entry at the correct chronological position.
192
225
  # System prompt entries (no ID) are always placed at position 0.
193
- def record_rendered(data, event_type: nil, id: nil)
226
+ def record_rendered(data, message_type: nil, id: nil)
194
227
  @mutex.synchronize do
195
- entry = {type: :rendered, data: data, event_type: event_type, id: id}
228
+ entry = {type: :rendered, data: data, message_type: message_type, id: id}
196
229
  insert_ordered(entry)
197
230
  @version += 1
198
231
  end
199
232
  true
200
233
  end
201
234
 
202
- # Inserts an entry in event-ID order. Entries without an ID are
235
+ # Inserts an entry in message-ID order. Entries without an ID are
203
236
  # appended. If an entry with the same ID already exists, updates
204
237
  # it in-place (deduplication for live/viewport replay races).
205
- # System prompt entries are always placed at position 0.
238
+ # Callers send system prompt entries with {Message::SYSTEM_PROMPT_ID}
239
+ # (0) so they sort before all positive-ID messages and deduplicate
240
+ # on subsequent broadcasts.
206
241
  #
207
242
  # @param entry [Hash] the entry to insert
208
243
  # @return [void]
209
244
  def insert_ordered(entry)
210
- if entry[:event_type] == "system_prompt"
211
- @entries.unshift(entry)
212
- return
213
- end
214
-
215
245
  id = entry[:id]
216
246
  unless id
217
247
  @entries << entry
@@ -222,7 +252,7 @@ module TUI
222
252
  if existing
223
253
  existing[:data] = entry[:data] if entry.key?(:data)
224
254
  existing[:content] = entry[:content] if entry.key?(:content)
225
- existing[:event_type] = entry[:event_type] if entry.key?(:event_type)
255
+ existing[:message_type] = entry[:message_type] if entry.key?(:message_type)
226
256
  return
227
257
  end
228
258
 
@@ -230,10 +260,14 @@ module TUI
230
260
  @entries_by_id[id] = entry
231
261
  end
232
262
 
233
- # Inserts an entry in sorted order by event ID. Optimized for the
234
- # common case where events arrive in order (appends without scanning).
235
- # Entries without IDs (tool counters, etc.) are skipped during the
236
- # sort scan and don't affect insertion position.
263
+ # Inserts an entry in sorted order by message ID. Optimized for two
264
+ # common cases: appending (live streaming, ascending order) and
265
+ # prepending (session history replay, descending/newest-first order).
266
+ # Falls back to binary scan for out-of-order arrivals.
267
+ #
268
+ # Note: prepending N messages via +unshift+ is O(n) per call. For
269
+ # large viewport replays this totals O(n²), acceptable at typical
270
+ # viewport sizes (50–100 messages).
237
271
  #
238
272
  # @param entry [Hash] entry with a non-nil +:id+
239
273
  # @return [void]
@@ -247,15 +281,25 @@ module TUI
247
281
  return
248
282
  end
249
283
 
284
+ # Fast path: entry belongs at the beginning (session history replay, newest-first).
285
+ # Only safe when the first entry has an ID — non-ID entries (tool counters)
286
+ # at the head would be displaced, so we fall through to the general path.
287
+ first_id = @entries.first&.dig(:id)
288
+ if first_id && id < first_id
289
+ @entries.unshift(entry)
290
+ return
291
+ end
292
+
250
293
  # Out-of-order arrival: insert before the first entry with a higher ID
251
294
  insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
252
295
  @entries.insert(insert_pos, entry)
253
296
  end
254
297
 
255
- # Returns the highest event ID in the entries array, scanning from the
298
+ # Returns the highest message ID in the entries array, scanning from the
256
299
  # end for efficiency (entries with IDs are typically at the tail).
300
+ # Used by {#insert_sorted_by_id} to detect the append fast path.
257
301
  #
258
- # @return [Integer, nil] the highest event ID, or nil if no entries have IDs
302
+ # @return [Integer, nil] the highest message ID, or nil if no entries have IDs
259
303
  def last_entry_id
260
304
  @entries.reverse_each { |e| return e[:id] if e[:id] }
261
305
  nil