anima-core 1.0.2 → 1.1.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/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +90 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Transient failure event emitted when LLM delivery fails inside the
|
|
5
|
+
# Bounce Back transaction. The user event record is rolled back, and
|
|
6
|
+
# this event notifies clients to remove the phantom message and
|
|
7
|
+
# restore the text to the input field.
|
|
8
|
+
#
|
|
9
|
+
# Not persisted — not included in {Event::TYPES}.
|
|
10
|
+
class BounceBack < Base
|
|
11
|
+
TYPE = "bounce_back"
|
|
12
|
+
|
|
13
|
+
# @return [String] human-readable error description
|
|
14
|
+
attr_reader :error
|
|
15
|
+
|
|
16
|
+
# @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
|
|
17
|
+
attr_reader :event_id
|
|
18
|
+
|
|
19
|
+
# @param content [String] original user message text to restore to input
|
|
20
|
+
# @param error [String] error description for the flash message
|
|
21
|
+
# @param session_id [Integer] session the message was intended for
|
|
22
|
+
# @param event_id [Integer, nil] ID of the event that was broadcast optimistically
|
|
23
|
+
def initialize(content:, error:, session_id:, event_id: nil)
|
|
24
|
+
super(content: content, session_id: session_id)
|
|
25
|
+
@error = error
|
|
26
|
+
@event_id = event_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def type
|
|
30
|
+
TYPE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
super.merge(error: error, event_id: event_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Reacts to non-pending {Events::UserMessage} emissions by scheduling
|
|
6
|
+
# {AgentRequestJob}. This is the event-driven bridge between the
|
|
7
|
+
# channel (which emits the intent) and the job (which persists and
|
|
8
|
+
# delivers the message).
|
|
9
|
+
#
|
|
10
|
+
# Pending messages are skipped — they are picked up by the running
|
|
11
|
+
# agent loop after it finishes the current turn.
|
|
12
|
+
class AgentDispatcher
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# @param event [Hash] Rails.event notification hash
|
|
16
|
+
def emit(event)
|
|
17
|
+
payload = event[:payload]
|
|
18
|
+
return unless payload.is_a?(Hash)
|
|
19
|
+
return unless payload[:type] == "user_message"
|
|
20
|
+
return if payload[:status] == Event::PENDING_STATUS
|
|
21
|
+
|
|
22
|
+
session_id = payload[:session_id]
|
|
23
|
+
return unless session_id
|
|
24
|
+
|
|
25
|
+
AgentRequestJob.perform_later(session_id, content: payload[:content])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -27,6 +27,12 @@ module Events
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Receives a Rails.event notification hash and persists it.
|
|
30
|
+
#
|
|
31
|
+
# Skips non-pending user messages — those are persisted by
|
|
32
|
+
# {AgentRequestJob} inside a transaction with LLM delivery
|
|
33
|
+
# (Bounce Back, #236). Also skips event types not in {Event::TYPES}
|
|
34
|
+
# (transient events like {Events::BounceBack}).
|
|
35
|
+
#
|
|
30
36
|
# @param event [Hash] with :payload containing event data
|
|
31
37
|
def emit(event)
|
|
32
38
|
payload = event[:payload]
|
|
@@ -34,6 +40,8 @@ module Events
|
|
|
34
40
|
|
|
35
41
|
event_type = payload[:type]
|
|
36
42
|
return if event_type.nil?
|
|
43
|
+
return unless Event::TYPES.include?(event_type)
|
|
44
|
+
return if persisted_by_job?(event_type, payload)
|
|
37
45
|
|
|
38
46
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
39
47
|
return unless target_session
|
|
@@ -52,6 +60,15 @@ module Events
|
|
|
52
60
|
def session=(new_session)
|
|
53
61
|
@mutex.synchronize { @session = new_session }
|
|
54
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Non-pending user messages are persisted by {AgentRequestJob} inside
|
|
67
|
+
# a transaction with LLM delivery. Pending messages are still
|
|
68
|
+
# auto-persisted here because they queue while the session is busy.
|
|
69
|
+
def persisted_by_job?(event_type, payload)
|
|
70
|
+
event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
|
|
71
|
+
end
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Routes text messages between parent and child sessions, enabling
|
|
6
|
+
# bidirectional @mention communication.
|
|
7
|
+
#
|
|
8
|
+
# **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
|
|
9
|
+
# the router persists a {Events::UserMessage} in the parent session
|
|
10
|
+
# with attribution prefix, then wakes the parent via {AgentRequestJob}.
|
|
11
|
+
#
|
|
12
|
+
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
13
|
+
# containing `@name` mentions, the router persists the message in each
|
|
14
|
+
# matching child session and wakes them via {AgentRequestJob}.
|
|
15
|
+
#
|
|
16
|
+
# Both directions use direct persistence + job enqueue (same pattern as
|
|
17
|
+
# {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
|
|
18
|
+
# {Persister} which skips non-pending user messages.
|
|
19
|
+
#
|
|
20
|
+
# This replaces the +return_result+ tool — sub-agents communicate
|
|
21
|
+
# through natural text messages instead of structured tool calls.
|
|
22
|
+
class SubagentMessageRouter
|
|
23
|
+
include Events::Subscriber
|
|
24
|
+
|
|
25
|
+
# Attribution prefix format for messages routed from child to parent.
|
|
26
|
+
# @example "[sub-agent @loop-sleuth]: Here's what I found..."
|
|
27
|
+
ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
|
|
28
|
+
|
|
29
|
+
# Regex to extract @mention names from parent agent messages.
|
|
30
|
+
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
31
|
+
|
|
32
|
+
# Routes agent text messages between parent and child sessions.
|
|
33
|
+
#
|
|
34
|
+
# For sub-agent sessions: forwards to parent with attribution prefix.
|
|
35
|
+
# For parent sessions: scans for @mentions and routes to matching children.
|
|
36
|
+
#
|
|
37
|
+
# @param event [Hash] Rails.event notification hash with +:payload+ containing
|
|
38
|
+
# an +agent_message+ event (type, session_id, content)
|
|
39
|
+
# @return [void]
|
|
40
|
+
def emit(event)
|
|
41
|
+
payload = event[:payload]
|
|
42
|
+
return unless payload.is_a?(Hash)
|
|
43
|
+
return unless payload[:type] == "agent_message"
|
|
44
|
+
|
|
45
|
+
session_id = payload[:session_id]
|
|
46
|
+
return unless session_id
|
|
47
|
+
|
|
48
|
+
content = payload[:content].to_s
|
|
49
|
+
return if content.empty?
|
|
50
|
+
|
|
51
|
+
session = Session.find_by(id: session_id)
|
|
52
|
+
return unless session
|
|
53
|
+
|
|
54
|
+
if session.sub_agent?
|
|
55
|
+
route_to_parent(session, content)
|
|
56
|
+
else
|
|
57
|
+
route_mentions_to_children(session, content)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Forwards a sub-agent's text message to its parent session.
|
|
64
|
+
# Persists directly and enqueues a job so the parent agent wakes
|
|
65
|
+
# up to process the message.
|
|
66
|
+
#
|
|
67
|
+
# @param child [Session] the sub-agent session
|
|
68
|
+
# @param content [String] the sub-agent's message text
|
|
69
|
+
def route_to_parent(child, content)
|
|
70
|
+
parent = child.parent_session
|
|
71
|
+
return unless parent
|
|
72
|
+
|
|
73
|
+
name = child.name || "agent-#{child.id}"
|
|
74
|
+
attributed = format(ATTRIBUTION_FORMAT, name, content)
|
|
75
|
+
|
|
76
|
+
parent.create_user_event(attributed)
|
|
77
|
+
AgentRequestJob.perform_later(parent.id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Scans a parent agent's message for @mentions and routes the message
|
|
81
|
+
# to each mentioned child session.
|
|
82
|
+
#
|
|
83
|
+
# @param parent [Session] the parent session
|
|
84
|
+
# @param content [String] the parent agent's message text
|
|
85
|
+
def route_mentions_to_children(parent, content)
|
|
86
|
+
mentioned_names = content.scan(MENTION_PATTERN).flatten.uniq
|
|
87
|
+
return if mentioned_names.empty?
|
|
88
|
+
|
|
89
|
+
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
90
|
+
return if active_children.empty?
|
|
91
|
+
|
|
92
|
+
mentioned_names.each do |name|
|
|
93
|
+
child = active_children[name]
|
|
94
|
+
next unless child
|
|
95
|
+
|
|
96
|
+
child.create_user_event(content)
|
|
97
|
+
AgentRequestJob.perform_later(child.id)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
|
+
# receive them over WebSocket. Persisted events reach clients via
|
|
7
|
+
# {Event::Broadcasting} callbacks; this subscriber handles events
|
|
8
|
+
# that never touch the database.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering at boot
|
|
11
|
+
# Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
12
|
+
class TransientBroadcaster
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
# Event types that are broadcast without persistence.
|
|
16
|
+
TRANSIENT_TYPES = [Events::BounceBack::TYPE].freeze
|
|
17
|
+
|
|
18
|
+
# @param event [Hash] Rails.event notification hash
|
|
19
|
+
def emit(event)
|
|
20
|
+
payload = event[:payload]
|
|
21
|
+
return unless payload.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
event_type = payload[:type]
|
|
24
|
+
return unless TRANSIENT_TYPES.include?(event_type)
|
|
25
|
+
|
|
26
|
+
session_id = payload[:session_id]
|
|
27
|
+
return unless session_id
|
|
28
|
+
|
|
29
|
+
ActionCable.server.broadcast(
|
|
30
|
+
"session_#{session_id}",
|
|
31
|
+
payload.transform_keys(&:to_s)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -70,10 +70,13 @@ module LLM
|
|
|
70
70
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
71
|
# @param registry [Tools::Registry] registered tools to make available
|
|
72
72
|
# @param session_id [Integer, String] session ID for emitted events
|
|
73
|
+
# @param first_response [Hash, nil] pre-fetched first API response from
|
|
74
|
+
# {AgentLoop#deliver!}. Skips the first API call when provided so
|
|
75
|
+
# the Bounce Back transaction doesn't duplicate work.
|
|
73
76
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
74
77
|
# @return [String, nil] the assistant's final text response, or nil when interrupted
|
|
75
78
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
76
|
-
def chat_with_tools(messages, registry:, session_id:, **options)
|
|
79
|
+
def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
|
|
77
80
|
messages = messages.dup
|
|
78
81
|
rounds = 0
|
|
79
82
|
|
|
@@ -84,13 +87,17 @@ module LLM
|
|
|
84
87
|
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
response =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
response = if first_response && rounds == 1
|
|
91
|
+
first_response
|
|
92
|
+
else
|
|
93
|
+
provider.create_message(
|
|
94
|
+
model: model,
|
|
95
|
+
messages: messages,
|
|
96
|
+
max_tokens: max_tokens,
|
|
97
|
+
tools: registry.schemas,
|
|
98
|
+
**options
|
|
99
|
+
)
|
|
100
|
+
end
|
|
94
101
|
|
|
95
102
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
96
103
|
|
|
@@ -189,6 +196,7 @@ module LLM
|
|
|
189
196
|
{error: "#{error.class}: #{error.message}"}
|
|
190
197
|
end
|
|
191
198
|
|
|
199
|
+
result = ToolDecorator.call(name, result)
|
|
192
200
|
result_content = format_tool_result(result)
|
|
193
201
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
194
202
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Builds a compressed viewport for Mneme's LLM context. Mneme sees
|
|
5
|
+
# conversation (user/agent messages and think events) but not mechanical
|
|
6
|
+
# execution (tool calls and responses). Tool calls are compressed to
|
|
7
|
+
# aggregate counters like `[4 tools called]`.
|
|
8
|
+
#
|
|
9
|
+
# The viewport is split into three zones separated by delimiters:
|
|
10
|
+
# - **Eviction zone** — events about to leave the viewport (upper third)
|
|
11
|
+
# - **Middle zone** — events in the middle of the viewport
|
|
12
|
+
# - **Recent zone** — the most recent events (lower third)
|
|
13
|
+
#
|
|
14
|
+
# Zone boundaries are calculated WITH tool call tokens (they affect
|
|
15
|
+
# position), then tool calls are removed and replaced with counters.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
|
|
19
|
+
# viewport.render #=> "── EVICTION ZONE ──\nevent 42 User: ..."
|
|
20
|
+
class CompressedViewport
|
|
21
|
+
ZONE_DELIMITERS = {
|
|
22
|
+
eviction: "── EVICTION ZONE (upper third) ──",
|
|
23
|
+
middle: "── MIDDLE ZONE ──",
|
|
24
|
+
recent: "── RECENT ZONE (lower third) ──"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# @param session [Session] the session to build viewport for
|
|
28
|
+
# @param token_budget [Integer] total tokens available for Mneme's viewport
|
|
29
|
+
# @param from_event_id [Integer, nil] start from this event ID (inclusive);
|
|
30
|
+
# when nil, uses the session's full viewport
|
|
31
|
+
def initialize(session, token_budget:, from_event_id: nil)
|
|
32
|
+
@session = session
|
|
33
|
+
@token_budget = token_budget
|
|
34
|
+
@from_event_id = from_event_id
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Renders the compressed viewport as a string ready for Mneme's LLM context.
|
|
38
|
+
#
|
|
39
|
+
# @return [String] compressed viewport with zone delimiters
|
|
40
|
+
def render
|
|
41
|
+
return "" if events.empty?
|
|
42
|
+
|
|
43
|
+
zones = split_into_zones(events)
|
|
44
|
+
render_zones(zones)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Event>] the raw events selected for this viewport
|
|
48
|
+
def events
|
|
49
|
+
@events ||= fetch_events
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Fetches events within token budget, starting from from_event_id.
|
|
55
|
+
# Selects newest-first until budget exhausted, returns chronological.
|
|
56
|
+
# Caches per-event token costs in @event_costs for reuse by split_into_zones.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Event>]
|
|
59
|
+
def fetch_events
|
|
60
|
+
scope = @session.events.context_events.deliverable
|
|
61
|
+
|
|
62
|
+
if @from_event_id
|
|
63
|
+
scope = scope.where("id >= ?", @from_event_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
selected = []
|
|
67
|
+
@event_costs = {}
|
|
68
|
+
remaining = @token_budget
|
|
69
|
+
|
|
70
|
+
scope.reorder(id: :desc).each do |event|
|
|
71
|
+
cost = event_token_cost(event)
|
|
72
|
+
break if cost > remaining && selected.any?
|
|
73
|
+
|
|
74
|
+
selected << event
|
|
75
|
+
@event_costs[event.id] = cost
|
|
76
|
+
remaining -= cost
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
selected.reverse
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Splits events into three zones by token count.
|
|
83
|
+
# Zone boundaries are calculated including ALL events (tool calls count
|
|
84
|
+
# toward position), but zone assignment uses cumulative tokens.
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash{Symbol => Array<Event>}] :eviction, :middle, :recent
|
|
87
|
+
def split_into_zones(events)
|
|
88
|
+
costs = events.map { |event| [event, @event_costs[event.id] || event_token_cost(event)] }
|
|
89
|
+
zone_size = costs.sum(&:last) / 3.0
|
|
90
|
+
|
|
91
|
+
result = {eviction: [], middle: [], recent: []}
|
|
92
|
+
cumulative = 0
|
|
93
|
+
|
|
94
|
+
costs.each do |event, cost|
|
|
95
|
+
cumulative += cost
|
|
96
|
+
result[zone_for_cumulative(cumulative, zone_size)] << event
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Renders zones with delimiters, compressing tool calls into counters.
|
|
103
|
+
#
|
|
104
|
+
# @param zones [Hash{Symbol => Array<Event>}]
|
|
105
|
+
# @return [String]
|
|
106
|
+
def render_zones(zones)
|
|
107
|
+
%i[eviction middle recent].flat_map { |name|
|
|
108
|
+
[ZONE_DELIMITERS[name], render_zone(zones[name])]
|
|
109
|
+
}.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Determines which zone an event belongs to based on cumulative token position.
|
|
113
|
+
#
|
|
114
|
+
# @param cumulative [Numeric] cumulative token count including this event
|
|
115
|
+
# @param zone_size [Float] token count per zone (total / 3)
|
|
116
|
+
# @return [Symbol] :eviction, :middle, or :recent
|
|
117
|
+
def zone_for_cumulative(cumulative, zone_size)
|
|
118
|
+
if cumulative <= zone_size
|
|
119
|
+
:eviction
|
|
120
|
+
elsif cumulative <= zone_size * 2
|
|
121
|
+
:middle
|
|
122
|
+
else
|
|
123
|
+
:recent
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Renders a single zone: conversation events as full text, consecutive
|
|
128
|
+
# tool calls/responses compressed into `[N tools called]` counters.
|
|
129
|
+
# tool_response events are intentionally silent — they affect zone boundaries
|
|
130
|
+
# via token cost but are not rendered; only tool_call events increment the counter.
|
|
131
|
+
#
|
|
132
|
+
# @param zone_events [Array<Event>]
|
|
133
|
+
# @return [String]
|
|
134
|
+
def render_zone(zone_events)
|
|
135
|
+
lines = []
|
|
136
|
+
tool_count = 0
|
|
137
|
+
|
|
138
|
+
zone_events.each do |event|
|
|
139
|
+
if conversation_event?(event) || think_event?(event)
|
|
140
|
+
lines << flush_tool_count(tool_count)
|
|
141
|
+
tool_count = 0
|
|
142
|
+
lines << render_event_line(event)
|
|
143
|
+
elsif event.event_type == "tool_call"
|
|
144
|
+
tool_count += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
lines << flush_tool_count(tool_count)
|
|
149
|
+
lines.compact.join("\n")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Boolean] true if event is a user/agent/system message
|
|
153
|
+
def conversation_event?(event)
|
|
154
|
+
event.event_type.in?(Event::CONVERSATION_TYPES)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Think events are tool_call events with tool_name == "think".
|
|
158
|
+
# They carry the agent's reasoning and are treated as conversation.
|
|
159
|
+
#
|
|
160
|
+
# @return [Boolean]
|
|
161
|
+
def think_event?(event)
|
|
162
|
+
event.event_type == "tool_call" && event.payload["tool_name"] == Event::THINK_TOOL
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
ROLE_LABELS = {
|
|
166
|
+
"user_message" => "User",
|
|
167
|
+
"agent_message" => "Assistant",
|
|
168
|
+
"system_message" => "System"
|
|
169
|
+
}.freeze
|
|
170
|
+
|
|
171
|
+
# Renders a single event as a transcript line.
|
|
172
|
+
#
|
|
173
|
+
# @param event [Event]
|
|
174
|
+
# @return [String]
|
|
175
|
+
def render_event_line(event)
|
|
176
|
+
prefix = "event #{event.id}"
|
|
177
|
+
data = event.payload
|
|
178
|
+
if think_event?(event)
|
|
179
|
+
"#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
|
|
180
|
+
else
|
|
181
|
+
"#{prefix} #{ROLE_LABELS.fetch(event.event_type)}: #{data["content"]}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Returns a tool count string if any tools were called, nil otherwise.
|
|
186
|
+
#
|
|
187
|
+
# @param count [Integer] number of tool calls to flush
|
|
188
|
+
# @return [String, nil]
|
|
189
|
+
def flush_tool_count(count)
|
|
190
|
+
return if count == 0
|
|
191
|
+
"[#{count} #{(count == 1) ? "tool" : "tools"} called]"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @return [Integer] token cost using cached count or heuristic
|
|
195
|
+
def event_token_cost(event)
|
|
196
|
+
cached = event.token_count
|
|
197
|
+
(cached > 0) ? cached : event.estimate_tokens
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Compresses multiple Level 1 snapshots into a single Level 2 snapshot.
|
|
5
|
+
# L2 snapshots capture days/weeks-scale context from hourly L1 summaries,
|
|
6
|
+
# preventing unbounded snapshot growth via recursive compression.
|
|
7
|
+
#
|
|
8
|
+
# Triggered from {MnemeJob} after an L1 snapshot is created, when enough
|
|
9
|
+
# uncovered L1 snapshots have accumulated (configurable via
|
|
10
|
+
# +mneme.l2_snapshot_threshold+ in config.toml).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# Mneme::L2Runner.new(session).call
|
|
14
|
+
class L2Runner
|
|
15
|
+
TOOLS = [
|
|
16
|
+
Tools::SaveSnapshot,
|
|
17
|
+
Tools::EverythingOk
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
21
|
+
You are Mneme, the memory department of an AI agent named Anima.
|
|
22
|
+
Your job is to compress multiple conversation summaries into a single
|
|
23
|
+
higher-level summary.
|
|
24
|
+
|
|
25
|
+
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
26
|
+
|
|
27
|
+
──────────────────────────────
|
|
28
|
+
WHAT YOU SEE
|
|
29
|
+
──────────────────────────────
|
|
30
|
+
Several Level 1 snapshots — hourly conversation summaries.
|
|
31
|
+
Each captures key decisions, goals discussed, and important context
|
|
32
|
+
from a portion of the conversation history.
|
|
33
|
+
|
|
34
|
+
──────────────────────────────
|
|
35
|
+
YOUR TASK
|
|
36
|
+
──────────────────────────────
|
|
37
|
+
Compress the snapshots into ONE Level 2 summary that captures the
|
|
38
|
+
essential arc across all of them. If the snapshots contain meaningful
|
|
39
|
+
content, call save_snapshot. If they are purely mechanical, call
|
|
40
|
+
everything_ok.
|
|
41
|
+
|
|
42
|
+
Preserve:
|
|
43
|
+
- Key decisions and their reasoning
|
|
44
|
+
- Goal progress across the time span
|
|
45
|
+
- Important context shifts or pivots
|
|
46
|
+
- Relationships and patterns across snapshots
|
|
47
|
+
|
|
48
|
+
Drop:
|
|
49
|
+
- Redundant details repeated across snapshots
|
|
50
|
+
- Mechanical execution details
|
|
51
|
+
- Interim decisions that were superseded by later ones
|
|
52
|
+
|
|
53
|
+
Always finish with exactly ONE tool call: either save_snapshot or everything_ok.
|
|
54
|
+
PROMPT
|
|
55
|
+
|
|
56
|
+
# @param session [Session] the main session whose L1 snapshots to compress
|
|
57
|
+
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
58
|
+
def initialize(session, client: nil)
|
|
59
|
+
@session = session
|
|
60
|
+
@client = client || LLM::Client.new(
|
|
61
|
+
model: Anima::Settings.fast_model,
|
|
62
|
+
max_tokens: Anima::Settings.mneme_max_tokens,
|
|
63
|
+
logger: Mneme.logger
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Compresses uncovered L1 snapshots into a single L2 snapshot.
|
|
68
|
+
# Returns early if not enough L1 snapshots have accumulated.
|
|
69
|
+
#
|
|
70
|
+
# @return [String, nil] LLM response text, or nil when skipped
|
|
71
|
+
def call
|
|
72
|
+
l1_snapshots = eligible_snapshots
|
|
73
|
+
threshold = Anima::Settings.mneme_l2_snapshot_threshold
|
|
74
|
+
sid = @session.id
|
|
75
|
+
snapshot_count = l1_snapshots.size
|
|
76
|
+
|
|
77
|
+
if snapshot_count < threshold
|
|
78
|
+
log.debug("session=#{sid} — only #{snapshot_count}/#{threshold} L1 snapshots, skipping L2")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
messages = build_messages(l1_snapshots)
|
|
83
|
+
registry = build_registry(l1_snapshots)
|
|
84
|
+
|
|
85
|
+
log.info("session=#{sid} — running L2 compression (#{snapshot_count} L1 snapshots)")
|
|
86
|
+
|
|
87
|
+
result = @client.chat_with_tools(
|
|
88
|
+
messages,
|
|
89
|
+
registry: registry,
|
|
90
|
+
session_id: nil,
|
|
91
|
+
system: SYSTEM_PROMPT
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
log.info("session=#{sid} — L2 compression done: #{result.to_s.truncate(200)}")
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# L1 snapshots that are not yet covered by any L2 snapshot.
|
|
101
|
+
#
|
|
102
|
+
# @return [Array<Snapshot>]
|
|
103
|
+
def eligible_snapshots
|
|
104
|
+
@session.snapshots.for_level(1).not_covered_by_l2.chronological.to_a
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Frames L1 snapshot texts as a user message for the LLM.
|
|
108
|
+
#
|
|
109
|
+
# @param snapshots [Array<Snapshot>]
|
|
110
|
+
# @return [Array<Hash>] single-element messages array
|
|
111
|
+
def build_messages(snapshots)
|
|
112
|
+
content = snapshots.map.with_index(1) { |snap, idx|
|
|
113
|
+
"--- Snapshot #{idx} (events #{snap.from_event_id}..#{snap.to_event_id}) ---\n#{snap.text}"
|
|
114
|
+
}.join("\n\n")
|
|
115
|
+
|
|
116
|
+
[{role: "user", content: "Compress these #{snapshots.size} Level 1 snapshots into a single Level 2 summary:\n\n#{content}"}]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Builds the tool registry with L2 context for SaveSnapshot.
|
|
120
|
+
# The event range spans from the first L1's start to the last L1's end.
|
|
121
|
+
#
|
|
122
|
+
# @param snapshots [Array<Snapshot>]
|
|
123
|
+
# @return [Tools::Registry]
|
|
124
|
+
def build_registry(snapshots)
|
|
125
|
+
registry = ::Tools::Registry.new(context: {
|
|
126
|
+
main_session: @session,
|
|
127
|
+
from_event_id: snapshots.first.from_event_id,
|
|
128
|
+
to_event_id: snapshots.last.to_event_id,
|
|
129
|
+
level: 2
|
|
130
|
+
})
|
|
131
|
+
TOOLS.each { |tool| registry.register(tool) }
|
|
132
|
+
registry
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Logger]
|
|
136
|
+
def log = Mneme.logger
|
|
137
|
+
end
|
|
138
|
+
end
|