anima-core 1.1.2 → 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 +8 -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 +46 -49
- 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/message.rb +132 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/session.rb +232 -192
- data/app/models/snapshot.rb +25 -25
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/lib/agent_loop.rb +17 -9
- data/lib/agents/registry.rb +1 -1
- 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 +19 -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/subagent_message_router.rb +12 -12
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/llm/client.rb +5 -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 +55 -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/skills/registry.rb +1 -1
- data/lib/tools/bash.rb +82 -7
- 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/lib/workflows/registry.rb +1 -1
- 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 +21 -4
- 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/event.rb +0 -110
- 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
|
@@ -9,19 +9,15 @@ module AnalyticalBrain
|
|
|
9
9
|
class ReadWorkflow < ::Tools::Base
|
|
10
10
|
def self.tool_name = "read_workflow"
|
|
11
11
|
|
|
12
|
-
def self.description = "
|
|
13
|
-
"Use the content to create appropriate goals with set_goal."
|
|
12
|
+
def self.description = "Activate a workflow and return its content for goal planning."
|
|
14
13
|
|
|
15
14
|
def self.input_schema
|
|
16
15
|
{
|
|
17
16
|
type: "object",
|
|
18
17
|
properties: {
|
|
19
|
-
|
|
20
|
-
type: "string",
|
|
21
|
-
description: "Name of the workflow to read (from the available workflows list)"
|
|
22
|
-
}
|
|
18
|
+
workflow_name: {type: "string"}
|
|
23
19
|
},
|
|
24
|
-
required: %w[
|
|
20
|
+
required: %w[workflow_name]
|
|
25
21
|
}
|
|
26
22
|
end
|
|
27
23
|
|
|
@@ -30,11 +26,11 @@ module AnalyticalBrain
|
|
|
30
26
|
@main_session = main_session
|
|
31
27
|
end
|
|
32
28
|
|
|
33
|
-
# @param input [Hash<String, Object>] with "
|
|
29
|
+
# @param input [Hash<String, Object>] with "workflow_name" key
|
|
34
30
|
# @return [String] workflow name, description, and full content
|
|
35
31
|
# @return [Hash] with :error key on validation failure
|
|
36
32
|
def execute(input)
|
|
37
|
-
workflow_name = input["
|
|
33
|
+
workflow_name = input["workflow_name"].to_s.strip
|
|
38
34
|
return {error: "Workflow name cannot be blank"} if workflow_name.empty?
|
|
39
35
|
|
|
40
36
|
workflow = @main_session.activate_workflow(workflow_name)
|
|
@@ -11,21 +11,14 @@ module AnalyticalBrain
|
|
|
11
11
|
class RenameSession < ::Tools::Base
|
|
12
12
|
def self.tool_name = "rename_session"
|
|
13
13
|
|
|
14
|
-
def self.description = "Rename the
|
|
15
|
-
"Use one emoji followed by 1-3 descriptive words."
|
|
14
|
+
def self.description = "Rename the session."
|
|
16
15
|
|
|
17
16
|
def self.input_schema
|
|
18
17
|
{
|
|
19
18
|
type: "object",
|
|
20
19
|
properties: {
|
|
21
|
-
emoji: {
|
|
22
|
-
|
|
23
|
-
description: "A single emoji representing the conversation topic"
|
|
24
|
-
},
|
|
25
|
-
name: {
|
|
26
|
-
type: "string",
|
|
27
|
-
description: "1-3 word descriptive name for the session"
|
|
28
|
-
}
|
|
20
|
+
emoji: {type: "string"},
|
|
21
|
+
name: {type: "string", description: "1-3 words."}
|
|
29
22
|
},
|
|
30
23
|
required: %w[emoji name]
|
|
31
24
|
}
|
|
@@ -8,8 +8,7 @@ module AnalyticalBrain
|
|
|
8
8
|
class SetGoal < ::Tools::Base
|
|
9
9
|
def self.tool_name = "set_goal"
|
|
10
10
|
|
|
11
|
-
def self.description = "Create a goal
|
|
12
|
-
"Omit parent_goal_id for a root goal, or provide it to create a sub-goal (TODO item)."
|
|
11
|
+
def self.description = "Create a goal or sub-goal."
|
|
13
12
|
|
|
14
13
|
def self.input_schema
|
|
15
14
|
{
|
|
@@ -17,12 +16,9 @@ module AnalyticalBrain
|
|
|
17
16
|
properties: {
|
|
18
17
|
description: {
|
|
19
18
|
type: "string",
|
|
20
|
-
description: "
|
|
19
|
+
description: "1 sentence."
|
|
21
20
|
},
|
|
22
|
-
parent_goal_id: {
|
|
23
|
-
type: "integer",
|
|
24
|
-
description: "ID of the parent goal (omit for root goals)"
|
|
25
|
-
}
|
|
21
|
+
parent_goal_id: {type: "integer"}
|
|
26
22
|
},
|
|
27
23
|
required: %w[description]
|
|
28
24
|
}
|
|
@@ -15,20 +15,16 @@ module AnalyticalBrain
|
|
|
15
15
|
class UpdateGoal < ::Tools::Base
|
|
16
16
|
def self.tool_name = "update_goal"
|
|
17
17
|
|
|
18
|
-
def self.description = "
|
|
19
|
-
"Use this to refine a goal as understanding evolves."
|
|
18
|
+
def self.description = "Refine a goal's wording as understanding evolves."
|
|
20
19
|
|
|
21
20
|
def self.input_schema
|
|
22
21
|
{
|
|
23
22
|
type: "object",
|
|
24
23
|
properties: {
|
|
25
|
-
goal_id: {
|
|
26
|
-
type: "integer",
|
|
27
|
-
description: "ID of the goal to update"
|
|
28
|
-
},
|
|
24
|
+
goal_id: {type: "integer"},
|
|
29
25
|
description: {
|
|
30
26
|
type: "string",
|
|
31
|
-
description: "
|
|
27
|
+
description: "1 sentence."
|
|
32
28
|
}
|
|
33
29
|
},
|
|
34
30
|
required: %w[goal_id description]
|
data/lib/anima/settings.rb
CHANGED
|
@@ -60,6 +60,13 @@ module Anima
|
|
|
60
60
|
self.config_path = nil
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# ─── Agent Identity ─────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
# The agent's display name. Separates engine identity ("Anima") from
|
|
66
|
+
# agent identity — any agent running on Anima can name itself.
|
|
67
|
+
# @return [String]
|
|
68
|
+
def agent_name = get("agent", "name")
|
|
69
|
+
|
|
63
70
|
# ─── LLM ───────────────────────────────────────────────────────
|
|
64
71
|
|
|
65
72
|
# Primary model for conversations.
|
|
@@ -131,6 +138,10 @@ module Anima
|
|
|
131
138
|
# @return [Integer]
|
|
132
139
|
def max_web_response_bytes = get("tools", "max_web_response_bytes")
|
|
133
140
|
|
|
141
|
+
# Minimum characters of extracted web content before flagging as possibly incomplete.
|
|
142
|
+
# @return [Integer]
|
|
143
|
+
def min_web_content_chars = get("tools", "min_web_content_chars")
|
|
144
|
+
|
|
134
145
|
# ─── Session ────────────────────────────────────────────────────
|
|
135
146
|
|
|
136
147
|
# View mode applied to new sessions: "basic", "verbose", or "debug".
|
|
@@ -191,9 +202,9 @@ module Anima
|
|
|
191
202
|
# @return [Boolean]
|
|
192
203
|
def analytical_brain_blocking_on_agent_message = get("analytical_brain", "blocking_on_agent_message")
|
|
193
204
|
|
|
194
|
-
# Number of recent
|
|
205
|
+
# Number of recent messages to include in the analytical brain's context window.
|
|
195
206
|
# @return [Integer]
|
|
196
|
-
def
|
|
207
|
+
def analytical_brain_message_window = get("analytical_brain", "message_window")
|
|
197
208
|
|
|
198
209
|
# ─── Mneme (Memory Department) ────────────────────────────────
|
|
199
210
|
|
|
@@ -217,8 +228,8 @@ module Anima
|
|
|
217
228
|
# @return [Integer]
|
|
218
229
|
def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
|
|
219
230
|
|
|
220
|
-
# Fraction of the main viewport token budget reserved for pinned
|
|
221
|
-
# Pinned
|
|
231
|
+
# Fraction of the main viewport token budget reserved for pinned messages.
|
|
232
|
+
# Pinned messages appear between snapshots and the sliding window.
|
|
222
233
|
# @return [Float]
|
|
223
234
|
def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
|
|
224
235
|
|
|
@@ -236,6 +247,10 @@ module Anima
|
|
|
236
247
|
# @return [Integer]
|
|
237
248
|
def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
|
|
238
249
|
|
|
250
|
+
# Recency decay factor for search ranking (0.0 = pure relevance).
|
|
251
|
+
# @return [Float]
|
|
252
|
+
def recall_recency_decay = get("recall", "recency_decay")
|
|
253
|
+
|
|
239
254
|
private
|
|
240
255
|
|
|
241
256
|
# Reads a setting from the config file.
|
data/lib/anima/version.rb
CHANGED
data/lib/events/bounce_back.rb
CHANGED
|
@@ -6,24 +6,24 @@ module Events
|
|
|
6
6
|
# this event notifies clients to remove the phantom message and
|
|
7
7
|
# restore the text to the input field.
|
|
8
8
|
#
|
|
9
|
-
# Not persisted — not included in {
|
|
9
|
+
# Not persisted — not included in {Message::TYPES}.
|
|
10
10
|
class BounceBack < Base
|
|
11
11
|
TYPE = "bounce_back"
|
|
12
12
|
|
|
13
13
|
# @return [String] human-readable error description
|
|
14
14
|
attr_reader :error
|
|
15
15
|
|
|
16
|
-
# @return [Integer, nil] database ID of the rolled-back
|
|
17
|
-
attr_reader :
|
|
16
|
+
# @return [Integer, nil] database ID of the rolled-back message (for client-side removal)
|
|
17
|
+
attr_reader :message_id
|
|
18
18
|
|
|
19
19
|
# @param content [String] original user message text to restore to input
|
|
20
20
|
# @param error [String] error description for the flash message
|
|
21
21
|
# @param session_id [Integer] session the message was intended for
|
|
22
|
-
# @param
|
|
23
|
-
def initialize(content:, error:, session_id:,
|
|
22
|
+
# @param message_id [Integer, nil] ID of the message that was broadcast optimistically
|
|
23
|
+
def initialize(content:, error:, session_id:, message_id: nil)
|
|
24
24
|
super(content: content, session_id: session_id)
|
|
25
25
|
@error = error
|
|
26
|
-
@
|
|
26
|
+
@message_id = message_id
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def type
|
|
@@ -31,7 +31,7 @@ module Events
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def to_h
|
|
34
|
-
super.merge(error: error,
|
|
34
|
+
super.merge(error: error, message_id: message_id)
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Persists all events to SQLite as they flow through the event bus.
|
|
6
|
-
# Each event is written as
|
|
6
|
+
# Each event is written as a Message record belonging to the active session.
|
|
7
7
|
#
|
|
8
8
|
# When initialized with a specific session, all events are saved to that
|
|
9
9
|
# session. When initialized without one (global mode), the session is
|
|
@@ -31,7 +31,7 @@ module Events
|
|
|
31
31
|
# Skips non-pending user messages — those are persisted by their
|
|
32
32
|
# callers ({SessionChannel#speak} for idle sessions,
|
|
33
33
|
# {AgentLoop#process} for direct usage). Also skips event types
|
|
34
|
-
# not in {
|
|
34
|
+
# not in {Message::TYPES} (transient events like {Events::BounceBack}).
|
|
35
35
|
#
|
|
36
36
|
# @param event [Hash] with :payload containing event data
|
|
37
37
|
def emit(event)
|
|
@@ -40,15 +40,15 @@ module Events
|
|
|
40
40
|
|
|
41
41
|
event_type = payload[:type]
|
|
42
42
|
return if event_type.nil?
|
|
43
|
-
return unless
|
|
43
|
+
return unless Message::TYPES.include?(event_type)
|
|
44
44
|
return if persisted_by_job?(event_type, payload)
|
|
45
45
|
|
|
46
46
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
47
47
|
return unless target_session
|
|
48
48
|
|
|
49
49
|
@mutex.synchronize do
|
|
50
|
-
target_session.
|
|
51
|
-
|
|
50
|
+
target_session.messages.create!(
|
|
51
|
+
message_type: event_type,
|
|
52
52
|
payload: payload,
|
|
53
53
|
status: payload[:status],
|
|
54
54
|
tool_use_id: payload[:tool_use_id],
|
|
@@ -64,12 +64,12 @@ module Events
|
|
|
64
64
|
private
|
|
65
65
|
|
|
66
66
|
# Non-pending user messages are persisted by their callers
|
|
67
|
-
# ({SessionChannel#speak}, {AgentLoop#process}) so the
|
|
67
|
+
# ({SessionChannel#speak}, {AgentLoop#process}) so the message ID
|
|
68
68
|
# is available for bounce-back cleanup if LLM delivery fails.
|
|
69
69
|
# Pending messages are still auto-persisted here because they
|
|
70
70
|
# queue while the session is busy.
|
|
71
71
|
def persisted_by_job?(event_type, payload)
|
|
72
|
-
event_type == "user_message" && payload[:status] !=
|
|
72
|
+
event_type == "user_message" && payload[:status] != Message::PENDING_STATUS
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
end
|
|
@@ -6,16 +6,19 @@ module Events
|
|
|
6
6
|
# bidirectional @mention communication.
|
|
7
7
|
#
|
|
8
8
|
# **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
|
|
9
|
-
# the router
|
|
10
|
-
# with attribution prefix
|
|
9
|
+
# the router creates a {Events::UserMessage} in the parent session
|
|
10
|
+
# with attribution prefix. If the parent is idle, persists directly
|
|
11
|
+
# and wakes it via {AgentRequestJob}. If the parent is mid-turn,
|
|
12
|
+
# emits a pending message that is promoted after the current loop
|
|
13
|
+
# completes — same mechanism as {SessionChannel#speak}.
|
|
11
14
|
#
|
|
12
15
|
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
13
16
|
# containing `@name` mentions, the router persists the message in each
|
|
14
17
|
# matching child session and wakes them via {AgentRequestJob}.
|
|
15
18
|
#
|
|
16
|
-
# Both directions
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
+
# Both directions delegate to {Session#enqueue_user_message}, which
|
|
20
|
+
# respects the target session's processing state — persisting directly
|
|
21
|
+
# when idle, deferring via pending queue when mid-turn.
|
|
19
22
|
#
|
|
20
23
|
# This replaces the +return_result+ tool — sub-agents communicate
|
|
21
24
|
# through natural text messages instead of structured tool calls.
|
|
@@ -60,9 +63,8 @@ module Events
|
|
|
60
63
|
|
|
61
64
|
private
|
|
62
65
|
|
|
63
|
-
# Forwards a sub-agent's text message to its parent session
|
|
64
|
-
#
|
|
65
|
-
# up to process the message.
|
|
66
|
+
# Forwards a sub-agent's text message to its parent session
|
|
67
|
+
# via {Session#enqueue_user_message}.
|
|
66
68
|
#
|
|
67
69
|
# @param child [Session] the sub-agent session
|
|
68
70
|
# @param content [String] the sub-agent's message text
|
|
@@ -73,8 +75,7 @@ module Events
|
|
|
73
75
|
name = child.name || "agent-#{child.id}"
|
|
74
76
|
attributed = format(ATTRIBUTION_FORMAT, name, content)
|
|
75
77
|
|
|
76
|
-
parent.
|
|
77
|
-
AgentRequestJob.perform_later(parent.id)
|
|
78
|
+
parent.enqueue_user_message(attributed)
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
# Scans a parent agent's message for @mentions and routes the message
|
|
@@ -93,8 +94,7 @@ module Events
|
|
|
93
94
|
child = active_children[name]
|
|
94
95
|
next unless child
|
|
95
96
|
|
|
96
|
-
child.
|
|
97
|
-
AgentRequestJob.perform_later(child.id)
|
|
97
|
+
child.enqueue_user_message(content)
|
|
98
98
|
end
|
|
99
99
|
end
|
|
100
100
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
|
-
# receive them over WebSocket. Persisted
|
|
7
|
-
# {
|
|
6
|
+
# receive them over WebSocket. Persisted messages reach clients via
|
|
7
|
+
# {Message::Broadcasting} callbacks; this subscriber handles events
|
|
8
8
|
# that never touch the database.
|
|
9
9
|
#
|
|
10
10
|
# @example Registering at boot
|
data/lib/llm/client.rb
CHANGED
|
@@ -177,9 +177,12 @@ module LLM
|
|
|
177
177
|
# tool raises. Per the Anthropic tool-use protocol, every tool_use must
|
|
178
178
|
# have a matching tool_result; a missing result permanently corrupts the
|
|
179
179
|
# conversation history and breaks the session.
|
|
180
|
+
#
|
|
181
|
+
# Falls back to SecureRandom.uuid when Anthropic omits the tool_use id,
|
|
182
|
+
# ensuring the ToolCall/ToolResponse pair always shares a valid identifier.
|
|
180
183
|
def execute_single_tool(tool_use, registry, session_id)
|
|
181
184
|
name = tool_use["name"]
|
|
182
|
-
id = tool_use["id"]
|
|
185
|
+
id = tool_use["id"] || SecureRandom.uuid
|
|
183
186
|
input = tool_use["input"] || {}
|
|
184
187
|
timeout = input["timeout"] || Anima::Settings.tool_timeout
|
|
185
188
|
|
|
@@ -231,7 +234,7 @@ module LLM
|
|
|
231
234
|
# @return [Hash] tool_result content block
|
|
232
235
|
def interrupt_tool(tool_use, session_id)
|
|
233
236
|
name = tool_use["name"]
|
|
234
|
-
id = tool_use["id"]
|
|
237
|
+
id = tool_use["id"] || SecureRandom.uuid
|
|
235
238
|
input = tool_use["input"] || {}
|
|
236
239
|
|
|
237
240
|
Events::Bus.emit(Events::ToolCall.new(
|
|
@@ -7,16 +7,16 @@ module Mneme
|
|
|
7
7
|
# aggregate counters like `[4 tools called]`.
|
|
8
8
|
#
|
|
9
9
|
# The viewport is split into three zones separated by delimiters:
|
|
10
|
-
# - **Eviction zone** —
|
|
11
|
-
# - **Middle zone** —
|
|
12
|
-
# - **Recent zone** — the most recent
|
|
10
|
+
# - **Eviction zone** — messages about to leave the viewport (upper third)
|
|
11
|
+
# - **Middle zone** — messages in the middle of the viewport
|
|
12
|
+
# - **Recent zone** — the most recent messages (lower third)
|
|
13
13
|
#
|
|
14
14
|
# Zone boundaries are calculated WITH tool call tokens (they affect
|
|
15
15
|
# position), then tool calls are removed and replaced with counters.
|
|
16
16
|
#
|
|
17
17
|
# @example
|
|
18
18
|
# viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
|
|
19
|
-
# viewport.render #=> "── EVICTION ZONE ──\
|
|
19
|
+
# viewport.render #=> "── EVICTION ZONE ──\nmessage 42 User: ..."
|
|
20
20
|
class CompressedViewport
|
|
21
21
|
ZONE_DELIMITERS = {
|
|
22
22
|
eviction: "── EVICTION ZONE (upper third) ──",
|
|
@@ -26,74 +26,74 @@ module Mneme
|
|
|
26
26
|
|
|
27
27
|
# @param session [Session] the session to build viewport for
|
|
28
28
|
# @param token_budget [Integer] total tokens available for Mneme's viewport
|
|
29
|
-
# @param
|
|
29
|
+
# @param from_message_id [Integer, nil] start from this message ID (inclusive);
|
|
30
30
|
# when nil, uses the session's full viewport
|
|
31
|
-
def initialize(session, token_budget:,
|
|
31
|
+
def initialize(session, token_budget:, from_message_id: nil)
|
|
32
32
|
@session = session
|
|
33
33
|
@token_budget = token_budget
|
|
34
|
-
@
|
|
34
|
+
@from_message_id = from_message_id
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Renders the compressed viewport as a string ready for Mneme's LLM context.
|
|
38
38
|
#
|
|
39
39
|
# @return [String] compressed viewport with zone delimiters
|
|
40
40
|
def render
|
|
41
|
-
return "" if
|
|
41
|
+
return "" if messages.empty?
|
|
42
42
|
|
|
43
|
-
zones = split_into_zones(
|
|
43
|
+
zones = split_into_zones(messages)
|
|
44
44
|
render_zones(zones)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# @return [Array<
|
|
48
|
-
def
|
|
49
|
-
@
|
|
47
|
+
# @return [Array<Message>] the raw messages selected for this viewport
|
|
48
|
+
def messages
|
|
49
|
+
@messages ||= fetch_messages
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
|
-
# Fetches
|
|
54
|
+
# Fetches messages within token budget, starting from from_message_id.
|
|
55
55
|
# Selects newest-first until budget exhausted, returns chronological.
|
|
56
|
-
# Caches per-
|
|
56
|
+
# Caches per-message token costs in @message_costs for reuse by split_into_zones.
|
|
57
57
|
#
|
|
58
|
-
# @return [Array<
|
|
59
|
-
def
|
|
60
|
-
scope = @session.
|
|
58
|
+
# @return [Array<Message>]
|
|
59
|
+
def fetch_messages
|
|
60
|
+
scope = @session.messages.context_messages.deliverable
|
|
61
61
|
|
|
62
|
-
if @
|
|
63
|
-
scope = scope.where("id >= ?", @
|
|
62
|
+
if @from_message_id
|
|
63
|
+
scope = scope.where("id >= ?", @from_message_id)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
selected = []
|
|
67
|
-
@
|
|
67
|
+
@message_costs = {}
|
|
68
68
|
remaining = @token_budget
|
|
69
69
|
|
|
70
|
-
scope.reorder(id: :desc).each do |
|
|
71
|
-
cost =
|
|
70
|
+
scope.reorder(id: :desc).each do |message|
|
|
71
|
+
cost = message_token_cost(message)
|
|
72
72
|
break if cost > remaining && selected.any?
|
|
73
73
|
|
|
74
|
-
selected <<
|
|
75
|
-
@
|
|
74
|
+
selected << message
|
|
75
|
+
@message_costs[message.id] = cost
|
|
76
76
|
remaining -= cost
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
selected.reverse
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# Splits
|
|
83
|
-
# Zone boundaries are calculated including ALL
|
|
82
|
+
# Splits messages into three zones by token count.
|
|
83
|
+
# Zone boundaries are calculated including ALL messages (tool calls count
|
|
84
84
|
# toward position), but zone assignment uses cumulative tokens.
|
|
85
85
|
#
|
|
86
|
-
# @return [Hash{Symbol => Array<
|
|
87
|
-
def split_into_zones(
|
|
88
|
-
costs =
|
|
86
|
+
# @return [Hash{Symbol => Array<Message>}] :eviction, :middle, :recent
|
|
87
|
+
def split_into_zones(messages)
|
|
88
|
+
costs = messages.map { |message| [message, @message_costs[message.id] || message_token_cost(message)] }
|
|
89
89
|
zone_size = costs.sum(&:last) / 3.0
|
|
90
90
|
|
|
91
91
|
result = {eviction: [], middle: [], recent: []}
|
|
92
92
|
cumulative = 0
|
|
93
93
|
|
|
94
|
-
costs.each do |
|
|
94
|
+
costs.each do |message, cost|
|
|
95
95
|
cumulative += cost
|
|
96
|
-
result[zone_for_cumulative(cumulative, zone_size)] <<
|
|
96
|
+
result[zone_for_cumulative(cumulative, zone_size)] << message
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
result
|
|
@@ -101,7 +101,7 @@ module Mneme
|
|
|
101
101
|
|
|
102
102
|
# Renders zones with delimiters, compressing tool calls into counters.
|
|
103
103
|
#
|
|
104
|
-
# @param zones [Hash{Symbol => Array<
|
|
104
|
+
# @param zones [Hash{Symbol => Array<Message>}]
|
|
105
105
|
# @return [String]
|
|
106
106
|
def render_zones(zones)
|
|
107
107
|
%i[eviction middle recent].flat_map { |name|
|
|
@@ -124,23 +124,23 @@ module Mneme
|
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
# Renders a single zone: conversation
|
|
127
|
+
# Renders a single zone: conversation messages as full text, consecutive
|
|
128
128
|
# tool calls/responses compressed into `[N tools called]` counters.
|
|
129
|
-
# tool_response
|
|
130
|
-
# via token cost but are not rendered; only tool_call
|
|
129
|
+
# tool_response messages are intentionally silent — they affect zone boundaries
|
|
130
|
+
# via token cost but are not rendered; only tool_call messages increment the counter.
|
|
131
131
|
#
|
|
132
|
-
# @param
|
|
132
|
+
# @param zone_messages [Array<Message>]
|
|
133
133
|
# @return [String]
|
|
134
|
-
def render_zone(
|
|
134
|
+
def render_zone(zone_messages)
|
|
135
135
|
lines = []
|
|
136
136
|
tool_count = 0
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
if
|
|
138
|
+
zone_messages.each do |message|
|
|
139
|
+
if conversation_message?(message) || think_message?(message)
|
|
140
140
|
lines << flush_tool_count(tool_count)
|
|
141
141
|
tool_count = 0
|
|
142
|
-
lines <<
|
|
143
|
-
elsif
|
|
142
|
+
lines << render_message_line(message)
|
|
143
|
+
elsif message.message_type == "tool_call"
|
|
144
144
|
tool_count += 1
|
|
145
145
|
end
|
|
146
146
|
end
|
|
@@ -149,17 +149,17 @@ module Mneme
|
|
|
149
149
|
lines.compact.join("\n")
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
# @return [Boolean] true if
|
|
153
|
-
def
|
|
154
|
-
|
|
152
|
+
# @return [Boolean] true if message is a user/agent/system message
|
|
153
|
+
def conversation_message?(message)
|
|
154
|
+
message.message_type.in?(Message::CONVERSATION_TYPES)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
# Think
|
|
157
|
+
# Think messages are tool_call messages with tool_name == "think".
|
|
158
158
|
# They carry the agent's reasoning and are treated as conversation.
|
|
159
159
|
#
|
|
160
160
|
# @return [Boolean]
|
|
161
|
-
def
|
|
162
|
-
|
|
161
|
+
def think_message?(message)
|
|
162
|
+
message.message_type == "tool_call" && message.payload["tool_name"] == Message::THINK_TOOL
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
ROLE_LABELS = {
|
|
@@ -168,17 +168,17 @@ module Mneme
|
|
|
168
168
|
"system_message" => "System"
|
|
169
169
|
}.freeze
|
|
170
170
|
|
|
171
|
-
# Renders a single
|
|
171
|
+
# Renders a single message as a transcript line.
|
|
172
172
|
#
|
|
173
|
-
# @param
|
|
173
|
+
# @param message [Message]
|
|
174
174
|
# @return [String]
|
|
175
|
-
def
|
|
176
|
-
prefix = "
|
|
177
|
-
data =
|
|
178
|
-
if
|
|
175
|
+
def render_message_line(message)
|
|
176
|
+
prefix = "message #{message.id}"
|
|
177
|
+
data = message.payload
|
|
178
|
+
if think_message?(message)
|
|
179
179
|
"#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
|
|
180
180
|
else
|
|
181
|
-
"#{prefix} #{ROLE_LABELS.fetch(
|
|
181
|
+
"#{prefix} #{ROLE_LABELS.fetch(message.message_type)}: #{data["content"]}"
|
|
182
182
|
end
|
|
183
183
|
end
|
|
184
184
|
|
|
@@ -192,9 +192,9 @@ module Mneme
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
# @return [Integer] token cost using cached count or heuristic
|
|
195
|
-
def
|
|
196
|
-
cached =
|
|
197
|
-
(cached > 0) ? cached :
|
|
195
|
+
def message_token_cost(message)
|
|
196
|
+
cached = message.token_count
|
|
197
|
+
(cached > 0) ? cached : message.estimate_tokens
|
|
198
198
|
end
|
|
199
199
|
end
|
|
200
200
|
end
|
data/lib/mneme/l2_runner.rb
CHANGED
|
@@ -110,22 +110,22 @@ module Mneme
|
|
|
110
110
|
# @return [Array<Hash>] single-element messages array
|
|
111
111
|
def build_messages(snapshots)
|
|
112
112
|
content = snapshots.map.with_index(1) { |snap, idx|
|
|
113
|
-
"--- Snapshot #{idx} (
|
|
113
|
+
"--- Snapshot #{idx} (messages #{snap.from_message_id}..#{snap.to_message_id}) ---\n#{snap.text}"
|
|
114
114
|
}.join("\n\n")
|
|
115
115
|
|
|
116
116
|
[{role: "user", content: "Compress these #{snapshots.size} Level 1 snapshots into a single Level 2 summary:\n\n#{content}"}]
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Builds the tool registry with L2 context for SaveSnapshot.
|
|
120
|
-
# The
|
|
120
|
+
# The message range spans from the first L1's start to the last L1's end.
|
|
121
121
|
#
|
|
122
122
|
# @param snapshots [Array<Snapshot>]
|
|
123
123
|
# @return [Tools::Registry]
|
|
124
124
|
def build_registry(snapshots)
|
|
125
125
|
registry = ::Tools::Registry.new(context: {
|
|
126
126
|
main_session: @session,
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
from_message_id: snapshots.first.from_message_id,
|
|
128
|
+
to_message_id: snapshots.last.to_message_id,
|
|
129
129
|
level: 2
|
|
130
130
|
})
|
|
131
131
|
TOOLS.each { |tool| registry.register(tool) }
|
data/lib/mneme/passive_recall.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Mneme
|
|
|
32
32
|
|
|
33
33
|
# Exclude events from the current session's viewport — no point recalling
|
|
34
34
|
# what the agent already sees.
|
|
35
|
-
viewport_ids = @session.
|
|
36
|
-
results.reject { |result| viewport_ids.include?(result.
|
|
35
|
+
viewport_ids = @session.viewport_message_ids.to_set
|
|
36
|
+
results.reject { |result| viewport_ids.include?(result.message_id) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
private
|