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,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
|
data/lib/mneme/search.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Full-text search over event history using SQLite FTS5.
|
|
5
|
+
# Covers user messages, agent messages, and think events across all sessions.
|
|
6
|
+
#
|
|
7
|
+
# The interface is intentionally abstract — callers receive {Result} structs
|
|
8
|
+
# and never touch FTS5 directly. A future semantic search backend (embeddings,
|
|
9
|
+
# BM25 + re-ranking) can replace the implementation without changing callers.
|
|
10
|
+
#
|
|
11
|
+
# @example Search across all sessions
|
|
12
|
+
# results = Mneme::Search.query("authentication flow")
|
|
13
|
+
# results.each { |r| puts "event #{r.event_id}: #{r.snippet}" }
|
|
14
|
+
#
|
|
15
|
+
# @example Search within a single session
|
|
16
|
+
# results = Mneme::Search.query("OAuth config", session_id: 42)
|
|
17
|
+
class Search
|
|
18
|
+
# A single search result with enough context for display and drill-down.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute event_id [Integer] the event's database ID
|
|
21
|
+
# @!attribute session_id [Integer] the session owning this event
|
|
22
|
+
# @!attribute snippet [String] highlighted excerpt from the matching content
|
|
23
|
+
# @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
|
|
24
|
+
# @!attribute event_type [String] one of Event::TYPES
|
|
25
|
+
Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# Searches event history for the given terms.
|
|
28
|
+
#
|
|
29
|
+
# @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
|
|
30
|
+
# @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
|
|
31
|
+
# @param limit [Integer] maximum results
|
|
32
|
+
# @return [Array<Result>] ranked by relevance (best first)
|
|
33
|
+
def self.query(terms, session_id: nil, limit: Anima::Settings.recall_max_results)
|
|
34
|
+
new(terms, session_id: session_id, limit: limit).call
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(terms, session_id: nil, limit: 5)
|
|
38
|
+
@terms = sanitize_query(terms)
|
|
39
|
+
@session_id = session_id
|
|
40
|
+
@limit = limit
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<Result>] ranked by relevance (best first)
|
|
44
|
+
def call
|
|
45
|
+
return [] if @terms.blank?
|
|
46
|
+
|
|
47
|
+
rows = execute_fts_query
|
|
48
|
+
rows.map { |row| build_result(row) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Executes the FTS5 MATCH query with optional session scoping.
|
|
54
|
+
# Joins back to events table for session_id and event_type.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Hash>] raw database rows
|
|
57
|
+
def execute_fts_query
|
|
58
|
+
if @session_id
|
|
59
|
+
connection.select_all(scoped_sql, "Mneme::Search", [@terms, @session_id, @limit]).to_a
|
|
60
|
+
else
|
|
61
|
+
connection.select_all(global_sql, "Mneme::Search", [@terms, @limit]).to_a
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# FTS5 query across all sessions.
|
|
66
|
+
# Contentless FTS5 can't use snippet() — extract content from events directly.
|
|
67
|
+
def global_sql
|
|
68
|
+
<<~SQL
|
|
69
|
+
SELECT
|
|
70
|
+
e.id AS event_id,
|
|
71
|
+
e.session_id,
|
|
72
|
+
e.event_type,
|
|
73
|
+
CASE
|
|
74
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
75
|
+
THEN substr(json_extract(e.payload, '$.content'), 1, 300)
|
|
76
|
+
WHEN e.event_type = 'tool_call'
|
|
77
|
+
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
78
|
+
END AS snippet,
|
|
79
|
+
rank
|
|
80
|
+
FROM events_fts
|
|
81
|
+
JOIN events e ON e.id = events_fts.rowid
|
|
82
|
+
WHERE events_fts MATCH ?
|
|
83
|
+
ORDER BY rank
|
|
84
|
+
LIMIT ?
|
|
85
|
+
SQL
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# FTS5 query scoped to a specific session.
|
|
89
|
+
def scoped_sql
|
|
90
|
+
<<~SQL
|
|
91
|
+
SELECT
|
|
92
|
+
e.id AS event_id,
|
|
93
|
+
e.session_id,
|
|
94
|
+
e.event_type,
|
|
95
|
+
CASE
|
|
96
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
97
|
+
THEN substr(json_extract(e.payload, '$.content'), 1, 300)
|
|
98
|
+
WHEN e.event_type = 'tool_call'
|
|
99
|
+
THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
|
|
100
|
+
END AS snippet,
|
|
101
|
+
rank
|
|
102
|
+
FROM events_fts
|
|
103
|
+
JOIN events e ON e.id = events_fts.rowid
|
|
104
|
+
WHERE events_fts MATCH ?
|
|
105
|
+
AND e.session_id = ?
|
|
106
|
+
ORDER BY rank
|
|
107
|
+
LIMIT ?
|
|
108
|
+
SQL
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Builds a Result from a raw database row.
|
|
112
|
+
#
|
|
113
|
+
# @param row [Hash]
|
|
114
|
+
# @return [Result]
|
|
115
|
+
def build_result(row)
|
|
116
|
+
Result.new(
|
|
117
|
+
event_id: row["event_id"],
|
|
118
|
+
session_id: row["session_id"],
|
|
119
|
+
snippet: row["snippet"],
|
|
120
|
+
rank: row["rank"],
|
|
121
|
+
event_type: row["event_type"]
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Sanitizes user input for FTS5 MATCH safety.
|
|
126
|
+
# Strips special FTS5 operators that could cause syntax errors,
|
|
127
|
+
# keeps only alphanumeric words and quoted phrases.
|
|
128
|
+
#
|
|
129
|
+
# @param raw [String]
|
|
130
|
+
# @return [String] safe FTS5 query
|
|
131
|
+
def sanitize_query(raw)
|
|
132
|
+
return "" unless raw
|
|
133
|
+
|
|
134
|
+
# Extract quoted phrases and individual words, drop FTS5 operators
|
|
135
|
+
tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
|
|
136
|
+
tokens.filter_map { |token| sanitize_token(token) }.join(" ")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def sanitize_token(token)
|
|
140
|
+
return token if token.start_with?('"')
|
|
141
|
+
|
|
142
|
+
cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
|
|
143
|
+
cleaned.empty? ? nil : cleaned
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def connection
|
|
147
|
+
ActiveRecord::Base.connection
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Pins critical events to active Goals so they survive viewport eviction.
|
|
6
|
+
# Mneme calls this when it sees important events (user instructions, key
|
|
7
|
+
# decisions, critical corrections) approaching the eviction zone.
|
|
8
|
+
#
|
|
9
|
+
# Events are pinned via a many-to-many join: one event can be attached
|
|
10
|
+
# to multiple Goals. When all referencing Goals complete, the pin is
|
|
11
|
+
# automatically released (reference-counted cleanup in {Goal#release_orphaned_pins!}).
|
|
12
|
+
class AttachEventsToGoals < ::Tools::Base
|
|
13
|
+
def self.tool_name = "attach_events_to_goals"
|
|
14
|
+
|
|
15
|
+
def self.description = "Pin critical events to active goals so they survive " \
|
|
16
|
+
"viewport eviction. Use this for events that are too important to lose — " \
|
|
17
|
+
"exact user instructions, key decisions, critical corrections. " \
|
|
18
|
+
"Events stay pinned until all attached goals complete."
|
|
19
|
+
|
|
20
|
+
def self.input_schema
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
event_ids: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {type: "integer"},
|
|
27
|
+
description: "Database IDs of events to pin (from `event N` prefixes in the viewport)"
|
|
28
|
+
},
|
|
29
|
+
goal_ids: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: {type: "integer"},
|
|
32
|
+
description: "IDs of active goals to attach the events to"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: %w[event_ids goal_ids]
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param main_session [Session] the session being observed
|
|
40
|
+
def initialize(main_session:, **)
|
|
41
|
+
@session = main_session
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param input [Hash<String, Object>] with "event_ids" and "goal_ids"
|
|
45
|
+
# @return [String] confirmation with link count, or error description
|
|
46
|
+
def execute(input)
|
|
47
|
+
event_ids = Array(input["event_ids"]).map(&:to_i).uniq
|
|
48
|
+
goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
|
|
49
|
+
|
|
50
|
+
return "Error: event_ids cannot be empty" if event_ids.empty?
|
|
51
|
+
return "Error: goal_ids cannot be empty" if goal_ids.empty?
|
|
52
|
+
|
|
53
|
+
events = @session.events.where(id: event_ids)
|
|
54
|
+
goals = @session.goals.active.where(id: goal_ids)
|
|
55
|
+
|
|
56
|
+
missing_events = event_ids - events.pluck(:id)
|
|
57
|
+
inactive_goal_ids = goal_ids - goals.pluck(:id)
|
|
58
|
+
|
|
59
|
+
errors = []
|
|
60
|
+
errors << "Events not found: #{missing_events.join(", ")}" if missing_events.any?
|
|
61
|
+
|
|
62
|
+
if inactive_goal_ids.any?
|
|
63
|
+
completed_ids = @session.goals.completed.where(id: inactive_goal_ids).pluck(:id)
|
|
64
|
+
not_found_ids = inactive_goal_ids - completed_ids
|
|
65
|
+
errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
|
|
66
|
+
errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return "Error: #{errors.join("; ")}" if errors.any?
|
|
70
|
+
|
|
71
|
+
attached = attach(events, goals)
|
|
72
|
+
"Pinned #{attached} event-goal links"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def attach(events, goals)
|
|
78
|
+
events.sum do |event|
|
|
79
|
+
pinned = find_or_create_pinned_event(event)
|
|
80
|
+
link_to_goals(pinned, goals)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def link_to_goals(pinned, goals)
|
|
85
|
+
goals.each { |goal| GoalPinnedEvent.find_or_create_by!(goal: goal, pinned_event: pinned) }
|
|
86
|
+
goals.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def find_or_create_pinned_event(event)
|
|
90
|
+
PinnedEvent.find_or_create_by!(event: event) do |pe|
|
|
91
|
+
pe.display_text = truncate_event_content(event)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def truncate_event_content(event)
|
|
96
|
+
content = event.payload&.dig("content").to_s.strip
|
|
97
|
+
content = "event #{event.id}" if content.empty?
|
|
98
|
+
|
|
99
|
+
if content.length > PinnedEvent::MAX_DISPLAY_TEXT_LENGTH
|
|
100
|
+
content[0, PinnedEvent::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
|
|
101
|
+
else
|
|
102
|
+
content
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Sentinel tool signaling that Mneme has reviewed the viewport and
|
|
6
|
+
# determined no snapshot is needed. Called when the conversation
|
|
7
|
+
# context doesn't contain enough meaningful content to summarize.
|
|
8
|
+
class EverythingOk < ::Tools::Base
|
|
9
|
+
def self.tool_name = "everything_ok"
|
|
10
|
+
|
|
11
|
+
def self.description = "Signal that no snapshot is needed. " \
|
|
12
|
+
"Call this when the eviction zone contains only mechanical " \
|
|
13
|
+
"activity (tool calls) with no meaningful conversation to summarize."
|
|
14
|
+
|
|
15
|
+
def self.input_schema
|
|
16
|
+
{type: "object", properties: {}, required: []}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def execute(_input)
|
|
20
|
+
"Acknowledged. No snapshot needed."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Saves a summary snapshot of conversation context that is about to
|
|
6
|
+
# leave the viewport. The snapshot captures the "gist" of what happened
|
|
7
|
+
# so the agent retains awareness of past context.
|
|
8
|
+
#
|
|
9
|
+
# The text field has a max_tokens limit for predictable sizing — each
|
|
10
|
+
# snapshot is a fixed-size tile, enabling calculation of how many fit
|
|
11
|
+
# at each compression level.
|
|
12
|
+
class SaveSnapshot < ::Tools::Base
|
|
13
|
+
def self.tool_name = "save_snapshot"
|
|
14
|
+
|
|
15
|
+
def self.description = "Save a summary of the conversation context " \
|
|
16
|
+
"that is about to leave the viewport. Write a concise summary " \
|
|
17
|
+
"capturing key decisions, topics discussed, and important context. " \
|
|
18
|
+
"Focus on WHAT was decided and WHY, not mechanical details."
|
|
19
|
+
|
|
20
|
+
def self.input_schema
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
text: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "The summary text. Be concise but preserve key decisions, " \
|
|
27
|
+
"goals discussed, and important context. Max #{Anima::Settings.mneme_max_tokens} tokens."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[text]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param main_session [Session] the session being observed
|
|
35
|
+
# @param from_event_id [Integer] first event ID covered by this snapshot
|
|
36
|
+
# @param to_event_id [Integer] last event ID covered by this snapshot
|
|
37
|
+
# @param level [Integer] compression level (1 = from events, 2 = from L1 snapshots)
|
|
38
|
+
def initialize(main_session:, from_event_id:, to_event_id:, level: 1, **)
|
|
39
|
+
@main_session = main_session
|
|
40
|
+
@from_event_id = from_event_id
|
|
41
|
+
@to_event_id = to_event_id
|
|
42
|
+
@level = level
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute(input)
|
|
46
|
+
text = input["text"].to_s.strip
|
|
47
|
+
return "Error: Summary text cannot be blank" if text.empty?
|
|
48
|
+
|
|
49
|
+
snapshot = @main_session.snapshots.create!(
|
|
50
|
+
text: text,
|
|
51
|
+
from_event_id: @from_event_id,
|
|
52
|
+
to_event_id: @to_event_id,
|
|
53
|
+
level: @level,
|
|
54
|
+
token_count: estimate_tokens(text)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
"Snapshot saved (id: #{snapshot.id}, events #{@from_event_id}..#{@to_event_id})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# @return [Integer] estimated token count for the summary text
|
|
63
|
+
def estimate_tokens(text)
|
|
64
|
+
[(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|