anima-core 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- 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 +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -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 -6
- 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 +5 -40
- data/lib/anima/installer.rb +168 -0
- 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/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -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 +11 -20
- 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 +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- 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/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- 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 +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -59
data/lib/tui/screens/chat.rb
CHANGED
|
@@ -16,6 +16,7 @@ module TUI
|
|
|
16
16
|
MOUSE_SCROLL_STEP = 2
|
|
17
17
|
|
|
18
18
|
TOOL_ICON = "\u{1F527}"
|
|
19
|
+
CLOCK_ICON = "\u{1F552}"
|
|
19
20
|
CHECKMARK = "\u2713"
|
|
20
21
|
RETURN_ARROW = "\u21A9"
|
|
21
22
|
ERROR_ICON = "\u274C"
|
|
@@ -26,7 +27,9 @@ module TUI
|
|
|
26
27
|
# independent of Rails. Must stay in sync when adding new modes.
|
|
27
28
|
VIEW_MODES = %w[basic verbose debug].freeze
|
|
28
29
|
|
|
29
|
-
attr_reader :message_store, :scroll_offset, :session_info, :view_mode
|
|
30
|
+
attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
|
|
31
|
+
:authentication_required, :token_save_result, :parent_session_id,
|
|
32
|
+
:chat_focused
|
|
30
33
|
|
|
31
34
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
32
35
|
# @param message_store [TUI::MessageStore, nil] injectable for testing
|
|
@@ -41,7 +44,15 @@ module TUI
|
|
|
41
44
|
@max_scroll = 0
|
|
42
45
|
@input_scroll_offset = 0
|
|
43
46
|
@view_mode = "basic"
|
|
44
|
-
@session_info = {id: cable_client.session_id, message_count: 0}
|
|
47
|
+
@session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: []}
|
|
48
|
+
@sessions_list = nil
|
|
49
|
+
@parent_session_id = nil
|
|
50
|
+
@authentication_required = false
|
|
51
|
+
@token_save_result = nil
|
|
52
|
+
@chat_focused = false
|
|
53
|
+
@input_history = []
|
|
54
|
+
@history_index = nil
|
|
55
|
+
@saved_input = nil
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
def messages
|
|
@@ -76,23 +87,40 @@ module TUI
|
|
|
76
87
|
render_input(frame, input_area, tui)
|
|
77
88
|
end
|
|
78
89
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
90
|
+
# Dispatches keyboard, mouse, and paste events. Supports two focus
|
|
91
|
+
# modes: input mode (default) where arrows navigate the input buffer
|
|
92
|
+
# with bash-style history overflow, and chat-focused mode where arrows
|
|
93
|
+
# scroll the chat pane.
|
|
94
|
+
#
|
|
95
|
+
# Page Up/Down and mouse scroll always control the chat pane
|
|
96
|
+
# regardless of focus mode.
|
|
81
97
|
def handle_event(event)
|
|
82
98
|
return handle_mouse_event(event) if event.mouse?
|
|
83
99
|
return handle_paste_event(event) if event.paste?
|
|
84
100
|
return handle_scroll_key(event) if event.page_up? || event.page_down?
|
|
85
|
-
return handle_scroll_key(event) if event.up? || event.down?
|
|
86
101
|
|
|
87
|
-
return
|
|
102
|
+
return handle_chat_focused_event(event) if @chat_focused
|
|
103
|
+
|
|
104
|
+
if event.up?
|
|
105
|
+
return true if @input_buffer.move_up
|
|
106
|
+
return true if @input_buffer.text.empty? && recall_pending_message
|
|
107
|
+
return navigate_history_back
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if event.down?
|
|
111
|
+
return true if @input_buffer.move_down
|
|
112
|
+
return navigate_history_forward
|
|
113
|
+
end
|
|
88
114
|
|
|
89
115
|
if event.enter?
|
|
90
116
|
submit_message
|
|
91
117
|
true
|
|
92
118
|
elsif event.backspace?
|
|
119
|
+
reset_history_browsing
|
|
93
120
|
@input_buffer.backspace
|
|
94
121
|
true
|
|
95
122
|
elsif event.delete?
|
|
123
|
+
reset_history_browsing
|
|
96
124
|
@input_buffer.delete
|
|
97
125
|
true
|
|
98
126
|
elsif event.left?
|
|
@@ -104,6 +132,7 @@ module TUI
|
|
|
104
132
|
elsif event.end?
|
|
105
133
|
@input_buffer.move_end
|
|
106
134
|
elsif printable_char?(event) && !@input_buffer.full?
|
|
135
|
+
reset_history_browsing
|
|
107
136
|
@input_buffer.insert(event.code)
|
|
108
137
|
else
|
|
109
138
|
false
|
|
@@ -118,13 +147,36 @@ module TUI
|
|
|
118
147
|
@cable_client.create_session
|
|
119
148
|
end
|
|
120
149
|
|
|
121
|
-
#
|
|
150
|
+
# Switches to an existing session through the WebSocket protocol.
|
|
151
|
+
# The brain switches the channel stream and sends a session_changed
|
|
152
|
+
# signal followed by chat history.
|
|
153
|
+
#
|
|
154
|
+
# @param session_id [Integer] target session to switch to
|
|
155
|
+
def switch_session(session_id)
|
|
156
|
+
@cable_client.switch_session(session_id)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Sends an explicit view mode switch command to the server.
|
|
122
160
|
# The server broadcasts the mode change and re-transmits the viewport
|
|
123
161
|
# decorated in the new mode to all connected clients.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
@cable_client.change_view_mode(
|
|
162
|
+
#
|
|
163
|
+
# @param mode [String] target view mode ("basic", "verbose", or "debug")
|
|
164
|
+
def switch_view_mode(mode)
|
|
165
|
+
@cable_client.change_view_mode(mode)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Clears the authentication_required flag after the App has consumed it.
|
|
169
|
+
# @return [void]
|
|
170
|
+
def clear_authentication_required
|
|
171
|
+
@authentication_required = false
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Returns and clears the token save result for one-shot consumption by the App.
|
|
175
|
+
# @return [Hash, nil] {success: true} or {success: false, message: "..."}, or nil
|
|
176
|
+
def consume_token_save_result
|
|
177
|
+
result = @token_save_result
|
|
178
|
+
@token_save_result = nil
|
|
179
|
+
result
|
|
128
180
|
end
|
|
129
181
|
|
|
130
182
|
def finalize
|
|
@@ -134,6 +186,18 @@ module TUI
|
|
|
134
186
|
@loading
|
|
135
187
|
end
|
|
136
188
|
|
|
189
|
+
# Switches focus to the chat pane for keyboard scrolling.
|
|
190
|
+
# @return [void]
|
|
191
|
+
def focus_chat
|
|
192
|
+
@chat_focused = true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns focus from the chat pane to the input field.
|
|
196
|
+
# @return [void]
|
|
197
|
+
def unfocus_chat
|
|
198
|
+
@chat_focused = false
|
|
199
|
+
end
|
|
200
|
+
|
|
137
201
|
private
|
|
138
202
|
|
|
139
203
|
# Drains the WebSocket message queue and feeds events to the message store
|
|
@@ -149,8 +213,25 @@ module TUI
|
|
|
149
213
|
handle_view_mode_changed(msg)
|
|
150
214
|
when "view_mode"
|
|
151
215
|
@view_mode = msg["view_mode"] if msg["view_mode"]
|
|
216
|
+
when "session_name_updated"
|
|
217
|
+
handle_session_name_updated(msg)
|
|
218
|
+
when "active_skills_updated"
|
|
219
|
+
handle_active_skills_updated(msg)
|
|
220
|
+
when "active_workflow_updated"
|
|
221
|
+
handle_active_workflow_updated(msg)
|
|
222
|
+
when "goals_updated"
|
|
223
|
+
handle_goals_updated(msg)
|
|
152
224
|
when "sessions_list"
|
|
153
225
|
@sessions_list = msg["sessions"]
|
|
226
|
+
when "user_message_recalled"
|
|
227
|
+
@message_store.remove_by_id(msg["event_id"]) if msg["event_id"]
|
|
228
|
+
when "authentication_required"
|
|
229
|
+
@authentication_required = true
|
|
230
|
+
when "token_saved"
|
|
231
|
+
@authentication_required = false
|
|
232
|
+
@token_save_result = {success: true}
|
|
233
|
+
when "token_error"
|
|
234
|
+
@token_save_result = {success: false, message: msg["message"]}
|
|
154
235
|
when "error"
|
|
155
236
|
# Silently ignored — no user-facing error display yet
|
|
156
237
|
else
|
|
@@ -159,25 +240,45 @@ module TUI
|
|
|
159
240
|
handle_connection_status(msg)
|
|
160
241
|
when "user_message"
|
|
161
242
|
@message_store.process_event(msg)
|
|
162
|
-
|
|
163
|
-
|
|
243
|
+
unless action == "update"
|
|
244
|
+
@session_info[:message_count] += 1
|
|
245
|
+
@loading = true
|
|
246
|
+
end
|
|
164
247
|
when "agent_message"
|
|
165
248
|
@message_store.process_event(msg)
|
|
166
|
-
|
|
167
|
-
|
|
249
|
+
unless action == "update"
|
|
250
|
+
@session_info[:message_count] += 1
|
|
251
|
+
@loading = false
|
|
252
|
+
end
|
|
168
253
|
else # tool_call, tool_response, and other event types
|
|
169
254
|
@message_store.process_event(msg)
|
|
170
255
|
end
|
|
171
256
|
end
|
|
257
|
+
|
|
258
|
+
handle_viewport_evictions(msg)
|
|
172
259
|
end
|
|
173
260
|
end
|
|
174
261
|
|
|
262
|
+
# Removes messages that left the LLM's context window. Event broadcasts
|
|
263
|
+
# include `evicted_event_ids` when old events are pushed out of the
|
|
264
|
+
# viewport by new ones.
|
|
265
|
+
#
|
|
266
|
+
# @param msg [Hash] incoming WebSocket message
|
|
267
|
+
def handle_viewport_evictions(msg)
|
|
268
|
+
evicted_ids = msg["evicted_event_ids"]
|
|
269
|
+
return unless evicted_ids.is_a?(Array) && evicted_ids.any?
|
|
270
|
+
|
|
271
|
+
@message_store.remove_by_ids(evicted_ids)
|
|
272
|
+
end
|
|
273
|
+
|
|
175
274
|
# Reacts to connection lifecycle changes from the WebSocket client.
|
|
176
|
-
# Clears stale state
|
|
177
|
-
#
|
|
275
|
+
# Clears stale state when subscription begins so the store is empty
|
|
276
|
+
# before history arrives. Action Cable sends confirm_subscription
|
|
277
|
+
# AFTER transmit calls in the subscribed callback, so clearing on
|
|
278
|
+
# "subscribed" would wipe history that already arrived.
|
|
178
279
|
def handle_connection_status(msg)
|
|
179
280
|
case msg["status"]
|
|
180
|
-
when "
|
|
281
|
+
when "subscribing"
|
|
181
282
|
@message_store.clear
|
|
182
283
|
@loading = false
|
|
183
284
|
@session_info[:message_count] = 0
|
|
@@ -191,12 +292,49 @@ module TUI
|
|
|
191
292
|
@cable_client.update_session_id(new_id)
|
|
192
293
|
@message_store.clear
|
|
193
294
|
@view_mode = msg["view_mode"] if msg["view_mode"]
|
|
194
|
-
@session_info = {id: new_id, message_count: msg["message_count"] || 0
|
|
295
|
+
@session_info = {id: new_id, name: msg["name"], message_count: msg["message_count"] || 0,
|
|
296
|
+
active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
|
|
297
|
+
goals: msg["goals"] || []}
|
|
298
|
+
@parent_session_id = msg["parent_session_id"]
|
|
195
299
|
@input_buffer.clear
|
|
196
300
|
@loading = false
|
|
197
301
|
@scroll_offset = 0
|
|
198
302
|
@auto_scroll = true
|
|
199
303
|
@input_scroll_offset = 0
|
|
304
|
+
@chat_focused = false
|
|
305
|
+
reset_history_browsing
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Updates the session name when a background job generates one.
|
|
309
|
+
# Only applies to the current session.
|
|
310
|
+
def handle_session_name_updated(msg)
|
|
311
|
+
return unless msg["session_id"] == @session_info[:id]
|
|
312
|
+
|
|
313
|
+
@session_info[:name] = msg["name"]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Updates the active skills list when the analytical brain activates or
|
|
317
|
+
# deactivates skills. Only applies to the current session.
|
|
318
|
+
def handle_active_skills_updated(msg)
|
|
319
|
+
return unless msg["session_id"] == @session_info[:id]
|
|
320
|
+
|
|
321
|
+
@session_info[:active_skills] = msg["active_skills"] || []
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Updates the active workflow when the analytical brain activates or
|
|
325
|
+
# deactivates a workflow. Only applies to the current session.
|
|
326
|
+
def handle_active_workflow_updated(msg)
|
|
327
|
+
return unless msg["session_id"] == @session_info[:id]
|
|
328
|
+
|
|
329
|
+
@session_info[:active_workflow] = msg["active_workflow"]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Updates the goals list when the analytical brain creates or
|
|
333
|
+
# completes goals. Only applies to the current session.
|
|
334
|
+
def handle_goals_updated(msg)
|
|
335
|
+
return unless msg["session_id"] == @session_info[:id]
|
|
336
|
+
|
|
337
|
+
@session_info[:goals] = msg["goals"] || []
|
|
200
338
|
end
|
|
201
339
|
|
|
202
340
|
# Handles server broadcast of view mode change. Clears the message store
|
|
@@ -230,25 +368,26 @@ module TUI
|
|
|
230
368
|
inner_width = [area.width - 2, 1].max
|
|
231
369
|
@visible_height = [area.height - 2, 0].max
|
|
232
370
|
|
|
233
|
-
|
|
234
|
-
content_height =
|
|
371
|
+
base_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
|
|
372
|
+
content_height = base_widget.line_count(inner_width)
|
|
235
373
|
|
|
236
374
|
@max_scroll = [content_height - @visible_height, 0].max
|
|
237
375
|
@scroll_offset = @max_scroll if @auto_scroll
|
|
238
376
|
@scroll_offset = @scroll_offset.clamp(0, @max_scroll)
|
|
239
377
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
378
|
+
chat_block = {
|
|
379
|
+
title: "Chat",
|
|
380
|
+
borders: [:all],
|
|
381
|
+
border_type: :rounded,
|
|
382
|
+
border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
|
|
383
|
+
}
|
|
384
|
+
if @chat_focused
|
|
385
|
+
chat_block[:titles] = [
|
|
386
|
+
{content: "\u2191\u2193 scroll Esc return", position: :bottom, alignment: :center}
|
|
387
|
+
]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
widget = base_widget.with(scroll: [@scroll_offset, 0], block: tui.block(**chat_block))
|
|
252
391
|
frame.render_widget(widget, area)
|
|
253
392
|
|
|
254
393
|
return unless @max_scroll > 0
|
|
@@ -325,14 +464,18 @@ module TUI
|
|
|
325
464
|
end
|
|
326
465
|
|
|
327
466
|
# Renders a user or assistant message with optional timestamp and token count.
|
|
467
|
+
# Pending messages are dimmed with a clock icon to indicate they haven't
|
|
468
|
+
# been sent to the LLM yet.
|
|
328
469
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
329
470
|
# @param data [Hash] structured data with "role", "content", and optional
|
|
330
|
-
# "timestamp", "tokens", "estimated"
|
|
471
|
+
# "timestamp", "tokens", "estimated", "status"
|
|
331
472
|
# @param role [String] "user" or "assistant"
|
|
332
473
|
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
333
474
|
def render_conversation_entry(tui, data, role)
|
|
334
|
-
|
|
475
|
+
pending = data["status"] == "pending"
|
|
476
|
+
color = pending ? "dark_gray" : ROLE_COLORS.fetch(role, "white")
|
|
335
477
|
prefix = ROLE_LABELS.fetch(role, role)
|
|
478
|
+
prefix = "#{CLOCK_ICON} #{prefix}" if pending
|
|
336
479
|
style = tui.style(fg: color)
|
|
337
480
|
|
|
338
481
|
meta = []
|
|
@@ -464,7 +607,7 @@ module TUI
|
|
|
464
607
|
end
|
|
465
608
|
|
|
466
609
|
def render_input(frame, area, tui)
|
|
467
|
-
disabled =
|
|
610
|
+
disabled = !connected?
|
|
468
611
|
styles = input_styles(tui, disabled)
|
|
469
612
|
|
|
470
613
|
title = input_title
|
|
@@ -490,7 +633,7 @@ module TUI
|
|
|
490
633
|
)
|
|
491
634
|
frame.render_widget(widget, area)
|
|
492
635
|
|
|
493
|
-
return if disabled
|
|
636
|
+
return if disabled || @chat_focused
|
|
494
637
|
|
|
495
638
|
cursor_x = area.x + 1 + @cursor_visual_col
|
|
496
639
|
cursor_y = area.y + 1 + @cursor_visual_row - input_scroll
|
|
@@ -501,16 +644,20 @@ module TUI
|
|
|
501
644
|
end
|
|
502
645
|
|
|
503
646
|
def input_styles(tui, disabled)
|
|
647
|
+
border_color = if disabled || @chat_focused
|
|
648
|
+
"dark_gray"
|
|
649
|
+
else
|
|
650
|
+
"green"
|
|
651
|
+
end
|
|
652
|
+
|
|
504
653
|
{
|
|
505
654
|
text: disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white"),
|
|
506
|
-
border:
|
|
655
|
+
border: {fg: border_color}
|
|
507
656
|
}
|
|
508
657
|
end
|
|
509
658
|
|
|
510
659
|
def input_title
|
|
511
|
-
if
|
|
512
|
-
"Waiting..."
|
|
513
|
-
elsif !connected?
|
|
660
|
+
if !connected?
|
|
514
661
|
"Disconnected"
|
|
515
662
|
else
|
|
516
663
|
"Input"
|
|
@@ -614,10 +761,112 @@ module TUI
|
|
|
614
761
|
return unless connected?
|
|
615
762
|
|
|
616
763
|
text = @input_buffer.consume
|
|
764
|
+
save_to_history(text)
|
|
765
|
+
reset_history_browsing
|
|
617
766
|
@input_scroll_offset = 0
|
|
618
767
|
@cable_client.speak(text)
|
|
619
768
|
end
|
|
620
769
|
|
|
770
|
+
# Recalls the last pending user message for editing. Removes it from
|
|
771
|
+
# the message store, puts its content back in the input buffer, and
|
|
772
|
+
# tells the server to delete the event.
|
|
773
|
+
#
|
|
774
|
+
# @return [Boolean] true if a message was recalled
|
|
775
|
+
def recall_pending_message
|
|
776
|
+
pending = @message_store.last_pending_user_message
|
|
777
|
+
return false unless pending
|
|
778
|
+
|
|
779
|
+
@message_store.remove_by_id(pending[:id])
|
|
780
|
+
@input_buffer.clear
|
|
781
|
+
@input_buffer.insert(pending[:content])
|
|
782
|
+
@cable_client.recall_pending(pending[:id])
|
|
783
|
+
true
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Handles keyboard events when the chat pane has focus.
|
|
787
|
+
# Up/Down scroll the chat; all other keys are ignored.
|
|
788
|
+
#
|
|
789
|
+
# @return [Boolean] true if the event was handled
|
|
790
|
+
def handle_chat_focused_event(event)
|
|
791
|
+
if event.up?
|
|
792
|
+
scroll_up(SCROLL_STEP)
|
|
793
|
+
true
|
|
794
|
+
elsif event.down?
|
|
795
|
+
scroll_down(SCROLL_STEP)
|
|
796
|
+
true
|
|
797
|
+
else
|
|
798
|
+
false
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# Navigates backward through input history (older entries).
|
|
803
|
+
# On first invocation, saves the current input buffer so it can be
|
|
804
|
+
# restored when the user navigates past the newest entry.
|
|
805
|
+
#
|
|
806
|
+
# @return [Boolean] true if a history entry was loaded
|
|
807
|
+
def navigate_history_back
|
|
808
|
+
return false if @input_history.empty?
|
|
809
|
+
|
|
810
|
+
if @history_index.nil?
|
|
811
|
+
@saved_input = @input_buffer.text
|
|
812
|
+
@history_index = @input_history.size - 1
|
|
813
|
+
elsif @history_index > 0
|
|
814
|
+
@history_index -= 1
|
|
815
|
+
else
|
|
816
|
+
return false
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
load_history_entry(@input_history[@history_index])
|
|
820
|
+
true
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
# Navigates forward through input history (newer entries).
|
|
824
|
+
# When navigating past the newest entry, restores the text that was
|
|
825
|
+
# in the input buffer before history browsing started.
|
|
826
|
+
#
|
|
827
|
+
# @return [Boolean] true if navigated, false if not browsing history
|
|
828
|
+
def navigate_history_forward
|
|
829
|
+
return false if @history_index.nil?
|
|
830
|
+
|
|
831
|
+
@history_index += 1
|
|
832
|
+
|
|
833
|
+
if @history_index >= @input_history.size
|
|
834
|
+
load_history_entry(@saved_input)
|
|
835
|
+
reset_history_browsing
|
|
836
|
+
else
|
|
837
|
+
load_history_entry(@input_history[@history_index])
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
true
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Replaces the input buffer content with a history entry.
|
|
844
|
+
# Cursor is placed at the end of the text.
|
|
845
|
+
#
|
|
846
|
+
# @param text [String] history entry or saved input to load
|
|
847
|
+
# @return [void]
|
|
848
|
+
def load_history_entry(text)
|
|
849
|
+
@input_buffer.clear
|
|
850
|
+
@input_buffer.insert(text)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Exits history browsing mode without changing the input buffer.
|
|
854
|
+
# @return [void]
|
|
855
|
+
def reset_history_browsing
|
|
856
|
+
@history_index = nil
|
|
857
|
+
@saved_input = nil
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
# Appends a message to input history, skipping consecutive duplicates.
|
|
861
|
+
#
|
|
862
|
+
# @param text [String] submitted message text
|
|
863
|
+
# @return [void]
|
|
864
|
+
def save_to_history(text)
|
|
865
|
+
return if @input_history.last == text
|
|
866
|
+
|
|
867
|
+
@input_history << text
|
|
868
|
+
end
|
|
869
|
+
|
|
621
870
|
# Dispatches arrow and page keys to {#scroll_up} or {#scroll_down}.
|
|
622
871
|
# @return [true] always redraws after scrolling
|
|
623
872
|
def handle_scroll_key(event)
|
|
@@ -669,13 +918,12 @@ module TUI
|
|
|
669
918
|
end
|
|
670
919
|
|
|
671
920
|
# Inserts pasted clipboard content at cursor position.
|
|
672
|
-
# Paste is dispatched before the generic loading guard in {#handle_event}
|
|
673
|
-
# but still blocked during loading to match the visually-disabled input.
|
|
674
921
|
# @param event [RatatuiRuby::Event::Paste] paste event with content
|
|
675
|
-
# @return [Boolean] true if content was inserted, false if
|
|
922
|
+
# @return [Boolean] true if content was inserted, false if buffer full
|
|
676
923
|
def handle_paste_event(event)
|
|
677
|
-
return false if @
|
|
924
|
+
return false if @input_buffer.full?
|
|
678
925
|
|
|
926
|
+
reset_history_browsing
|
|
679
927
|
@input_buffer.insert(event.content)
|
|
680
928
|
end
|
|
681
929
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Workflows
|
|
6
|
+
class InvalidDefinitionError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# A workflow parsed from a Markdown definition file.
|
|
9
|
+
# YAML frontmatter holds metadata; the Markdown body contains free-form
|
|
10
|
+
# instructions that the analytical brain reads and converts into goals.
|
|
11
|
+
#
|
|
12
|
+
# Workflows are operational recipes — they describe WHAT to do step by
|
|
13
|
+
# step. The analytical brain uses judgment to decompose workflow prose
|
|
14
|
+
# into tracked goals based on the user's specific context.
|
|
15
|
+
#
|
|
16
|
+
# @example Workflow file format
|
|
17
|
+
# ---
|
|
18
|
+
# name: feature
|
|
19
|
+
# description: "Implement a GitHub issue end-to-end."
|
|
20
|
+
# ---
|
|
21
|
+
#
|
|
22
|
+
# ## Context
|
|
23
|
+
# Create and complete a new feature...
|
|
24
|
+
class Definition
|
|
25
|
+
# @return [String] unique workflow identifier used in read_workflow(name: "...")
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
28
|
+
# @return [String] description shown to the analytical brain for relevance matching
|
|
29
|
+
attr_reader :description
|
|
30
|
+
|
|
31
|
+
# @return [String] workflow content (Markdown body) — free-form instructions
|
|
32
|
+
attr_reader :content
|
|
33
|
+
|
|
34
|
+
# @return [String] file path this definition was loaded from
|
|
35
|
+
attr_reader :source_path
|
|
36
|
+
|
|
37
|
+
def initialize(name:, description:, content:, source_path: "")
|
|
38
|
+
@name = name
|
|
39
|
+
@description = description
|
|
40
|
+
@content = content
|
|
41
|
+
@source_path = source_path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parses a Markdown file with YAML frontmatter into a Definition.
|
|
45
|
+
#
|
|
46
|
+
# @param path [String, Pathname] path to the .md file
|
|
47
|
+
# @return [Definition]
|
|
48
|
+
# @raise [InvalidDefinitionError] if required fields are missing or frontmatter is malformed
|
|
49
|
+
def self.from_file(path)
|
|
50
|
+
raw = File.read(path)
|
|
51
|
+
frontmatter, body = parse_frontmatter(raw)
|
|
52
|
+
|
|
53
|
+
validate_required_fields!(frontmatter, path)
|
|
54
|
+
|
|
55
|
+
new(
|
|
56
|
+
name: frontmatter["name"].to_s.strip,
|
|
57
|
+
description: frontmatter["description"].to_s.strip,
|
|
58
|
+
content: body.strip,
|
|
59
|
+
source_path: path.to_s
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param raw [String] raw file content with YAML frontmatter
|
|
64
|
+
# @return [Array(Hash, String)] parsed frontmatter and body text
|
|
65
|
+
# @raise [InvalidDefinitionError] if frontmatter is missing or malformed
|
|
66
|
+
def self.parse_frontmatter(raw)
|
|
67
|
+
# Opening "---" must be followed by a newline (not just whitespace).
|
|
68
|
+
# Non-greedy (.*?\n) captures YAML lines up to the closing "---".
|
|
69
|
+
# Closing "---" may optionally be followed by a newline before the body.
|
|
70
|
+
# The /m flag lets (.*) in the body capture across newlines.
|
|
71
|
+
match = raw.match(/\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m)
|
|
72
|
+
raise InvalidDefinitionError, "Missing YAML frontmatter" unless match
|
|
73
|
+
|
|
74
|
+
frontmatter = YAML.safe_load(match[1])
|
|
75
|
+
raise InvalidDefinitionError, "Frontmatter is not a valid YAML mapping" unless frontmatter.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
[frontmatter, match[2]]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
NAME_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
81
|
+
|
|
82
|
+
def self.validate_required_fields!(frontmatter, path)
|
|
83
|
+
%w[name description].each do |field|
|
|
84
|
+
value = frontmatter[field].to_s.strip
|
|
85
|
+
raise InvalidDefinitionError, "Missing required field '#{field}' in #{path}" if value.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
name = frontmatter["name"].to_s.strip
|
|
89
|
+
unless name.match?(NAME_FORMAT)
|
|
90
|
+
raise InvalidDefinitionError,
|
|
91
|
+
"Invalid workflow name '#{name}' in #{path} — must be lowercase alphanumeric with hyphens/underscores"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :parse_frontmatter, :validate_required_fields!
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workflows
|
|
4
|
+
# Loads workflow definitions from Markdown files and provides lookup.
|
|
5
|
+
# Scans two directories:
|
|
6
|
+
# 1. Built-in workflows shipped with Anima (workflows/ in the gem root)
|
|
7
|
+
# 2. User-defined workflows (~/.anima/workflows/)
|
|
8
|
+
# User workflows override built-in ones when names collide.
|
|
9
|
+
class Registry
|
|
10
|
+
# @return [Hash{String => Definition}] loaded definitions keyed by name
|
|
11
|
+
attr_reader :workflows
|
|
12
|
+
|
|
13
|
+
BUILTIN_DIR = File.expand_path("../../workflows", __dir__).freeze
|
|
14
|
+
USER_DIR = File.expand_path("~/.anima/workflows").freeze
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@workflows = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the global registry, lazily loaded on first access.
|
|
21
|
+
#
|
|
22
|
+
# @return [Registry]
|
|
23
|
+
def self.instance
|
|
24
|
+
@instance ||= new.load_all
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Reloads the global registry from disk.
|
|
28
|
+
#
|
|
29
|
+
# @return [Registry]
|
|
30
|
+
def self.reload!
|
|
31
|
+
@instance = new.load_all
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Loads definitions from both built-in and user directories.
|
|
35
|
+
# User definitions override built-in ones with the same name.
|
|
36
|
+
#
|
|
37
|
+
# @return [self]
|
|
38
|
+
def load_all
|
|
39
|
+
load_directory(BUILTIN_DIR)
|
|
40
|
+
load_directory(USER_DIR)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Loads workflow definitions from a single directory (flat .md files only).
|
|
45
|
+
#
|
|
46
|
+
# @param dir [String] directory path to scan for workflow definitions
|
|
47
|
+
# @return [void]
|
|
48
|
+
def load_directory(dir)
|
|
49
|
+
return unless Dir.exist?(dir)
|
|
50
|
+
|
|
51
|
+
Dir.glob(File.join(dir, "*.md")).sort.each do |path|
|
|
52
|
+
definition = Definition.from_file(path)
|
|
53
|
+
@workflows[definition.name] = definition
|
|
54
|
+
rescue InvalidDefinitionError => error
|
|
55
|
+
Rails.logger.warn("Skipping invalid workflow definition #{path}: #{error.message}")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Looks up a named workflow definition.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] workflow name
|
|
62
|
+
# @return [Definition, nil]
|
|
63
|
+
def find(name)
|
|
64
|
+
@workflows[name]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Workflow names and descriptions for inclusion in the analytical brain's context.
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash{String => String}] name => description
|
|
70
|
+
def catalog
|
|
71
|
+
@workflows.transform_values(&:description)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<String>] registered workflow names
|
|
75
|
+
def available_names
|
|
76
|
+
@workflows.keys
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def any?
|
|
81
|
+
@workflows.any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Integer]
|
|
85
|
+
def size
|
|
86
|
+
@workflows.size
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|