anima-core 1.1.3 → 1.2.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 +2 -0
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +1 -1
- data/app/channels/session_channel.rb +44 -43
- 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 +2 -2
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +3 -3
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +20 -20
- 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 +4 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/{event.rb → message.rb} +42 -39
- data/app/models/pinned_message.rb +41 -0
- data/app/models/session.rb +206 -198
- data/app/models/snapshot.rb +25 -25
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/lib/agent_loop.rb +6 -6
- data/lib/analytical_brain/runner.rb +35 -35
- 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/settings.rb +15 -4
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +7 -7
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- 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/tools/bash.rb +4 -12
- data/lib/tools/edit.rb +4 -6
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +4 -4
- data/lib/tools/registry.rb +1 -1
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/spawn_specialist.rb +12 -23
- data/lib/tools/spawn_subagent.rb +9 -19
- data/lib/tools/subagent_prompts.rb +0 -2
- data/lib/tools/think.rb +3 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +3 -3
- data/lib/tui/cable_client.rb +3 -3
- data/lib/tui/message_store.rb +37 -37
- data/lib/tui/screens/chat.rb +27 -15
- 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 +16 -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 +10 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- 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
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Supports two modes:
|
|
7
7
|
#
|
|
8
|
-
# **Immediate Persist (
|
|
8
|
+
# **Immediate Persist (message_id provided):** The user message was already
|
|
9
9
|
# persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
|
|
10
10
|
# The job verifies LLM delivery — if the first API call fails, the
|
|
11
|
-
#
|
|
11
|
+
# message is deleted and a {Events::BounceBack} is emitted so clients
|
|
12
12
|
# can restore the text to the input field.
|
|
13
13
|
#
|
|
14
|
-
# **Standard (no
|
|
14
|
+
# **Standard (no message_id):** Processes already-persisted messages (e.g.
|
|
15
15
|
# after pending message promotion). Uses ActiveJob retry/discard for
|
|
16
16
|
# error handling.
|
|
17
17
|
#
|
|
18
|
-
# @example Immediate Persist —
|
|
19
|
-
# AgentRequestJob.perform_later(session.id,
|
|
18
|
+
# @example Immediate Persist — message already saved by SessionChannel
|
|
19
|
+
# AgentRequestJob.perform_later(session.id, message_id: 42)
|
|
20
20
|
#
|
|
21
21
|
# @example Standard — pending message processing
|
|
22
22
|
# AgentRequestJob.perform_later(session.id)
|
|
@@ -49,8 +49,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# @param session_id [Integer] ID of the session to process
|
|
52
|
-
# @param
|
|
53
|
-
def perform(session_id,
|
|
52
|
+
# @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
|
|
53
|
+
def perform(session_id, message_id: nil)
|
|
54
54
|
session = Session.find(session_id)
|
|
55
55
|
|
|
56
56
|
# Atomic: only one job processes a session at a time.
|
|
@@ -60,8 +60,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
60
60
|
|
|
61
61
|
agent_loop = AgentLoop.new(session: session)
|
|
62
62
|
|
|
63
|
-
if
|
|
64
|
-
|
|
63
|
+
if message_id
|
|
64
|
+
deliver_persisted_message(session, message_id, agent_loop)
|
|
65
65
|
else
|
|
66
66
|
agent_loop.run
|
|
67
67
|
end
|
|
@@ -82,11 +82,11 @@ class AgentRequestJob < ApplicationJob
|
|
|
82
82
|
|
|
83
83
|
private
|
|
84
84
|
|
|
85
|
-
# Verifies LLM delivery for a pre-persisted user
|
|
85
|
+
# Verifies LLM delivery for a pre-persisted user message.
|
|
86
86
|
#
|
|
87
|
-
# The
|
|
87
|
+
# The message was already created and broadcast by the caller, so
|
|
88
88
|
# the user sees their message immediately. This method makes the
|
|
89
|
-
# first LLM API call — if it fails, the
|
|
89
|
+
# first LLM API call — if it fails, the message is deleted and a
|
|
90
90
|
# {Events::BounceBack} notifies clients to remove the phantom
|
|
91
91
|
# message and restore the text to the input field. For
|
|
92
92
|
# {Providers::Anthropic::AuthenticationError}, an additional
|
|
@@ -102,26 +102,26 @@ class AgentRequestJob < ApplicationJob
|
|
|
102
102
|
# execution, subsequent API calls).
|
|
103
103
|
#
|
|
104
104
|
# @param session [Session] the conversation session
|
|
105
|
-
# @param
|
|
105
|
+
# @param message_id [Integer] database ID of the pre-persisted user message
|
|
106
106
|
# @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
#
|
|
107
|
+
def deliver_persisted_message(session, message_id, agent_loop)
|
|
108
|
+
message = Message.find_by(id: message_id, session_id: session.id)
|
|
109
|
+
# Message may have been deleted between SessionChannel#speak and job
|
|
110
110
|
# execution (e.g. user recalled the message). Exit silently — there
|
|
111
111
|
# is nothing to deliver or bounce back.
|
|
112
|
-
return unless
|
|
112
|
+
return unless message
|
|
113
113
|
|
|
114
|
-
content =
|
|
114
|
+
content = message.payload["content"]
|
|
115
115
|
|
|
116
116
|
begin
|
|
117
117
|
agent_loop.deliver!
|
|
118
118
|
rescue => error
|
|
119
|
-
|
|
119
|
+
message.destroy!
|
|
120
120
|
Events::Bus.emit(Events::BounceBack.new(
|
|
121
121
|
content: content,
|
|
122
122
|
error: error.message,
|
|
123
123
|
session_id: session.id,
|
|
124
|
-
|
|
124
|
+
message_id: message_id
|
|
125
125
|
))
|
|
126
126
|
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
127
127
|
return
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Counts tokens in a message's payload via the Anthropic API and
|
|
4
|
+
# caches the result on the message record. Enqueued automatically
|
|
5
|
+
# after each LLM message is created.
|
|
6
|
+
class CountMessageTokensJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
|
|
10
|
+
discard_on ActiveRecord::RecordNotFound
|
|
11
|
+
|
|
12
|
+
# @param message_id [Integer] the Message record to count tokens for
|
|
13
|
+
def perform(message_id)
|
|
14
|
+
message = Message.find(message_id)
|
|
15
|
+
return if already_counted?(message)
|
|
16
|
+
|
|
17
|
+
provider = Providers::Anthropic.new
|
|
18
|
+
api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
|
|
19
|
+
|
|
20
|
+
token_count = provider.count_tokens(
|
|
21
|
+
model: Anima::Settings.model,
|
|
22
|
+
messages: api_messages
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Guard against parallel jobs: reload and re-check before writing.
|
|
26
|
+
# Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
|
|
27
|
+
# broadcasts the updated token count to connected clients.
|
|
28
|
+
message.reload
|
|
29
|
+
return if already_counted?(message)
|
|
30
|
+
|
|
31
|
+
message.update!(token_count: token_count)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def already_counted?(message)
|
|
37
|
+
message.token_count > 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Runs passive recall after goal updates — searches
|
|
3
|
+
# Runs passive recall after goal updates — searches message history for
|
|
4
4
|
# context relevant to active goals and caches results on the session
|
|
5
5
|
# for viewport injection.
|
|
6
6
|
#
|
|
@@ -20,10 +20,10 @@ class PassiveRecallJob < ApplicationJob
|
|
|
20
20
|
results = Mneme::PassiveRecall.new(session).call
|
|
21
21
|
|
|
22
22
|
if results.any?
|
|
23
|
-
session.update_column(:
|
|
23
|
+
session.update_column(:recalled_message_ids, results.map(&:message_id))
|
|
24
24
|
Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
|
|
25
|
-
elsif session.
|
|
26
|
-
session.update_column(:
|
|
25
|
+
elsif session.recalled_message_ids.present?
|
|
26
|
+
session.update_column(:recalled_message_ids, [])
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -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}
|
|
@@ -52,14 +52,14 @@ class Goal < ApplicationRecord
|
|
|
52
52
|
sub_goals.active.update_all(status: "completed", completed_at: now, updated_at: now)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Releases pinned
|
|
55
|
+
# Releases pinned messages that have no remaining active Goal references
|
|
56
56
|
# anywhere in the session. Called after goal (and cascade) completion —
|
|
57
57
|
# the orphaned scope checks all Goals, so pins shared with other active
|
|
58
58
|
# Goals survive automatically via reference counting.
|
|
59
59
|
#
|
|
60
60
|
# @return [Integer] number of released pins
|
|
61
61
|
def release_orphaned_pins!
|
|
62
|
-
orphaned = session.
|
|
62
|
+
orphaned = session.pinned_messages.orphaned
|
|
63
63
|
orphaned.destroy_all.size
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -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
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# A persisted record of
|
|
4
|
-
#
|
|
5
|
-
# there is no separate chat log, only
|
|
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
6
|
#
|
|
7
|
-
#
|
|
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
|
|
8
11
|
# @return [String] one of {TYPES}: system_message, user_message,
|
|
9
12
|
# agent_message, tool_call, tool_response
|
|
10
13
|
# @!attribute payload
|
|
11
|
-
# @return [Hash]
|
|
14
|
+
# @return [Hash] message-specific data (content, tool_name, tool_input, etc.)
|
|
12
15
|
# @!attribute timestamp
|
|
13
16
|
# @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
|
|
14
17
|
# @!attribute token_count
|
|
15
|
-
# @return [Integer] cached token count for this
|
|
18
|
+
# @return [Integer] cached token count for this message's payload (0 until counted)
|
|
16
19
|
# @!attribute tool_use_id
|
|
17
|
-
# @return [String] ID correlating tool_call and tool_response
|
|
20
|
+
# @return [String] ID correlating tool_call and tool_response messages
|
|
18
21
|
# (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
|
|
19
|
-
# required for tool_call and tool_response
|
|
20
|
-
class
|
|
21
|
-
include
|
|
22
|
+
# required for tool_call and tool_response messages)
|
|
23
|
+
class Message < ApplicationRecord
|
|
24
|
+
include Message::Broadcasting
|
|
22
25
|
|
|
23
26
|
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
24
27
|
LLM_TYPES = %w[user_message agent_message].freeze
|
|
@@ -28,7 +31,7 @@ class Event < ApplicationRecord
|
|
|
28
31
|
SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
|
|
29
32
|
PENDING_STATUS = "pending"
|
|
30
33
|
|
|
31
|
-
#
|
|
34
|
+
# Message types that require a tool_use_id to pair call with response.
|
|
32
35
|
TOOL_TYPES = %w[tool_call tool_response].freeze
|
|
33
36
|
|
|
34
37
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
@@ -37,25 +40,25 @@ class Event < ApplicationRecord
|
|
|
37
40
|
BYTES_PER_TOKEN = 4
|
|
38
41
|
|
|
39
42
|
belongs_to :session
|
|
40
|
-
has_many :
|
|
43
|
+
has_many :pinned_messages, dependent: :destroy
|
|
41
44
|
|
|
42
|
-
validates :
|
|
45
|
+
validates :message_type, presence: true, inclusion: {in: TYPES}
|
|
43
46
|
validates :payload, presence: true
|
|
44
47
|
validates :timestamp, presence: true
|
|
45
48
|
# Anthropic requires every tool_use to have a matching tool_result with the same ID
|
|
46
|
-
validates :tool_use_id, presence: true, if: -> {
|
|
49
|
+
validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
|
|
47
50
|
|
|
48
51
|
after_create :schedule_token_count, if: :llm_message?
|
|
49
52
|
|
|
50
53
|
# @!method self.llm_messages
|
|
51
|
-
#
|
|
54
|
+
# Messages that represent conversation turns sent to the LLM API.
|
|
52
55
|
# @return [ActiveRecord::Relation]
|
|
53
|
-
scope :llm_messages, -> { where(
|
|
56
|
+
scope :llm_messages, -> { where(message_type: LLM_TYPES) }
|
|
54
57
|
|
|
55
|
-
# @!method self.
|
|
56
|
-
#
|
|
58
|
+
# @!method self.context_messages
|
|
59
|
+
# Messages included in the LLM context window (conversation + tool interactions).
|
|
57
60
|
# @return [ActiveRecord::Relation]
|
|
58
|
-
scope :
|
|
61
|
+
scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
|
|
59
62
|
|
|
60
63
|
# @!method self.pending
|
|
61
64
|
# User messages queued during active agent processing, not yet sent to LLM.
|
|
@@ -63,36 +66,36 @@ class Event < ApplicationRecord
|
|
|
63
66
|
scope :pending, -> { where(status: PENDING_STATUS) }
|
|
64
67
|
|
|
65
68
|
# @!method self.deliverable
|
|
66
|
-
#
|
|
69
|
+
# Messages eligible for LLM context (excludes pending messages).
|
|
67
70
|
# NULL status means delivered/processed — the only excluded value is "pending".
|
|
68
71
|
# @return [ActiveRecord::Relation]
|
|
69
72
|
scope :deliverable, -> { where(status: nil) }
|
|
70
73
|
|
|
71
|
-
# @!method self.
|
|
72
|
-
# Excludes spawn_subagent/spawn_specialist tool_call and tool_response
|
|
73
|
-
# Used when building parent context for sub-agents — spawn
|
|
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
|
|
74
77
|
# confusion because the sub-agent sees sibling spawn results and mistakes
|
|
75
78
|
# itself for the parent.
|
|
76
79
|
# @return [ActiveRecord::Relation]
|
|
77
|
-
scope :
|
|
78
|
-
where.not("
|
|
80
|
+
scope :excluding_spawn_messages, -> {
|
|
81
|
+
where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
|
|
79
82
|
TOOL_TYPES, SPAWN_TOOLS)
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
# Maps
|
|
85
|
+
# Maps message_type to the Anthropic Messages API role.
|
|
83
86
|
# @return [String] "user" or "assistant"
|
|
84
87
|
def api_role
|
|
85
|
-
ROLE_MAP.fetch(
|
|
88
|
+
ROLE_MAP.fetch(message_type)
|
|
86
89
|
end
|
|
87
90
|
|
|
88
|
-
# @return [Boolean] true if this
|
|
91
|
+
# @return [Boolean] true if this message represents an LLM conversation turn
|
|
89
92
|
def llm_message?
|
|
90
|
-
|
|
93
|
+
message_type.in?(LLM_TYPES)
|
|
91
94
|
end
|
|
92
95
|
|
|
93
|
-
# @return [Boolean] true if this
|
|
94
|
-
def
|
|
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)
|
|
96
99
|
end
|
|
97
100
|
|
|
98
101
|
# @return [Boolean] true if this is a pending message not yet sent to the LLM
|
|
@@ -100,20 +103,20 @@ class Event < ApplicationRecord
|
|
|
100
103
|
status == PENDING_STATUS
|
|
101
104
|
end
|
|
102
105
|
|
|
103
|
-
# @return [Boolean] true if this is a conversation
|
|
104
|
-
# or a think tool_call — the
|
|
106
|
+
# @return [Boolean] true if this is a conversation message (user/agent/system)
|
|
107
|
+
# or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
|
|
105
108
|
def conversation_or_think?
|
|
106
|
-
|
|
107
|
-
(
|
|
109
|
+
message_type.in?(CONVERSATION_TYPES) ||
|
|
110
|
+
(message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# Heuristic token estimate: ~4 bytes per token for English prose.
|
|
111
|
-
# Tool
|
|
114
|
+
# Tool messages are estimated from the full payload JSON since tool_input
|
|
112
115
|
# and tool metadata contribute to token count. Messages use content only.
|
|
113
116
|
#
|
|
114
117
|
# @return [Integer] estimated token count (at least 1)
|
|
115
118
|
def estimate_tokens
|
|
116
|
-
text = if
|
|
119
|
+
text = if message_type.in?(TOOL_TYPES)
|
|
117
120
|
payload.to_json
|
|
118
121
|
else
|
|
119
122
|
payload["content"].to_s
|
|
@@ -124,6 +127,6 @@ class Event < ApplicationRecord
|
|
|
124
127
|
private
|
|
125
128
|
|
|
126
129
|
def schedule_token_count
|
|
127
|
-
|
|
130
|
+
CountMessageTokensJob.perform_later(id)
|
|
128
131
|
end
|
|
129
132
|
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
|