anima-core 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base decorator for {PendingMessage} records, providing multi-resolution
4
+ # rendering for the TUI ("basic" / "verbose" / "debug") and for the
5
+ # enrichment subsystems Mneme and Melete.
6
+ #
7
+ # Each PM type has a dedicated subclass that mirrors the visual treatment
8
+ # of its promoted-{Message} counterpart, with +status: "pending"+ added
9
+ # so the TUI can render it dimmed.
10
+ #
11
+ # Subclasses must override {#render_basic}. Default delegations form a
12
+ # two-step chain: +render_debug → render_verbose → render_basic+. A
13
+ # subclass that only overrides +render_verbose+ inherits its
14
+ # +render_debug+ for free. Melete and Mneme transcript modes return nil
15
+ # by default — subclasses opt in by overriding {#render_melete} or
16
+ # {#render_mneme}.
17
+ #
18
+ # Instantiate via +pending_message.decorate+ — {PendingMessage#decorator_class}
19
+ # picks the concrete subclass based on +message_type+.
20
+ class PendingMessageDecorator < ApplicationDecorator
21
+ delegate_all
22
+
23
+ RENDER_DISPATCH = {
24
+ "basic" => :render_basic,
25
+ "verbose" => :render_verbose,
26
+ "debug" => :render_debug,
27
+ "melete" => :render_melete,
28
+ "mneme" => :render_mneme
29
+ }.freeze
30
+ private_constant :RENDER_DISPATCH
31
+
32
+ # Dispatches to the render method for the given view mode.
33
+ #
34
+ # @param mode [String] one of "basic", "verbose", "debug", "melete", "mneme"
35
+ # @return [Hash, String, nil] structured TUI payload, transcript line, or nil to hide
36
+ # @raise [ArgumentError] if the mode is not supported
37
+ def render(mode)
38
+ method = RENDER_DISPATCH[mode]
39
+ raise ArgumentError, "Invalid view mode: #{mode.inspect}" unless method
40
+
41
+ public_send(method)
42
+ end
43
+
44
+ # @abstract Subclasses must implement to render the pending message for basic view mode.
45
+ # @return [Hash, nil] structured payload, or nil to hide
46
+ def render_basic
47
+ raise NotImplementedError, "#{self.class} must implement #render_basic"
48
+ end
49
+
50
+ # @return [Hash, nil] verbose payload (defaults to basic)
51
+ def render_verbose
52
+ render_basic
53
+ end
54
+
55
+ # @return [Hash, nil] debug payload (defaults to verbose)
56
+ def render_debug
57
+ render_verbose
58
+ end
59
+
60
+ # @return [String, nil] Melete transcript line, or nil to skip
61
+ def render_melete
62
+ nil
63
+ end
64
+
65
+ # @return [String, nil] Mneme transcript line, or nil to skip
66
+ def render_mneme
67
+ nil
68
+ end
69
+
70
+ protected
71
+
72
+ MIDDLE_TRUNCATION_MARKER = MessageDecorator::MIDDLE_TRUNCATION_MARKER
73
+
74
+ # Mirror of {MessageDecorator#truncate_middle} — duplicated here rather than
75
+ # inherited to keep the two decorator families independent.
76
+ def truncate_middle(text, max_chars: 500)
77
+ str = text.to_s
78
+ return str if str.length <= max_chars
79
+
80
+ keep = max_chars - MIDDLE_TRUNCATION_MARKER.length
81
+ head = keep / 2
82
+ tail = keep - head
83
+ "#{str[0, head]}#{MIDDLE_TRUNCATION_MARKER}#{str[-tail, tail]}"
84
+ end
85
+
86
+ # Mirror of {MessageDecorator#truncate_lines}.
87
+ def truncate_lines(text, max_lines:)
88
+ str = text.to_s
89
+ lines = str.split("\n")
90
+ return str unless lines.size > max_lines
91
+
92
+ lines.first(max_lines).push("...").join("\n")
93
+ end
94
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +subagent+ {PendingMessage} — a sub-agent's reply that
4
+ # landed on the parent's mailbox via {SubagentMessageRouter}. Promotes
5
+ # into a phantom +from_<nickname>+ tool_call/tool_response pair, but
6
+ # while pending it surfaces as a labeled inbound delivery so the user
7
+ # sees which sub-agent is talking to her.
8
+ #
9
+ # Hidden in basic (matches the promoted tool pair, which is hidden in
10
+ # basic). Visible from verbose with a +[from <nickname>]+ badge.
11
+ class PendingSubagentDecorator < PendingMessageDecorator
12
+ # @return [nil] sub-agent deliveries are hidden in basic mode
13
+ def render_basic
14
+ nil
15
+ end
16
+
17
+ # @return [Hash] dimmed sub-agent delivery payload
18
+ def render_verbose
19
+ {
20
+ role: :pending_subagent,
21
+ source: source_name,
22
+ content: truncate_lines(content, max_lines: 3),
23
+ status: "pending"
24
+ }
25
+ end
26
+
27
+ # @return [Hash] full sub-agent delivery payload
28
+ def render_debug
29
+ {
30
+ role: :pending_subagent,
31
+ source: source_name,
32
+ content: content,
33
+ status: "pending"
34
+ }
35
+ end
36
+
37
+ # @return [String] Melete transcript line
38
+ def render_melete
39
+ "Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
40
+ end
41
+
42
+ # @return [String] Mneme transcript line
43
+ def render_mneme
44
+ "Sub-agent #{source_name} (pending): #{truncate_middle(content)}"
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +tool_response+ {PendingMessage} — a tool result waiting in
4
+ # the mailbox before the drain pairs it with its tool_call and feeds the
5
+ # next LLM turn. Mirrors {ToolResponseDecorator}: hidden in basic
6
+ # (aggregated by the tool counter), structured tool output in verbose,
7
+ # full untruncated content in debug — all dimmed via
8
+ # +status: "pending"+.
9
+ class PendingToolResponseDecorator < PendingMessageDecorator
10
+ # @return [nil] tool responses are hidden in basic mode
11
+ def render_basic
12
+ nil
13
+ end
14
+
15
+ # @return [Hash] truncated tool response payload tagged as pending
16
+ def render_verbose
17
+ {
18
+ role: :tool_response,
19
+ tool: source_name,
20
+ content: truncate_lines(content, max_lines: 3),
21
+ # nil treated as success; only an explicit false flips the indicator
22
+ # — mirrors {ToolResponseDecorator}'s convention so legacy PMs
23
+ # without an explicit success column don't render as failures.
24
+ success: success != false,
25
+ tool_use_id: tool_use_id,
26
+ status: "pending"
27
+ }
28
+ end
29
+
30
+ # @return [Hash] full tool response payload tagged as pending
31
+ def render_debug
32
+ {
33
+ role: :tool_response,
34
+ tool: source_name,
35
+ content: content,
36
+ success: success != false,
37
+ tool_use_id: tool_use_id,
38
+ status: "pending"
39
+ }
40
+ end
41
+
42
+ # @return [String] Melete transcript line
43
+ def render_melete
44
+ "tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
45
+ end
46
+
47
+ # @return [String] Mneme transcript line
48
+ def render_mneme
49
+ "tool_response #{tool_use_id} (pending): #{truncate_middle(content)}"
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates a +user_message+ {PendingMessage} — the user's input as it
4
+ # sits in the mailbox between submission and promotion. Mirrors
5
+ # {UserMessageDecorator}'s shape, with +status: "pending"+ added so the
6
+ # TUI dims the entry.
7
+ class PendingUserMessageDecorator < PendingMessageDecorator
8
+ # @return [Hash] dimmed user message payload
9
+ def render_basic
10
+ {role: :user, content: content, status: "pending"}
11
+ end
12
+
13
+ # @return [String] Melete transcript line
14
+ def render_melete
15
+ "User (pending): #{truncate_middle(content)}"
16
+ end
17
+
18
+ # @return [String] Mneme transcript line
19
+ def render_mneme
20
+ "User (pending): #{truncate_middle(content)}"
21
+ end
22
+ end
@@ -18,4 +18,9 @@ class SystemMessageDecorator < MessageDecorator
18
18
  def render_debug
19
19
  render_verbose
20
20
  end
21
+
22
+ # @return [String] transcript line for Mneme's eviction/context zones
23
+ def render_mneme
24
+ "message #{id} System: #{content}"
25
+ end
21
26
  end
@@ -50,8 +50,8 @@ class ToolCallDecorator < MessageDecorator
50
50
 
51
51
  # Think calls get full text — the agent's reasoning IS the signal.
52
52
  # Other tool calls show tool name + params (compact JSON).
53
- # @return [String] transcript line for the analytical brain
54
- def render_brain
53
+ # @return [String] transcript line for Melete
54
+ def render_melete
55
55
  if think?
56
56
  "Think: #{thoughts}"
57
57
  else
@@ -59,6 +59,17 @@ class ToolCallDecorator < MessageDecorator
59
59
  end
60
60
  end
61
61
 
62
+ # Think calls render as conversation. Regular tool calls return
63
+ # a +:tool_call+ marker for the counter accumulator.
64
+ # @return [String, Symbol] transcript line or counter marker
65
+ def render_mneme
66
+ if think?
67
+ "message #{id} Think: #{thoughts}"
68
+ else
69
+ :tool_call
70
+ end
71
+ end
72
+
62
73
  private
63
74
 
64
75
  def think?
@@ -105,10 +116,10 @@ class ToolCallDecorator < MessageDecorator
105
116
  # Formats write tool input with file path header and content body.
106
117
  # Content newlines are preserved so the TUI can render them as
107
118
  # separate lines, matching how read_file tool responses display file content.
108
- # @param input [Hash] tool input hash with "file_path" and "content" keys
119
+ # @param input [Hash] tool input hash with "path" and "content" keys
109
120
  # @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
110
121
  def format_write_content(input)
111
- path = input.dig("file_path").to_s
122
+ path = input.dig("path").to_s
112
123
  content = input.dig("content").to_s
113
124
  return Toon.encode(input) if content.empty?
114
125
 
@@ -126,7 +137,7 @@ class ToolCallDecorator < MessageDecorator
126
137
  when "web_get"
127
138
  "GET #{input&.dig("url")}"
128
139
  when "read_file", "edit_file", "write_file"
129
- input&.dig("file_path").to_s
140
+ input&.dig("path").to_s
130
141
  else
131
142
  truncate_lines(Toon.encode(input), max_lines: 2)
132
143
  end
@@ -46,10 +46,10 @@ class ToolResponseDecorator < MessageDecorator
46
46
  }.merge(token_info)
47
47
  end
48
48
 
49
- # Think responses ("OK") are noise — excluded from the brain's transcript.
49
+ # Think responses ("OK") are noise — excluded from Melete's transcript.
50
50
  # Other tool responses are compressed to success/failure indicators only.
51
51
  # @return [String, nil] ✅ or ❌ indicator, nil for think responses
52
- def render_brain
52
+ def render_melete
53
53
  return if think?
54
54
 
55
55
  (payload["success"] != false) ? "\u2705" : "\u274C"
@@ -19,9 +19,14 @@ class UserMessageDecorator < MessageDecorator
19
19
  render_verbose.merge(token_info)
20
20
  end
21
21
 
22
- # @return [String] user message for the analytical brain, middle-truncated
22
+ # @return [String] user message for Melete, middle-truncated
23
23
  # if very long (preserves intent at start and conclusion at end)
24
- def render_brain
24
+ def render_melete
25
25
  "User: #{truncate_middle(content)}"
26
26
  end
27
+
28
+ # @return [String] transcript line for Mneme's eviction/context zones
29
+ def render_mneme
30
+ "message #{id} User: #{content}"
31
+ end
27
32
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refines a record's +token_count+ with the real Anthropic tokenizer count,
4
+ # replacing the local heuristic seeded during creation. Accepts any record
5
+ # that includes {TokenEstimation} and implements +#tokenization_text+ —
6
+ # Messages, Snapshots, and PinnedMessages share the same pipeline.
7
+ class CountTokensJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
11
+ discard_on ActiveRecord::RecordNotFound
12
+
13
+ # @param record [ActiveRecord::Base] any record responding to
14
+ # +#tokenization_text+ and +token_count=+
15
+ def perform(record)
16
+ count = Providers::Anthropic.new.count_tokens(
17
+ model: Anima::Settings.model,
18
+ messages: [{role: "user", content: record.tokenization_text}]
19
+ )
20
+
21
+ record.update!(token_count: count)
22
+ end
23
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drains the PendingMessage mailbox into a single LLM round-trip.
4
+ #
5
+ # One invocation == one half-step of the event-driven agent loop:
6
+ # 1. Claim the session via {Session#start_processing!} (atomic; bails if
7
+ # another drain already holds the session OR the in-flight tool round
8
+ # is incomplete — the AASM guard +tool_round_complete?+ handles both).
9
+ # 2. Promote pending work into the conversation — every tool_response PM
10
+ # of the freshly completed round flushes in one transaction so the
11
+ # LLM sees a whole assistant turn paired with a whole user turn;
12
+ # background phantom pairs flush; one active FIFO message rides
13
+ # along. Promotion lives on {PendingMessage#promote!} — the job only
14
+ # decides what to pick and in which order.
15
+ # 3. Make one LLM API call and emit {Events::LLMResponded}.
16
+ #
17
+ # On the happy path the job never releases the session — state
18
+ # transitions after the emit belong to
19
+ # {Events::Subscribers::LLMResponseHandler} (on text or tool dispatch).
20
+ # {Events::Subscribers::ToolResponseCreator} no longer touches state;
21
+ # the +executing → awaiting+ branch of +start_processing+ closes the
22
+ # tool round and claims in one atomic, lock-protected step.
23
+ #
24
+ # The job DOES release its own claim when there is no responder to do
25
+ # it: an empty mailbox (spurious kickoff) or an exception raised before
26
+ # the LLM call succeeded. Those are lifecycle edges of the claim itself,
27
+ # not hand-offs to responders.
28
+ #
29
+ # @see Events::Subscribers::DrainKickoff — enqueues this job
30
+ # @see Events::LLMResponded — the event emitted on LLM completion
31
+ class DrainJob < ApplicationJob
32
+ queue_as :default
33
+
34
+ # Transient provider errors retry inline within {#call_llm_and_emit}.
35
+ # A job-level +retry_on+ would be a no-op here: {PendingMessage#promote!}
36
+ # destroys the PM rows *before* the LLM call, so a retried job would find
37
+ # an empty mailbox and exit without ever re-issuing the request.
38
+
39
+ discard_on Providers::Anthropic::AuthenticationError do |job, error|
40
+ Events::Bus.emit(Events::AuthenticationRequired.new(
41
+ session_id: job.arguments.first,
42
+ content: error.message
43
+ ))
44
+ end
45
+
46
+ discard_on ActiveRecord::RecordNotFound
47
+
48
+ # @param session_id [Integer]
49
+ def perform(session_id)
50
+ @session = Session.find(session_id)
51
+ return unless @session.start_processing!
52
+
53
+ drained = drain_mailbox
54
+ return @session.response_complete! if drained.zero?
55
+
56
+ call_llm_and_emit
57
+ rescue Providers::Anthropic::AuthenticationError => error
58
+ release_after_failure(error) if @session
59
+ raise
60
+ rescue => error
61
+ release_after_failure(error) if @session
62
+ raise unless @active_pm&.bounce_back?
63
+ end
64
+
65
+ private
66
+
67
+ # Decides what the upcoming LLM round will carry and promotes those PMs.
68
+ #
69
+ # 1. All +tool_response+ PMs of the just-completed round — the AASM
70
+ # guard guarantees they are all present when we arrive from
71
+ # +:executing+; from +:idle+ this set is empty.
72
+ # 2. Pick one active FIFO message (user_message or subagent) to ride
73
+ # along. If there are no tool_responses AND no active message, the
74
+ # LLM call is a no-op: release the claim and do NOT flush background
75
+ # PMs (they stay in the mailbox for the next turn).
76
+ # 3. Flush background phantom pairs so they sit above the active pick.
77
+ # 4. Promote the active pick.
78
+ #
79
+ # @return [Integer] count of PMs promoted this cycle (0 means "release
80
+ # the claim without calling the LLM")
81
+ def drain_mailbox
82
+ tool_responses = @session.pending_messages.where(message_type: "tool_response").order(:created_at).to_a
83
+ @active_pm = @session.pending_messages.active.where.not(message_type: "tool_response").order(:created_at).first
84
+
85
+ promoted = tool_responses.size + (@active_pm ? 1 : 0)
86
+ return 0 if promoted.zero?
87
+
88
+ tool_responses.each(&:promote!)
89
+ @session.pending_messages.background.find_each(&:promote!)
90
+ @active_pm&.promote!
91
+
92
+ promoted
93
+ end
94
+
95
+ def call_llm_and_emit
96
+ prompt = @session.system_prompt
97
+ @session.broadcast_debug_context(system: prompt, tools: registry.schemas)
98
+
99
+ response = with_transient_retry do
100
+ client.provider.create_message(
101
+ model: client.model,
102
+ messages: @session.messages_for_llm,
103
+ max_tokens: client.max_tokens,
104
+ tools: registry.schemas,
105
+ include_metrics: true,
106
+ **(prompt ? {system: prompt} : {})
107
+ )
108
+ end
109
+
110
+ Events::Bus.emit(Events::LLMResponded.new(
111
+ session_id: @session.id,
112
+ response: response.to_h.stringify_keys,
113
+ api_metrics: response.api_metrics
114
+ ))
115
+ end
116
+
117
+ # Retries the LLM call in-place on transient provider errors. The
118
+ # polynomial-backoff formula mirrors ActiveJob's +:polynomially_longer+.
119
+ # On final exhaustion the subscriber-visible SystemMessage is emitted
120
+ # before the error re-raises into {#perform}'s rescue path for release.
121
+ TRANSIENT_RETRY_ATTEMPTS = 5
122
+
123
+ def with_transient_retry
124
+ tries = 0
125
+ begin
126
+ yield
127
+ rescue Providers::Anthropic::TransientError => error
128
+ tries += 1
129
+ if tries >= TRANSIENT_RETRY_ATTEMPTS
130
+ Events::Bus.emit(Events::SystemMessage.new(
131
+ content: "Failed after multiple retries: #{error.message}",
132
+ session_id: @session.id
133
+ ))
134
+ raise
135
+ end
136
+ sleep(transient_backoff(tries))
137
+ retry
138
+ end
139
+ end
140
+
141
+ def transient_backoff(attempt)
142
+ base = attempt**4
143
+ base + (rand * 0.15 * base)
144
+ end
145
+
146
+ def release_after_failure(error)
147
+ if @active_pm&.bounce_back?
148
+ @session.release_with_bounce_back(@active_pm, error)
149
+ elsif @session.may_response_complete?
150
+ @session.response_complete!
151
+ end
152
+ end
153
+
154
+ def client
155
+ @client ||= if @session.sub_agent?
156
+ LLM::Client.new(model: Anima::Settings.subagent_model)
157
+ else
158
+ LLM::Client.new
159
+ end
160
+ end
161
+
162
+ def registry
163
+ @registry ||= Tools::Registry.build(session: @session, shell_session: shell_session)
164
+ end
165
+
166
+ def shell_session
167
+ @shell_session ||= ShellSession.for_session(@session)
168
+ end
169
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MeleteEnrichmentJob < ApplicationJob
4
+ # Scoped subscriber that watches the event bus for goal mutation events
5
+ # (+anima.goal.created+, +anima.goal.updated+) belonging to one session,
6
+ # for the duration of a block.
7
+ #
8
+ # Returns +true+ from {.observe} when at least one matching event fired
9
+ # during the block, +false+ otherwise. The subscription is registered
10
+ # before the block runs and removed in an +ensure+ so it is cleaned up
11
+ # even if the block raises.
12
+ #
13
+ # @example
14
+ # goal_changed = GoalChangeListener.observe(session_id: 42) do
15
+ # Melete::Runner.new(session).call
16
+ # end
17
+ class GoalChangeListener
18
+ EVENT_NAMES = [
19
+ "#{Events::Bus::NAMESPACE}.#{Events::GoalCreated::TYPE}",
20
+ "#{Events::Bus::NAMESPACE}.#{Events::GoalUpdated::TYPE}"
21
+ ].freeze
22
+
23
+ # @param session_id [Integer] only events whose payload session_id matches count
24
+ # @yield runs while the subscription is active
25
+ # @return [Boolean] whether a matching event fired during the block
26
+ def self.observe(session_id:, &block)
27
+ new(session_id).observe(&block)
28
+ end
29
+
30
+ def initialize(session_id)
31
+ @session_id = session_id
32
+ @triggered = false
33
+ end
34
+
35
+ def observe
36
+ Events::Bus.subscribe(self) do |event|
37
+ EVENT_NAMES.include?(event[:name]) &&
38
+ event[:payload][:session_id] == @session_id
39
+ end
40
+ yield
41
+ @triggered
42
+ ensure
43
+ Events::Bus.unsubscribe(self)
44
+ end
45
+
46
+ # Bus subscriber contract — flips the latch on any matching event.
47
+ # @api private
48
+ def emit(_event)
49
+ @triggered = true
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # First stage of the drain pipeline: runs Melete to activate skills,
4
+ # read workflows, and refine goals. Hands off to either Mneme (when goals
5
+ # changed during this run) or directly to the drain loop.
6
+ #
7
+ # Triggered by {Events::Subscribers::MeleteKickoff} in response to
8
+ # {Events::StartMelete}. Runs the existing synchronous {Melete::Runner}
9
+ # — the event is only the entry/exit plumbing.
10
+ #
11
+ # A {GoalChangeListener} observes {Events::GoalCreated} and
12
+ # {Events::GoalUpdated} for the duration of the runner call. When a goal
13
+ # mutation is heard the job emits {Events::StartMneme} so Mneme recalls
14
+ # against the fresh goal set; otherwise it emits {Events::StartProcessing}
15
+ # and Mneme is skipped — there is no new search seed to recall against.
16
+ #
17
+ # Sub-agents skip Melete entirely (sub-agent nickname assignment is a
18
+ # one-time step, not part of the recurring pipeline). With no runner
19
+ # call, the listener never fires and the job falls through to
20
+ # {Events::StartProcessing}.
21
+ #
22
+ # Exceptions from {Melete::Runner#call} propagate — no defensive rescue.
23
+ # A crashed Melete leaves the session idle with the PM still in the
24
+ # mailbox; the next PM create (or idle-wake re-route) retries the full
25
+ # chain. Swallowing would surface a degraded response to the user without
26
+ # the failure being visible anywhere (anti-pattern per the project's
27
+ # "soft error paths" principle).
28
+ class MeleteEnrichmentJob < ApplicationJob
29
+ queue_as :default
30
+
31
+ discard_on ActiveRecord::RecordNotFound
32
+
33
+ # @param session_id [Integer]
34
+ # @param pending_message_id [Integer, nil] the PM that kicked off the chain
35
+ def perform(session_id, pending_message_id: nil)
36
+ session = Session.find(session_id)
37
+
38
+ goal_changed = GoalChangeListener.observe(session_id: session_id) do
39
+ Melete::Runner.new(session).call unless session.sub_agent?
40
+ end
41
+
42
+ next_event_class = goal_changed ? Events::StartMneme : Events::StartProcessing
43
+ Events::Bus.emit(next_event_class.new(
44
+ session_id: session_id,
45
+ pending_message_id: pending_message_id
46
+ ))
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Second stage of the drain pipeline: runs Mneme's recall loop so any
4
+ # older memory she judges useful lands in the mailbox as background
5
+ # {PendingMessage}s, then hands off to the drain loop via
6
+ # {Events::StartProcessing}.
7
+ #
8
+ # Triggered by {Events::Subscribers::MnemeKickoff} in response to
9
+ # {Events::StartMneme}, which {MeleteEnrichmentJob} only emits when goals
10
+ # changed during the preceding Melete run. Runs the phantom
11
+ # {Mneme::RecallRunner} loop — the event is only the entry/exit plumbing.
12
+ #
13
+ # Mneme recall is *enrichment* — it adds recalled memories as background
14
+ # phantom pairs but is never required for the primary pipeline to make
15
+ # progress. If recall raises (bad FTS5 input, SQL glitch, …) the handoff
16
+ # to the drain loop must still happen, otherwise the session's user
17
+ # message is stranded in the mailbox with no retry trigger. Exceptions
18
+ # are logged loudly so failures stay visible — they just don't gate the drain.
19
+ class MnemeEnrichmentJob < ApplicationJob
20
+ queue_as :default
21
+
22
+ discard_on ActiveRecord::RecordNotFound
23
+
24
+ # @param session_id [Integer]
25
+ # @param pending_message_id [Integer, nil] the PM that kicked off the chain
26
+ def perform(session_id, pending_message_id: nil)
27
+ session = Session.find(session_id)
28
+
29
+ run_recall(session)
30
+
31
+ Events::Bus.emit(Events::StartProcessing.new(
32
+ session_id: session_id,
33
+ pending_message_id: pending_message_id
34
+ ))
35
+ end
36
+
37
+ private
38
+
39
+ def run_recall(session)
40
+ Mneme::RecallRunner.new(session).call
41
+ rescue => error
42
+ msg = "FAILED (recall) session=#{session.id}: #{error.class}: #{error.message}"
43
+ Rails.logger.error("Mneme #{msg}")
44
+ Mneme.logger.error("#{msg}\n#{error.backtrace&.first(10)&.join("\n")}")
45
+ end
46
+ end