anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Broadcasts Event records to connected WebSocket clients via ActionCable.
4
- # Follows the Turbo Streams pattern: events are broadcast on both create
3
+ # Broadcasts Message records to connected WebSocket clients via ActionCable.
4
+ # Follows the Turbo Streams pattern: messages are broadcast on both create
5
5
  # and update, with an action type so clients can distinguish append from
6
6
  # replace operations.
7
7
  #
8
- # Each broadcast includes the Event's database ID, enabling clients to
8
+ # Each broadcast includes the Message's database ID, enabling clients to
9
9
  # maintain an ID-indexed store for efficient in-place updates (e.g. when
10
- # token counts arrive asynchronously from {CountEventTokensJob}).
10
+ # token counts arrive asynchronously from {CountMessageTokensJob}).
11
11
  #
12
- # When a new event pushes old events out of the LLM's context window,
13
- # the broadcast includes `evicted_event_ids` so clients can remove
12
+ # When a new message pushes old messages out of the LLM's context window,
13
+ # the broadcast includes `evicted_message_ids` so clients can remove
14
14
  # phantom messages that the agent no longer knows about.
15
15
  #
16
16
  # @example Create broadcast payload
@@ -24,7 +24,7 @@
24
24
  # {
25
25
  # "type" => "agent_message", "content" => "...", ...,
26
26
  # "id" => 99, "action" => "create",
27
- # "evicted_event_ids" => [101, 102, 103]
27
+ # "evicted_message_ids" => [101, 102, 103]
28
28
  # }
29
29
  #
30
30
  # @example Update broadcast payload (e.g. token count arrives)
@@ -33,7 +33,7 @@
33
33
  # "id" => 42, "action" => "update",
34
34
  # "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
35
35
  # }
36
- module Event::Broadcasting
36
+ module Message::Broadcasting
37
37
  extend ActiveSupport::Concern
38
38
 
39
39
  ACTION_CREATE = "create"
@@ -47,26 +47,26 @@ module Event::Broadcasting
47
47
  private
48
48
 
49
49
  def broadcast_create
50
- broadcast_event(action: ACTION_CREATE)
50
+ broadcast_message(action: ACTION_CREATE)
51
51
  end
52
52
 
53
53
  def broadcast_update
54
- broadcast_event(action: ACTION_UPDATE)
54
+ broadcast_message(action: ACTION_UPDATE)
55
55
  end
56
56
 
57
- # Decorates the event for the session's current view mode and broadcasts
57
+ # Decorates the message for the session's current view mode and broadcasts
58
58
  # the payload to the session's ActionCable stream. Includes viewport
59
59
  # eviction metadata so clients can remove messages the LLM has forgotten.
60
60
  #
61
- # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the event
62
- def broadcast_event(action:)
61
+ # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the message
62
+ def broadcast_message(action:)
63
63
  return unless session_id
64
64
 
65
65
  session = Session.find_by(id: session_id)
66
66
  return unless session
67
67
 
68
68
  mode = session.view_mode
69
- decorator = EventDecorator.for(self)
69
+ decorator = MessageDecorator.for(self)
70
70
  broadcast_payload = payload.merge("id" => id, "action" => action)
71
71
 
72
72
  if decorator
@@ -74,11 +74,11 @@ module Event::Broadcasting
74
74
  end
75
75
 
76
76
  evicted_ids = session.recalculate_viewport!
77
- broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
77
+ broadcast_payload["evicted_message_ids"] = evicted_ids if evicted_ids.any?
78
78
 
79
79
  # The nil? branch fires on every broadcast until boundary initializes, but
80
80
  # schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
81
- session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
81
+ session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_message_id.nil?
82
82
 
83
83
  ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
84
84
  end
data/app/models/goal.rb CHANGED
@@ -13,8 +13,8 @@ class Goal < ApplicationRecord
13
13
  belongs_to :session
14
14
  belongs_to :parent_goal, class_name: "Goal", optional: true
15
15
  has_many :sub_goals, -> { order(:created_at) }, class_name: "Goal", foreign_key: :parent_goal_id, dependent: :destroy
16
- has_many :goal_pinned_events, dependent: :destroy
17
- has_many :pinned_events, through: :goal_pinned_events
16
+ has_many :goal_pinned_messages, dependent: :destroy
17
+ has_many :pinned_messages, through: :goal_pinned_messages
18
18
 
19
19
  validates :description, presence: true
20
20
  validates :status, inclusion: {in: STATUSES}
@@ -25,6 +25,16 @@ 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 pending eviction — visible to the brain for age-based review.
35
+ # @return [ActiveRecord::Relation]
36
+ scope :evictable, -> { completed.where(evicted_at: nil) }
37
+
28
38
  after_commit :broadcast_goals_update
29
39
  after_commit :schedule_passive_recall, on: [:create, :update]
30
40
 
@@ -37,6 +47,9 @@ class Goal < ApplicationRecord
37
47
  # @return [Boolean] true if this is a root goal (no parent)
38
48
  def root? = !parent_goal_id
39
49
 
50
+ # @return [Boolean] true if this goal has been evicted from display
51
+ def evicted? = evicted_at.present?
52
+
40
53
  # Cascades completion to all active sub-goals. Called when a root goal
41
54
  # is finished — remaining sub-items are implicitly resolved because
42
55
  # the semantic episode that spawned them has ended.
@@ -52,14 +65,14 @@ class Goal < ApplicationRecord
52
65
  sub_goals.active.update_all(status: "completed", completed_at: now, updated_at: now)
53
66
  end
54
67
 
55
- # Releases pinned events that have no remaining active Goal references
68
+ # Releases pinned messages that have no remaining active Goal references
56
69
  # anywhere in the session. Called after goal (and cascade) completion —
57
70
  # the orphaned scope checks all Goals, so pins shared with other active
58
71
  # Goals survive automatically via reference counting.
59
72
  #
60
73
  # @return [Integer] number of released pins
61
74
  def release_orphaned_pins!
62
- orphaned = session.pinned_events.orphaned
75
+ orphaned = session.pinned_messages.orphaned
63
76
  orphaned.destroy_all.size
64
77
  end
65
78
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Join record linking a {Goal} to a {PinnedMessage}. Many-to-many: one message
4
+ # can be pinned to multiple Goals, and one Goal can reference multiple pins.
5
+ # When the last Goal referencing a pin completes, the pin is released.
6
+ class GoalPinnedMessage < ApplicationRecord
7
+ belongs_to :goal
8
+ belongs_to :pinned_message
9
+
10
+ validates :pinned_message_id, uniqueness: {scope: :goal_id}
11
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A persisted record of what was said during a session — by whom and when.
4
+ # Messages are the single source of truth for conversation history —
5
+ # there is no separate chat log, only messages attached to a session.
6
+ #
7
+ # Not to be confused with {Events::Base} (transient bus signals).
8
+ # Messages persist to SQLite; events flow through the bus and are gone.
9
+ #
10
+ # @!attribute message_type
11
+ # @return [String] one of {TYPES}: system_message, user_message,
12
+ # agent_message, tool_call, tool_response
13
+ # @!attribute payload
14
+ # @return [Hash] message-specific data (content, tool_name, tool_input, etc.)
15
+ # @!attribute timestamp
16
+ # @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
17
+ # @!attribute token_count
18
+ # @return [Integer] cached token count for this message's payload (0 until counted)
19
+ # @!attribute tool_use_id
20
+ # @return [String] ID correlating tool_call and tool_response messages
21
+ # (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
22
+ # required for tool_call and tool_response messages)
23
+ class Message < ApplicationRecord
24
+ include Message::Broadcasting
25
+
26
+ TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
27
+ LLM_TYPES = %w[user_message agent_message].freeze
28
+ CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
29
+ CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
30
+ THINK_TOOL = "think"
31
+ SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
32
+
33
+ # Message types that require a tool_use_id to pair call with response.
34
+ TOOL_TYPES = %w[tool_call tool_response].freeze
35
+
36
+ ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
37
+
38
+ # Heuristic: average bytes per token for English prose.
39
+ BYTES_PER_TOKEN = 4
40
+
41
+ # Synthetic ID for system prompt entries in the TUI message store.
42
+ # Real message IDs are positive integers from the database, so 0
43
+ # is safe for deduplication without collision risk.
44
+ SYSTEM_PROMPT_ID = 0
45
+
46
+ # Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
47
+ # @param bytesize [Integer] number of bytes
48
+ # @return [Integer] estimated token count (at least 1)
49
+ def self.estimate_token_count(bytesize)
50
+ [(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
51
+ end
52
+
53
+ belongs_to :session
54
+ has_many :pinned_messages, dependent: :destroy
55
+
56
+ validates :message_type, presence: true, inclusion: {in: TYPES}
57
+ validates :payload, presence: true
58
+ validates :timestamp, presence: true
59
+ # Anthropic requires every tool_use to have a matching tool_result with the same ID
60
+ validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
61
+
62
+ after_create :schedule_token_count, if: :llm_message?
63
+
64
+ # @!method self.llm_messages
65
+ # Messages that represent conversation turns sent to the LLM API.
66
+ # @return [ActiveRecord::Relation]
67
+ scope :llm_messages, -> { where(message_type: LLM_TYPES) }
68
+
69
+ # @!method self.context_messages
70
+ # Messages included in the LLM context window (conversation + tool interactions).
71
+ # @return [ActiveRecord::Relation]
72
+ scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
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
+ # Maps message_type to the Anthropic Messages API role.
86
+ # @return [String] "user" or "assistant"
87
+ def api_role
88
+ ROLE_MAP.fetch(message_type)
89
+ end
90
+
91
+ # @return [Boolean] true if this message represents an LLM conversation turn
92
+ def llm_message?
93
+ message_type.in?(LLM_TYPES)
94
+ end
95
+
96
+ # @return [Boolean] true if this message is part of the LLM context window
97
+ def context_message?
98
+ message_type.in?(CONTEXT_TYPES)
99
+ end
100
+
101
+ # @return [Boolean] true if this is a conversation message (user/agent/system)
102
+ # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
103
+ def conversation_or_think?
104
+ message_type.in?(CONVERSATION_TYPES) ||
105
+ (message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
106
+ end
107
+
108
+ # Heuristic token estimate: ~4 bytes per token for English prose.
109
+ # Tool messages are estimated from the full payload JSON since tool_input
110
+ # and tool metadata contribute to token count. Messages use content only.
111
+ #
112
+ # @return [Integer] estimated token count (at least 1)
113
+ def estimate_tokens
114
+ text = if message_type.in?(TOOL_TYPES)
115
+ payload.to_json
116
+ else
117
+ payload["content"].to_s
118
+ end
119
+ self.class.estimate_token_count(text.bytesize)
120
+ end
121
+
122
+ private
123
+
124
+ def schedule_token_count
125
+ CountMessageTokensJob.perform_later(id)
126
+ end
127
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A user 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 user sends a message 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
+ # @see Session#enqueue_user_message
14
+ # @see Session#promote_pending_messages!
15
+ class PendingMessage < ApplicationRecord
16
+ belongs_to :session
17
+
18
+ validates :content, presence: true
19
+
20
+ after_create_commit :broadcast_created
21
+ after_destroy_commit :broadcast_removed
22
+
23
+ private
24
+
25
+ # Broadcasts a pending message appearance so TUI clients render the
26
+ # dimmed indicator immediately.
27
+ def broadcast_created
28
+ ActionCable.server.broadcast("session_#{session_id}", {
29
+ "action" => "pending_message_created",
30
+ "pending_message_id" => id,
31
+ "content" => content
32
+ })
33
+ end
34
+
35
+ # Broadcasts pending message removal so TUI clients clear the entry.
36
+ # Fires on both promotion (normal flow) and recall (user edit).
37
+ def broadcast_removed
38
+ ActionCable.server.broadcast("session_#{session_id}", {
39
+ "action" => "pending_message_removed",
40
+ "pending_message_id" => id
41
+ })
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A conversation message pinned to one or more Goals by Mneme to protect it
4
+ # from viewport eviction. Pinned messages appear in the Goals section of
5
+ # the viewport, giving the main agent access to critical context that
6
+ # would otherwise scroll out of the sliding window.
7
+ #
8
+ # Pinning is goal-scoped: when all Goals referencing a pin complete,
9
+ # the pin is automatically released (reference-counted cleanup).
10
+ #
11
+ # @!attribute display_text
12
+ # @return [String] truncated message content (~200 chars) shown in the Goals section
13
+ class PinnedMessage < ApplicationRecord
14
+ # Display text limit — enough to recognize content, cheap on tokens.
15
+ MAX_DISPLAY_TEXT_LENGTH = 200
16
+
17
+ belongs_to :message
18
+
19
+ has_many :goal_pinned_messages, dependent: :destroy
20
+ has_many :goals, through: :goal_pinned_messages
21
+
22
+ validates :display_text, presence: true, length: {maximum: MAX_DISPLAY_TEXT_LENGTH}
23
+ validates :message_id, uniqueness: true
24
+
25
+ # Pinned messages with no remaining active goals — safe to release.
26
+ #
27
+ # @return [ActiveRecord::Relation]
28
+ scope :orphaned, -> {
29
+ where.not(
30
+ "EXISTS (SELECT 1 FROM goal_pinned_messages gpm " \
31
+ "JOIN goals ON goals.id = gpm.goal_id " \
32
+ "WHERE gpm.pinned_message_id = pinned_messages.id " \
33
+ "AND goals.status = 'active')"
34
+ )
35
+ }
36
+
37
+ # @return [Integer] token cost estimate for viewport budget accounting
38
+ def token_cost
39
+ [(display_text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
40
+ end
41
+ 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