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.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +42 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- 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
|
|
4
|
-
# Follows the Turbo Streams pattern:
|
|
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
|
|
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 {
|
|
10
|
+
# token counts arrive asynchronously from {CountMessageTokensJob}).
|
|
11
11
|
#
|
|
12
|
-
# When a new
|
|
13
|
-
# the broadcast includes `
|
|
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
|
-
# "
|
|
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
|
|
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
|
-
|
|
50
|
+
broadcast_message(action: ACTION_CREATE)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def broadcast_update
|
|
54
|
-
|
|
54
|
+
broadcast_message(action: ACTION_UPDATE)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Decorates the
|
|
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
|
|
62
|
-
def
|
|
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 =
|
|
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["
|
|
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.
|
|
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 :
|
|
17
|
-
has_many :
|
|
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
|
|
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.
|
|
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
|