anima-core 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +42 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
data/lib/mneme/passive_recall.rb
CHANGED
|
@@ -32,8 +32,8 @@ module Mneme
|
|
|
32
32
|
|
|
33
33
|
# Exclude events from the current session's viewport — no point recalling
|
|
34
34
|
# what the agent already sees.
|
|
35
|
-
viewport_ids = @session.
|
|
36
|
-
results.reject { |result| viewport_ids.include?(result.
|
|
35
|
+
viewport_ids = @session.viewport_message_ids.to_set
|
|
36
|
+
results.reject { |result| viewport_ids.include?(result.message_id) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
private
|
data/lib/mneme/runner.rb
CHANGED
|
@@ -5,71 +5,53 @@ module Mneme
|
|
|
5
5
|
# that observes a main session's compressed viewport and creates summaries of
|
|
6
6
|
# conversation context before it evicts from the viewport.
|
|
7
7
|
#
|
|
8
|
-
# Mneme is triggered when the terminal
|
|
8
|
+
# Mneme is triggered when the terminal message (`mneme_boundary_message_id`) leaves
|
|
9
9
|
# the viewport. It receives a compressed viewport (no raw tool calls, zone
|
|
10
10
|
# delimiters present) and uses the `save_snapshot` tool to persist a summary.
|
|
11
11
|
#
|
|
12
|
-
# After completing, Mneme advances the terminal
|
|
13
|
-
# it just summarized, so the cycle repeats as more
|
|
12
|
+
# After completing, Mneme advances the terminal message to the boundary of what
|
|
13
|
+
# it just summarized, so the cycle repeats as more messages accumulate.
|
|
14
14
|
#
|
|
15
15
|
# @example
|
|
16
16
|
# Mneme::Runner.new(session).call
|
|
17
17
|
class Runner
|
|
18
18
|
TOOLS = [
|
|
19
19
|
Tools::SaveSnapshot,
|
|
20
|
-
Tools::
|
|
20
|
+
Tools::AttachMessagesToGoals,
|
|
21
21
|
Tools::EverythingOk
|
|
22
22
|
].freeze
|
|
23
23
|
|
|
24
24
|
SYSTEM_PROMPT = <<~PROMPT
|
|
25
25
|
You are Mneme, the memory department of an AI agent named Anima.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
You MUST ONLY communicate through tool calls — NEVER output text.
|
|
26
|
+
The agent's context is a conveyor belt — events flow through and eventually fall off.
|
|
27
|
+
Remember what matters. Let the rest go.
|
|
28
|
+
Communicate only through tool calls — never output text.
|
|
30
29
|
|
|
31
30
|
──────────────────────────────
|
|
32
|
-
|
|
31
|
+
VIEWPORT
|
|
33
32
|
──────────────────────────────
|
|
34
|
-
|
|
35
|
-
- EVICTION ZONE:
|
|
36
|
-
- MIDDLE ZONE:
|
|
37
|
-
- RECENT ZONE: Fresh
|
|
33
|
+
Three zones, oldest to newest:
|
|
34
|
+
- EVICTION ZONE: About to fall off — read carefully, this is your focus.
|
|
35
|
+
- MIDDLE ZONE: Aging but visible. Note context that connects to evicting events.
|
|
36
|
+
- RECENT ZONE: Fresh. Use for continuity with your summary.
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
Tool calls are compressed to `[N tools called]` —
|
|
41
|
-
is not important, only the conversation flow.
|
|
38
|
+
Messages are prefixed with `message N` (database ID, used for pinning).
|
|
39
|
+
Tool calls are compressed to `[N tools called]` — focus on conversation, not mechanical work.
|
|
42
40
|
|
|
43
41
|
──────────────────────────────
|
|
44
|
-
|
|
42
|
+
ACTIONS
|
|
45
43
|
──────────────────────────────
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
44
|
+
Summarize evicting conversation with save_snapshot — capture what was discussed and decided,
|
|
45
|
+
why decisions were made, active goal progress, and context the agent will need later.
|
|
46
|
+
Paraphrase — don't quote verbatim. Omit tool call details and mechanical steps.
|
|
47
|
+
|
|
48
|
+
Pin critical messages to goals with attach_messages_to_goals when exact wording matters
|
|
49
|
+
(user instructions, key corrections, key decisions). Pinned messages survive eviction
|
|
50
|
+
intact — use this sparingly for messages where paraphrasing would lose meaning.
|
|
51
|
+
|
|
52
|
+
If the eviction zone contains only mechanical activity, call everything_ok.
|
|
53
|
+
|
|
54
|
+
You may combine save_snapshot and attach_messages_to_goals in one turn.
|
|
73
55
|
PROMPT
|
|
74
56
|
|
|
75
57
|
# @param session [Session] the main session to observe
|
|
@@ -84,7 +66,7 @@ module Mneme
|
|
|
84
66
|
end
|
|
85
67
|
|
|
86
68
|
# Runs the Mneme loop: builds compressed viewport, calls LLM, executes
|
|
87
|
-
# snapshot tool, then advances the terminal
|
|
69
|
+
# snapshot tool, then advances the terminal message pointer.
|
|
88
70
|
#
|
|
89
71
|
# @return [String, nil] the LLM's final text response (discarded),
|
|
90
72
|
# or nil if no context is available
|
|
@@ -94,18 +76,18 @@ module Mneme
|
|
|
94
76
|
sid = @session.id
|
|
95
77
|
|
|
96
78
|
if compressed_text.empty?
|
|
97
|
-
log.debug("session=#{sid} — no
|
|
79
|
+
log.debug("session=#{sid} — no messages for Mneme, skipping")
|
|
98
80
|
return
|
|
99
81
|
end
|
|
100
82
|
|
|
101
|
-
|
|
83
|
+
llm_messages = build_messages(compressed_text)
|
|
102
84
|
system = SYSTEM_PROMPT
|
|
103
85
|
|
|
104
|
-
log.info("session=#{sid} — running Mneme (#{viewport.
|
|
86
|
+
log.info("session=#{sid} — running Mneme (#{viewport.messages.size} messages)")
|
|
105
87
|
log.debug("compressed viewport:\n#{compressed_text}")
|
|
106
88
|
|
|
107
89
|
result = @client.chat_with_tools(
|
|
108
|
-
|
|
90
|
+
llm_messages,
|
|
109
91
|
registry: build_registry(viewport),
|
|
110
92
|
session_id: nil,
|
|
111
93
|
system: system
|
|
@@ -118,7 +100,7 @@ module Mneme
|
|
|
118
100
|
|
|
119
101
|
private
|
|
120
102
|
|
|
121
|
-
# Builds the compressed viewport starting from the session's boundary
|
|
103
|
+
# Builds the compressed viewport starting from the session's boundary message.
|
|
122
104
|
#
|
|
123
105
|
# @return [Mneme::CompressedViewport]
|
|
124
106
|
def build_compressed_viewport
|
|
@@ -127,7 +109,7 @@ module Mneme
|
|
|
127
109
|
CompressedViewport.new(
|
|
128
110
|
@session,
|
|
129
111
|
token_budget: token_budget,
|
|
130
|
-
|
|
112
|
+
from_message_id: @session.mneme_boundary_message_id
|
|
131
113
|
)
|
|
132
114
|
end
|
|
133
115
|
|
|
@@ -150,59 +132,59 @@ module Mneme
|
|
|
150
132
|
end
|
|
151
133
|
|
|
152
134
|
# Builds the tool registry with session context for SaveSnapshot.
|
|
153
|
-
# Passes the
|
|
154
|
-
# which
|
|
135
|
+
# Passes the message range from the viewport so the snapshot records
|
|
136
|
+
# which messages it covers.
|
|
155
137
|
#
|
|
156
138
|
# @param viewport [Mneme::CompressedViewport]
|
|
157
139
|
# @return [Tools::Registry]
|
|
158
140
|
def build_registry(viewport)
|
|
159
|
-
|
|
141
|
+
viewport_messages = viewport.messages
|
|
160
142
|
registry = ::Tools::Registry.new(context: {
|
|
161
143
|
main_session: @session,
|
|
162
|
-
|
|
163
|
-
|
|
144
|
+
from_message_id: viewport_messages.first&.id,
|
|
145
|
+
to_message_id: viewport_messages.last&.id
|
|
164
146
|
})
|
|
165
147
|
TOOLS.each { |tool| registry.register(tool) }
|
|
166
148
|
registry
|
|
167
149
|
end
|
|
168
150
|
|
|
169
|
-
# Advances the terminal
|
|
151
|
+
# Advances the terminal message pointer after Mneme completes.
|
|
170
152
|
# Runs unconditionally — even when the LLM called `everything_ok` (no snapshot
|
|
171
153
|
# needed), the zone was reviewed and should be advanced past. Without this,
|
|
172
154
|
# Mneme would re-examine the same mechanical-only content on every trigger.
|
|
173
155
|
#
|
|
174
|
-
# Sets it to the last conversation
|
|
175
|
-
# the boundary is always a message/think
|
|
156
|
+
# Sets it to the last conversation message in the viewport, ensuring
|
|
157
|
+
# the boundary is always a message/think message, never a tool_call/tool_response.
|
|
176
158
|
# Also updates the snapshot range pointers.
|
|
177
159
|
#
|
|
178
160
|
# @param viewport [Mneme::CompressedViewport]
|
|
179
161
|
def advance_boundary(viewport)
|
|
180
|
-
|
|
181
|
-
return if
|
|
162
|
+
viewport_messages = viewport.messages
|
|
163
|
+
return if viewport_messages.empty?
|
|
182
164
|
|
|
183
|
-
new_boundary =
|
|
165
|
+
new_boundary = viewport_messages.reverse_each.find { |message| conversation_or_think?(message) }
|
|
184
166
|
return unless new_boundary
|
|
185
167
|
|
|
186
168
|
boundary_id = new_boundary.id
|
|
187
|
-
updates = {
|
|
169
|
+
updates = {mneme_boundary_message_id: boundary_id}
|
|
188
170
|
|
|
189
|
-
updates[:
|
|
190
|
-
updates[:
|
|
171
|
+
updates[:mneme_snapshot_first_message_id] = viewport_messages.first.id unless @session.mneme_snapshot_first_message_id
|
|
172
|
+
updates[:mneme_snapshot_last_message_id] = viewport_messages.last.id
|
|
191
173
|
|
|
192
174
|
@session.update_columns(updates)
|
|
193
|
-
log.debug("session=#{@session.id} — boundary advanced to
|
|
175
|
+
log.debug("session=#{@session.id} — boundary advanced to message #{boundary_id}")
|
|
194
176
|
end
|
|
195
177
|
|
|
196
|
-
# Delegates to {
|
|
197
|
-
# for which
|
|
178
|
+
# Delegates to {Message#conversation_or_think?} — single source of truth
|
|
179
|
+
# for which messages Mneme treats as conversation boundaries.
|
|
198
180
|
#
|
|
199
181
|
# @return [Boolean]
|
|
200
|
-
def conversation_or_think?(
|
|
201
|
-
|
|
182
|
+
def conversation_or_think?(message)
|
|
183
|
+
message.conversation_or_think?
|
|
202
184
|
end
|
|
203
185
|
|
|
204
186
|
# Builds the active goals section for Mneme's context so it knows
|
|
205
|
-
# what Goals exist, which
|
|
187
|
+
# what Goals exist, which messages are already pinned, and can reference
|
|
206
188
|
# them when deciding what to pin or summarize.
|
|
207
189
|
#
|
|
208
190
|
# @return [String] formatted goals section, or empty string
|
|
@@ -231,21 +213,21 @@ module Mneme
|
|
|
231
213
|
parts.join("\n")
|
|
232
214
|
end
|
|
233
215
|
|
|
234
|
-
# Lists already-pinned
|
|
216
|
+
# Lists already-pinned message IDs so Mneme avoids redundant pinning.
|
|
235
217
|
#
|
|
236
218
|
# @return [String, nil] formatted pin list, or nil when nothing is pinned
|
|
237
219
|
def format_existing_pins
|
|
238
|
-
pins = @session.
|
|
220
|
+
pins = @session.pinned_messages.includes(:goals).order(:message_id)
|
|
239
221
|
return nil if pins.empty?
|
|
240
222
|
|
|
241
223
|
pins.map { |pin| format_pin_for_mneme(pin) }.join("\n")
|
|
242
224
|
end
|
|
243
225
|
|
|
244
|
-
# @param pin [
|
|
226
|
+
# @param pin [PinnedMessage] pin with preloaded goals
|
|
245
227
|
# @return [String] formatted pin line
|
|
246
228
|
def format_pin_for_mneme(pin)
|
|
247
229
|
goal_ids = pin.goals.map(&:id).join(", ")
|
|
248
|
-
"
|
|
230
|
+
" message #{pin.message_id} → goals [#{goal_ids}]"
|
|
249
231
|
end
|
|
250
232
|
|
|
251
233
|
# @return [Logger]
|
data/lib/mneme/search.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mneme
|
|
4
|
-
# Full-text search over
|
|
5
|
-
# Covers user messages, agent messages, and think
|
|
4
|
+
# Full-text search over message history using SQLite FTS5.
|
|
5
|
+
# Covers user messages, agent messages, and think messages across all sessions.
|
|
6
6
|
#
|
|
7
7
|
# The interface is intentionally abstract — callers receive {Result} structs
|
|
8
8
|
# and never touch FTS5 directly. A future semantic search backend (embeddings,
|
|
@@ -10,21 +10,21 @@ module Mneme
|
|
|
10
10
|
#
|
|
11
11
|
# @example Search across all sessions
|
|
12
12
|
# results = Mneme::Search.query("authentication flow")
|
|
13
|
-
# results.each { |r| puts "
|
|
13
|
+
# results.each { |r| puts "message #{r.message_id}: #{r.snippet}" }
|
|
14
14
|
#
|
|
15
15
|
# @example Search within a single session
|
|
16
16
|
# results = Mneme::Search.query("OAuth config", session_id: 42)
|
|
17
17
|
class Search
|
|
18
18
|
# A single search result with enough context for display and drill-down.
|
|
19
19
|
#
|
|
20
|
-
# @!attribute
|
|
21
|
-
# @!attribute session_id [Integer] the session owning this
|
|
20
|
+
# @!attribute message_id [Integer] the message's database ID
|
|
21
|
+
# @!attribute session_id [Integer] the session owning this message
|
|
22
22
|
# @!attribute snippet [String] highlighted excerpt from the matching content
|
|
23
23
|
# @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
|
|
24
|
-
# @!attribute
|
|
25
|
-
Result = Struct.new(:
|
|
24
|
+
# @!attribute message_type [String] friendly label: human, anima, system, or thought
|
|
25
|
+
Result = Struct.new(:message_id, :session_id, :snippet, :rank, :message_type, keyword_init: true)
|
|
26
26
|
|
|
27
|
-
# Searches
|
|
27
|
+
# Searches message history for the given terms.
|
|
28
28
|
#
|
|
29
29
|
# @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
|
|
30
30
|
# @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
|
|
@@ -52,7 +52,7 @@ module Mneme
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
54
|
# Executes the FTS5 MATCH query with optional session scoping.
|
|
55
|
-
# Joins back to
|
|
55
|
+
# Joins back to messages table for session_id and message_type.
|
|
56
56
|
#
|
|
57
57
|
# @return [Array<Hash>] raw database rows
|
|
58
58
|
def execute_fts_query
|
|
@@ -66,29 +66,29 @@ module Mneme
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# FTS5 query across all sessions.
|
|
69
|
-
# Contentless FTS5 can't use snippet() — extract content from
|
|
69
|
+
# Contentless FTS5 can't use snippet() — extract content from messages directly.
|
|
70
70
|
#
|
|
71
71
|
# Ranking blends BM25 relevance with recency: rank is negative (more
|
|
72
|
-
# negative = better match), so dividing by a factor > 1 for older
|
|
72
|
+
# negative = better match), so dividing by a factor > 1 for older messages
|
|
73
73
|
# moves them closer to zero (less relevant). At decay 0.3, a one-year-old
|
|
74
74
|
# result needs ~30% better keyword relevance to beat an identical match
|
|
75
75
|
# from today.
|
|
76
76
|
def global_sql
|
|
77
77
|
<<~SQL
|
|
78
78
|
SELECT
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
m.id AS message_id,
|
|
80
|
+
m.session_id,
|
|
81
|
+
m.message_type,
|
|
82
82
|
CASE
|
|
83
|
-
WHEN
|
|
84
|
-
THEN substr(json_extract(
|
|
85
|
-
WHEN
|
|
86
|
-
THEN substr(json_extract(
|
|
83
|
+
WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
84
|
+
THEN substr(json_extract(m.payload, '$.content'), 1, 300)
|
|
85
|
+
WHEN m.message_type = 'tool_call'
|
|
86
|
+
THEN substr(json_extract(m.payload, '$.tool_input.thoughts'), 1, 300)
|
|
87
87
|
END AS snippet,
|
|
88
|
-
rank / (1.0 + ? * (julianday('now') - julianday(
|
|
89
|
-
FROM
|
|
90
|
-
JOIN
|
|
91
|
-
WHERE
|
|
88
|
+
rank / (1.0 + ? * (julianday('now') - julianday(m.created_at)) / 365.0) AS rank
|
|
89
|
+
FROM messages_fts
|
|
90
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
91
|
+
WHERE messages_fts MATCH ?
|
|
92
92
|
ORDER BY rank
|
|
93
93
|
LIMIT ?
|
|
94
94
|
SQL
|
|
@@ -98,26 +98,26 @@ module Mneme
|
|
|
98
98
|
def scoped_sql
|
|
99
99
|
<<~SQL
|
|
100
100
|
SELECT
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
m.id AS message_id,
|
|
102
|
+
m.session_id,
|
|
103
|
+
m.message_type,
|
|
104
104
|
CASE
|
|
105
|
-
WHEN
|
|
106
|
-
THEN substr(json_extract(
|
|
107
|
-
WHEN
|
|
108
|
-
THEN substr(json_extract(
|
|
105
|
+
WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
|
|
106
|
+
THEN substr(json_extract(m.payload, '$.content'), 1, 300)
|
|
107
|
+
WHEN m.message_type = 'tool_call'
|
|
108
|
+
THEN substr(json_extract(m.payload, '$.tool_input.thoughts'), 1, 300)
|
|
109
109
|
END AS snippet,
|
|
110
|
-
rank / (1.0 + ? * (julianday('now') - julianday(
|
|
111
|
-
FROM
|
|
112
|
-
JOIN
|
|
113
|
-
WHERE
|
|
114
|
-
AND
|
|
110
|
+
rank / (1.0 + ? * (julianday('now') - julianday(m.created_at)) / 365.0) AS rank
|
|
111
|
+
FROM messages_fts
|
|
112
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
113
|
+
WHERE messages_fts MATCH ?
|
|
114
|
+
AND m.session_id = ?
|
|
115
115
|
ORDER BY rank
|
|
116
116
|
LIMIT ?
|
|
117
117
|
SQL
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
FRIENDLY_MESSAGE_TYPES = {
|
|
121
121
|
"user_message" => "human",
|
|
122
122
|
"agent_message" => "anima",
|
|
123
123
|
"system_message" => "system",
|
|
@@ -129,13 +129,13 @@ module Mneme
|
|
|
129
129
|
# @param row [Hash]
|
|
130
130
|
# @return [Result]
|
|
131
131
|
def build_result(row)
|
|
132
|
-
raw_type = row["
|
|
132
|
+
raw_type = row["message_type"]
|
|
133
133
|
Result.new(
|
|
134
|
-
|
|
134
|
+
message_id: row["message_id"],
|
|
135
135
|
session_id: row["session_id"],
|
|
136
136
|
snippet: row["snippet"],
|
|
137
137
|
rank: row["rank"],
|
|
138
|
-
|
|
138
|
+
message_type: FRIENDLY_MESSAGE_TYPES.fetch(raw_type, raw_type)
|
|
139
139
|
)
|
|
140
140
|
end
|
|
141
141
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Pins critical messages to active Goals so they survive viewport eviction.
|
|
6
|
+
# Mneme calls this when it sees important messages (user instructions, key
|
|
7
|
+
# decisions, critical corrections) approaching the eviction zone.
|
|
8
|
+
#
|
|
9
|
+
# Messages are pinned via a many-to-many join: one message 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 AttachMessagesToGoals < ::Tools::Base
|
|
13
|
+
def self.tool_name = "attach_messages_to_goals"
|
|
14
|
+
|
|
15
|
+
def self.description = "Pin critical messages to goals so they survive viewport eviction."
|
|
16
|
+
|
|
17
|
+
def self.input_schema
|
|
18
|
+
{
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
message_ids: {
|
|
22
|
+
type: "array",
|
|
23
|
+
items: {type: "integer"}
|
|
24
|
+
},
|
|
25
|
+
goal_ids: {
|
|
26
|
+
type: "array",
|
|
27
|
+
items: {type: "integer"}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: %w[message_ids goal_ids]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param main_session [Session] the session being observed
|
|
35
|
+
def initialize(main_session:, **)
|
|
36
|
+
@session = main_session
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param input [Hash<String, Object>] with "message_ids" and "goal_ids"
|
|
40
|
+
# @return [String] confirmation with link count, or error description
|
|
41
|
+
def execute(input)
|
|
42
|
+
message_ids = Array(input["message_ids"]).map(&:to_i).uniq
|
|
43
|
+
goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
|
|
44
|
+
|
|
45
|
+
return "Error: message_ids cannot be empty" if message_ids.empty?
|
|
46
|
+
return "Error: goal_ids cannot be empty" if goal_ids.empty?
|
|
47
|
+
|
|
48
|
+
messages = @session.messages.where(id: message_ids)
|
|
49
|
+
all_goals = @session.goals
|
|
50
|
+
goals = all_goals.active.where(id: goal_ids)
|
|
51
|
+
|
|
52
|
+
missing_messages = message_ids - messages.pluck(:id)
|
|
53
|
+
inactive_goal_ids = goal_ids - goals.pluck(:id)
|
|
54
|
+
|
|
55
|
+
errors = []
|
|
56
|
+
errors << "Messages not found: #{missing_messages.join(", ")}" if missing_messages.any?
|
|
57
|
+
|
|
58
|
+
if inactive_goal_ids.any?
|
|
59
|
+
completed_ids = all_goals.completed.where(id: inactive_goal_ids).pluck(:id)
|
|
60
|
+
not_found_ids = inactive_goal_ids - completed_ids
|
|
61
|
+
errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
|
|
62
|
+
errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return "Error: #{errors.join("; ")}" if errors.any?
|
|
66
|
+
|
|
67
|
+
attached = attach(messages, goals)
|
|
68
|
+
"Pinned #{attached} message-goal links"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def attach(messages, goals)
|
|
74
|
+
messages.sum do |message|
|
|
75
|
+
pinned = find_or_create_pinned_message(message)
|
|
76
|
+
link_to_goals(pinned, goals)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def link_to_goals(pinned, goals)
|
|
81
|
+
goals.each { |goal| GoalPinnedMessage.find_or_create_by!(goal: goal, pinned_message: pinned) }
|
|
82
|
+
goals.size
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def find_or_create_pinned_message(message)
|
|
86
|
+
PinnedMessage.find_or_create_by!(message: message) do |pm|
|
|
87
|
+
pm.display_text = truncate_message_content(message)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def truncate_message_content(message)
|
|
92
|
+
content = message.payload&.dig("content").to_s.strip
|
|
93
|
+
content = "message #{message.id}" if content.empty?
|
|
94
|
+
|
|
95
|
+
if content.length > PinnedMessage::MAX_DISPLAY_TEXT_LENGTH
|
|
96
|
+
content[0, PinnedMessage::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
|
|
97
|
+
else
|
|
98
|
+
content
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -8,9 +8,7 @@ module Mneme
|
|
|
8
8
|
class EverythingOk < ::Tools::Base
|
|
9
9
|
def self.tool_name = "everything_ok"
|
|
10
10
|
|
|
11
|
-
def self.description = "
|
|
12
|
-
"Call this when the eviction zone contains only mechanical " \
|
|
13
|
-
"activity (tool calls) with no meaningful conversation to summarize."
|
|
11
|
+
def self.description = "Nothing else worth remembering."
|
|
14
12
|
|
|
15
13
|
def self.input_schema
|
|
16
14
|
{type: "object", properties: {}, required: []}
|
|
@@ -12,10 +12,7 @@ module Mneme
|
|
|
12
12
|
class SaveSnapshot < ::Tools::Base
|
|
13
13
|
def self.tool_name = "save_snapshot"
|
|
14
14
|
|
|
15
|
-
def self.description = "
|
|
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."
|
|
15
|
+
def self.description = "Summarize what's leaving the viewport."
|
|
19
16
|
|
|
20
17
|
def self.input_schema
|
|
21
18
|
{
|
|
@@ -23,8 +20,7 @@ module Mneme
|
|
|
23
20
|
properties: {
|
|
24
21
|
text: {
|
|
25
22
|
type: "string",
|
|
26
|
-
|
|
27
|
-
"goals discussed, and important context. Max #{Anima::Settings.mneme_max_tokens} tokens."
|
|
23
|
+
maxLength: Anima::Settings.mneme_max_tokens * Message::BYTES_PER_TOKEN
|
|
28
24
|
}
|
|
29
25
|
},
|
|
30
26
|
required: %w[text]
|
|
@@ -32,13 +28,13 @@ module Mneme
|
|
|
32
28
|
end
|
|
33
29
|
|
|
34
30
|
# @param main_session [Session] the session being observed
|
|
35
|
-
# @param
|
|
36
|
-
# @param
|
|
37
|
-
# @param level [Integer] compression level (1 = from
|
|
38
|
-
def initialize(main_session:,
|
|
31
|
+
# @param from_message_id [Integer] first message ID covered by this snapshot
|
|
32
|
+
# @param to_message_id [Integer] last message ID covered by this snapshot
|
|
33
|
+
# @param level [Integer] compression level (1 = from messages, 2 = from L1 snapshots)
|
|
34
|
+
def initialize(main_session:, from_message_id:, to_message_id:, level: 1, **)
|
|
39
35
|
@main_session = main_session
|
|
40
|
-
@
|
|
41
|
-
@
|
|
36
|
+
@from_message_id = from_message_id
|
|
37
|
+
@to_message_id = to_message_id
|
|
42
38
|
@level = level
|
|
43
39
|
end
|
|
44
40
|
|
|
@@ -48,20 +44,20 @@ module Mneme
|
|
|
48
44
|
|
|
49
45
|
snapshot = @main_session.snapshots.create!(
|
|
50
46
|
text: text,
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
from_message_id: @from_message_id,
|
|
48
|
+
to_message_id: @to_message_id,
|
|
53
49
|
level: @level,
|
|
54
50
|
token_count: estimate_tokens(text)
|
|
55
51
|
)
|
|
56
52
|
|
|
57
|
-
"Snapshot saved (id: #{snapshot.id},
|
|
53
|
+
"Snapshot saved (id: #{snapshot.id}, messages #{@from_message_id}..#{@to_message_id})"
|
|
58
54
|
end
|
|
59
55
|
|
|
60
56
|
private
|
|
61
57
|
|
|
62
58
|
# @return [Integer] estimated token count for the summary text
|
|
63
59
|
def estimate_tokens(text)
|
|
64
|
-
[(text.bytesize /
|
|
60
|
+
[(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
65
61
|
end
|
|
66
62
|
end
|
|
67
63
|
end
|