anima-core 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +18 -20
- data/README.md +61 -95
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +13 -2
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +468 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +118 -171
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/shell_session.rb +287 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +16 -10
- data/lib/tools/spawn_subagent.rb +20 -14
- data/lib/tools/subagent_prompts.rb +4 -4
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +86 -28
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -204
- data/lib/mneme/passive_recall.rb +0 -138
data/app/models/goal.rb
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# A persistent objective tracked by
|
|
3
|
+
# A persistent objective tracked by Melete during a session.
|
|
4
4
|
# Goals form a two-level hierarchy: root goals represent high-level
|
|
5
5
|
# objectives (semantic episodes), while sub-goals are TODO-style steps
|
|
6
6
|
# rendered as checklist items in the agent's system prompt.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
8
|
+
# Melete creates and completes goals; the main agent sees
|
|
9
9
|
# them in its context window but never manages them directly.
|
|
10
10
|
class Goal < ApplicationRecord
|
|
11
11
|
STATUSES = %w[active completed].freeze
|
|
@@ -26,7 +26,7 @@ class Goal < ApplicationRecord
|
|
|
26
26
|
scope :root, -> { where(parent_goal_id: nil) }
|
|
27
27
|
|
|
28
28
|
# @!method self.not_evicted
|
|
29
|
-
# Goals still visible in context (not yet evicted by
|
|
29
|
+
# Goals still visible in context (not yet evicted by Melete).
|
|
30
30
|
# @return [ActiveRecord::Relation]
|
|
31
31
|
scope :not_evicted, -> { where(evicted_at: nil) }
|
|
32
32
|
|
|
@@ -37,7 +37,8 @@ class Goal < ApplicationRecord
|
|
|
37
37
|
scope :evictable, -> { completed.where(evicted_at: nil) }
|
|
38
38
|
|
|
39
39
|
after_commit :broadcast_goals_update
|
|
40
|
-
after_commit :
|
|
40
|
+
after_commit :emit_goal_created, on: :create
|
|
41
|
+
after_commit :emit_goal_updated, on: :update, if: :saved_change_to_description?
|
|
41
42
|
|
|
42
43
|
# @return [Boolean] true if this goal has been completed
|
|
43
44
|
def completed? = status == "completed"
|
|
@@ -56,7 +57,7 @@ class Goal < ApplicationRecord
|
|
|
56
57
|
# the semantic episode that spawned them has ended.
|
|
57
58
|
#
|
|
58
59
|
# Uses +update_all+ to avoid N per-record +after_commit+ broadcasts;
|
|
59
|
-
# the caller ({
|
|
60
|
+
# the caller ({Melete::Tools::FinishGoal}) wraps the whole
|
|
60
61
|
# operation in a transaction so the root goal's single broadcast
|
|
61
62
|
# includes the cascaded state.
|
|
62
63
|
#
|
|
@@ -107,14 +108,24 @@ class Goal < ApplicationRecord
|
|
|
107
108
|
errors.add(:parent_goal, "cannot nest deeper than two levels")
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
111
|
+
# Announces a freshly created goal so the active drain pipeline can
|
|
112
|
+
# decide whether Mneme should recall against the updated goal set.
|
|
113
|
+
# Only {MeleteEnrichmentJob}'s scoped listener observes these events;
|
|
114
|
+
# outside of a Melete run they are silently dropped.
|
|
112
115
|
#
|
|
113
116
|
# @return [void]
|
|
114
|
-
def
|
|
115
|
-
|
|
117
|
+
def emit_goal_created
|
|
118
|
+
Events::Bus.emit(Events::GoalCreated.new(session_id: session_id, goal_id: id))
|
|
119
|
+
end
|
|
116
120
|
|
|
117
|
-
|
|
121
|
+
# Announces a description-level change to an existing goal. Status-only
|
|
122
|
+
# updates (finish, cascade, mark_goal_completed) are filtered out by the
|
|
123
|
+
# +saved_change_to_description?+ guard on the callback — a completed goal
|
|
124
|
+
# carries no new search seed for Mneme.
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
def emit_goal_updated
|
|
128
|
+
Events::Bus.emit(Events::GoalUpdated.new(session_id: session_id, goal_id: id))
|
|
118
129
|
end
|
|
119
130
|
|
|
120
131
|
# Broadcasts goal changes to all clients subscribed to this session.
|
data/app/models/message.rb
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
# Not to be confused with {Events::Base} (transient bus signals).
|
|
8
8
|
# Messages persist to SQLite; events flow through the bus and are gone.
|
|
9
9
|
#
|
|
10
|
+
# After commit, emits {Events::MessageCreated} and {Events::MessageUpdated}
|
|
11
|
+
# lifecycle events so subscribers ({Events::Subscribers::MessageBroadcaster},
|
|
12
|
+
# {Events::Subscribers::MnemeScheduler}) can react without coupling.
|
|
13
|
+
#
|
|
10
14
|
# @!attribute message_type
|
|
11
15
|
# @return [String] one of {TYPES}: system_message, user_message,
|
|
12
16
|
# agent_message, tool_call, tool_response
|
|
@@ -15,17 +19,18 @@
|
|
|
15
19
|
# @!attribute timestamp
|
|
16
20
|
# @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
|
|
17
21
|
# @!attribute token_count
|
|
18
|
-
# @return [Integer]
|
|
22
|
+
# @return [Integer] token count for this message's payload. Seeded with
|
|
23
|
+
# a local estimate on create and later refined by {CountTokensJob} using
|
|
24
|
+
# the real Anthropic tokenizer. Always positive — never zero or nil.
|
|
19
25
|
# @!attribute tool_use_id
|
|
20
26
|
# @return [String] ID correlating tool_call and tool_response messages
|
|
21
27
|
# (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
|
|
22
28
|
# required for tool_call and tool_response messages)
|
|
23
29
|
class Message < ApplicationRecord
|
|
24
|
-
include
|
|
30
|
+
include TokenEstimation
|
|
25
31
|
|
|
26
32
|
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
27
33
|
LLM_TYPES = %w[user_message agent_message].freeze
|
|
28
|
-
CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
29
34
|
CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
|
|
30
35
|
THINK_TOOL = "think"
|
|
31
36
|
# Message types that require a tool_use_id to pair call with response.
|
|
@@ -33,21 +38,11 @@ class Message < ApplicationRecord
|
|
|
33
38
|
|
|
34
39
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
35
40
|
|
|
36
|
-
# Heuristic: average bytes per token for English prose.
|
|
37
|
-
BYTES_PER_TOKEN = 4
|
|
38
|
-
|
|
39
41
|
# Synthetic ID for system prompt entries in the TUI message store.
|
|
40
42
|
# Real message IDs are positive integers from the database, so 0
|
|
41
43
|
# is safe for deduplication without collision risk.
|
|
42
44
|
SYSTEM_PROMPT_ID = 0
|
|
43
45
|
|
|
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
|
-
|
|
51
46
|
belongs_to :session
|
|
52
47
|
has_many :pinned_messages, dependent: :destroy
|
|
53
48
|
|
|
@@ -57,17 +52,23 @@ class Message < ApplicationRecord
|
|
|
57
52
|
# Anthropic requires every tool_use to have a matching tool_result with the same ID
|
|
58
53
|
validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
after_create_commit :emit_created_event
|
|
56
|
+
after_update_commit :emit_updated_event
|
|
61
57
|
|
|
62
58
|
# @!method self.llm_messages
|
|
63
59
|
# Messages that represent conversation turns sent to the LLM API.
|
|
64
60
|
# @return [ActiveRecord::Relation]
|
|
65
61
|
scope :llm_messages, -> { where(message_type: LLM_TYPES) }
|
|
66
62
|
|
|
67
|
-
# @!method self.
|
|
68
|
-
#
|
|
63
|
+
# @!method self.conversation_or_think
|
|
64
|
+
# Conversation messages (user/agent/system) and think tool_calls —
|
|
65
|
+
# the messages Mneme treats as boundary-eligible.
|
|
69
66
|
# @return [ActiveRecord::Relation]
|
|
70
|
-
scope :
|
|
67
|
+
scope :conversation_or_think, -> {
|
|
68
|
+
where(message_type: CONVERSATION_TYPES)
|
|
69
|
+
.or(where(message_type: "tool_call")
|
|
70
|
+
.where("json_extract(payload, '$.tool_name') = ?", THINK_TOOL))
|
|
71
|
+
}
|
|
71
72
|
|
|
72
73
|
# Maps message_type to the Anthropic Messages API role.
|
|
73
74
|
# @return [String] "user" or "assistant"
|
|
@@ -75,16 +76,6 @@ class Message < ApplicationRecord
|
|
|
75
76
|
ROLE_MAP.fetch(message_type)
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
# @return [Boolean] true if this message represents an LLM conversation turn
|
|
79
|
-
def llm_message?
|
|
80
|
-
message_type.in?(LLM_TYPES)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# @return [Boolean] true if this message is part of the LLM context window
|
|
84
|
-
def context_message?
|
|
85
|
-
message_type.in?(CONTEXT_TYPES)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
79
|
# @return [Boolean] true if this is a conversation message (user/agent/system)
|
|
89
80
|
# or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
|
|
90
81
|
def conversation_or_think?
|
|
@@ -92,23 +83,43 @@ class Message < ApplicationRecord
|
|
|
92
83
|
(message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
|
|
93
84
|
end
|
|
94
85
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# and
|
|
86
|
+
# String fed to the token estimator and the remote tokenizer. Tool
|
|
87
|
+
# messages serialize the full payload as JSON so +tool_name+, +tool_input+,
|
|
88
|
+
# and +tool_use_id+ contribute to the count; conversation messages use
|
|
89
|
+
# the content field only.
|
|
98
90
|
#
|
|
99
|
-
# @return [
|
|
100
|
-
def
|
|
101
|
-
|
|
91
|
+
# @return [String]
|
|
92
|
+
def tokenization_text
|
|
93
|
+
if message_type.in?(TOOL_TYPES)
|
|
102
94
|
payload.to_json
|
|
103
95
|
else
|
|
104
96
|
payload["content"].to_s
|
|
105
97
|
end
|
|
106
|
-
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Draper hook: picks the concrete decorator subclass based on
|
|
101
|
+
# {#message_type}. Overrides {Draper::Decoratable#decorator_class},
|
|
102
|
+
# which would otherwise default to the abstract {MessageDecorator}
|
|
103
|
+
# base class. Called implicitly by +message.decorate+.
|
|
104
|
+
#
|
|
105
|
+
# @return [Class] a {MessageDecorator} subclass
|
|
106
|
+
def decorator_class
|
|
107
|
+
case message_type
|
|
108
|
+
when "user_message" then UserMessageDecorator
|
|
109
|
+
when "agent_message" then AgentMessageDecorator
|
|
110
|
+
when "system_message" then SystemMessageDecorator
|
|
111
|
+
when "tool_call" then ToolCallDecorator
|
|
112
|
+
when "tool_response" then ToolResponseDecorator
|
|
113
|
+
end
|
|
107
114
|
end
|
|
108
115
|
|
|
109
116
|
private
|
|
110
117
|
|
|
111
|
-
def
|
|
112
|
-
|
|
118
|
+
def emit_created_event
|
|
119
|
+
Events::Bus.emit(Events::MessageCreated.new(self))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def emit_updated_event
|
|
123
|
+
Events::Bus.emit(Events::MessageUpdated.new(self))
|
|
113
124
|
end
|
|
114
125
|
end
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
# message stream and have no database ID that could interleave with
|
|
6
6
|
# tool_call/tool_response pairs.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# Entry point of the event-driven drain pipeline. Every inbound
|
|
9
|
+
# message destined for the LLM — user input, tool responses,
|
|
10
|
+
# sub-agent replies, Mneme recalls, Melete skills/goals — lands here
|
|
11
|
+
# first, then gets promoted into a real {Message} by {DrainJob}.
|
|
12
12
|
#
|
|
13
13
|
# Each pending message knows its source (+source_type+, +source_name+)
|
|
14
14
|
# and how to serialize itself for the LLM conversation via {#to_llm_messages}.
|
|
@@ -16,30 +16,38 @@
|
|
|
16
16
|
# goal events) become synthetic tool_use/tool_result pairs so the LLM sees
|
|
17
17
|
# "a tool I invoked returned a result" rather than "a user wrote me."
|
|
18
18
|
#
|
|
19
|
+
# Classifies itself for the pipeline via +kind+ (+active+ triggers the
|
|
20
|
+
# drain loop, +background+ enriches context silently) and +message_type+
|
|
21
|
+
# (selects which pipeline event to emit on create).
|
|
22
|
+
#
|
|
19
23
|
# @see Session#enqueue_user_message
|
|
20
|
-
# @see
|
|
24
|
+
# @see DrainJob — promotes PMs into Messages
|
|
25
|
+
# @see Events::StartMelete
|
|
26
|
+
# @see Events::StartProcessing
|
|
21
27
|
class PendingMessage < ApplicationRecord
|
|
22
|
-
#
|
|
23
|
-
# the
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
RECALL_GOAL_TOOL = "recall_goal"
|
|
28
|
+
# Phantom tool names follow the `from_<sender>` convention: the prefix
|
|
29
|
+
# tells the LLM these are messages delivered to it by its sisters or
|
|
30
|
+
# sub-agents, not tools it invoked. Melete's contributions carry the
|
|
31
|
+
# type in the suffix so the viewport query can filter by kind.
|
|
32
|
+
MELETE_SKILL_TOOL = "from_melete_skill"
|
|
33
|
+
MELETE_WORKFLOW_TOOL = "from_melete_workflow"
|
|
34
|
+
MELETE_GOAL_TOOL = "from_melete_goal"
|
|
35
|
+
MNEME_TOOL = "from_mneme"
|
|
31
36
|
|
|
32
37
|
# Source types that produce phantom tool_use/tool_result pairs on promotion.
|
|
33
38
|
# User messages produce plain text blocks instead.
|
|
34
39
|
PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
|
|
35
40
|
|
|
36
|
-
# Maps each phantom pair source type to
|
|
41
|
+
# Maps each phantom pair source type to a lambda that builds its
|
|
42
|
+
# synthetic tool name. Each Melete contribution carries the type in
|
|
43
|
+
# its suffix; recalled memories come from Mneme; sub-agents encode
|
|
44
|
+
# their nickname directly (e.g. `from_sleuth`).
|
|
37
45
|
PHANTOM_TOOL_NAMES = {
|
|
38
|
-
"subagent" =>
|
|
39
|
-
"skill" =>
|
|
40
|
-
"workflow" =>
|
|
41
|
-
"recall" =>
|
|
42
|
-
"goal" =>
|
|
46
|
+
"subagent" => ->(name) { "from_#{name}" },
|
|
47
|
+
"skill" => ->(_) { MELETE_SKILL_TOOL },
|
|
48
|
+
"workflow" => ->(_) { MELETE_WORKFLOW_TOOL },
|
|
49
|
+
"recall" => ->(_) { MNEME_TOOL },
|
|
50
|
+
"goal" => ->(_) { MELETE_GOAL_TOOL }
|
|
43
51
|
}.freeze
|
|
44
52
|
|
|
45
53
|
# Maps each phantom pair source type to a lambda building its tool input.
|
|
@@ -51,13 +59,65 @@ class PendingMessage < ApplicationRecord
|
|
|
51
59
|
"goal" => ->(name) { {goal_id: name.to_i} }
|
|
52
60
|
}.freeze
|
|
53
61
|
|
|
62
|
+
# Every message_type has a defined drain-pipeline role. +active+ types
|
|
63
|
+
# trigger the drain loop when the session is idle; +background+ types
|
|
64
|
+
# enrich context silently and ride the next active turn into the LLM.
|
|
65
|
+
# {#kind} is derived from this map in {#derive_kind} — callers only
|
|
66
|
+
# supply +message_type+.
|
|
67
|
+
MESSAGE_TYPE_KINDS = {
|
|
68
|
+
"user_message" => "active",
|
|
69
|
+
"tool_response" => "active",
|
|
70
|
+
"subagent" => "active",
|
|
71
|
+
"from_mneme" => "background",
|
|
72
|
+
"from_melete_skill" => "background",
|
|
73
|
+
"from_melete_workflow" => "background",
|
|
74
|
+
"from_melete_goal" => "background"
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
MESSAGE_TYPES = MESSAGE_TYPE_KINDS.keys.freeze
|
|
78
|
+
|
|
79
|
+
# Routes active message types to the event that begins the drain pipeline.
|
|
80
|
+
# User messages enter through Melete (skill/workflow/goal preparation);
|
|
81
|
+
# Mneme then runs conditionally only when Melete actually mutates goals
|
|
82
|
+
# (set_goal / update_goal), so recall always fires against fresh goals.
|
|
83
|
+
# Tool responses and sub-agent deliveries bypass enrichment and go
|
|
84
|
+
# straight to the drain loop. Background message types route to nothing
|
|
85
|
+
# — they wait in the mailbox until an active turn drains them.
|
|
86
|
+
MESSAGE_TYPE_ROUTES = {
|
|
87
|
+
"user_message" => Events::StartMelete,
|
|
88
|
+
"tool_response" => Events::StartProcessing,
|
|
89
|
+
"subagent" => Events::StartProcessing
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
54
92
|
belongs_to :session
|
|
55
93
|
|
|
94
|
+
# In-memory id of the +Message+ this PM becomes on {#promote!}. Not
|
|
95
|
+
# persisted — the PM row is destroyed as part of the promotion transaction.
|
|
96
|
+
# Used by {Session#release_with_bounce_back} to destroy the exact message
|
|
97
|
+
# that should bounce, instead of guessing from +messages.last+ (which could
|
|
98
|
+
# race under parallel drains).
|
|
99
|
+
attr_accessor :promoted_message_id
|
|
100
|
+
|
|
101
|
+
enum :kind, {background: "background", active: "active"}
|
|
102
|
+
|
|
103
|
+
before_validation :derive_kind, if: :message_type
|
|
104
|
+
|
|
56
105
|
validates :content, presence: true
|
|
57
|
-
validates :source_type, inclusion: {in: %w[user subagent skill workflow recall goal]}
|
|
58
106
|
validates :source_name, presence: true, unless: :user?
|
|
107
|
+
validates :message_type, presence: true, inclusion: {in: MESSAGE_TYPES}
|
|
108
|
+
validates :tool_use_id, presence: true, if: -> { message_type == "tool_response" }
|
|
109
|
+
|
|
110
|
+
# Tool responses take priority over other active messages: they complete
|
|
111
|
+
# a tool round the LLM is waiting on, so promoting them first preserves
|
|
112
|
+
# the tool_use/tool_result pairing in the conversation. Other actives
|
|
113
|
+
# (user messages, sub-agent replies) wait their FIFO turn behind the
|
|
114
|
+
# completion.
|
|
115
|
+
scope :ordered_for_drain, -> {
|
|
116
|
+
active.order(Arel.sql("message_type = 'tool_response' DESC")).order(:created_at)
|
|
117
|
+
}
|
|
59
118
|
|
|
60
119
|
after_create_commit :broadcast_created
|
|
120
|
+
after_create_commit :route_to_event_bus
|
|
61
121
|
after_destroy_commit :broadcast_removed
|
|
62
122
|
|
|
63
123
|
# @return [Boolean] true when this is a plain user message
|
|
@@ -95,12 +155,69 @@ class PendingMessage < ApplicationRecord
|
|
|
95
155
|
source_type.in?(PHANTOM_PAIR_TYPES)
|
|
96
156
|
end
|
|
97
157
|
|
|
158
|
+
# Draper hook: picks the concrete decorator subclass based on
|
|
159
|
+
# {#message_type}. Mirrors {Message#decorator_class} so each PM type
|
|
160
|
+
# renders with the same visual treatment as its promoted counterpart,
|
|
161
|
+
# marked dimmed via +status: "pending"+.
|
|
162
|
+
#
|
|
163
|
+
# PMs are the universal intake queue — every new message_type added
|
|
164
|
+
# under #427 lands here first. Raises on unmapped types so a missing
|
|
165
|
+
# decorator surfaces immediately as a hard failure instead of a
|
|
166
|
+
# silent nil that breaks downstream rendering.
|
|
167
|
+
#
|
|
168
|
+
# @return [Class] a {PendingMessageDecorator} subclass
|
|
169
|
+
# @raise [ArgumentError] if no decorator is registered for the message_type
|
|
170
|
+
def decorator_class
|
|
171
|
+
case message_type
|
|
172
|
+
when "user_message" then PendingUserMessageDecorator
|
|
173
|
+
when "tool_response" then PendingToolResponseDecorator
|
|
174
|
+
when "subagent" then PendingSubagentDecorator
|
|
175
|
+
when "from_mneme" then PendingFromMnemeDecorator
|
|
176
|
+
when "from_melete_skill" then PendingFromMeleteSkillDecorator
|
|
177
|
+
when "from_melete_workflow" then PendingFromMeleteWorkflowDecorator
|
|
178
|
+
when "from_melete_goal" then PendingFromMeleteGoalDecorator
|
|
179
|
+
else raise ArgumentError, "No decorator for PendingMessage message_type: #{message_type.inspect}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Promotes this PendingMessage into the session's conversation history.
|
|
184
|
+
# Dispatches on +message_type+: tool responses become +tool_response+
|
|
185
|
+
# Messages, user messages become +user_message+ Messages, phantom pair
|
|
186
|
+
# types (sub-agent, skill, workflow, recall, goal) become synthetic
|
|
187
|
+
# tool_use/tool_result pairs. The PM row is destroyed in the same
|
|
188
|
+
# transaction so partial promotion can never leave a stray mailbox entry.
|
|
189
|
+
#
|
|
190
|
+
# For promotions that yield a single Message, {#promoted_message_id}
|
|
191
|
+
# captures the new record's id — callers can then act on that specific
|
|
192
|
+
# message (e.g. {Session#release_with_bounce_back}) without guessing.
|
|
193
|
+
#
|
|
194
|
+
# @return [void]
|
|
195
|
+
def promote!
|
|
196
|
+
session.transaction do
|
|
197
|
+
if message_type == "tool_response"
|
|
198
|
+
self.promoted_message_id = promote_as_tool_response!.id
|
|
199
|
+
elsif message_type == "user_message"
|
|
200
|
+
self.promoted_message_id = session.create_user_message(
|
|
201
|
+
display_content,
|
|
202
|
+
source_type: source_type,
|
|
203
|
+
source_name: source_name
|
|
204
|
+
).id
|
|
205
|
+
elsif phantom_pair?
|
|
206
|
+
promote_as_phantom_pair!
|
|
207
|
+
else
|
|
208
|
+
raise "PendingMessage ##{id} cannot promote: message_type=#{message_type.inspect}"
|
|
209
|
+
end
|
|
210
|
+
destroy!
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
98
214
|
# Phantom tool name for DB persistence and LLM injection.
|
|
99
|
-
# Each phantom pair source type maps to a synthetic tool name
|
|
215
|
+
# Each phantom pair source type maps to a synthetic tool name via
|
|
216
|
+
# {PHANTOM_TOOL_NAMES} — a lambda so sub-agent names can flow through.
|
|
100
217
|
#
|
|
101
218
|
# @return [String] phantom tool name
|
|
102
219
|
def phantom_tool_name
|
|
103
|
-
PHANTOM_TOOL_NAMES.fetch(source_type)
|
|
220
|
+
PHANTOM_TOOL_NAMES.fetch(source_type).call(source_name)
|
|
104
221
|
end
|
|
105
222
|
|
|
106
223
|
# Phantom tool input hash for DB persistence and LLM injection.
|
|
@@ -144,8 +261,130 @@ class PendingMessage < ApplicationRecord
|
|
|
144
261
|
build_phantom_pair(phantom_tool_name, phantom_tool_input)
|
|
145
262
|
end
|
|
146
263
|
|
|
264
|
+
# Emits the event that kicks off the drain pipeline for active messages
|
|
265
|
+
# whenever the session is currently claimable. Claimability is delegated
|
|
266
|
+
# to the AASM via +may_start_processing?+ — true from +:idle+ always,
|
|
267
|
+
# true from +:executing+ only once +tool_round_complete?+ holds. This
|
|
268
|
+
# lets a tool_response PM landing mid-round wake the drain only when
|
|
269
|
+
# its sibling responses are all present.
|
|
270
|
+
#
|
|
271
|
+
# Background messages never trigger; active messages landing while the
|
|
272
|
+
# session is unclaimable queue silently —
|
|
273
|
+
# {Session#wake_drain_pipeline_if_pending} re-invokes this on the next
|
|
274
|
+
# transition into +:idle+.
|
|
275
|
+
#
|
|
276
|
+
# Also fires from +after_create_commit+ so freshly enqueued PMs route
|
|
277
|
+
# themselves on persistence.
|
|
278
|
+
def route_to_event_bus
|
|
279
|
+
return unless active?
|
|
280
|
+
return unless session.may_start_processing?
|
|
281
|
+
|
|
282
|
+
event_class = MESSAGE_TYPE_ROUTES.fetch(message_type)
|
|
283
|
+
Events::Bus.emit(event_class.new(session_id: session_id, pending_message_id: id))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Builds the structured +pending_message_created+ payload for transmit/
|
|
287
|
+
# broadcast paths. Wraps the per-mode decorator output in the +rendered+
|
|
288
|
+
# key so the TUI's existing +extract_rendered+ pipeline applies.
|
|
289
|
+
#
|
|
290
|
+
# Required arg — callers always know the session view_mode. A default
|
|
291
|
+
# of +session.view_mode+ would trigger a SELECT per +after_create_commit+
|
|
292
|
+
# when the association isn't preloaded.
|
|
293
|
+
#
|
|
294
|
+
# The raw +content+ field is intentionally absent: decorators decide
|
|
295
|
+
# what crosses the wire per view_mode (e.g. background PMs return nil
|
|
296
|
+
# in basic so the user doesn't see internal pipeline noise). Sending
|
|
297
|
+
# raw content alongside +rendered+ would undercut that boundary.
|
|
298
|
+
#
|
|
299
|
+
# @param mode [String] view mode for decoration
|
|
300
|
+
# @return [Hash] payload ready for ActionCable transmission
|
|
301
|
+
def broadcast_payload(mode)
|
|
302
|
+
{
|
|
303
|
+
"action" => "pending_message_created",
|
|
304
|
+
"pending_message_id" => id,
|
|
305
|
+
"message_type" => message_type,
|
|
306
|
+
"rendered" => {mode => decorate.render(mode)}
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
147
310
|
private
|
|
148
311
|
|
|
312
|
+
# Persists a +tool_response+ Message for this PM and returns it.
|
|
313
|
+
# Mirrors the tool_use_id / payload shape emitted by
|
|
314
|
+
# {Events::Subscribers::LLMResponseHandler} for the paired +tool_call+.
|
|
315
|
+
#
|
|
316
|
+
# @return [Message]
|
|
317
|
+
def promote_as_tool_response!
|
|
318
|
+
session.messages.create!(
|
|
319
|
+
message_type: "tool_response",
|
|
320
|
+
tool_use_id: tool_use_id,
|
|
321
|
+
payload: {
|
|
322
|
+
"tool_name" => source_name,
|
|
323
|
+
"tool_use_id" => tool_use_id,
|
|
324
|
+
"content" => content,
|
|
325
|
+
"success" => success
|
|
326
|
+
},
|
|
327
|
+
timestamp: Time.current.to_ns,
|
|
328
|
+
token_count: TokenEstimation.estimate_token_count(content)
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Persists a synthetic +tool_call+/+tool_response+ Message pair so the
|
|
333
|
+
# LLM sees this contribution (sub-agent delivery, Melete skill/workflow/
|
|
334
|
+
# goal, Mneme recall) as a past tool invocation of its own. Keeps the
|
|
335
|
+
# system prompt stable for prompt caching — phantom pairs flow through
|
|
336
|
+
# the sliding-window conversation instead.
|
|
337
|
+
#
|
|
338
|
+
# @return [void]
|
|
339
|
+
def promote_as_phantom_pair!
|
|
340
|
+
tool_name = phantom_tool_name
|
|
341
|
+
uid = "#{tool_name}_#{id}"
|
|
342
|
+
now = Time.current.to_ns
|
|
343
|
+
|
|
344
|
+
session.messages.create!(
|
|
345
|
+
message_type: "tool_call",
|
|
346
|
+
tool_use_id: uid,
|
|
347
|
+
payload: {
|
|
348
|
+
"tool_name" => tool_name,
|
|
349
|
+
"tool_use_id" => uid,
|
|
350
|
+
"tool_input" => phantom_tool_input.stringify_keys,
|
|
351
|
+
"content" => display_content.lines.first.chomp
|
|
352
|
+
},
|
|
353
|
+
timestamp: now,
|
|
354
|
+
token_count: Mneme::TOOL_PAIR_OVERHEAD_TOKENS
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
session.messages.create!(
|
|
358
|
+
message_type: "tool_response",
|
|
359
|
+
tool_use_id: uid,
|
|
360
|
+
payload: {
|
|
361
|
+
"tool_name" => tool_name,
|
|
362
|
+
"tool_use_id" => uid,
|
|
363
|
+
"content" => content,
|
|
364
|
+
"success" => true
|
|
365
|
+
},
|
|
366
|
+
timestamp: now,
|
|
367
|
+
token_count: TokenEstimation.estimate_token_count(content)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
restore_subagent_hud_visibility! if subagent?
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Flips the delivering sub-agent back to +hud_visible: true+ when the
|
|
374
|
+
# phantom pair we just persisted reintroduces a trace. A subsequent
|
|
375
|
+
# eviction that passes every trace will hide the sub-agent again via
|
|
376
|
+
# {Mneme::Runner#refresh_subagent_visibility}.
|
|
377
|
+
#
|
|
378
|
+
# Re-broadcasts the parent's children list on flip so the TUI adds the
|
|
379
|
+
# HUD entry back without waiting for the next AASM state change.
|
|
380
|
+
def restore_subagent_hud_visibility!
|
|
381
|
+
child = session.child_sessions.find_by(name: source_name)
|
|
382
|
+
return unless child && !child.hud_visible
|
|
383
|
+
|
|
384
|
+
child.update_column(:hud_visible, true)
|
|
385
|
+
child.broadcast_children_update_to_parent
|
|
386
|
+
end
|
|
387
|
+
|
|
149
388
|
# Builds a phantom tool_use/tool_result message pair.
|
|
150
389
|
# Follows the same format for all non-user source types — the only
|
|
151
390
|
# difference is the tool name and input hash.
|
|
@@ -171,13 +410,11 @@ class PendingMessage < ApplicationRecord
|
|
|
171
410
|
end
|
|
172
411
|
|
|
173
412
|
# Broadcasts a pending message appearance so TUI clients render the
|
|
174
|
-
# dimmed indicator immediately.
|
|
413
|
+
# type-specific dimmed indicator immediately. Includes the decorated
|
|
414
|
+
# payload for the session's current view mode so the TUI can dispatch
|
|
415
|
+
# by message type without a second round-trip.
|
|
175
416
|
def broadcast_created
|
|
176
|
-
ActionCable.server.broadcast("session_#{session_id}",
|
|
177
|
-
"action" => "pending_message_created",
|
|
178
|
-
"pending_message_id" => id,
|
|
179
|
-
"content" => content
|
|
180
|
-
})
|
|
417
|
+
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload(session.view_mode))
|
|
181
418
|
end
|
|
182
419
|
|
|
183
420
|
# Broadcasts pending message removal so TUI clients clear the entry.
|
|
@@ -188,4 +425,14 @@ class PendingMessage < ApplicationRecord
|
|
|
188
425
|
"pending_message_id" => id
|
|
189
426
|
})
|
|
190
427
|
end
|
|
428
|
+
|
|
429
|
+
# Populates +kind+ from {MESSAGE_TYPE_KINDS} so callers only need to
|
|
430
|
+
# supply +message_type+. The mapping is the single source of truth for
|
|
431
|
+
# whether a message type triggers the drain loop or rides along as
|
|
432
|
+
# enrichment. Guarded by +if: :message_type+ on the callback — rows
|
|
433
|
+
# without a +message_type+ fail validation explicitly rather than
|
|
434
|
+
# crashing here.
|
|
435
|
+
def derive_kind
|
|
436
|
+
self.kind = MESSAGE_TYPE_KINDS.fetch(message_type)
|
|
437
|
+
end
|
|
191
438
|
end
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
#
|
|
11
11
|
# @!attribute display_text
|
|
12
12
|
# @return [String] truncated message content (~200 chars) shown in the Goals section
|
|
13
|
+
# @!attribute token_count
|
|
14
|
+
# @return [Integer] token count of {#display_text}. Seeded with a local
|
|
15
|
+
# estimate on create and later refined by {CountTokensJob}.
|
|
13
16
|
class PinnedMessage < ApplicationRecord
|
|
17
|
+
include TokenEstimation
|
|
18
|
+
|
|
14
19
|
# Display text limit — enough to recognize content, cheap on tokens.
|
|
15
20
|
MAX_DISPLAY_TEXT_LENGTH = 200
|
|
16
21
|
|
|
@@ -34,8 +39,8 @@ class PinnedMessage < ApplicationRecord
|
|
|
34
39
|
)
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
# @return [
|
|
38
|
-
def
|
|
39
|
-
|
|
42
|
+
# @return [String] truncated display text used for token estimation and counting
|
|
43
|
+
def tokenization_text
|
|
44
|
+
display_text.to_s
|
|
40
45
|
end
|
|
41
46
|
end
|