anima-core 1.0.1 → 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 +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- 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/20260316094817_add_interrupt_requested_to_sessions.rb +5 -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 +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- 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 +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- 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 +99 -14
- 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/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- 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 +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
data/lib/llm/client.rb
CHANGED
|
@@ -15,6 +15,9 @@ module LLM
|
|
|
15
15
|
# registry.register(Tools::WebGet)
|
|
16
16
|
# client.chat_with_tools(messages, registry: registry, session_id: session.id)
|
|
17
17
|
class Client
|
|
18
|
+
# Synthetic tool_result message when a tool is skipped due to user interrupt.
|
|
19
|
+
INTERRUPT_MESSAGE = "Stopped by user"
|
|
20
|
+
|
|
18
21
|
# @return [Providers::Anthropic] the underlying API provider
|
|
19
22
|
attr_reader :provider
|
|
20
23
|
|
|
@@ -61,13 +64,19 @@ module LLM
|
|
|
61
64
|
# Emits {Events::ToolCall} and {Events::ToolResponse} events for each
|
|
62
65
|
# tool interaction so they're persisted and visible in the event stream.
|
|
63
66
|
#
|
|
67
|
+
# When the user interrupts via Escape, remaining tools receive synthetic
|
|
68
|
+
# "Stopped by user" results and the loop exits without another LLM call.
|
|
69
|
+
#
|
|
64
70
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
65
71
|
# @param registry [Tools::Registry] registered tools to make available
|
|
66
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.
|
|
67
76
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
68
|
-
# @return [String] the assistant's final text response
|
|
77
|
+
# @return [String, nil] the assistant's final text response, or nil when interrupted
|
|
69
78
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
70
|
-
def chat_with_tools(messages, registry:, session_id:, **options)
|
|
79
|
+
def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
|
|
71
80
|
messages = messages.dup
|
|
72
81
|
rounds = 0
|
|
73
82
|
|
|
@@ -78,13 +87,17 @@ module LLM
|
|
|
78
87
|
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
79
88
|
end
|
|
80
89
|
|
|
81
|
-
response =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
101
|
|
|
89
102
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
90
103
|
|
|
@@ -95,6 +108,11 @@ module LLM
|
|
|
95
108
|
{role: "assistant", content: response["content"]},
|
|
96
109
|
{role: "user", content: tool_results}
|
|
97
110
|
]
|
|
111
|
+
|
|
112
|
+
if interrupted?(session_id)
|
|
113
|
+
clear_interrupt!(session_id)
|
|
114
|
+
return nil
|
|
115
|
+
end
|
|
98
116
|
else
|
|
99
117
|
return extract_text(response)
|
|
100
118
|
end
|
|
@@ -122,20 +140,43 @@ module LLM
|
|
|
122
140
|
end
|
|
123
141
|
|
|
124
142
|
# Executes all tool_use blocks from a response, emitting events for each.
|
|
143
|
+
# Checks for user interrupt between tools — remaining tools receive
|
|
144
|
+
# synthetic results to satisfy the Anthropic API's tool_use/tool_result
|
|
145
|
+
# pairing requirement (a missing result permanently breaks the conversation).
|
|
125
146
|
#
|
|
126
147
|
# @param response [Hash] Anthropic API response with tool_use content blocks
|
|
127
148
|
# @param registry [Tools::Registry] tool registry for dispatch
|
|
128
149
|
# @param session_id [Integer, String] session ID for events
|
|
129
150
|
# @return [Array<Hash>] tool_result content blocks for the next API call
|
|
130
151
|
def execute_tools(response, registry, session_id)
|
|
131
|
-
extract_tool_uses(response)
|
|
132
|
-
|
|
152
|
+
tool_uses = extract_tool_uses(response)
|
|
153
|
+
results = []
|
|
154
|
+
|
|
155
|
+
tool_uses.each_with_index do |tool_use, index|
|
|
156
|
+
if interrupted?(session_id)
|
|
157
|
+
remaining = tool_uses[index..]
|
|
158
|
+
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
159
|
+
break
|
|
160
|
+
end
|
|
161
|
+
results << execute_single_tool(tool_use, registry, session_id)
|
|
133
162
|
end
|
|
163
|
+
|
|
164
|
+
results
|
|
134
165
|
end
|
|
135
166
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
167
|
+
# Creates synthetic "Stopped by user" results for all tools in the list.
|
|
168
|
+
#
|
|
169
|
+
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
170
|
+
# @param session_id [Integer, String] session ID for events
|
|
171
|
+
# @return [Array<Hash>] tool_result content blocks
|
|
172
|
+
def interrupt_remaining_tools(tool_uses, session_id)
|
|
173
|
+
tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Executes a single tool and always returns a tool_result — even if the
|
|
177
|
+
# tool raises. Per the Anthropic tool-use protocol, every tool_use must
|
|
178
|
+
# have a matching tool_result; a missing result permanently corrupts the
|
|
179
|
+
# conversation history and breaks the session.
|
|
139
180
|
def execute_single_tool(tool_use, registry, session_id)
|
|
140
181
|
name = tool_use["name"]
|
|
141
182
|
id = tool_use["id"]
|
|
@@ -155,6 +196,7 @@ module LLM
|
|
|
155
196
|
{error: "#{error.class}: #{error.message}"}
|
|
156
197
|
end
|
|
157
198
|
|
|
199
|
+
result = ToolDecorator.call(name, result)
|
|
158
200
|
result_content = format_tool_result(result)
|
|
159
201
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
160
202
|
|
|
@@ -167,6 +209,49 @@ module LLM
|
|
|
167
209
|
{type: "tool_result", tool_use_id: id, content: result_content}
|
|
168
210
|
end
|
|
169
211
|
|
|
212
|
+
# Creates a synthetic "Stopped by user" result for a tool that was not
|
|
213
|
+
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
214
|
+
# events so the TUI shows the interrupted tool in the event stream.
|
|
215
|
+
#
|
|
216
|
+
# @param tool_use [Hash] Anthropic tool_use content block
|
|
217
|
+
# @param session_id [Integer, String] session ID for events
|
|
218
|
+
# @return [Hash] tool_result content block
|
|
219
|
+
def interrupt_tool(tool_use, session_id)
|
|
220
|
+
name = tool_use["name"]
|
|
221
|
+
id = tool_use["id"]
|
|
222
|
+
input = tool_use["input"] || {}
|
|
223
|
+
|
|
224
|
+
Events::Bus.emit(Events::ToolCall.new(
|
|
225
|
+
content: "Skipped #{name} (interrupted)", tool_name: name,
|
|
226
|
+
tool_input: input, tool_use_id: id, session_id: session_id
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
Events::Bus.emit(Events::ToolResponse.new(
|
|
230
|
+
content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
|
|
231
|
+
success: false, session_id: session_id
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Checks the database for a pending interrupt flag on the session.
|
|
238
|
+
#
|
|
239
|
+
# @param session_id [Integer, String] session to check
|
|
240
|
+
# @return [Boolean] whether the session has a pending interrupt request
|
|
241
|
+
def interrupted?(session_id)
|
|
242
|
+
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Clears the interrupt flag so the agent loop can continue with pending
|
|
246
|
+
# messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
|
|
247
|
+
# net for unexpected exits.
|
|
248
|
+
#
|
|
249
|
+
# @param session_id [Integer, String] session to clear
|
|
250
|
+
# @return [void]
|
|
251
|
+
def clear_interrupt!(session_id)
|
|
252
|
+
Session.where(id: session_id).update_all(interrupt_requested: false)
|
|
253
|
+
end
|
|
254
|
+
|
|
170
255
|
def log(level, message)
|
|
171
256
|
return unless @logger
|
|
172
257
|
|
|
@@ -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Passive recall — automatic memory surfacing triggered by Goal updates.
|
|
5
|
+
# When goals are created or updated, searches event history for related
|
|
6
|
+
# context and caches the results on the session for viewport injection.
|
|
7
|
+
#
|
|
8
|
+
# The agent never calls a tool; relevant memories appear automatically
|
|
9
|
+
# in the viewport between snapshots and the sliding window. This mirrors
|
|
10
|
+
# recognition memory in humans — context surfaces without conscious effort.
|
|
11
|
+
#
|
|
12
|
+
# @example Trigger after a goal update
|
|
13
|
+
# Mneme::PassiveRecall.new(session).call
|
|
14
|
+
class PassiveRecall
|
|
15
|
+
# @param session [Session] the session whose goals drive recall
|
|
16
|
+
def initialize(session)
|
|
17
|
+
@session = session
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Searches event history using active goal descriptions as queries.
|
|
21
|
+
# Returns recall results suitable for viewport injection.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Mneme::Search::Result>] deduplicated, relevance-sorted
|
|
24
|
+
def call
|
|
25
|
+
goals = @session.goals.active.root.includes(:sub_goals)
|
|
26
|
+
return [] if goals.empty?
|
|
27
|
+
|
|
28
|
+
search_terms = build_search_terms(goals)
|
|
29
|
+
return [] if search_terms.blank?
|
|
30
|
+
|
|
31
|
+
results = Mneme::Search.query(search_terms, limit: Anima::Settings.recall_max_results)
|
|
32
|
+
|
|
33
|
+
# Exclude events from the current session's viewport — no point recalling
|
|
34
|
+
# what the agent already sees.
|
|
35
|
+
viewport_ids = @session.viewport_event_ids.to_set
|
|
36
|
+
results.reject { |result| viewport_ids.include?(result.event_id) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
STOP_WORDS = Set.new(%w[
|
|
42
|
+
a an the is are was were be been being do does did
|
|
43
|
+
have has had in on at to for of and or but not with
|
|
44
|
+
this that it its by from as up out if about into
|
|
45
|
+
fix add create update remove implement check set get
|
|
46
|
+
]).freeze
|
|
47
|
+
|
|
48
|
+
# Extracts meaningful keywords from active goals and joins with OR.
|
|
49
|
+
# Stop words and generic verbs are stripped — they're too common to
|
|
50
|
+
# produce useful recall results.
|
|
51
|
+
#
|
|
52
|
+
# @param goals [ActiveRecord::Relation<Goal>]
|
|
53
|
+
# @return [String] FTS5 OR-joined keywords
|
|
54
|
+
def build_search_terms(goals)
|
|
55
|
+
descriptions = goals.flat_map { |goal|
|
|
56
|
+
[goal.description] + goal.sub_goals.reject(&:completed?).map(&:description)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
words = descriptions.join(" ")
|
|
60
|
+
.gsub(/[^a-zA-Z0-9\s-]/, "")
|
|
61
|
+
.downcase
|
|
62
|
+
.split
|
|
63
|
+
.uniq
|
|
64
|
+
.reject { |word| STOP_WORDS.include?(word) || word.length < 3 }
|
|
65
|
+
|
|
66
|
+
words.join(" OR ").truncate(500)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|