anima-core 0.3.0 → 1.0.1

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 (270) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +4 -1
  11. data/app/channels/session_channel.rb +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +182 -6
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -2
  270. data/.mise.toml +0 -2
@@ -3,17 +3,31 @@
3
3
  # A conversation session — the fundamental unit of agent interaction.
4
4
  # Owns an ordered stream of {Event} records representing everything
5
5
  # that happened: user messages, agent responses, tool calls, etc.
6
+ #
7
+ # Sessions form a hierarchy: a main session can spawn child sessions
8
+ # (sub-agents) that inherit the parent's viewport context at fork time.
6
9
  class Session < ApplicationRecord
7
- # Claude Sonnet 4 context window minus system prompt reserve.
8
- DEFAULT_TOKEN_BUDGET = 190_000
10
+ class MissingSoulError < StandardError; end
9
11
 
10
12
  VIEW_MODES = %w[basic verbose debug].freeze
11
13
 
14
+ serialize :granted_tools, coder: JSON
15
+
12
16
  has_many :events, -> { order(:id) }, dependent: :destroy
17
+ has_many :goals, dependent: :destroy
18
+
19
+ belongs_to :parent_session, class_name: "Session", optional: true
20
+ has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
13
21
 
14
22
  validates :view_mode, inclusion: {in: VIEW_MODES}
23
+ validates :name, length: {maximum: 255}, allow_nil: true
24
+
25
+ after_update_commit :broadcast_name_update, if: :saved_change_to_name?
26
+ after_update_commit :broadcast_active_skills_update, if: :saved_change_to_active_skills?
27
+ after_update_commit :broadcast_active_workflow_update, if: :saved_change_to_active_workflow?
15
28
 
16
29
  scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
30
+ scope :root_sessions, -> { where(parent_session_id: nil) }
17
31
 
18
32
  # Cycles to the next view mode: basic → verbose → debug → basic.
19
33
  #
@@ -23,39 +37,167 @@ class Session < ApplicationRecord
23
37
  VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
24
38
  end
25
39
 
40
+ # @return [Boolean] true if this session is a sub-agent (has a parent)
41
+ def sub_agent?
42
+ parent_session_id.present?
43
+ end
44
+
45
+ # Enqueues the analytical brain to perform background maintenance on
46
+ # this session. Currently handles session naming; future phases add
47
+ # skill activation, goal tracking, and memory.
48
+ #
49
+ # Runs after the first exchange and periodically as the conversation
50
+ # evolves, so the name stays relevant to the current topic.
51
+ #
52
+ # @return [void]
53
+ def schedule_analytical_brain!
54
+ return if sub_agent?
55
+
56
+ count = events.llm_messages.count
57
+ return if count < 2
58
+ # Already named — only regenerate at interval boundaries (30, 60, 90, …)
59
+ return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
60
+
61
+ AnalyticalBrainJob.perform_later(id)
62
+ end
63
+
26
64
  # Returns the events currently visible in the LLM context window.
27
65
  # Walks events newest-first and includes them until the token budget
28
66
  # is exhausted. Events are full-size or excluded entirely.
29
67
  #
68
+ # Sub-agent sessions inherit parent context via virtual viewport:
69
+ # child events are prioritized and fill the budget first (newest-first),
70
+ # then parent events from before the fork point fill the remaining budget.
71
+ # The final array is chronological: parent events first, then child events.
72
+ #
30
73
  # @param token_budget [Integer] maximum tokens to include (positive)
31
74
  # @param include_pending [Boolean] whether to include pending messages (true for
32
75
  # display, false for LLM context assembly)
33
76
  # @return [Array<Event>] chronologically ordered
34
- def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET, include_pending: true)
35
- scope = events.context_events
36
- scope = scope.deliverable unless include_pending
77
+ def viewport_events(token_budget: Anima::Settings.token_budget, include_pending: true)
78
+ own_events = select_events(own_event_scope(include_pending), budget: token_budget)
79
+ remaining = token_budget - own_events.sum { |e| event_token_cost(e) }
37
80
 
38
- selected = []
39
- remaining = token_budget
81
+ if sub_agent? && remaining > 0
82
+ parent_events = select_events(parent_event_scope(include_pending), budget: remaining)
83
+ trim_trailing_tool_calls(parent_events) + own_events
84
+ else
85
+ own_events
86
+ end
87
+ end
40
88
 
41
- scope.reorder(id: :desc).each do |event|
42
- cost = (event.token_count > 0) ? event.token_count : estimate_tokens(event)
43
- break if cost > remaining && selected.any?
89
+ # Recalculates the viewport and returns IDs of events evicted since the
90
+ # last snapshot. Updates the stored viewport_event_ids atomically.
91
+ # Piggybacks on event broadcasts to notify clients which messages left
92
+ # the LLM's context window.
93
+ #
94
+ # @return [Array<Integer>] IDs of events no longer in the viewport
95
+ def recalculate_viewport!
96
+ new_ids = viewport_events.map(&:id)
97
+ old_ids = viewport_event_ids
44
98
 
45
- selected << event
46
- remaining -= cost
47
- end
99
+ evicted = old_ids - new_ids
100
+ update_column(:viewport_event_ids, new_ids) if old_ids != new_ids
101
+ evicted
102
+ end
48
103
 
49
- selected.reverse
104
+ # Overwrites the viewport snapshot without computing evictions.
105
+ # Used when transmitting or broadcasting a full viewport refresh,
106
+ # where eviction notifications are unnecessary (clients clear their
107
+ # store first).
108
+ #
109
+ # @param ids [Array<Integer>] event IDs now in the viewport
110
+ # @return [void]
111
+ def snapshot_viewport!(ids)
112
+ update_column(:viewport_event_ids, ids)
113
+ end
114
+
115
+ # Returns the system prompt for this session.
116
+ # Sub-agent sessions use their stored prompt. Main sessions assemble
117
+ # a system prompt from active skills and current goals.
118
+ #
119
+ # @param environment_context [String, nil] pre-assembled environment block
120
+ # from {EnvironmentProbe}; injected between soul and expertise sections
121
+ # @return [String, nil] the system prompt text, or nil when nothing to inject
122
+ def system_prompt(environment_context: nil)
123
+ sub_agent? ? prompt : assemble_system_prompt(environment_context: environment_context)
124
+ end
125
+
126
+ # Activates a skill on this session. Validates the skill exists in the
127
+ # registry, adds it to active_skills, and persists.
128
+ #
129
+ # @param skill_name [String] name of the skill to activate
130
+ # @return [Skills::Definition] the activated skill
131
+ # @raise [Skills::InvalidDefinitionError] if skill not found in registry
132
+ # @raise [ActiveRecord::RecordInvalid] if save fails
133
+ def activate_skill(skill_name)
134
+ definition = Skills::Registry.instance.find(skill_name)
135
+ raise Skills::InvalidDefinitionError, "Unknown skill: #{skill_name}" unless definition
136
+
137
+ return definition if active_skills.include?(skill_name)
138
+
139
+ self.active_skills = active_skills + [skill_name]
140
+ save!
141
+ definition
142
+ end
143
+
144
+ # Deactivates a skill on this session. Removes it from active_skills and persists.
145
+ #
146
+ # @param skill_name [String] name of the skill to deactivate
147
+ # @return [void]
148
+ def deactivate_skill(skill_name)
149
+ return unless active_skills.include?(skill_name)
150
+
151
+ self.active_skills = active_skills - [skill_name]
152
+ save!
153
+ end
154
+
155
+ # Activates a workflow on this session. Validates the workflow exists in the
156
+ # registry, sets it as the active workflow, and persists. Only one workflow
157
+ # can be active at a time — activating a new one replaces the previous.
158
+ #
159
+ # @param workflow_name [String] name of the workflow to activate
160
+ # @return [Workflows::Definition] the activated workflow
161
+ # @raise [Workflows::InvalidDefinitionError] if workflow not found in registry
162
+ # @raise [ActiveRecord::RecordInvalid] if save fails
163
+ def activate_workflow(workflow_name)
164
+ definition = Workflows::Registry.instance.find(workflow_name)
165
+ raise Workflows::InvalidDefinitionError, "Unknown workflow: #{workflow_name}" unless definition
166
+
167
+ return definition if active_workflow == workflow_name
168
+
169
+ self.active_workflow = workflow_name
170
+ save!
171
+ definition
172
+ end
173
+
174
+ # Deactivates the current workflow on this session.
175
+ #
176
+ # @return [void]
177
+ def deactivate_workflow
178
+ return unless active_workflow.present?
179
+
180
+ self.active_workflow = nil
181
+ save!
182
+ end
183
+
184
+ # Assembles the system prompt: soul first, then environment context,
185
+ # then skills/workflow, then goals.
186
+ # The soul is always present — "who am I" before "what can I do."
187
+ #
188
+ # @param environment_context [String, nil] pre-assembled environment block
189
+ # @return [String] composed system prompt
190
+ def assemble_system_prompt(environment_context: nil)
191
+ [assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
50
192
  end
51
193
 
52
- # Returns the assembled system prompt for this session.
53
- # The system prompt includes system instructions, goals, and memories.
54
- # Currently a placeholder — these subsystems are not yet implemented.
194
+ # Serializes active goals as a lightweight summary for ActionCable
195
+ # broadcasts and TUI display. Returns a nested structure: root goals
196
+ # with their sub-goals inlined.
55
197
  #
56
- # @return [String, nil] the system prompt text, or nil if not configured
57
- def system_prompt
58
- nil
198
+ # @return [Array<Hash>] each with :id, :description, :status, and :sub_goals
199
+ def goals_summary
200
+ goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
59
201
  end
60
202
 
61
203
  # Builds the message array expected by the Anthropic Messages API.
@@ -67,7 +209,7 @@ class Session < ApplicationRecord
67
209
  #
68
210
  # @param token_budget [Integer] maximum tokens to include (positive)
69
211
  # @return [Array<Hash>] Anthropic Messages API format
70
- def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
212
+ def messages_for_llm(token_budget: Anima::Settings.token_budget)
71
213
  assemble_messages(viewport_events(token_budget: token_budget, include_pending: false))
72
214
  end
73
215
 
@@ -87,7 +229,175 @@ class Session < ApplicationRecord
87
229
 
88
230
  private
89
231
 
232
+ # Reads the soul file — the agent's self-authored identity.
233
+ # Loaded as the first section of every system prompt, before skills,
234
+ # workflows, and goals.
235
+ #
236
+ # @return [String] soul content
237
+ # @raise [MissingSoulError] when the soul file does not exist
238
+ def assemble_soul_section
239
+ path = Anima::Settings.soul_path
240
+ unless File.exist?(path)
241
+ raise MissingSoulError, "Soul file not found: #{path}. Run `anima install` to create it."
242
+ end
243
+
244
+ File.read(path).strip
245
+ end
246
+
247
+ # Assembles the expertise section of the system prompt from active skills
248
+ # and the active workflow. Both are injected into the same "Your Expertise"
249
+ # section — the main agent treats them identically as domain knowledge.
250
+ #
251
+ # @return [String, nil] expertise section, or nil when nothing is active
252
+ def assemble_expertise_section
253
+ sections = active_skills.filter_map do |skill_name|
254
+ definition = Skills::Registry.instance.find(skill_name)
255
+ format_expertise_section(definition, skill_name)
256
+ end
257
+
258
+ if active_workflow.present?
259
+ definition = Workflows::Registry.instance.find(active_workflow)
260
+ sections << format_expertise_section(definition, active_workflow) if definition
261
+ end
262
+
263
+ return if sections.empty?
264
+
265
+ "## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
266
+ end
267
+
268
+ # Assembles the goals section of the system prompt.
269
+ # Active root goals render as `###` headings with sub-goal checkboxes.
270
+ # Completed root goals collapse to a single strikethrough line.
271
+ #
272
+ # @return [String, nil] goals section, or nil when no goals exist
273
+ def assemble_goals_section
274
+ root_goals = goals.root.includes(:sub_goals).order(:created_at)
275
+ return if root_goals.empty?
276
+
277
+ entries = root_goals.map { |goal| render_goal_markdown(goal) }
278
+ "## Current Goals\n\n#{entries.join("\n\n")}"
279
+ end
280
+
281
+ # Renders a single root goal with its sub-goals as Markdown.
282
+ # Active goals show full hierarchy; completed goals collapse to one line.
283
+ #
284
+ # @param goal [Goal] a root goal
285
+ # @return [String] Markdown fragment
286
+ def render_goal_markdown(goal)
287
+ description = goal.description
288
+ return "### ~~#{description}~~ ✓" if goal.completed?
289
+
290
+ lines = ["### #{description}"]
291
+ goal.sub_goals.each do |sub|
292
+ checkbox = sub.completed? ? "[x]" : "[ ]"
293
+ lines << "- #{checkbox} #{sub.description}"
294
+ end
295
+ lines.join("\n")
296
+ end
297
+
298
+ # Formats a definition (skill or workflow) as a Markdown section for the
299
+ # expertise prompt. Extracts the first Markdown heading from content for
300
+ # the section title; falls back to the definition name when content has
301
+ # no heading.
302
+ #
303
+ # @param definition [Skills::Definition, Workflows::Definition, nil] the definition to format
304
+ # @param fallback_name [String] name to use if content has no heading
305
+ # @return [String, nil] formatted section, or nil if definition is nil
306
+ def format_expertise_section(definition, fallback_name)
307
+ return unless definition
308
+
309
+ content = definition.content
310
+ heading = content.lines.first&.sub(/^#+ /, "")&.strip || fallback_name
311
+ "### #{heading}\n\n#{content}"
312
+ end
313
+
314
+ # Broadcasts a name change to all clients subscribed to this session.
315
+ # Triggered by after_update_commit so clients see name updates in real time.
316
+ #
317
+ # @return [void]
318
+ def broadcast_name_update
319
+ ActionCable.server.broadcast("session_#{id}", {
320
+ "action" => "session_name_updated",
321
+ "session_id" => id,
322
+ "name" => name
323
+ })
324
+ end
325
+
326
+ # Broadcasts active skill changes to all clients subscribed to this session.
327
+ # Triggered by after_update_commit so the TUI info panel updates reactively.
328
+ #
329
+ # @return [void]
330
+ def broadcast_active_skills_update
331
+ ActionCable.server.broadcast("session_#{id}", {
332
+ "action" => "active_skills_updated",
333
+ "session_id" => id,
334
+ "active_skills" => active_skills
335
+ })
336
+ end
337
+
338
+ # Broadcasts active workflow change to all clients subscribed to this session.
339
+ # Triggered by after_update_commit so the TUI info panel updates reactively.
340
+ #
341
+ # @return [void]
342
+ def broadcast_active_workflow_update
343
+ ActionCable.server.broadcast("session_#{id}", {
344
+ "action" => "active_workflow_updated",
345
+ "session_id" => id,
346
+ "active_workflow" => active_workflow
347
+ })
348
+ end
349
+
350
+ # Scopes own events for viewport assembly.
351
+ # @return [ActiveRecord::Relation]
352
+ def own_event_scope(include_pending)
353
+ scope = events.context_events
354
+ include_pending ? scope : scope.deliverable
355
+ end
356
+
357
+ # Scopes parent events created before this session's fork point.
358
+ # @return [ActiveRecord::Relation]
359
+ def parent_event_scope(include_pending)
360
+ scope = parent_session.events.context_events.where(created_at: ...created_at)
361
+ include_pending ? scope : scope.deliverable
362
+ end
363
+
364
+ # Walks events newest-first, selecting until the token budget is exhausted.
365
+ # Always includes at least the newest event even if it exceeds budget.
366
+ #
367
+ # @param scope [ActiveRecord::Relation] event scope to select from
368
+ # @param budget [Integer] maximum tokens to include
369
+ # @return [Array<Event>] chronologically ordered
370
+ def select_events(scope, budget:)
371
+ selected = []
372
+ remaining = budget
373
+
374
+ scope.reorder(id: :desc).each do |event|
375
+ cost = event_token_cost(event)
376
+ break if cost > remaining && selected.any?
377
+
378
+ selected << event
379
+ remaining -= cost
380
+ end
381
+
382
+ selected.reverse
383
+ end
384
+
385
+ # @return [Integer] token cost, using cached count or heuristic estimate
386
+ def event_token_cost(event)
387
+ (event.token_count > 0) ? event.token_count : estimate_tokens(event)
388
+ end
389
+
390
+ # Removes trailing tool_call events that lack matching tool_response.
391
+ # Prevents orphaned tool_use blocks at the parent/child viewport boundary
392
+ # (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
393
+ # but its tool_response comes after — so the cutoff can split them).
394
+ def trim_trailing_tool_calls(event_list)
395
+ event_list.pop while event_list.last&.event_type == "tool_call"
396
+ event_list
397
+ end
398
+
90
399
  # Converts a chronological list of events into Anthropic wire-format messages.
400
+ # Prepends a compact timestamp to each user message for LLM time awareness.
91
401
  # Groups consecutive tool_call events into one assistant message and
92
402
  # consecutive tool_response events into one user message.
93
403
  #
@@ -97,13 +407,17 @@ class Session < ApplicationRecord
97
407
  events.each_with_object([]) do |event, messages|
98
408
  case event.event_type
99
409
  when "user_message"
100
- messages << {role: "user", content: event.payload["content"].to_s}
410
+ content = "#{format_event_time(event.timestamp)}\n#{event.payload["content"]}"
411
+ messages << {role: "user", content: content}
101
412
  when "agent_message"
102
413
  messages << {role: "assistant", content: event.payload["content"].to_s}
103
414
  when "tool_call"
104
415
  append_grouped_block(messages, "assistant", tool_use_block(event.payload))
105
416
  when "tool_response"
106
417
  append_grouped_block(messages, "user", tool_result_block(event.payload))
418
+ when "system_message"
419
+ # Wrapped as user role with prefix — Claude API has no system role in conversation history
420
+ messages << {role: "user", content: "[system] #{event.payload["content"]}"}
107
421
  end
108
422
  end
109
423
  end
@@ -135,6 +449,17 @@ class Session < ApplicationRecord
135
449
  }
136
450
  end
137
451
 
452
+ # Formats an event's nanosecond timestamp as a compact time prefix for LLM context.
453
+ # Gives the agent awareness of time of day, day of week, and pauses between messages.
454
+ #
455
+ # @param timestamp_ns [Integer] nanoseconds since epoch
456
+ # @return [String] e.g. "Sat Mar 14 09:51"
457
+ # @example
458
+ # format_event_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
459
+ def format_event_time(timestamp_ns)
460
+ Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
461
+ end
462
+
138
463
  # Delegates to {Event#estimate_tokens} for events not yet counted
139
464
  # by the background job.
140
465
  #
@@ -14,6 +14,8 @@ require "solid_queue"
14
14
 
15
15
  Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
16
16
 
17
+ require_relative "../lib/anima"
18
+
17
19
  module Anima
18
20
  class Application < Rails::Application
19
21
  config.load_defaults 8.1
@@ -0,0 +1,6 @@
1
+ class AddSubagentSupportToSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_reference :sessions, :parent_session, foreign_key: {to_table: :sessions}, null: true
4
+ add_column :sessions, :prompt, :text
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddGrantedToolsToSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :sessions, :granted_tools, :text
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddNameToSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :sessions, :name, :string
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddViewportEventIdsToSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :sessions, :viewport_event_ids, :json, default: [], null: false
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddActiveSkillsToSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :sessions, :active_skills, :json, default: [], null: false
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateGoals < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :goals do |t|
6
+ t.references :session, null: false, foreign_key: true
7
+ t.references :parent_goal, foreign_key: {to_table: :goals}, null: true
8
+ t.text :description, null: false
9
+ t.string :status, default: "active", null: false
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :goals, [:session_id, :status]
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ class AddCompletedAtToGoals < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :goals, :completed_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddActiveWorkflowToSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :sessions, :active_workflow, :string
4
+ end
5
+ end
data/lib/agent_loop.rb CHANGED
@@ -73,7 +73,15 @@ class AgentLoop
73
73
  @registry ||= build_tool_registry
74
74
 
75
75
  messages = @session.messages_for_llm
76
- response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id)
76
+ options = {}
77
+
78
+ unless @session.sub_agent?
79
+ env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
80
+ end
81
+ prompt = @session.system_prompt(environment_context: env_context)
82
+ options[:system] = prompt if prompt
83
+
84
+ response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
77
85
  Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
78
86
  response
79
87
  end
@@ -84,17 +92,65 @@ class AgentLoop
84
92
  @shell_session&.finalize
85
93
  end
86
94
 
95
+ # Tool classes available to all sessions by default.
96
+ # @return [Array<Class<Tools::Base>>]
97
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet].freeze
98
+
99
+ # Name-to-class mapping for tool restriction validation and registry building.
100
+ # @return [Hash{String => Class<Tools::Base>}]
101
+ STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
102
+
87
103
  private
88
104
 
89
- # Builds the default tool registry with all available tools.
90
- # @return [Tools::Registry] registry with all available tools
105
+ # Builds the tool registry appropriate for this session type.
106
+ # Main sessions get standard tools + spawn_subagent + spawn_specialist.
107
+ # Sub-agent sessions get granted standard tools + return_result (no spawning).
108
+ # Sub-agents cannot spawn further sub-agents (no recursive nesting).
109
+ # When {Session#granted_tools} is nil, all standard tools are granted.
110
+ # MCP tools from configured servers are registered for all session types.
111
+ #
112
+ # @return [Tools::Registry] registry with available tools
91
113
  def build_tool_registry
92
- registry = Tools::Registry.new(context: {shell_session: @shell_session})
93
- registry.register(Tools::Bash)
94
- registry.register(Tools::Read)
95
- registry.register(Tools::Write)
96
- registry.register(Tools::Edit)
97
- registry.register(Tools::WebGet)
114
+ context = {shell_session: @shell_session, session: @session}
115
+ registry = Tools::Registry.new(context: context)
116
+
117
+ granted_standard_tools.each { |tool| registry.register(tool) }
118
+
119
+ if @session.sub_agent?
120
+ registry.register(Tools::ReturnResult)
121
+ else
122
+ registry.register(Tools::SpawnSubagent)
123
+ registry.register(Tools::SpawnSpecialist)
124
+ registry.register(Tools::RequestFeature)
125
+ end
126
+
127
+ register_mcp_tools(registry)
128
+
98
129
  registry
99
130
  end
131
+
132
+ # Loads tools from configured MCP servers and adds them to the registry.
133
+ # Warnings are emitted as system messages — visible to both the user
134
+ # (in verbose mode) and the LLM (via CONTEXT_TYPES) so the agent can
135
+ # explain config issues instead of guessing.
136
+ #
137
+ # @param registry [Tools::Registry] the registry to add MCP tools to
138
+ # @return [void]
139
+ def register_mcp_tools(registry)
140
+ warnings = Mcp::ClientManager.new.register_tools(registry)
141
+ warnings.each do |message|
142
+ Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: @session.id))
143
+ end
144
+ end
145
+
146
+ # Standard tools available to this session.
147
+ # Returns all when {Session#granted_tools} is nil (no restriction).
148
+ # Returns only matching tools when granted_tools is an array.
149
+ #
150
+ # @return [Array<Class<Tools::Base>>] tool classes to register
151
+ def granted_standard_tools
152
+ return STANDARD_TOOLS unless @session.granted_tools
153
+
154
+ @session.granted_tools.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
155
+ end
100
156
  end