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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Fractal-resolution zoom into event history. Returns a window centered
5
+ # on a target event with full detail at the center and compressed context
6
+ # at the edges — sharp fovea, blurry periphery.
7
+ #
8
+ # Output structure:
9
+ # [Previous snapshots — compressed context before]
10
+ # [Events N-M — full detail, tool_responses compressed to checkmarks]
11
+ # [Following snapshots — compressed context after]
12
+ #
13
+ # The agent discovers target events via FTS5 search results embedded in
14
+ # viewport recall snippets. This tool drills down into the full context.
15
+ #
16
+ # @example
17
+ # remember(event_id: 42)
18
+ class Remember < Base
19
+ # Events around the target to include at full resolution.
20
+ # ±10 events provides sharp foveal detail while keeping output readable.
21
+ CONTEXT_WINDOW = 20
22
+
23
+ ROLE_LABELS = {
24
+ "user_message" => "User",
25
+ "agent_message" => "Assistant",
26
+ "system_message" => "System"
27
+ }.freeze
28
+
29
+ def self.tool_name = "remember"
30
+
31
+ def self.description
32
+ "Recall the full context around a past event. " \
33
+ "Returns a fractal-resolution window: high detail at the center " \
34
+ "(the target event and its neighbors), compressed context at the edges " \
35
+ "(snapshots before and after). Use this when search results surface " \
36
+ "a relevant event and you need the surrounding conversation."
37
+ end
38
+
39
+ def self.input_schema
40
+ {
41
+ type: "object",
42
+ properties: {
43
+ event_id: {
44
+ type: "integer",
45
+ description: "The event ID to zoom into (from search results or recall snippets)"
46
+ }
47
+ },
48
+ required: ["event_id"]
49
+ }
50
+ end
51
+
52
+ def initialize(session:, **)
53
+ @session = session
54
+ end
55
+
56
+ # @param input [Hash] with "event_id"
57
+ # @return [String] fractal-resolution window around the target event
58
+ def execute(input)
59
+ event_id = input["event_id"].to_i
60
+ target = Event.find_by(id: event_id)
61
+ return {error: "Event #{event_id} not found"} unless target
62
+
63
+ build_fractal_window(target)
64
+ end
65
+
66
+ private
67
+
68
+ # Assembles the three-zone fractal window.
69
+ #
70
+ # @param target [Event] the center event
71
+ # @return [String] formatted fractal window
72
+ def build_fractal_window(target)
73
+ target_session = target.session
74
+ center_events = fetch_center_events(target, target_session)
75
+ first_center_id = center_events.first&.id
76
+ last_center_id = center_events.last&.id
77
+
78
+ sections = build_sections(
79
+ target_session: target_session,
80
+ center_events: center_events,
81
+ target_id: target.id,
82
+ first_center_id: first_center_id,
83
+ last_center_id: last_center_id
84
+ )
85
+ sections.join("\n")
86
+ end
87
+
88
+ # Builds ordered sections: header, before snapshots, center, after snapshots.
89
+ def build_sections(target_session:, center_events:, target_id:, first_center_id:, last_center_id:)
90
+ sections = [session_header(target_session)]
91
+
92
+ append_snapshot_sections(sections, target_session.snapshots
93
+ .where("to_event_id < ?", first_center_id)
94
+ .chronological.last(3), label: "PREVIOUS CONTEXT")
95
+
96
+ sections << "── FULL CONTEXT (events #{first_center_id}..#{last_center_id}) ──"
97
+ center_events.each { |event| sections << render_center_event(event, target_id) }
98
+
99
+ append_snapshot_sections(sections, target_session.snapshots
100
+ .where("from_event_id > ?", last_center_id)
101
+ .chronological.first(3), label: "FOLLOWING CONTEXT")
102
+
103
+ sections
104
+ end
105
+
106
+ def session_header(target_session)
107
+ label = target_session.name || "Session ##{target_session.id}"
108
+ "── recalled from: #{label} ──"
109
+ end
110
+
111
+ # Appends snapshot sections if any exist.
112
+ def append_snapshot_sections(sections, snapshots, label:)
113
+ return if snapshots.empty?
114
+
115
+ sections << "── #{label} (compressed) ──"
116
+ snapshots.each { |snapshot| sections << format_snapshot(snapshot) }
117
+ end
118
+
119
+ # Fetches conversation events around the target within a fixed window.
120
+ #
121
+ # @return [Array<Event>] chronologically ordered
122
+ def fetch_center_events(target, target_session)
123
+ half = CONTEXT_WINDOW / 2
124
+ scope = target_session.events.context_events.deliverable
125
+ target_id = target.id
126
+
127
+ before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
128
+ after = scope.where("id > ?", target_id).reorder(id: :asc).limit(half).to_a
129
+
130
+ before + after
131
+ end
132
+
133
+ # Renders a center event at full resolution.
134
+ # Conversation events show full content. Tool calls show name + input.
135
+ # Tool responses compressed to status indicator.
136
+ #
137
+ # @param event [Event]
138
+ # @param target_id [Integer] the event being zoomed into (marked with arrow)
139
+ # @return [String]
140
+ def render_center_event(event, target_id)
141
+ marker = (event.id == target_id) ? "→" : " "
142
+ prefix = "#{marker} event #{event.id}"
143
+
144
+ "#{prefix} #{format_event_content(event)}"
145
+ end
146
+
147
+ # Formats event content based on type.
148
+ def format_event_content(event)
149
+ data = event.payload
150
+ content = data["content"]
151
+
152
+ if ROLE_LABELS.key?(event.event_type)
153
+ "#{ROLE_LABELS[event.event_type]}: #{content}"
154
+ elsif event.event_type == "tool_call"
155
+ format_tool_call(data)
156
+ elsif event.event_type == "tool_response"
157
+ status = content.to_s.start_with?("Error") ? "error" : "ok"
158
+ "ToolResult: [#{status}] #{data["tool_use_id"]}"
159
+ end
160
+ end
161
+
162
+ def format_tool_call(data)
163
+ if data["tool_name"] == Event::THINK_TOOL
164
+ "Think: #{data.dig("tool_input", "thoughts")}"
165
+ else
166
+ "Tool: #{data["tool_name"]}(#{data["tool_input"].to_json.truncate(200)})"
167
+ end
168
+ end
169
+
170
+ # Formats a snapshot as compressed context.
171
+ #
172
+ # @param snapshot [Snapshot]
173
+ # @return [String]
174
+ def format_snapshot(snapshot)
175
+ level = (snapshot.level == 2) ? "L2" : "L1"
176
+ "[#{level} snapshot, events #{snapshot.from_event_id}..#{snapshot.to_event_id}]\n#{snapshot.text}"
177
+ end
178
+ end
179
+ end
@@ -5,7 +5,12 @@ module Tools
5
5
  # The specialist has a predefined system prompt and tool set defined
6
6
  # in its Markdown definition file under agents/.
7
7
  #
8
- # Results are delivered back through {Tools::ReturnResult}.
8
+ # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
9
+ # runs synchronously at spawn time, generating a unique nickname based
10
+ # on the task — same as generic sub-agents.
11
+ #
12
+ # Results are delivered through natural text messages routed by
13
+ # {Events::Subscribers::SubagentMessageRouter}.
9
14
  #
10
15
  # @see Agents::Registry
11
16
  # @see Agents::Definition
@@ -17,7 +22,9 @@ module Tools
17
22
  # Builds description dynamically to include available specialists.
18
23
  def self.description
19
24
  base = "Spawn a named specialist sub-agent to work on a task autonomously. " \
20
- "The specialist has a predefined role, system prompt, and tool set."
25
+ "The specialist has a predefined role, system prompt, and tool set. " \
26
+ "Its text messages are forwarded to you automatically. " \
27
+ "Address it via @name to send follow-up instructions."
21
28
 
22
29
  registry = Agents::Registry.instance
23
30
  return base unless registry.any?
@@ -34,7 +41,7 @@ module Tools
34
41
  name: name_property,
35
42
  task: {
36
43
  type: "string",
37
- description: "What the specialist should do (emitted as its first user message)"
44
+ description: "What the specialist should do (persisted as its first user message)"
38
45
  },
39
46
  expected_output: {
40
47
  type: "string",
@@ -66,7 +73,8 @@ module Tools
66
73
  end
67
74
 
68
75
  # Creates a child session with the specialist's predefined prompt and tools,
69
- # emits the task as a user message, and queues background processing.
76
+ # persists the task as a user message, and queues background processing.
77
+ # Returns immediately (non-blocking).
70
78
  #
71
79
  # @param input [Hash<String, Object>] with "name", "task", and "expected_output"
72
80
  # @return [String] confirmation with child session ID
@@ -84,7 +92,10 @@ module Tools
84
92
  return {error: "Unknown agent: #{name}"} unless definition
85
93
 
86
94
  child = spawn_child(definition, task, expected_output)
87
- "Specialist '#{name}' spawned (session #{child.id}). Result will arrive as a tool response."
95
+ nickname = child.name
96
+ "Specialist @#{nickname} spawned (session #{child.id}). " \
97
+ "Its messages will appear in your conversation. " \
98
+ "Reply with @#{nickname} to send it instructions."
88
99
  end
89
100
 
90
101
  private
@@ -94,16 +105,17 @@ module Tools
94
105
  child = Session.create!(
95
106
  parent_session_id: @session.id,
96
107
  prompt: prompt,
97
- granted_tools: definition.tools,
98
- name: definition.name
108
+ granted_tools: definition.tools
99
109
  )
100
- Events::Bus.emit(Events::UserMessage.new(content: task, session_id: child.id))
110
+ child.create_user_event(task)
111
+ assign_nickname_via_brain(child)
112
+ child.broadcast_children_update_to_parent
101
113
  AgentRequestJob.perform_later(child.id)
102
114
  child
103
115
  end
104
116
 
105
117
  def build_prompt(definition, expected_output)
106
- "#{definition.prompt}\n\n#{RETURN_INSTRUCTION}\n\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}"
118
+ "#{definition.prompt}\n\n#{COMMUNICATION_INSTRUCTION}\n\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}"
107
119
  end
108
120
  end
109
121
  end
@@ -3,21 +3,26 @@
3
3
  module Tools
4
4
  # Spawns a generic child session that works on a task autonomously.
5
5
  # The sub-agent inherits the parent's viewport context at fork time,
6
- # runs via {AgentRequestJob}, and delivers results back
7
- # through {Tools::ReturnResult}.
6
+ # runs via {AgentRequestJob}, and communicates with the parent through
7
+ # natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
8
+ #
9
+ # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
10
+ # runs synchronously at spawn time — the same brain that manages skills,
11
+ # goals, and workflows for the main session.
8
12
  #
9
13
  # For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
10
14
  class SpawnSubagent < Base
11
15
  include SubagentPrompts
12
16
 
13
- GENERIC_PROMPT = "You are a focused sub-agent. #{RETURN_INSTRUCTION}\n"
17
+ GENERIC_PROMPT = "You are a focused sub-agent. #{COMMUNICATION_INSTRUCTION}\n"
14
18
 
15
19
  def self.tool_name = "spawn_subagent"
16
20
 
17
21
  def self.description
18
22
  "Spawn a generic sub-agent to work on a task autonomously. " \
19
23
  "The sub-agent inherits your conversation context, works independently, " \
20
- "and returns results as a tool response when done."
24
+ "and its text messages are forwarded to you automatically. " \
25
+ "Address it via @nickname to send follow-up instructions."
21
26
  end
22
27
 
23
28
  def self.input_schema
@@ -26,7 +31,7 @@ module Tools
26
31
  properties: {
27
32
  task: {
28
33
  type: "string",
29
- description: "What the sub-agent should do (emitted as its first user message)"
34
+ description: "What the sub-agent should do (persisted as its first user message)"
30
35
  },
31
36
  expected_output: {
32
37
  type: "string",
@@ -36,7 +41,7 @@ module Tools
36
41
  type: "array",
37
42
  items: {type: "string"},
38
43
  description: "Tool names to grant the sub-agent. " \
39
- "Omit for all standard tools. Empty array for pure reasoning (return_result only). " \
44
+ "Omit for all standard tools. Empty array for pure reasoning. " \
40
45
  "Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
41
46
  }
42
47
  },
@@ -49,11 +54,12 @@ module Tools
49
54
  @session = session
50
55
  end
51
56
 
52
- # Creates a child session, emits the task as a user message, and
53
- # queues background processing. Returns immediately (non-blocking).
57
+ # Creates a child session, runs the analytical brain to assign a nickname,
58
+ # persists the task as a user message, and queues background processing.
59
+ # Returns immediately after brain completes (blocking for ~200ms).
54
60
  #
55
61
  # @param input [Hash<String, Object>] with "task", "expected_output", and optional "tools"
56
- # @return [String] confirmation with child session ID
62
+ # @return [String] confirmation with child session ID and @nickname
57
63
  # @return [Hash{Symbol => String}] with :error key on validation failure
58
64
  def execute(input)
59
65
  task = input["task"].to_s.strip
@@ -68,7 +74,10 @@ module Tools
68
74
  return error if error
69
75
 
70
76
  child = spawn_child(task, expected_output, tools)
71
- "Sub-agent spawned (session #{child.id}). Result will arrive as a tool response."
77
+ nickname = child.name
78
+ "Sub-agent @#{nickname} spawned (session #{child.id}). " \
79
+ "Its messages will appear in your conversation. " \
80
+ "Reply with @#{nickname} to send it instructions."
72
81
  end
73
82
 
74
83
  private
@@ -79,7 +88,9 @@ module Tools
79
88
  prompt: "#{GENERIC_PROMPT}\n#{EXPECTED_DELIVERABLE_PREFIX}#{expected_output}",
80
89
  granted_tools: granted_tools
81
90
  )
82
- Events::Bus.emit(Events::UserMessage.new(content: task, session_id: child.id))
91
+ child.create_user_event(task)
92
+ assign_nickname_via_brain(child)
93
+ child.broadcast_children_update_to_parent
83
94
  AgentRequestJob.perform_later(child.id)
84
95
  child
85
96
  end
@@ -1,12 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Shared prompt fragments for tools that spawn sub-agent sessions.
4
+ # Shared prompt fragments and nickname logic for tools that spawn sub-agent sessions.
5
5
  # Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
6
6
  module SubagentPrompts
7
- RETURN_INSTRUCTION = "Complete the assigned task, then call the return_result tool with your deliverable. " \
8
- "Do not ask follow-up questions work with the context you have."
7
+ COMMUNICATION_INSTRUCTION = "Your text messages are automatically forwarded to the parent agent. " \
8
+ "When you finish, write your final summary and stop no special tool needed. " \
9
+ "If you need clarification, just ask — the parent can reply."
9
10
 
10
11
  EXPECTED_DELIVERABLE_PREFIX = "Expected deliverable: "
12
+
13
+ private
14
+
15
+ # Runs the analytical brain synchronously to assign a nickname.
16
+ # Falls back to a sequential "agent-N" name on any failure.
17
+ def assign_nickname_via_brain(child)
18
+ AnalyticalBrain::Runner.new(child).call
19
+ child.reload
20
+ rescue => error
21
+ Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
22
+ child.update!(name: fallback_nickname)
23
+ end
24
+
25
+ def fallback_nickname
26
+ "agent-#{@session.child_sessions.count}"
27
+ end
11
28
  end
12
29
  end
data/lib/tools/web_get.rb CHANGED
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "certifi"
3
4
  require "httparty"
4
5
 
5
6
  module Tools
6
- # Fetches content from a URL via HTTP GET. Returns the response body
7
- # as plain text, truncated to {Anima::Settings.max_web_response_bytes} to prevent memory issues.
7
+ # Fetches content from a URL via HTTP GET. Returns a structured result with
8
+ # the response body and Content-Type header so that {ToolDecorator} can apply
9
+ # format-specific conversion (HTML → Markdown, JSON → TOON, etc.).
10
+ #
11
+ # The body is truncated to {Anima::Settings.max_web_response_bytes} before
12
+ # decoration to cap memory usage on large responses.
8
13
  #
9
14
  # Only http and https schemes are allowed.
10
15
  class WebGet < Base
@@ -23,8 +28,8 @@ module Tools
23
28
  end
24
29
 
25
30
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
26
- # @return [String] response body (possibly truncated)
27
- # @return [Hash] with :error key on failure
31
+ # @return [Hash] `{body: String, content_type: String}` on success
32
+ # @return [Hash] `{error: String}` on failure
28
33
  def execute(input)
29
34
  validate_and_fetch(input["url"].to_s)
30
35
  end
@@ -39,7 +44,11 @@ module Tools
39
44
  return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
40
45
  end
41
46
 
42
- truncate_body(HTTParty.get(url, timeout: timeout, follow_redirects: false).body.to_s)
47
+ response = HTTParty.get(url, timeout: timeout, follow_redirects: false, ssl_ca_file: Certifi.where)
48
+ body = truncate_body(response.body.to_s)
49
+ content_type = response.content_type || "text/plain"
50
+
51
+ {body: body, content_type: content_type}
43
52
  rescue URI::InvalidURIError => error
44
53
  {error: "Invalid URL: #{error.message}"}
45
54
  rescue Net::OpenTimeout, Net::ReadTimeout
@@ -54,7 +63,7 @@ module Tools
54
63
  max_bytes = Anima::Settings.max_web_response_bytes
55
64
  return body if body.bytesize <= max_bytes
56
65
 
57
- body.byteslice(0, max_bytes) +
66
+ body.byteslice(0, max_bytes).scrub +
58
67
  "\n\n[Truncated: response exceeded #{max_bytes} bytes]"
59
68
  end
60
69
  end