anima-core 1.0.2 → 1.1.1
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 +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -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 +93 -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 +4 -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 +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- 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 +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- 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 +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- 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 +21 -10
- 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 +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -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
|
data/lib/mneme/runner.rb
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Orchestrates the Mneme memory department — a phantom (non-persisted) LLM loop
|
|
5
|
+
# that observes a main session's compressed viewport and creates summaries of
|
|
6
|
+
# conversation context before it evicts from the viewport.
|
|
7
|
+
#
|
|
8
|
+
# Mneme is triggered when the terminal event (`mneme_boundary_event_id`) leaves
|
|
9
|
+
# the viewport. It receives a compressed viewport (no raw tool calls, zone
|
|
10
|
+
# delimiters present) and uses the `save_snapshot` tool to persist a summary.
|
|
11
|
+
#
|
|
12
|
+
# After completing, Mneme advances the terminal event to the boundary of what
|
|
13
|
+
# it just summarized, so the cycle repeats as more events accumulate.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Mneme::Runner.new(session).call
|
|
17
|
+
class Runner
|
|
18
|
+
TOOLS = [
|
|
19
|
+
Tools::SaveSnapshot,
|
|
20
|
+
Tools::AttachEventsToGoals,
|
|
21
|
+
Tools::EverythingOk
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
25
|
+
You are Mneme, the memory department of an AI agent named Anima.
|
|
26
|
+
Your job is to create concise summaries of conversation context that is
|
|
27
|
+
about to leave the agent's context window.
|
|
28
|
+
|
|
29
|
+
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
30
|
+
|
|
31
|
+
──────────────────────────────
|
|
32
|
+
WHAT YOU SEE
|
|
33
|
+
──────────────────────────────
|
|
34
|
+
A compressed viewport with three zones:
|
|
35
|
+
- EVICTION ZONE: Events about to leave the viewport. Summarize these.
|
|
36
|
+
- MIDDLE ZONE: Events still visible but aging. Note key context.
|
|
37
|
+
- RECENT ZONE: Fresh events. Use for continuity with the summary.
|
|
38
|
+
|
|
39
|
+
Events are prefixed with `event N` (their database ID).
|
|
40
|
+
Tool calls are compressed to `[N tools called]` — the mechanical work
|
|
41
|
+
is not important, only the conversation flow.
|
|
42
|
+
|
|
43
|
+
──────────────────────────────
|
|
44
|
+
YOUR TASK
|
|
45
|
+
──────────────────────────────
|
|
46
|
+
1. Read the eviction zone carefully.
|
|
47
|
+
2. If it contains meaningful conversation (decisions, goals, context):
|
|
48
|
+
Call save_snapshot with a concise summary.
|
|
49
|
+
3. If any events in the eviction zone are too important to summarize
|
|
50
|
+
(exact user instructions, critical corrections, key decisions),
|
|
51
|
+
pin them to active goals with attach_events_to_goals.
|
|
52
|
+
Pinned events survive eviction intact — use this sparingly for
|
|
53
|
+
events where the exact wording matters.
|
|
54
|
+
4. If it contains only mechanical activity with no conversation:
|
|
55
|
+
Call everything_ok.
|
|
56
|
+
|
|
57
|
+
You may call BOTH save_snapshot AND attach_events_to_goals in one turn
|
|
58
|
+
when the zone has a mix of summarizable and pin-worthy events.
|
|
59
|
+
|
|
60
|
+
Write summaries that capture:
|
|
61
|
+
- What was discussed and decided
|
|
62
|
+
- Why decisions were made
|
|
63
|
+
- Active goals and their progress
|
|
64
|
+
- Key context the agent would need later
|
|
65
|
+
|
|
66
|
+
Do NOT include:
|
|
67
|
+
- Tool call details (which files were read, commands run)
|
|
68
|
+
- Mechanical execution steps
|
|
69
|
+
- Verbatim quotes (paraphrase instead)
|
|
70
|
+
|
|
71
|
+
Always finish with at least one tool call: save_snapshot, attach_events_to_goals,
|
|
72
|
+
or everything_ok. You may combine save_snapshot with attach_events_to_goals.
|
|
73
|
+
PROMPT
|
|
74
|
+
|
|
75
|
+
# @param session [Session] the main session to observe
|
|
76
|
+
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
77
|
+
def initialize(session, client: nil)
|
|
78
|
+
@session = session
|
|
79
|
+
@client = client || LLM::Client.new(
|
|
80
|
+
model: Anima::Settings.fast_model,
|
|
81
|
+
max_tokens: Anima::Settings.mneme_max_tokens,
|
|
82
|
+
logger: Mneme.logger
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Runs the Mneme loop: builds compressed viewport, calls LLM, executes
|
|
87
|
+
# snapshot tool, then advances the terminal event pointer.
|
|
88
|
+
#
|
|
89
|
+
# @return [String, nil] the LLM's final text response (discarded),
|
|
90
|
+
# or nil if no context is available
|
|
91
|
+
def call
|
|
92
|
+
viewport = build_compressed_viewport
|
|
93
|
+
compressed_text = viewport.render
|
|
94
|
+
sid = @session.id
|
|
95
|
+
|
|
96
|
+
if compressed_text.empty?
|
|
97
|
+
log.debug("session=#{sid} — no events for Mneme, skipping")
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
messages = build_messages(compressed_text)
|
|
102
|
+
system = SYSTEM_PROMPT
|
|
103
|
+
|
|
104
|
+
log.info("session=#{sid} — running Mneme (#{viewport.events.size} events)")
|
|
105
|
+
log.debug("compressed viewport:\n#{compressed_text}")
|
|
106
|
+
|
|
107
|
+
result = @client.chat_with_tools(
|
|
108
|
+
messages,
|
|
109
|
+
registry: build_registry(viewport),
|
|
110
|
+
session_id: nil,
|
|
111
|
+
system: system
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
advance_boundary(viewport)
|
|
115
|
+
log.info("session=#{sid} — Mneme done: #{result.to_s.truncate(200)}")
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Builds the compressed viewport starting from the session's boundary event.
|
|
122
|
+
#
|
|
123
|
+
# @return [Mneme::CompressedViewport]
|
|
124
|
+
def build_compressed_viewport
|
|
125
|
+
token_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_viewport_fraction).to_i
|
|
126
|
+
|
|
127
|
+
CompressedViewport.new(
|
|
128
|
+
@session,
|
|
129
|
+
token_budget: token_budget,
|
|
130
|
+
from_event_id: @session.mneme_boundary_event_id
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Frames the compressed viewport as a user message for the LLM.
|
|
135
|
+
#
|
|
136
|
+
# @param compressed_text [String] the rendered compressed viewport
|
|
137
|
+
# @return [Array<Hash>] single-element messages array
|
|
138
|
+
def build_messages(compressed_text)
|
|
139
|
+
goals_context = active_goals_section
|
|
140
|
+
|
|
141
|
+
content = <<~MSG.strip
|
|
142
|
+
Here is the compressed viewport of the main session:
|
|
143
|
+
|
|
144
|
+
#{compressed_text}
|
|
145
|
+
#{goals_context}
|
|
146
|
+
Review the eviction zone and decide whether to save a snapshot or signal everything_ok.
|
|
147
|
+
MSG
|
|
148
|
+
|
|
149
|
+
[{role: "user", content: content}]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Builds the tool registry with session context for SaveSnapshot.
|
|
153
|
+
# Passes the event range from the viewport so the snapshot records
|
|
154
|
+
# which events it covers.
|
|
155
|
+
#
|
|
156
|
+
# @param viewport [Mneme::CompressedViewport]
|
|
157
|
+
# @return [Tools::Registry]
|
|
158
|
+
def build_registry(viewport)
|
|
159
|
+
viewport_events = viewport.events
|
|
160
|
+
registry = ::Tools::Registry.new(context: {
|
|
161
|
+
main_session: @session,
|
|
162
|
+
from_event_id: viewport_events.first&.id,
|
|
163
|
+
to_event_id: viewport_events.last&.id
|
|
164
|
+
})
|
|
165
|
+
TOOLS.each { |tool| registry.register(tool) }
|
|
166
|
+
registry
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Advances the terminal event pointer after Mneme completes.
|
|
170
|
+
# Runs unconditionally — even when the LLM called `everything_ok` (no snapshot
|
|
171
|
+
# needed), the zone was reviewed and should be advanced past. Without this,
|
|
172
|
+
# Mneme would re-examine the same mechanical-only content on every trigger.
|
|
173
|
+
#
|
|
174
|
+
# Sets it to the last conversation event in the viewport, ensuring
|
|
175
|
+
# the boundary is always a message/think event, never a tool_call/tool_response.
|
|
176
|
+
# Also updates the snapshot range pointers.
|
|
177
|
+
#
|
|
178
|
+
# @param viewport [Mneme::CompressedViewport]
|
|
179
|
+
def advance_boundary(viewport)
|
|
180
|
+
viewport_events = viewport.events
|
|
181
|
+
return if viewport_events.empty?
|
|
182
|
+
|
|
183
|
+
new_boundary = viewport_events.reverse_each.find { |event| conversation_or_think?(event) }
|
|
184
|
+
return unless new_boundary
|
|
185
|
+
|
|
186
|
+
boundary_id = new_boundary.id
|
|
187
|
+
updates = {mneme_boundary_event_id: boundary_id}
|
|
188
|
+
|
|
189
|
+
updates[:mneme_snapshot_first_event_id] = viewport_events.first.id if @session.mneme_snapshot_first_event_id.nil?
|
|
190
|
+
updates[:mneme_snapshot_last_event_id] = viewport_events.last.id
|
|
191
|
+
|
|
192
|
+
@session.update_columns(updates)
|
|
193
|
+
log.debug("session=#{@session.id} — boundary advanced to event #{boundary_id}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Delegates to {Event#conversation_or_think?} — single source of truth
|
|
197
|
+
# for which events Mneme treats as conversation boundaries.
|
|
198
|
+
#
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
def conversation_or_think?(event)
|
|
201
|
+
event.conversation_or_think?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Builds the active goals section for Mneme's context so it knows
|
|
205
|
+
# what Goals exist, which events are already pinned, and can reference
|
|
206
|
+
# them when deciding what to pin or summarize.
|
|
207
|
+
#
|
|
208
|
+
# @return [String] formatted goals section, or empty string
|
|
209
|
+
def active_goals_section
|
|
210
|
+
root_goals = @session.goals.root.includes(:sub_goals).active.order(:created_at)
|
|
211
|
+
return "" if root_goals.empty?
|
|
212
|
+
|
|
213
|
+
lines = root_goals.map { |goal| format_goal_for_mneme(goal) }
|
|
214
|
+
pinned = format_existing_pins
|
|
215
|
+
|
|
216
|
+
section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
|
|
217
|
+
section += "\n📌 Already Pinned\n#{pinned}\n" if pinned
|
|
218
|
+
section
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Formats a goal with sub-goals for Mneme's context.
|
|
222
|
+
#
|
|
223
|
+
# @param goal [Goal] root goal with preloaded sub_goals
|
|
224
|
+
# @return [String]
|
|
225
|
+
def format_goal_for_mneme(goal)
|
|
226
|
+
parts = [" ● #{goal.description} (id: #{goal.id})"]
|
|
227
|
+
goal.sub_goals.each do |sub|
|
|
228
|
+
checkbox = sub.completed? ? "[x]" : "[ ]"
|
|
229
|
+
parts << " #{checkbox} #{sub.description} (id: #{sub.id})"
|
|
230
|
+
end
|
|
231
|
+
parts.join("\n")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Lists already-pinned event IDs so Mneme avoids redundant pinning.
|
|
235
|
+
#
|
|
236
|
+
# @return [String, nil] formatted pin list, or nil when nothing is pinned
|
|
237
|
+
def format_existing_pins
|
|
238
|
+
pins = @session.pinned_events.includes(:goals).order(:event_id)
|
|
239
|
+
return nil if pins.empty?
|
|
240
|
+
|
|
241
|
+
pins.map { |pin| format_pin_for_mneme(pin) }.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @param pin [PinnedEvent] pin with preloaded goals
|
|
245
|
+
# @return [String] formatted pin line
|
|
246
|
+
def format_pin_for_mneme(pin)
|
|
247
|
+
goal_ids = pin.goals.map(&:id).join(", ")
|
|
248
|
+
" event #{pin.event_id} → goals [#{goal_ids}]"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @return [Logger]
|
|
252
|
+
def log = Mneme.logger
|
|
253
|
+
end
|
|
254
|
+
end
|