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.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +4 -0
- data/README.md +219 -25
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +76 -28
- data/app/jobs/agent_request_job.rb +24 -0
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/concerns/event/broadcasting.rb +20 -2
- data/app/models/event.rb +1 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +347 -22
- data/config/application.rb +2 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -9
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +4 -0
- data/lib/anima/installer.rb +182 -6
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +8 -7
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +3 -4
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +15 -16
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tui/app.rb +332 -43
- data/lib/tui/message_store.rb +20 -0
- data/lib/tui/screens/chat.rb +207 -20
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +284 -2
- data/.mise.toml +0 -2
data/app/models/session.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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 [
|
|
57
|
-
def
|
|
58
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
#
|
data/config/application.rb
CHANGED
|
@@ -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
|
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
|
-
|
|
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
|
|
90
|
-
#
|
|
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
|
-
|
|
93
|
-
registry.
|
|
94
|
-
|
|
95
|
-
registry.register(
|
|
96
|
-
|
|
97
|
-
|
|
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
|