anima-core 1.0.2 → 1.1.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +47 -0
  4. data/README.md +60 -26
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +29 -10
  7. data/app/decorators/tool_call_decorator.rb +7 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +90 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +18 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +335 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/config/initializers/event_subscribers.rb +14 -3
  22. data/config/initializers/fts5_schema_dump.rb +21 -0
  23. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  24. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  25. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  26. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  27. data/lib/agent_loop.rb +63 -20
  28. data/lib/analytical_brain/runner.rb +158 -65
  29. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  30. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  31. data/lib/anima/cli.rb +2 -1
  32. data/lib/anima/installer.rb +11 -12
  33. data/lib/anima/settings.rb +41 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/events/bounce_back.rb +37 -0
  36. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  37. data/lib/events/subscribers/persister.rb +17 -0
  38. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  39. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  40. data/lib/llm/client.rb +16 -8
  41. data/lib/mneme/compressed_viewport.rb +200 -0
  42. data/lib/mneme/l2_runner.rb +138 -0
  43. data/lib/mneme/passive_recall.rb +69 -0
  44. data/lib/mneme/runner.rb +254 -0
  45. data/lib/mneme/search.rb +150 -0
  46. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  47. data/lib/mneme/tools/everything_ok.rb +24 -0
  48. data/lib/mneme/tools/save_snapshot.rb +68 -0
  49. data/lib/mneme.rb +29 -0
  50. data/lib/providers/anthropic.rb +57 -13
  51. data/lib/shell_session.rb +188 -59
  52. data/lib/tasks/fts5.rake +6 -0
  53. data/lib/tools/remember.rb +179 -0
  54. data/lib/tools/spawn_specialist.rb +21 -9
  55. data/lib/tools/spawn_subagent.rb +22 -11
  56. data/lib/tools/subagent_prompts.rb +20 -3
  57. data/lib/tools/web_get.rb +15 -6
  58. data/lib/tui/app.rb +222 -125
  59. data/lib/tui/decorators/base_decorator.rb +165 -0
  60. data/lib/tui/decorators/bash_decorator.rb +20 -0
  61. data/lib/tui/decorators/edit_decorator.rb +19 -0
  62. data/lib/tui/decorators/read_decorator.rb +24 -0
  63. data/lib/tui/decorators/think_decorator.rb +36 -0
  64. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  65. data/lib/tui/decorators/write_decorator.rb +19 -0
  66. data/lib/tui/flash.rb +139 -0
  67. data/lib/tui/formatting.rb +28 -0
  68. data/lib/tui/height_map.rb +93 -0
  69. data/lib/tui/message_store.rb +25 -1
  70. data/lib/tui/performance_logger.rb +90 -0
  71. data/lib/tui/screens/chat.rb +358 -133
  72. data/templates/config.toml +40 -0
  73. metadata +83 -4
  74. data/CHANGELOG.md +0 -80
  75. data/Gemfile +0 -17
  76. data/lib/tools/return_result.rb +0 -81
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders edit tool calls and responses.
6
+ # Calls show the file path with a pencil icon.
7
+ class EditDecorator < BaseDecorator
8
+ ICON = "\u270F\uFE0F" # pencil
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "yellow"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders read tool calls and responses.
6
+ # Calls show the file path with a page icon.
7
+ # Responses show file content in dim text.
8
+ class ReadDecorator < BaseDecorator
9
+ ICON = "\u{1F4C4}" # page facing up
10
+
11
+ def icon
12
+ ICON
13
+ end
14
+
15
+ def color
16
+ "cyan"
17
+ end
18
+
19
+ def response_color
20
+ "dark_gray"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders think tool events — the agent's inner reasoning.
6
+ # "aloud" thoughts use yellow (narration for the user), "inner"
7
+ # thoughts use dark_gray (dimmed to signal internality).
8
+ class ThinkDecorator < BaseDecorator
9
+ THOUGHT_BUBBLE = "\u{1F4AD}" # thought balloon
10
+
11
+ def icon
12
+ THOUGHT_BUBBLE
13
+ end
14
+
15
+ # Think events always dispatch here via BaseDecorator#render.
16
+ #
17
+ # @param tui [RatatuiRuby] TUI rendering API
18
+ # @return [Array<RatatuiRuby::Widgets::Line>]
19
+ def render_think(tui)
20
+ aloud = data["visibility"] == "aloud"
21
+ fg = aloud ? "yellow" : "dark_gray"
22
+ style = tui.style(fg: fg)
23
+ ts = data["timestamp"]
24
+
25
+ meta = []
26
+ meta << "[#{format_ns_timestamp(ts)}]" if ts
27
+ header = meta.empty? ? icon : "#{meta.join(" ")} #{icon}"
28
+
29
+ content_lines = data["content"].to_s.split("\n", -1)
30
+ lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
31
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
32
+ lines
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders web_get tool calls and responses.
6
+ # Calls show the URL with a globe icon.
7
+ class WebGetDecorator < BaseDecorator
8
+ ICON = "\u{1F310}" # globe with meridians
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "blue"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders write tool calls and responses.
6
+ # Calls show the file path with a memo icon.
7
+ class WriteDecorator < BaseDecorator
8
+ ICON = "\u{1F4DD}" # memo
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "yellow"
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/tui/flash.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Ephemeral notification system for the TUI, modeled after Rails flash
5
+ # messages. Notifications render as a colored bar at the top of the
6
+ # chat pane and auto-dismiss after a configurable timeout or on any
7
+ # keypress.
8
+ #
9
+ # Reusable beyond Bounce Back — useful for connection status changes,
10
+ # background task notifications, and any transient user feedback that
11
+ # doesn't belong in the chat stream.
12
+ #
13
+ # @example Adding a flash
14
+ # flash = TUI::Flash.new
15
+ # flash.error("Message not delivered: API token not configured")
16
+ # flash.warning("Rate limited, retry in 30s")
17
+ # flash.info("Reconnected to server")
18
+ #
19
+ # @example Rendering (returns height consumed)
20
+ # flash_height = flash.render(frame, area, tui)
21
+ #
22
+ # @example Dismissing
23
+ # flash.dismiss!
24
+ class Flash
25
+ AUTO_DISMISS_SECONDS = 5.0
26
+
27
+ # Flash area occupies at most 1/3 of the chat pane height.
28
+ MAX_HEIGHT_FRACTION = 3
29
+
30
+ Entry = Struct.new(:message, :level, :created_at, keyword_init: true)
31
+
32
+ LEVEL_STYLES = {
33
+ error: {fg: "white", bg: "red", icon: " \u2718 "},
34
+ warning: {fg: "black", bg: "yellow", icon: " \u26A0 "},
35
+ info: {fg: "white", bg: "blue", icon: " \u2139 "}
36
+ }.freeze
37
+
38
+ def initialize
39
+ @entries = []
40
+ end
41
+
42
+ # @param message [String]
43
+ def error(message)
44
+ push(message, :error)
45
+ end
46
+
47
+ # @param message [String]
48
+ def warning(message)
49
+ push(message, :warning)
50
+ end
51
+
52
+ # @param message [String]
53
+ def info(message)
54
+ push(message, :info)
55
+ end
56
+
57
+ # Removes expired entries and returns true if any remain.
58
+ def any?
59
+ expire!
60
+ @entries.any?
61
+ end
62
+
63
+ # @return [Boolean]
64
+ def empty?
65
+ !any?
66
+ end
67
+
68
+ # Removes all entries immediately (e.g. on keypress).
69
+ def dismiss!
70
+ @entries.clear
71
+ end
72
+
73
+ # Renders flash entries as colored bars at the top of the given area.
74
+ # Returns the height consumed so the caller can adjust layout.
75
+ #
76
+ # @param frame [RatatuiRuby::Frame]
77
+ # @param area [RatatuiRuby::Rect] full chat area (flash renders at top)
78
+ # @param tui [RatatuiRuby::TUI]
79
+ # @return [Integer] number of rows consumed
80
+ def render(frame, area, tui)
81
+ expire!
82
+ return 0 if @entries.empty?
83
+
84
+ height = [@entries.size, area.height / MAX_HEIGHT_FRACTION].min
85
+
86
+ flash_area, _ = tui.split(
87
+ area,
88
+ direction: :vertical,
89
+ constraints: [
90
+ tui.constraint_length(height),
91
+ tui.constraint_fill(1)
92
+ ]
93
+ )
94
+
95
+ @entries.each_with_index do |entry, index|
96
+ break if index >= height
97
+
98
+ row_area = row_rect(flash_area, index, tui)
99
+ render_entry(frame, row_area, entry, tui)
100
+ end
101
+
102
+ height
103
+ end
104
+
105
+ private
106
+
107
+ def push(message, level)
108
+ @entries << Entry.new(message: message, level: level, created_at: monotonic_now)
109
+ end
110
+
111
+ def expire!
112
+ now = monotonic_now
113
+ @entries.reject! { |entry| now - entry.created_at > AUTO_DISMISS_SECONDS }
114
+ end
115
+
116
+ def row_rect(area, index, tui)
117
+ rows = (0...area.height).map { tui.constraint_length(1) }
118
+ chunks = tui.split(area, direction: :vertical, constraints: rows)
119
+ chunks[index]
120
+ end
121
+
122
+ def render_entry(frame, area, entry, tui)
123
+ config = LEVEL_STYLES.fetch(entry.level, LEVEL_STYLES[:info])
124
+ style = tui.style(fg: config[:fg], bg: config[:bg], modifiers: [:bold])
125
+
126
+ text = "#{config[:icon]}#{entry.message} "
127
+ # Pad to full width so background color fills the entire row
128
+ padded = text.ljust(area.width)
129
+
130
+ line = tui.line(spans: [tui.span(content: padded, style: style)])
131
+ widget = tui.paragraph(text: [line])
132
+ frame.render_widget(widget, area)
133
+ end
134
+
135
+ def monotonic_now
136
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Shared formatting helpers for timestamps and token counts.
5
+ # Used by both the Chat screen and client-side decorators
6
+ # to avoid duplicating display logic.
7
+ module Formatting
8
+ # Formats a token count for display, with tilde prefix for estimates.
9
+ # @param tokens [Integer, nil] token count
10
+ # @param estimated [Boolean] whether the count is an estimate
11
+ # @return [String] formatted label, e.g. "[42 tok]" or "[~28 tok]"
12
+ def format_token_label(tokens, estimated)
13
+ return "" unless tokens
14
+
15
+ label = estimated ? "~#{tokens}" : tokens.to_s
16
+ "[#{label} tok]"
17
+ end
18
+
19
+ # Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
20
+ # @param ns [Integer, nil] nanosecond timestamp
21
+ # @return [String] formatted time, or "--:--:--" when nil
22
+ def format_ns_timestamp(ns)
23
+ return "--:--:--" unless ns
24
+
25
+ Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Tracks estimated visual heights for chat entries, enabling
5
+ # viewport virtualization. Heights are in visual (wrapped) line
6
+ # units, estimated from content length and terminal width.
7
+ #
8
+ # Provides efficient scroll-position-to-entry-index mapping so
9
+ # the chat screen can render only visible messages instead of
10
+ # processing the entire conversation history.
11
+ #
12
+ # @example
13
+ # map = HeightMap.new
14
+ # map.update(entries, 80) { |entry, width| estimate(entry, width) }
15
+ # first, last = map.visible_range(scroll_offset, viewport_height)
16
+ # total = map.total_height
17
+ class HeightMap
18
+ # @return [Integer] number of tracked entries
19
+ attr_reader :size
20
+
21
+ def initialize
22
+ @heights = []
23
+ @size = 0
24
+ end
25
+
26
+ # Replaces all heights from a fresh estimation pass.
27
+ # Each entry's height is computed by the caller-supplied block.
28
+ #
29
+ # @param entries [Array<Hash>] message store entries
30
+ # @param width [Integer] terminal width for wrap estimation
31
+ # @yield [entry, width] block returning estimated visual line count
32
+ # @return [void]
33
+ def update(entries, width)
34
+ @heights = entries.map { |entry| [yield(entry, width), 1].max }
35
+ @size = @heights.size
36
+ end
37
+
38
+ # @return [Integer] sum of all estimated entry heights
39
+ def total_height
40
+ @heights.sum(0)
41
+ end
42
+
43
+ # Cumulative height of entries before the given index.
44
+ #
45
+ # @param index [Integer] entry index (0-based)
46
+ # @return [Integer] total visual lines above this entry
47
+ def cumulative_height(index)
48
+ return 0 if index <= 0 || @heights.empty?
49
+ return @heights.sum(0) if index >= @size
50
+
51
+ @heights[0...index].sum(0)
52
+ end
53
+
54
+ # Finds the entry range visible within a scroll window.
55
+ # An entry is visible if any of its lines fall within
56
+ # [scroll_offset, scroll_offset + visible_height).
57
+ #
58
+ # @param scroll_offset [Integer] top of viewport in visual lines
59
+ # @param visible_height [Integer] viewport height in visual lines
60
+ # @return [Array(Integer, Integer)] [first_visible, last_visible]
61
+ def visible_range(scroll_offset, visible_height)
62
+ return [0, 0] if @heights.empty?
63
+
64
+ first_found = false
65
+ first = 0
66
+ last = 0
67
+ cumulative = 0
68
+ end_line = scroll_offset + visible_height
69
+
70
+ @heights.each_with_index do |entry_height, idx|
71
+ entry_end = cumulative + entry_height
72
+ unless first_found
73
+ if entry_end > scroll_offset
74
+ first = idx
75
+ first_found = true
76
+ end
77
+ end
78
+ last = idx if cumulative < end_line
79
+ cumulative = entry_end
80
+ end
81
+
82
+ first = [@size - 1, 0].max unless first_found
83
+ [first, [last, first].max]
84
+ end
85
+
86
+ # Clears all tracked heights.
87
+ # @return [void]
88
+ def reset
89
+ @heights.clear
90
+ @size = 0
91
+ end
92
+ end
93
+ end
@@ -32,6 +32,15 @@ module TUI
32
32
  @entries = []
33
33
  @entries_by_id = {}
34
34
  @mutex = Mutex.new
35
+ @version = 0
36
+ end
37
+
38
+ # Monotonically increasing counter that bumps on every mutation.
39
+ # Consumers compare this to a cached value to detect changes
40
+ # without copying the full entries array on every frame.
41
+ # @return [Integer]
42
+ def version
43
+ @mutex.synchronize { @version }
35
44
  end
36
45
 
37
46
  # @return [Array<Hash>] thread-safe copy of stored entries
@@ -39,6 +48,11 @@ module TUI
39
48
  @mutex.synchronize { @entries.dup }
40
49
  end
41
50
 
51
+ # @return [Integer] number of stored entries (no array copy)
52
+ def size
53
+ @mutex.synchronize { @entries.size }
54
+ end
55
+
42
56
  # Processes a raw event payload from the WebSocket channel.
43
57
  # Uses structured decorator data when available; falls back to
44
58
  # role/content extraction for messages and tool counter aggregation.
@@ -77,6 +91,7 @@ module TUI
77
91
  @mutex.synchronize do
78
92
  @entries = []
79
93
  @entries_by_id = {}
94
+ @version += 1
80
95
  end
81
96
  end
82
97
 
@@ -111,6 +126,7 @@ module TUI
111
126
  return false unless entry
112
127
 
113
128
  @entries.delete(entry)
129
+ @version += 1
114
130
  true
115
131
  end
116
132
  end
@@ -131,6 +147,7 @@ module TUI
131
147
  @entries.delete(entry)
132
148
  removed += 1
133
149
  end
150
+ @version += 1 if removed > 0
134
151
  removed
135
152
  end
136
153
  end
@@ -151,6 +168,7 @@ module TUI
151
168
  return false unless entry
152
169
 
153
170
  entry[:data] = rendered
171
+ @version += 1
154
172
  true
155
173
  end
156
174
  end
@@ -170,6 +188,7 @@ module TUI
170
188
  entry = {type: :rendered, data: data, event_type: event_type, id: id}
171
189
  @entries << entry
172
190
  @entries_by_id[id] = entry if id
191
+ @version += 1
173
192
  end
174
193
  true
175
194
  end
@@ -182,6 +201,7 @@ module TUI
182
201
  else
183
202
  @entries << {type: :tool_counter, calls: 1, responses: 0}
184
203
  end
204
+ @version += 1
185
205
  end
186
206
  true
187
207
  end
@@ -189,7 +209,10 @@ module TUI
189
209
  def record_tool_response
190
210
  @mutex.synchronize do
191
211
  current = current_tool_counter
192
- current[:responses] += 1 if current
212
+ return false unless current
213
+
214
+ current[:responses] += 1
215
+ @version += 1
193
216
  end
194
217
  true
195
218
  end
@@ -204,6 +227,7 @@ module TUI
204
227
  entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_id}
205
228
  @entries << entry
206
229
  @entries_by_id[event_id] = entry if event_id
230
+ @version += 1
207
231
  end
208
232
  true
209
233
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module TUI
6
+ # Frame-level performance logger for TUI render profiling.
7
+ #
8
+ # When enabled via `--debug`, logs timing data for each render phase
9
+ # to `log/tui_performance.log`. Each frame produces one log line with
10
+ # phase durations in milliseconds, enabling bottleneck identification.
11
+ #
12
+ # Uses monotonic clock to avoid wall-clock jitter.
13
+ #
14
+ # @example
15
+ # logger = PerformanceLogger.new(enabled: true)
16
+ # logger.start_frame
17
+ # logger.measure(:build_lines) { build_message_lines(tui) }
18
+ # logger.measure(:line_count) { widget.line_count(width) }
19
+ # logger.end_frame
20
+ class PerformanceLogger
21
+ LOG_PATH = "log/tui_performance.log"
22
+
23
+ # @param enabled [Boolean] whether to actually log (no-op when false)
24
+ def initialize(enabled: false)
25
+ @enabled = enabled
26
+ @phases = {}
27
+ @frame_start = nil
28
+ @frame_count = 0
29
+ @logger = nil
30
+
31
+ return unless @enabled
32
+
33
+ @logger = Logger.new(LOG_PATH, 1, 5 * 1024 * 1024) # 5MB rotation
34
+ @logger.formatter = proc { |_sev, time, _prog, msg| "#{time.strftime("%H:%M:%S.%L")} #{msg}\n" }
35
+ @logger.info("TUI Performance Logger started — pid=#{Process.pid}")
36
+ end
37
+
38
+ # @return [Boolean] true when logging is active
39
+ def enabled?
40
+ @enabled
41
+ end
42
+
43
+ # Marks the beginning of a render frame.
44
+ def start_frame
45
+ return unless @enabled
46
+
47
+ @frame_start = monotonic_now
48
+ @phases = {}
49
+ end
50
+
51
+ # Measures a named phase within the current frame.
52
+ # Returns the block's result so it can be used inline.
53
+ #
54
+ # @param name [Symbol] phase name (e.g. :build_lines, :line_count)
55
+ # @yield the code to measure
56
+ # @return [Object] the block's return value
57
+ def measure(name)
58
+ return yield unless @enabled
59
+
60
+ start = monotonic_now
61
+ result = yield
62
+ @phases[name] = ((monotonic_now - start) * 1000).round(2)
63
+ result
64
+ end
65
+
66
+ # Logs the completed frame with all phase timings.
67
+ def end_frame
68
+ return unless @enabled
69
+
70
+ total = ((monotonic_now - @frame_start) * 1000).round(2)
71
+ @frame_count += 1
72
+
73
+ parts = @phases.map { |name, ms| "#{name}=#{ms}ms" }
74
+ @logger.info("frame=#{@frame_count} total=#{total}ms #{parts.join(" ")}")
75
+ end
76
+
77
+ # Logs a one-off informational message (e.g. cache hit/miss).
78
+ #
79
+ # @param message [String]
80
+ def info(message)
81
+ @logger&.info(message)
82
+ end
83
+
84
+ private
85
+
86
+ def monotonic_now
87
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ end
89
+ end
90
+ end