anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -3,22 +3,15 @@
3
3
  # Decorates user_message records for display in the TUI.
4
4
  # Basic mode returns role and content. Verbose mode adds a timestamp.
5
5
  # Debug mode adds token count (exact when counted, estimated when not).
6
- # Pending messages include `status: "pending"` so the TUI renders them
7
- # with a visual indicator (dimmed, clock icon).
8
6
  class UserMessageDecorator < MessageDecorator
9
- # @return [Hash] structured user message data
10
- # `{role: :user, content: String}` or with `status: "pending"` when queued
7
+ # @return [Hash] structured user message data `{role: :user, content: String}`
11
8
  def render_basic
12
- base = {role: :user, content: content}
13
- base[:status] = "pending" if pending?
14
- base
9
+ {role: :user, content: content}
15
10
  end
16
11
 
17
12
  # @return [Hash] structured user message with nanosecond timestamp
18
13
  def render_verbose
19
- base = {role: :user, content: content, timestamp: timestamp}
20
- base[:status] = "pending" if pending?
21
- base
14
+ {role: :user, content: content, timestamp: timestamp}
22
15
  end
23
16
 
24
17
  # @return [Hash] verbose output plus token count for debugging
@@ -31,11 +24,4 @@ class UserMessageDecorator < MessageDecorator
31
24
  def render_brain
32
25
  "User: #{truncate_middle(content)}"
33
26
  end
34
-
35
- private
36
-
37
- # @return [Boolean] true when this message is queued but not yet sent to LLM
38
- def pending?
39
- payload["status"] == Message::PENDING_STATUS
40
- end
41
27
  end
@@ -66,10 +66,10 @@ class AgentRequestJob < ApplicationJob
66
66
  agent_loop.run
67
67
  end
68
68
 
69
- # Process any pending messages queued while we were busy.
69
+ # Process any pending messages that arrived after the last tool round.
70
70
  loop do
71
71
  promoted = session.promote_pending_messages!
72
- break if promoted == 0
72
+ break if promoted[:texts].empty? && promoted[:pairs].empty?
73
73
  agent_loop.run
74
74
  end
75
75
 
@@ -155,18 +155,27 @@ class AgentRequestJob < ApplicationJob
155
155
 
156
156
  # Sets the session's processing flag atomically. Returns true if this
157
157
  # job claimed the lock, false if another job already holds it.
158
- # Broadcasts the state change to the parent session's HUD.
158
+ # Broadcasts +session_state: llm_generating+ and the state change to
159
+ # the parent session's HUD.
159
160
  def claim_processing(session_id)
160
161
  claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
161
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
162
+ if claimed
163
+ session = Session.find_by(id: session_id)
164
+ session&.broadcast_session_state("llm_generating")
165
+ session&.broadcast_children_update_to_parent
166
+ end
162
167
  claimed
163
168
  end
164
169
 
165
170
  # Clears the processing flag so the session can accept new jobs.
166
- # Broadcasts the state change to the parent session's HUD.
171
+ # Broadcasts +session_state: idle+ to the session stream (replaces
172
+ # the old +processing_stopped+ action) and +children_updated+ to the
173
+ # parent session's HUD.
167
174
  def release_processing(session_id)
168
175
  Session.where(id: session_id).update_all(processing: false)
169
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent
176
+ session = Session.find_by(id: session_id)
177
+ session&.broadcast_session_state("idle")
178
+ session&.broadcast_children_update_to_parent
170
179
  end
171
180
 
172
181
  # Safety-net clearing of the interrupt flag.
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Runs passive recall after goal updates — searches message history for
4
- # context relevant to active goals and caches results on the session
5
- # for viewport injection.
4
+ # context relevant to active goals and injects phantom tool_call/tool_response
5
+ # pairs into the session's message stream.
6
6
  #
7
- # Idempotent: multiple enqueues for the same session safely overwrite
8
- # each other's results last one wins.
7
+ # Phantom pairs ride the conveyor belt like regular messages, getting
8
+ # cached, evicted, and compressed by Mneme naturally.
9
9
  #
10
10
  # @example
11
11
  # PassiveRecallJob.perform_later(session.id)
@@ -17,13 +17,8 @@ class PassiveRecallJob < ApplicationJob
17
17
  # @param session_id [Integer]
18
18
  def perform(session_id)
19
19
  session = Session.find(session_id)
20
- results = Mneme::PassiveRecall.new(session).call
20
+ count = Mneme::PassiveRecall.new(session).call
21
21
 
22
- if results.any?
23
- session.update_column(:recalled_message_ids, results.map(&:message_id))
24
- Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
- elsif session.recalled_message_ids.present?
26
- session.update_column(:recalled_message_ids, [])
27
- end
22
+ Mneme.logger.info("session=#{session_id} — passive recall injected #{count} phantom pairs") if count > 0
28
23
  end
29
24
  end
@@ -68,6 +68,7 @@ module Message::Broadcasting
68
68
  mode = session.view_mode
69
69
  decorator = MessageDecorator.for(self)
70
70
  broadcast_payload = payload.merge("id" => id, "action" => action)
71
+ broadcast_payload["api_metrics"] = api_metrics if api_metrics.present?
71
72
 
72
73
  if decorator
73
74
  broadcast_payload["rendered"] = {mode => decorator.render(mode)}
data/app/models/goal.rb CHANGED
@@ -25,6 +25,17 @@ class Goal < ApplicationRecord
25
25
  scope :completed, -> { where(status: "completed") }
26
26
  scope :root, -> { where(parent_goal_id: nil) }
27
27
 
28
+ # @!method self.not_evicted
29
+ # Goals still visible in context (not yet evicted by the analytical brain).
30
+ # @return [ActiveRecord::Relation]
31
+ scope :not_evicted, -> { where(evicted_at: nil) }
32
+
33
+ # @!method self.evictable
34
+ # Completed goals not yet evicted — their phantom pairs remain in the
35
+ # sliding window until Mneme compresses them during the eviction cycle.
36
+ # @return [ActiveRecord::Relation]
37
+ scope :evictable, -> { completed.where(evicted_at: nil) }
38
+
28
39
  after_commit :broadcast_goals_update
29
40
  after_commit :schedule_passive_recall, on: [:create, :update]
30
41
 
@@ -37,6 +48,9 @@ class Goal < ApplicationRecord
37
48
  # @return [Boolean] true if this is a root goal (no parent)
38
49
  def root? = !parent_goal_id
39
50
 
51
+ # @return [Boolean] true if this goal has been evicted from display
52
+ def evicted? = evicted_at.present?
53
+
40
54
  # Cascades completion to all active sub-goals. Called when a root goal
41
55
  # is finished — remaining sub-items are implicitly resolved because
42
56
  # the semantic episode that spawned them has ended.
@@ -28,9 +28,6 @@ class Message < ApplicationRecord
28
28
  CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
29
29
  CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
30
30
  THINK_TOOL = "think"
31
- SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
32
- PENDING_STATUS = "pending"
33
-
34
31
  # Message types that require a tool_use_id to pair call with response.
35
32
  TOOL_TYPES = %w[tool_call tool_response].freeze
36
33
 
@@ -39,6 +36,18 @@ class Message < ApplicationRecord
39
36
  # Heuristic: average bytes per token for English prose.
40
37
  BYTES_PER_TOKEN = 4
41
38
 
39
+ # Synthetic ID for system prompt entries in the TUI message store.
40
+ # Real message IDs are positive integers from the database, so 0
41
+ # is safe for deduplication without collision risk.
42
+ SYSTEM_PROMPT_ID = 0
43
+
44
+ # Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
45
+ # @param bytesize [Integer] number of bytes
46
+ # @return [Integer] estimated token count (at least 1)
47
+ def self.estimate_token_count(bytesize)
48
+ [(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
49
+ end
50
+
42
51
  belongs_to :session
43
52
  has_many :pinned_messages, dependent: :destroy
44
53
 
@@ -60,28 +69,6 @@ class Message < ApplicationRecord
60
69
  # @return [ActiveRecord::Relation]
61
70
  scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
62
71
 
63
- # @!method self.pending
64
- # User messages queued during active agent processing, not yet sent to LLM.
65
- # @return [ActiveRecord::Relation]
66
- scope :pending, -> { where(status: PENDING_STATUS) }
67
-
68
- # @!method self.deliverable
69
- # Messages eligible for LLM context (excludes pending messages).
70
- # NULL status means delivered/processed — the only excluded value is "pending".
71
- # @return [ActiveRecord::Relation]
72
- scope :deliverable, -> { where(status: nil) }
73
-
74
- # @!method self.excluding_spawn_messages
75
- # Excludes spawn_subagent/spawn_specialist tool_call and tool_response messages.
76
- # Used when building parent context for sub-agents — spawn messages cause role
77
- # confusion because the sub-agent sees sibling spawn results and mistakes
78
- # itself for the parent.
79
- # @return [ActiveRecord::Relation]
80
- scope :excluding_spawn_messages, -> {
81
- where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
82
- TOOL_TYPES, SPAWN_TOOLS)
83
- }
84
-
85
72
  # Maps message_type to the Anthropic Messages API role.
86
73
  # @return [String] "user" or "assistant"
87
74
  def api_role
@@ -98,11 +85,6 @@ class Message < ApplicationRecord
98
85
  message_type.in?(CONTEXT_TYPES)
99
86
  end
100
87
 
101
- # @return [Boolean] true if this is a pending message not yet sent to the LLM
102
- def pending?
103
- status == PENDING_STATUS
104
- end
105
-
106
88
  # @return [Boolean] true if this is a conversation message (user/agent/system)
107
89
  # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
108
90
  def conversation_or_think?
@@ -121,7 +103,7 @@ class Message < ApplicationRecord
121
103
  else
122
104
  payload["content"].to_s
123
105
  end
124
- [(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
106
+ self.class.estimate_token_count(text.bytesize)
125
107
  end
126
108
 
127
109
  private
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A message waiting to enter a session's conversation history.
4
+ # Pending messages live in their own table — they are NOT part of the
5
+ # message stream and have no database ID that could interleave with
6
+ # tool_call/tool_response pairs.
7
+ #
8
+ # Created when a message arrives while the session is processing.
9
+ # Promoted to a real {Message} (delete + create in transaction) when
10
+ # the current agent loop completes, giving the new message an ID that
11
+ # naturally follows the tool batch.
12
+ #
13
+ # Each pending message knows its source (+source_type+, +source_name+)
14
+ # and how to serialize itself for the LLM conversation via {#to_llm_messages}.
15
+ # Non-user messages (sub-agent results, recalled skills, workflows, recall,
16
+ # goal events) become synthetic tool_use/tool_result pairs so the LLM sees
17
+ # "a tool I invoked returned a result" rather than "a user wrote me."
18
+ #
19
+ # @see Session#enqueue_user_message
20
+ # @see Session#promote_pending_messages!
21
+ class PendingMessage < ApplicationRecord
22
+ # Synthetic tool names used in tool_use/tool_result pairs injected into
23
+ # the parent LLM conversation when non-user messages are promoted.
24
+ # These tools don't exist in the agent's registry — the agent sees
25
+ # them as its own past actions (phantom tool calls).
26
+ SUBAGENT_TOOL = "subagent_message"
27
+ RECALL_SKILL_TOOL = "recall_skill"
28
+ RECALL_WORKFLOW_TOOL = "recall_workflow"
29
+ RECALL_MEMORY_TOOL = "recall_memory"
30
+ RECALL_GOAL_TOOL = "recall_goal"
31
+
32
+ # Source types that produce phantom tool_use/tool_result pairs on promotion.
33
+ # User messages produce plain text blocks instead.
34
+ PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
35
+
36
+ # Maps each phantom pair source type to its synthetic tool name.
37
+ PHANTOM_TOOL_NAMES = {
38
+ "subagent" => SUBAGENT_TOOL,
39
+ "skill" => RECALL_SKILL_TOOL,
40
+ "workflow" => RECALL_WORKFLOW_TOOL,
41
+ "recall" => RECALL_MEMORY_TOOL,
42
+ "goal" => RECALL_GOAL_TOOL
43
+ }.freeze
44
+
45
+ # Maps each phantom pair source type to a lambda building its tool input.
46
+ PHANTOM_TOOL_INPUTS = {
47
+ "subagent" => ->(name) { {from: name} },
48
+ "skill" => ->(name) { {skill: name} },
49
+ "workflow" => ->(name) { {workflow: name} },
50
+ "recall" => ->(name) { {message_id: name.to_i} },
51
+ "goal" => ->(name) { {goal_id: name.to_i} }
52
+ }.freeze
53
+
54
+ belongs_to :session
55
+
56
+ validates :content, presence: true
57
+ validates :source_type, inclusion: {in: %w[user subagent skill workflow recall goal]}
58
+ validates :source_name, presence: true, unless: :user?
59
+
60
+ after_create_commit :broadcast_created
61
+ after_destroy_commit :broadcast_removed
62
+
63
+ # @return [Boolean] true when this is a plain user message
64
+ def user?
65
+ source_type == "user"
66
+ end
67
+
68
+ # @return [Boolean] true when this message originated from a sub-agent
69
+ def subagent?
70
+ source_type == "subagent"
71
+ end
72
+
73
+ # @return [Boolean] true when this message carries recalled skill content
74
+ def skill?
75
+ source_type == "skill"
76
+ end
77
+
78
+ # @return [Boolean] true when this message carries recalled workflow content
79
+ def workflow?
80
+ source_type == "workflow"
81
+ end
82
+
83
+ # @return [Boolean] true when this message is an associative recall phantom pair
84
+ def recall?
85
+ source_type == "recall"
86
+ end
87
+
88
+ # @return [Boolean] true when this message carries a goal event
89
+ def goal?
90
+ source_type == "goal"
91
+ end
92
+
93
+ # @return [Boolean] true when promotion produces phantom tool_use/tool_result pairs
94
+ def phantom_pair?
95
+ source_type.in?(PHANTOM_PAIR_TYPES)
96
+ end
97
+
98
+ # Phantom tool name for DB persistence and LLM injection.
99
+ # Each phantom pair source type maps to a synthetic tool name.
100
+ #
101
+ # @return [String] phantom tool name
102
+ def phantom_tool_name
103
+ PHANTOM_TOOL_NAMES.fetch(source_type)
104
+ end
105
+
106
+ # Phantom tool input hash for DB persistence and LLM injection.
107
+ #
108
+ # @return [Hash] tool input hash
109
+ def phantom_tool_input
110
+ PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name)
111
+ end
112
+
113
+ # Content formatted for display and history persistence.
114
+ # Sub-agent messages include an attribution prefix. Skill/workflow
115
+ # messages include a recall label. User messages pass through unchanged.
116
+ #
117
+ # @return [String]
118
+ def display_content
119
+ case source_type
120
+ when "subagent"
121
+ format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
122
+ when "skill"
123
+ "[recalled skill: #{source_name}]\n#{content}"
124
+ when "workflow"
125
+ "[recalled workflow: #{source_name}]\n#{content}"
126
+ when "goal"
127
+ "[goal #{source_name}]\n#{content}"
128
+ else
129
+ content
130
+ end
131
+ end
132
+
133
+ # Builds LLM message hashes for this pending message.
134
+ #
135
+ # Phantom pair types become synthetic tool_use/tool_result pairs so the
136
+ # LLM sees them as its own past invocations. User messages return plain
137
+ # content for injection as text blocks within the current tool_results turn.
138
+ #
139
+ # @return [Array<Hash>] synthetic tool pair for phantom pair types
140
+ # @return [String] raw content for user messages
141
+ def to_llm_messages
142
+ return content unless phantom_pair?
143
+
144
+ build_phantom_pair(phantom_tool_name, phantom_tool_input)
145
+ end
146
+
147
+ private
148
+
149
+ # Builds a phantom tool_use/tool_result message pair.
150
+ # Follows the same format for all non-user source types — the only
151
+ # difference is the tool name and input hash.
152
+ #
153
+ # Phantom pairs keep the system prompt stable for prompt caching (#395).
154
+ # Instead of injecting skills/workflows into the system prompt (which
155
+ # busts the cache on every change), they flow through the sliding window
156
+ # as messages the LLM "recalls" via phantom tool invocations.
157
+ #
158
+ # @param tool_name [String] phantom tool name (not in the agent's registry)
159
+ # @param input [Hash] tool input hash
160
+ # @return [Array<Hash>] two-element array: assistant tool_use + user tool_result
161
+ def build_phantom_pair(tool_name, input)
162
+ tool_use_id = "#{tool_name}_#{id}"
163
+ [
164
+ {role: "assistant", content: [
165
+ {type: "tool_use", id: tool_use_id, name: tool_name, input: input}
166
+ ]},
167
+ {role: "user", content: [
168
+ {type: "tool_result", tool_use_id: tool_use_id, content: content}
169
+ ]}
170
+ ]
171
+ end
172
+
173
+ # Broadcasts a pending message appearance so TUI clients render the
174
+ # dimmed indicator immediately.
175
+ def broadcast_created
176
+ ActionCable.server.broadcast("session_#{session_id}", {
177
+ "action" => "pending_message_created",
178
+ "pending_message_id" => id,
179
+ "content" => content
180
+ })
181
+ end
182
+
183
+ # Broadcasts pending message removal so TUI clients clear the entry.
184
+ # Fires on both promotion (normal flow) and recall (user edit).
185
+ def broadcast_removed
186
+ ActionCable.server.broadcast("session_#{session_id}", {
187
+ "action" => "pending_message_removed",
188
+ "pending_message_id" => id
189
+ })
190
+ end
191
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Encrypted key-value storage for runtime secrets (API tokens, credentials).
4
+ # Replaces Rails encrypted credentials for secrets that must be readable
5
+ # across forked Solid Queue workers without cache-busting hacks.
6
+ #
7
+ # Secrets are organized by namespace (e.g. +"anthropic"+, +"mcp"+) and key
8
+ # (e.g. +"subscription_token"+). Values are encrypted at rest using Active
9
+ # Record Encryption — only the +value+ column is encrypted; +namespace+ and
10
+ # +key+ are plain text for queryability.
11
+ #
12
+ # @!attribute namespace
13
+ # @return [String] grouping key (e.g. "anthropic", "mcp")
14
+ # @!attribute key
15
+ # @return [String] credential identifier within the namespace
16
+ # @!attribute value
17
+ # @return [String] the secret value (encrypted at rest)
18
+ class Secret < ApplicationRecord
19
+ encrypts :value
20
+
21
+ validates :namespace, presence: true
22
+ validates :key, presence: true
23
+ validates :value, presence: true
24
+ validates :key, uniqueness: {scope: :namespace}
25
+
26
+ # @!method self.for_namespace(ns)
27
+ # @param ns [String] namespace to filter by
28
+ # @return [ActiveRecord::Relation] secrets in the given namespace
29
+ scope :for_namespace, ->(ns) { where(namespace: ns) }
30
+
31
+ # Reads a single secret value.
32
+ #
33
+ # @param namespace [String] top-level grouping key
34
+ # @param key [String] credential key within the namespace
35
+ # @return [String, nil] decrypted value or nil if not found
36
+ def self.read(namespace, key)
37
+ find_by(namespace: namespace, key: key)&.value
38
+ end
39
+
40
+ # Writes one or more key-value pairs under a namespace.
41
+ # Each pair is upserted (insert or update). The entire batch is wrapped
42
+ # in a transaction so partial writes cannot occur.
43
+ #
44
+ # @param namespace [String] top-level grouping key
45
+ # @param pairs [Hash<String, String>] key-value pairs to store
46
+ # @return [void]
47
+ def self.write(namespace, pairs)
48
+ transaction do
49
+ pairs.each do |secret_key, secret_value|
50
+ record = find_or_initialize_by(namespace: namespace, key: secret_key)
51
+ record.update!(value: secret_value)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Lists all keys under a namespace (not values).
57
+ #
58
+ # @param namespace [String] top-level grouping key
59
+ # @return [Array<String>] credential keys
60
+ def self.list(namespace)
61
+ for_namespace(namespace).pluck(:key)
62
+ end
63
+
64
+ # Removes a single key from a namespace.
65
+ #
66
+ # @param namespace [String] top-level grouping key
67
+ # @param key [String] credential key to remove
68
+ # @return [void]
69
+ def self.remove(namespace, key)
70
+ find_by(namespace: namespace, key: key)&.destroy!
71
+ end
72
+ end