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.
- checksums.yaml +4 -4
- data/.reek.yml +14 -8
- data/README.md +96 -23
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/lib/tui/message_store.rb
CHANGED
|
@@ -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] `{
|
|
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
|
-
@
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
234
|
-
# common
|
|
235
|
-
#
|
|
236
|
-
#
|
|
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(
|
|
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
|