anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "settings"
4
+
3
5
  module TUI
4
6
  # Thread-safe in-memory store for chat entries displayed in the TUI.
5
7
  # Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
@@ -36,8 +38,11 @@ module TUI
36
38
  def initialize
37
39
  @entries = []
38
40
  @entries_by_id = {}
41
+ @pending_entries = []
42
+ @pending_by_id = {}
39
43
  @mutex = Mutex.new
40
44
  @version = 0
45
+ @token_economy = default_token_economy
41
46
  end
42
47
 
43
48
  # Monotonically increasing counter that bumps on every mutation.
@@ -48,14 +53,38 @@ module TUI
48
53
  @mutex.synchronize { @version }
49
54
  end
50
55
 
51
- # @return [Array<Hash>] thread-safe copy of stored entries
56
+ # @return [Array<Hash>] thread-safe copy of stored entries (pending messages at the end)
52
57
  def messages
53
- @mutex.synchronize { @entries.dup }
58
+ @mutex.synchronize { @entries.dup + @pending_entries.dup }
54
59
  end
55
60
 
56
- # @return [Integer] number of stored entries (no array copy)
61
+ # @return [Integer] number of stored entries including pending (no array copy)
57
62
  def size
58
- @mutex.synchronize { @entries.size }
63
+ @mutex.synchronize { @entries.size + @pending_entries.size }
64
+ end
65
+
66
+ # Returns aggregated token economy data for HUD display.
67
+ # Includes running totals, cache hit rate, and latest rate limit snapshot.
68
+ #
69
+ # @return [Hash] token economy stats:
70
+ # - :input_tokens [Integer] total input tokens across all calls
71
+ # - :output_tokens [Integer] total output tokens
72
+ # - :cache_read_input_tokens [Integer] total cached token reads
73
+ # - :cache_creation_input_tokens [Integer] total cache writes
74
+ # - :call_count [Integer] number of API calls tracked
75
+ # - :cache_hit_rate [Float] percentage of input served from cache (0.0-1.0)
76
+ # - :rate_limits [Hash, nil] latest rate limit values from API
77
+ def token_economy
78
+ @mutex.synchronize do
79
+ stats = @token_economy.dup
80
+ total_input = stats[:input_tokens] + stats[:cache_read_input_tokens] + stats[:cache_creation_input_tokens]
81
+ stats[:cache_hit_rate] = if total_input > 0
82
+ stats[:cache_read_input_tokens].to_f / total_input
83
+ else
84
+ 0.0
85
+ end
86
+ stats
87
+ end
59
88
  end
60
89
 
61
90
  # Processes a raw event payload from the WebSocket channel.
@@ -65,12 +94,19 @@ module TUI
65
94
  # Events with `"action" => "update"` and a matching `"id"` replace
66
95
  # the existing entry's data in-place rather than appending.
67
96
  #
97
+ # Extracts api_metrics when present and accumulates token economy data.
98
+ #
68
99
  # @param event_data [Hash] Action Cable event payload with "type", "content",
69
- # and optionally "rendered" (hash of mode => lines), "id", "action"
100
+ # and optionally "rendered" (hash of mode => lines), "id", "action", "api_metrics"
70
101
  # @return [Boolean] true if the event type was recognized and handled
71
102
  def process_event(event_data)
72
103
  message_id = event_data["id"]
73
104
 
105
+ # Track API metrics for token economy HUD (only on create, not update)
106
+ if event_data["action"] != "update"
107
+ accumulate_api_metrics(event_data["api_metrics"])
108
+ end
109
+
74
110
  if event_data["action"] == "update" && message_id
75
111
  return update_existing(event_data, message_id)
76
112
  end
@@ -91,32 +127,65 @@ module TUI
91
127
 
92
128
  # Removes all entries. Called on view mode change and session switch
93
129
  # to prepare for re-decorated viewport messages from the server.
130
+ # Resets token economy totals since we're starting fresh.
94
131
  # @return [void]
95
132
  def clear
96
133
  @mutex.synchronize do
97
134
  @entries = []
98
135
  @entries_by_id = {}
136
+ @pending_entries = []
137
+ @pending_by_id = {}
138
+ @token_economy = default_token_economy
139
+ @version += 1
140
+ end
141
+ end
142
+
143
+ # Adds a pending message to the separate pending list.
144
+ # Pending messages always render after real messages.
145
+ #
146
+ # @param pending_message_id [Integer] PendingMessage database ID
147
+ # @param content [String] message text
148
+ # @return [void]
149
+ def add_pending(pending_message_id, content)
150
+ @mutex.synchronize do
151
+ entry = {
152
+ type: :rendered,
153
+ data: {"role" => "user", "content" => content, "status" => "pending"},
154
+ message_type: "user_message",
155
+ pending_message_id: pending_message_id
156
+ }
157
+ old = @pending_by_id[pending_message_id]
158
+ @pending_entries.delete(old) if old
159
+ @pending_entries << entry
160
+ @pending_by_id[pending_message_id] = entry
161
+ @version += 1
162
+ end
163
+ end
164
+
165
+ # Removes a pending message by its PendingMessage ID.
166
+ #
167
+ # @param pending_message_id [Integer] PendingMessage database ID
168
+ # @return [Boolean] true if found and removed
169
+ def remove_pending(pending_message_id)
170
+ @mutex.synchronize do
171
+ entry = @pending_by_id.delete(pending_message_id)
172
+ return false unless entry
173
+
174
+ @pending_entries.delete(entry)
99
175
  @version += 1
176
+ true
100
177
  end
101
178
  end
102
179
 
103
180
  # Returns the last pending user message for recall editing.
104
- # Walks entries backwards and returns the first pending user_message found.
105
181
  #
106
- # @return [Hash, nil] `{id: Integer, content: String}` or nil if none pending
182
+ # @return [Hash, nil] `{pending_message_id: Integer, content: String}` or nil
107
183
  def last_pending_user_message
108
184
  @mutex.synchronize do
109
- @entries.reverse_each do |entry|
110
- next unless entry[:message_type] == "user_message"
111
-
112
- if entry[:type] == :rendered && entry.dig(:data, "status") == "pending"
113
- return {id: entry[:id], content: entry.dig(:data, "content")}
114
- end
185
+ entry = @pending_entries.last
186
+ return nil unless entry
115
187
 
116
- # Only check the most recent user message
117
- break
118
- end
119
- nil
188
+ {pending_message_id: entry[:pending_message_id], content: entry.dig(:data, "content")}
120
189
  end
121
190
  end
122
191
 
@@ -202,16 +271,13 @@ module TUI
202
271
  # Inserts an entry in message-ID order. Entries without an ID are
203
272
  # appended. If an entry with the same ID already exists, updates
204
273
  # it in-place (deduplication for live/viewport replay races).
205
- # System prompt entries are always placed at position 0.
274
+ # Callers send system prompt entries with {Message::SYSTEM_PROMPT_ID}
275
+ # (0) so they sort before all positive-ID messages and deduplicate
276
+ # on subsequent broadcasts.
206
277
  #
207
278
  # @param entry [Hash] the entry to insert
208
279
  # @return [void]
209
280
  def insert_ordered(entry)
210
- if entry[:message_type] == "system_prompt"
211
- @entries.unshift(entry)
212
- return
213
- end
214
-
215
281
  id = entry[:id]
216
282
  unless id
217
283
  @entries << entry
@@ -230,10 +296,14 @@ module TUI
230
296
  @entries_by_id[id] = entry
231
297
  end
232
298
 
233
- # Inserts an entry in sorted order by message ID. Optimized for the
234
- # common case where messages 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.
299
+ # Inserts an entry in sorted order by message ID. Optimized for two
300
+ # common cases: appending (live streaming, ascending order) and
301
+ # prepending (session history replay, descending/newest-first order).
302
+ # Falls back to binary scan for out-of-order arrivals.
303
+ #
304
+ # Note: prepending N messages via +unshift+ is O(n) per call. For
305
+ # large viewport replays this totals O(n²), acceptable at typical
306
+ # viewport sizes (50–100 messages).
237
307
  #
238
308
  # @param entry [Hash] entry with a non-nil +:id+
239
309
  # @return [void]
@@ -247,6 +317,15 @@ module TUI
247
317
  return
248
318
  end
249
319
 
320
+ # Fast path: entry belongs at the beginning (session history replay, newest-first).
321
+ # Only safe when the first entry has an ID — non-ID entries (tool counters)
322
+ # at the head would be displaced, so we fall through to the general path.
323
+ first_id = @entries.first&.dig(:id)
324
+ if first_id && id < first_id
325
+ @entries.unshift(entry)
326
+ return
327
+ end
328
+
250
329
  # Out-of-order arrival: insert before the first entry with a higher ID
251
330
  insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
252
331
  @entries.insert(insert_pos, entry)
@@ -254,6 +333,7 @@ module TUI
254
333
 
255
334
  # Returns the highest message ID in the entries array, scanning from the
256
335
  # end for efficiency (entries with IDs are typically at the tail).
336
+ # Used by {#insert_sorted_by_id} to detect the append fast path.
257
337
  #
258
338
  # @return [Integer, nil] the highest message ID, or nil if no entries have IDs
259
339
  def last_entry_id
@@ -302,5 +382,57 @@ module TUI
302
382
  last = @entries.last
303
383
  last if last&.dig(:type) == :tool_counter
304
384
  end
385
+
386
+ # Default token economy state for initialization and reset.
387
+ # @return [Hash]
388
+ def default_token_economy
389
+ {
390
+ input_tokens: 0,
391
+ output_tokens: 0,
392
+ cache_read_input_tokens: 0,
393
+ cache_creation_input_tokens: 0,
394
+ call_count: 0,
395
+ rate_limits: nil,
396
+ cache_history: []
397
+ }
398
+ end
399
+
400
+ # Accumulates API metrics from a message into running totals.
401
+ # Updates rate limits with the latest snapshot (most recent wins).
402
+ #
403
+ # @param api_metrics [Hash, nil] metrics from API response with "usage" and "rate_limits"
404
+ # @return [void]
405
+ def accumulate_api_metrics(api_metrics)
406
+ return unless api_metrics.is_a?(Hash)
407
+
408
+ @mutex.synchronize do
409
+ usage = api_metrics["usage"]
410
+ if usage.is_a?(Hash)
411
+ input = usage["input_tokens"].to_i
412
+ cache_read = usage["cache_read_input_tokens"].to_i
413
+ cache_create = usage["cache_creation_input_tokens"].to_i
414
+
415
+ @token_economy[:input_tokens] += input
416
+ @token_economy[:output_tokens] += usage["output_tokens"].to_i
417
+ @token_economy[:cache_read_input_tokens] += cache_read
418
+ @token_economy[:cache_creation_input_tokens] += cache_create
419
+ @token_economy[:call_count] += 1
420
+
421
+ # Per-call cache hit rate for sparkline graph
422
+ total = input + cache_read + cache_create
423
+ hit_rate = (total > 0) ? cache_read.to_f / total : 0.0
424
+ history = @token_economy[:cache_history]
425
+ history.shift if history.size >= Settings.message_store_max_cache_history
426
+ history << hit_rate
427
+ end
428
+
429
+ rate_limits = api_metrics["rate_limits"]
430
+ if rate_limits.is_a?(Hash)
431
+ @token_economy[:rate_limits] = rate_limits
432
+ end
433
+
434
+ @version += 1
435
+ end
436
+ end
305
437
  end
306
438
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require_relative "settings"
4
5
 
5
6
  module TUI
6
7
  # Frame-level performance logger for TUI render profiling.
@@ -18,8 +19,6 @@ module TUI
18
19
  # logger.measure(:line_count) { widget.line_count(width) }
19
20
  # logger.end_frame
20
21
  class PerformanceLogger
21
- LOG_PATH = "log/tui_performance.log"
22
-
23
22
  # @param enabled [Boolean] whether to actually log (no-op when false)
24
23
  def initialize(enabled: false)
25
24
  @enabled = enabled
@@ -30,7 +29,7 @@ module TUI
30
29
 
31
30
  return unless @enabled
32
31
 
33
- @logger = Logger.new(LOG_PATH, 1, 5 * 1024 * 1024) # 5MB rotation
32
+ @logger = Logger.new(Settings.performance_log_path, 1, 5 * 1024 * 1024) # 5MB rotation
34
33
  @logger.formatter = proc { |_sev, time, _prog, msg| "#{time.strftime("%H:%M:%S.%L")} #{msg}\n" }
35
34
  @logger.info("TUI Performance Logger started — pid=#{Process.pid}")
36
35
  end