anima-core 1.3.0 → 1.5.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 +23 -26
- data/README.md +118 -104
- data/agents/thoughts-analyzer.md +12 -7
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +38 -58
- data/app/decorators/agent_message_decorator.rb +7 -2
- data/app/decorators/message_decorator.rb +31 -100
- data/app/decorators/pending_from_melete_decorator.rb +36 -0
- data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
- data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
- data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
- data/app/decorators/pending_from_mneme_decorator.rb +44 -0
- data/app/decorators/pending_message_decorator.rb +94 -0
- data/app/decorators/pending_subagent_decorator.rb +46 -0
- data/app/decorators/pending_tool_response_decorator.rb +51 -0
- data/app/decorators/pending_user_message_decorator.rb +22 -0
- data/app/decorators/system_message_decorator.rb +5 -0
- data/app/decorators/tool_call_decorator.rb +16 -5
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +7 -2
- data/app/jobs/count_tokens_job.rb +23 -0
- data/app/jobs/drain_job.rb +169 -0
- data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
- data/app/jobs/melete_enrichment_job.rb +48 -0
- data/app/jobs/mneme_enrichment_job.rb +46 -0
- data/app/jobs/tool_execution_job.rb +87 -0
- data/app/models/concerns/token_estimation.rb +54 -0
- data/app/models/goal.rb +23 -11
- data/app/models/message.rb +46 -48
- data/app/models/pending_message.rb +407 -12
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +660 -566
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +157 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/application.rb +1 -0
- data/config/database.yml +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
- data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
- data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
- data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
- data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
- data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
- data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
- data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
- data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +133 -0
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +13 -0
- data/lib/anima/settings.rb +16 -36
- data/lib/anima/version.rb +1 -1
- data/lib/events/authentication_required.rb +24 -0
- data/lib/events/bounce_back.rb +4 -4
- data/lib/events/eviction_completed.rb +28 -0
- data/lib/events/goal_created.rb +28 -0
- data/lib/events/goal_updated.rb +32 -0
- data/lib/events/llm_responded.rb +35 -0
- data/lib/events/message_created.rb +27 -0
- data/lib/events/message_updated.rb +25 -0
- data/lib/events/session_state_changed.rb +30 -0
- data/lib/events/skill_activated.rb +28 -0
- data/lib/events/start_melete.rb +36 -0
- data/lib/events/start_mneme.rb +33 -0
- data/lib/events/start_processing.rb +32 -0
- data/lib/events/subagent_evicted.rb +31 -0
- data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
- data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
- data/lib/events/subscribers/drain_kickoff.rb +20 -0
- data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
- data/lib/events/subscribers/llm_response_handler.rb +111 -0
- data/lib/events/subscribers/melete_kickoff.rb +24 -0
- data/lib/events/subscribers/message_broadcaster.rb +34 -0
- data/lib/events/subscribers/mneme_kickoff.rb +24 -0
- data/lib/events/subscribers/mneme_scheduler.rb +21 -0
- data/lib/events/subscribers/persister.rb +8 -9
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +28 -34
- data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
- data/lib/events/subscribers/tool_response_creator.rb +33 -0
- data/lib/events/subscribers/transient_broadcaster.rb +1 -1
- data/lib/events/tool_executed.rb +34 -0
- data/lib/events/workflow_activated.rb +27 -0
- data/lib/llm/client.rb +46 -199
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +73 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
- data/lib/melete/tools/goal_messaging.rb +29 -0
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
- data/lib/{analytical_brain.rb → melete.rb} +6 -3
- data/lib/mneme/base_runner.rb +121 -0
- data/lib/mneme/l2_runner.rb +14 -20
- data/lib/mneme/recall_runner.rb +132 -0
- data/lib/mneme/runner.rb +123 -165
- data/lib/mneme/search.rb +104 -62
- data/lib/mneme/tools/nothing_to_surface.rb +25 -0
- data/lib/mneme/tools/save_snapshot.rb +2 -10
- data/lib/mneme/tools/surface_memory.rb +89 -0
- data/lib/mneme.rb +11 -5
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +290 -432
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -1
- data/lib/tools/bash.rb +25 -55
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/mark_goal_completed.rb +4 -5
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +85 -4
- data/lib/tools/response_truncator.rb +1 -1
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +22 -14
- data/lib/tools/spawn_subagent.rb +30 -20
- data/lib/tools/subagent_prompts.rb +17 -19
- data/lib/tools/think.rb +1 -1
- data/lib/tools/{remember.rb → view_messages.rb} +10 -10
- data/lib/tools/write.rb +2 -0
- data/lib/tui/app.rb +393 -149
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/cable_client.rb +9 -16
- data/lib/tui/decorators/base_decorator.rb +47 -6
- data/lib/tui/decorators/bash_decorator.rb +1 -1
- data/lib/tui/decorators/edit_decorator.rb +4 -2
- data/lib/tui/decorators/read_decorator.rb +4 -2
- data/lib/tui/decorators/think_decorator.rb +2 -2
- data/lib/tui/decorators/web_get_decorator.rb +1 -1
- data/lib/tui/decorators/write_decorator.rb +4 -2
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +20 -9
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +165 -28
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +149 -79
- data/lib/tui/settings.rb +93 -0
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +16 -32
- data/templates/tui.toml +209 -0
- data/workflows/review_pr.md +18 -14
- metadata +98 -29
- data/app/jobs/agent_request_job.rb +0 -199
- data/app/jobs/analytical_brain_job.rb +0 -33
- data/app/jobs/count_message_tokens_job.rb +0 -39
- data/app/jobs/passive_recall_job.rb +0 -29
- data/app/models/concerns/message/broadcasting.rb +0 -85
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/agent_loop.rb +0 -186
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
- data/lib/environment_probe.rb +0 -232
- data/lib/events/agent_message.rb +0 -11
- data/lib/events/subscribers/message_collector.rb +0 -64
- data/lib/events/tool_call.rb +0 -31
- data/lib/events/tool_response.rb +0 -33
- data/lib/mneme/compressed_viewport.rb +0 -200
- data/lib/mneme/passive_recall.rb +0 -69
data/lib/mneme/runner.rb
CHANGED
|
@@ -1,198 +1,170 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mneme
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# the
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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.
|
|
4
|
+
# Mneme in eviction mode — a phantom LLM loop that summarizes the oldest
|
|
5
|
+
# slice of the viewport before it slides off. She sees the eviction zone
|
|
6
|
+
# (what she's compressing) plus the remaining viewport (context she needs
|
|
7
|
+
# to write a faithful summary), calls {Tools::SaveSnapshot} to persist
|
|
8
|
+
# the compressed memory, optionally pins critical messages to goals, then
|
|
9
|
+
# advances the Mneme boundary past the zone so the cycle repeats as more
|
|
10
|
+
# messages accumulate.
|
|
14
11
|
#
|
|
15
12
|
# @example
|
|
16
13
|
# Mneme::Runner.new(session).call
|
|
17
|
-
class Runner
|
|
14
|
+
class Runner < BaseRunner
|
|
18
15
|
TOOLS = [
|
|
19
16
|
Tools::SaveSnapshot,
|
|
20
17
|
Tools::AttachMessagesToGoals,
|
|
21
18
|
Tools::EverythingOk
|
|
22
19
|
].freeze
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
21
|
+
TASK_PROMPT = <<~PROMPT
|
|
22
|
+
Right now your work is compression. As Aoide's viewport slides forward, you catch what's about to fall off and turn it into something she can carry.
|
|
29
23
|
|
|
30
24
|
──────────────────────────────
|
|
31
|
-
|
|
25
|
+
WHAT YOU SEE
|
|
32
26
|
──────────────────────────────
|
|
33
|
-
|
|
34
|
-
- EVICTION ZONE:
|
|
35
|
-
-
|
|
36
|
-
- RECENT ZONE: Fresh. Use for continuity with your summary.
|
|
27
|
+
Two sections of the viewport, oldest to newest:
|
|
28
|
+
- EVICTION ZONE: about to fall off. This is what you summarize.
|
|
29
|
+
- CONTEXT: the live viewport past the eviction zone. Use it for continuity — Aoide is still seeing it.
|
|
37
30
|
|
|
38
31
|
Messages are prefixed with `message N` (database ID, used for pinning).
|
|
39
32
|
Tool calls are compressed to `[N tools called]` — focus on conversation, not mechanical work.
|
|
40
33
|
|
|
41
34
|
──────────────────────────────
|
|
42
|
-
|
|
35
|
+
HOW TO REMEMBER
|
|
43
36
|
──────────────────────────────
|
|
44
|
-
Summarize
|
|
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.
|
|
37
|
+
Summarize the eviction zone with save_snapshot: what was discussed and decided, why, goal progress, and the context Aoide will need later. Paraphrase — don't quote verbatim. Drop mechanical steps.
|
|
47
38
|
|
|
48
|
-
|
|
49
|
-
(user instructions, key corrections, key decisions). Pinned messages survive eviction
|
|
50
|
-
intact — use this sparingly for messages where paraphrasing would lose meaning.
|
|
39
|
+
A snapshot is a tax on Aoide's viewport budget. Every word you write takes a word she can't spend on the current exchange. Capture the load-bearing details; let the rest go.
|
|
51
40
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
You may combine save_snapshot and attach_messages_to_goals in one turn.
|
|
55
|
-
PROMPT
|
|
41
|
+
Pin critical messages to goals with attach_messages_to_goals when exact wording matters — user instructions, key corrections, key decisions. A pinned message survives eviction intact. Use it sparingly: each pin is another slice of viewport Aoide carries forward.
|
|
56
42
|
|
|
57
|
-
|
|
58
|
-
# @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
|
|
59
|
-
def initialize(session, client: nil)
|
|
60
|
-
@session = session
|
|
61
|
-
@client = client || LLM::Client.new(
|
|
62
|
-
model: Anima::Settings.fast_model,
|
|
63
|
-
max_tokens: Anima::Settings.mneme_max_tokens,
|
|
64
|
-
logger: Mneme.logger
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Runs the Mneme loop: builds compressed viewport, calls LLM, executes
|
|
69
|
-
# snapshot tool, then advances the terminal message pointer.
|
|
70
|
-
#
|
|
71
|
-
# @return [String, nil] the LLM's final text response (discarded),
|
|
72
|
-
# or nil if no context is available
|
|
73
|
-
def call
|
|
74
|
-
viewport = build_compressed_viewport
|
|
75
|
-
compressed_text = viewport.render
|
|
76
|
-
sid = @session.id
|
|
77
|
-
|
|
78
|
-
if compressed_text.empty?
|
|
79
|
-
log.debug("session=#{sid} — no messages for Mneme, skipping")
|
|
80
|
-
return
|
|
81
|
-
end
|
|
43
|
+
If the eviction zone holds only mechanical activity — tool calls, no conversation — call everything_ok and let it fall off without a snapshot.
|
|
82
44
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
log.info("session=#{sid} — running Mneme (#{viewport.messages.size} messages)")
|
|
87
|
-
log.debug("compressed viewport:\n#{compressed_text}")
|
|
88
|
-
|
|
89
|
-
result = @client.chat_with_tools(
|
|
90
|
-
llm_messages,
|
|
91
|
-
registry: build_registry(viewport),
|
|
92
|
-
session_id: nil,
|
|
93
|
-
system: system
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
advance_boundary(viewport)
|
|
97
|
-
log.info("session=#{sid} — Mneme done: #{result.to_s.truncate(200)}")
|
|
98
|
-
result
|
|
99
|
-
end
|
|
45
|
+
save_snapshot and attach_messages_to_goals can be called together in one turn.
|
|
46
|
+
PROMPT
|
|
100
47
|
|
|
101
48
|
private
|
|
102
49
|
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
# @return [Mneme::CompressedViewport]
|
|
106
|
-
def build_compressed_viewport
|
|
107
|
-
token_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_viewport_fraction).to_i
|
|
108
|
-
|
|
109
|
-
CompressedViewport.new(
|
|
110
|
-
@session,
|
|
111
|
-
token_budget: token_budget,
|
|
112
|
-
from_message_id: @session.mneme_boundary_message_id
|
|
113
|
-
)
|
|
114
|
-
end
|
|
50
|
+
def task_prompt = TASK_PROMPT
|
|
115
51
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
52
|
+
def user_messages
|
|
53
|
+
eviction = @eviction ||= session.eviction_zone_messages
|
|
54
|
+
context = @context ||= session.viewport_messages.where("messages.id > ?", eviction.last.id)
|
|
55
|
+
transcript = render_transcript(eviction, context)
|
|
56
|
+
goals = active_goals_section
|
|
57
|
+
|
|
58
|
+
log.info("session=#{session.id} — eviction (#{eviction.size} eviction + #{context.size} context)")
|
|
59
|
+
log.debug("compressed viewport:\n#{transcript}")
|
|
122
60
|
|
|
123
61
|
content = <<~MSG.strip
|
|
124
|
-
Here is
|
|
62
|
+
Here is Aoide's viewport:
|
|
125
63
|
|
|
126
|
-
#{
|
|
127
|
-
#{
|
|
128
|
-
Review the eviction zone and
|
|
64
|
+
#{transcript}
|
|
65
|
+
#{goals}
|
|
66
|
+
Review the eviction zone and summarize it with save_snapshot.
|
|
67
|
+
If the zone holds only mechanical activity, call everything_ok.
|
|
129
68
|
MSG
|
|
130
69
|
|
|
131
70
|
[{role: "user", content: content}]
|
|
132
71
|
end
|
|
133
72
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# which messages it covers.
|
|
137
|
-
#
|
|
138
|
-
# @param viewport [Mneme::CompressedViewport]
|
|
139
|
-
# @return [Tools::Registry]
|
|
140
|
-
def build_registry(viewport)
|
|
141
|
-
viewport_messages = viewport.messages
|
|
73
|
+
def build_registry
|
|
74
|
+
eviction = @eviction ||= session.eviction_zone_messages
|
|
142
75
|
registry = ::Tools::Registry.new(context: {
|
|
143
|
-
main_session:
|
|
144
|
-
from_message_id:
|
|
145
|
-
to_message_id:
|
|
76
|
+
main_session: session,
|
|
77
|
+
from_message_id: session.mneme_boundary_message_id,
|
|
78
|
+
to_message_id: eviction.last.id
|
|
146
79
|
})
|
|
147
80
|
TOOLS.each { |tool| registry.register(tool) }
|
|
148
81
|
registry
|
|
149
82
|
end
|
|
150
83
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
84
|
+
def after_call(_result)
|
|
85
|
+
eviction = @eviction or return
|
|
86
|
+
last_evicted_id = eviction.last.id
|
|
87
|
+
|
|
88
|
+
new_boundary_id = session.messages
|
|
89
|
+
.conversation_or_think
|
|
90
|
+
.where("id > ?", last_evicted_id)
|
|
91
|
+
.order(:id)
|
|
92
|
+
.pick(:id) || last_evicted_id
|
|
93
|
+
|
|
94
|
+
session.update_column(:mneme_boundary_message_id, new_boundary_id)
|
|
95
|
+
Events::Bus.emit(Events::EvictionCompleted.new(
|
|
96
|
+
session_id: session.id,
|
|
97
|
+
evict_above_id: last_evicted_id
|
|
98
|
+
))
|
|
99
|
+
refresh_subagent_visibility
|
|
100
|
+
log.debug("session=#{session.id} — boundary advanced to message #{new_boundary_id}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Flips visible sub-agents to +hud_visible: false+ once every one of
|
|
104
|
+
# their viewport traces (spawn pair + +from_{nickname}+ phantom pairs)
|
|
105
|
+
# has fallen past the Mneme boundary. Emits {Events::SubagentEvicted}
|
|
106
|
+
# per flip so the broadcaster removes them from the HUD panel on the
|
|
107
|
+
# parent stream. Flips are logged so the transition is auditable.
|
|
108
|
+
def refresh_subagent_visibility
|
|
109
|
+
session_id = session.id
|
|
110
|
+
session.child_sessions.where(hud_visible: true).each do |child|
|
|
111
|
+
next if session.subagent_trace_in_viewport?(child)
|
|
112
|
+
|
|
113
|
+
child_id = child.id
|
|
114
|
+
child.update_column(:hud_visible, false)
|
|
115
|
+
Events::Bus.emit(Events::SubagentEvicted.new(session_id: session_id, child_id: child_id))
|
|
116
|
+
log.debug("session=#{session_id} — sub-agent #{child_id} evicted from HUD")
|
|
117
|
+
end
|
|
176
118
|
end
|
|
177
119
|
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
120
|
+
# Renders eviction zone and context as a Mneme transcript using
|
|
121
|
+
# message decorators. Tool calls are compressed into counters.
|
|
122
|
+
def render_transcript(eviction, context)
|
|
123
|
+
[
|
|
124
|
+
"── EVICTION ZONE ──",
|
|
125
|
+
render_messages(eviction),
|
|
126
|
+
"── CONTEXT ──",
|
|
127
|
+
render_messages(context)
|
|
128
|
+
].join("\n")
|
|
184
129
|
end
|
|
185
130
|
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
131
|
+
# Renders messages using decorators, compressing consecutive
|
|
132
|
+
# tool calls into `[N tools called]` counters.
|
|
133
|
+
def render_messages(messages)
|
|
134
|
+
lines = []
|
|
135
|
+
tool_count = 0
|
|
136
|
+
|
|
137
|
+
messages.each do |message|
|
|
138
|
+
rendered = message.decorate.render("mneme")
|
|
139
|
+
|
|
140
|
+
case rendered
|
|
141
|
+
when :tool_call
|
|
142
|
+
tool_count += 1
|
|
143
|
+
when nil
|
|
144
|
+
next
|
|
145
|
+
else
|
|
146
|
+
lines << flush_tool_count(tool_count) if tool_count > 0
|
|
147
|
+
tool_count = 0
|
|
148
|
+
lines << rendered
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
lines << flush_tool_count(tool_count) if tool_count > 0
|
|
153
|
+
lines.join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def flush_tool_count(count)
|
|
157
|
+
"[#{count} #{(count == 1) ? "tool" : "tools"} called]"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Active-goals block so Mneme knows what Goals exist, which messages
|
|
161
|
+
# are already pinned, and can reference them when deciding what to
|
|
162
|
+
# pin or summarize.
|
|
191
163
|
def active_goals_section
|
|
192
|
-
root_goals =
|
|
164
|
+
root_goals = session.goals.root.includes(:sub_goals).active.order(:created_at)
|
|
193
165
|
return "" if root_goals.empty?
|
|
194
166
|
|
|
195
|
-
lines = root_goals.map { |goal|
|
|
167
|
+
lines = root_goals.map { |goal| format_goal(goal) }
|
|
196
168
|
pinned = format_existing_pins
|
|
197
169
|
|
|
198
170
|
section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
|
|
@@ -200,11 +172,7 @@ module Mneme
|
|
|
200
172
|
section
|
|
201
173
|
end
|
|
202
174
|
|
|
203
|
-
|
|
204
|
-
#
|
|
205
|
-
# @param goal [Goal] root goal with preloaded sub_goals
|
|
206
|
-
# @return [String]
|
|
207
|
-
def format_goal_for_mneme(goal)
|
|
175
|
+
def format_goal(goal)
|
|
208
176
|
parts = [" ● #{goal.description} (id: #{goal.id})"]
|
|
209
177
|
goal.sub_goals.each do |sub|
|
|
210
178
|
checkbox = sub.completed? ? "[x]" : "[ ]"
|
|
@@ -213,24 +181,14 @@ module Mneme
|
|
|
213
181
|
parts.join("\n")
|
|
214
182
|
end
|
|
215
183
|
|
|
216
|
-
# Lists already-pinned message IDs so Mneme avoids redundant pinning.
|
|
217
|
-
#
|
|
218
|
-
# @return [String, nil] formatted pin list, or nil when nothing is pinned
|
|
219
184
|
def format_existing_pins
|
|
220
|
-
pins =
|
|
185
|
+
pins = session.pinned_messages.includes(:goals).order(:message_id)
|
|
221
186
|
return nil if pins.empty?
|
|
222
187
|
|
|
223
|
-
pins.map { |pin|
|
|
188
|
+
pins.map { |pin|
|
|
189
|
+
goal_ids = pin.goals.map(&:id).join(", ")
|
|
190
|
+
" message #{pin.message_id} → goals [#{goal_ids}]"
|
|
191
|
+
}.join("\n")
|
|
224
192
|
end
|
|
225
|
-
|
|
226
|
-
# @param pin [PinnedMessage] pin with preloaded goals
|
|
227
|
-
# @return [String] formatted pin line
|
|
228
|
-
def format_pin_for_mneme(pin)
|
|
229
|
-
goal_ids = pin.goals.map(&:id).join(", ")
|
|
230
|
-
" message #{pin.message_id} → goals [#{goal_ids}]"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# @return [Logger]
|
|
234
|
-
def log = Mneme.logger
|
|
235
193
|
end
|
|
236
194
|
end
|
data/lib/mneme/search.rb
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mneme
|
|
4
|
-
# Full-text search over message history
|
|
5
|
-
# Covers user messages, agent messages,
|
|
4
|
+
# Full-text search over long-term memory — the message history outside
|
|
5
|
+
# the caller's current viewport. Covers user messages, agent messages,
|
|
6
|
+
# and think messages across every session Anima has ever held.
|
|
6
7
|
#
|
|
7
8
|
# The interface is intentionally abstract — callers receive {Result} structs
|
|
8
9
|
# and never touch FTS5 directly. A future semantic search backend (embeddings,
|
|
9
10
|
# BM25 + re-ranking) can replace the implementation without changing callers.
|
|
10
11
|
#
|
|
11
|
-
# @example
|
|
12
|
-
#
|
|
13
|
-
# results.each { |r| puts "message #{r.message_id}: #{r.snippet}" }
|
|
12
|
+
# @example Mneme's recall muse searching for the main session
|
|
13
|
+
# Mneme::Search.query("authentication flow", caller_session: session)
|
|
14
14
|
#
|
|
15
|
-
# @example
|
|
16
|
-
#
|
|
15
|
+
# @example Aoide searching actively from her own session
|
|
16
|
+
# Mneme::Search.query("OAuth config", caller_session: session)
|
|
17
17
|
class Search
|
|
18
18
|
# A single search result with enough context for display and drill-down.
|
|
19
19
|
#
|
|
@@ -24,19 +24,27 @@ module Mneme
|
|
|
24
24
|
# @!attribute message_type [String] friendly label: human, anima, system, or thought
|
|
25
25
|
Result = Struct.new(:message_id, :session_id, :snippet, :rank, :message_type, keyword_init: true)
|
|
26
26
|
|
|
27
|
-
# Searches
|
|
27
|
+
# Searches long-term memory for the given terms.
|
|
28
|
+
#
|
|
29
|
+
# Excludes messages currently in the caller's viewport so a `LIMIT`-bounded
|
|
30
|
+
# search never burns its slots returning things the caller already has in
|
|
31
|
+
# front of them. A caller with no established Mneme boundary yet (fresh
|
|
32
|
+
# main session, sub-agent) treats the whole session as "in viewport" — none
|
|
33
|
+
# of its own messages surface.
|
|
28
34
|
#
|
|
29
35
|
# @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
|
|
30
|
-
# @param
|
|
36
|
+
# @param caller_session [Session] the session doing the search — used to
|
|
37
|
+
# exclude its own viewport from the results. Required; search always
|
|
38
|
+
# happens from the perspective of a specific session.
|
|
31
39
|
# @param limit [Integer] maximum results
|
|
32
40
|
# @return [Array<Result>] ranked by relevance (best first)
|
|
33
|
-
def self.query(terms,
|
|
34
|
-
new(terms,
|
|
41
|
+
def self.query(terms, caller_session:, limit: Anima::Settings.recall_max_results)
|
|
42
|
+
new(terms, caller_session: caller_session, limit: limit).call
|
|
35
43
|
end
|
|
36
44
|
|
|
37
|
-
def initialize(terms,
|
|
45
|
+
def initialize(terms, caller_session:, limit: 5)
|
|
38
46
|
@terms = sanitize_query(terms)
|
|
39
|
-
@
|
|
47
|
+
@caller_session = caller_session
|
|
40
48
|
@limit = limit
|
|
41
49
|
@recency_decay = Anima::Settings.recall_recency_decay
|
|
42
50
|
end
|
|
@@ -51,30 +59,29 @@ module Mneme
|
|
|
51
59
|
|
|
52
60
|
private
|
|
53
61
|
|
|
54
|
-
# Executes the FTS5 MATCH query with
|
|
55
|
-
# Joins back to messages table for session_id and message_type.
|
|
62
|
+
# Executes the FTS5 MATCH query with viewport exclusion for the caller.
|
|
56
63
|
#
|
|
57
64
|
# @return [Array<Hash>] raw database rows
|
|
58
65
|
def execute_fts_query
|
|
59
|
-
sql =
|
|
60
|
-
|
|
61
|
-
else
|
|
62
|
-
Arel.sql(global_sql, @recency_decay, @terms, @limit)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
connection.select_all(sql, "Mneme::Search").to_a
|
|
66
|
+
sql, binds = build_sql_and_binds
|
|
67
|
+
connection.select_all(Arel.sql(sql, *binds), "Mneme::Search").to_a
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
# FTS5
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
# Builds the FTS5 SQL. Viewport exclusion depends on whether the caller's
|
|
71
|
+
# session has a Mneme boundary:
|
|
72
|
+
# * boundary set → exclude caller's messages at or above it (they're visible).
|
|
73
|
+
# * boundary nil → exclude the caller's whole session (no eviction has
|
|
74
|
+
# happened yet, so everything is visible).
|
|
75
|
+
# Other sessions are always unfiltered — their IDs and boundaries mean
|
|
76
|
+
# nothing to the caller's context.
|
|
77
|
+
def build_sql_and_binds
|
|
78
|
+
binds = [@recency_decay, @terms]
|
|
79
|
+
|
|
80
|
+
viewport_clause, viewport_binds = caller_viewport_exclusion
|
|
81
|
+
binds.concat(viewport_binds)
|
|
82
|
+
binds << @limit
|
|
83
|
+
|
|
84
|
+
sql = <<~SQL
|
|
78
85
|
SELECT
|
|
79
86
|
m.id AS message_id,
|
|
80
87
|
m.session_id,
|
|
@@ -89,32 +96,22 @@ module Mneme
|
|
|
89
96
|
FROM messages_fts
|
|
90
97
|
JOIN messages m ON m.id = messages_fts.rowid
|
|
91
98
|
WHERE messages_fts MATCH ?
|
|
99
|
+
AND #{viewport_clause}
|
|
92
100
|
ORDER BY rank
|
|
93
101
|
LIMIT ?
|
|
94
102
|
SQL
|
|
103
|
+
|
|
104
|
+
[sql, binds]
|
|
95
105
|
end
|
|
96
106
|
|
|
97
|
-
#
|
|
98
|
-
def
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
END AS snippet,
|
|
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
|
-
ORDER BY rank
|
|
116
|
-
LIMIT ?
|
|
117
|
-
SQL
|
|
107
|
+
# Returns the SQL fragment + bind params that exclude the caller's viewport.
|
|
108
|
+
def caller_viewport_exclusion
|
|
109
|
+
boundary = @caller_session.mneme_boundary_message_id
|
|
110
|
+
if boundary
|
|
111
|
+
["(m.session_id != ? OR m.id < ?)", [@caller_session.id, boundary]]
|
|
112
|
+
else
|
|
113
|
+
["m.session_id != ?", [@caller_session.id]]
|
|
114
|
+
end
|
|
118
115
|
end
|
|
119
116
|
|
|
120
117
|
FRIENDLY_MESSAGE_TYPES = {
|
|
@@ -139,25 +136,70 @@ module Mneme
|
|
|
139
136
|
)
|
|
140
137
|
end
|
|
141
138
|
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
139
|
+
# FTS5 logical-operator keywords callers may pass verbatim. Everything
|
|
140
|
+
# else is quote-wrapped, which is SQLite's recommended way to feed
|
|
141
|
+
# untrusted text to +MATCH+ — inside a quoted phrase the tokenizer
|
|
142
|
+
# treats +- : * ^ { } ( )+ and any future operator character as
|
|
143
|
+
# ordinary content, so hazards like +sub-agents+ (parsed as
|
|
144
|
+
# +sub NOT agents+ → +no such column: agents+) and +agents:foo+
|
|
145
|
+
# (parsed as a column filter) become literal phrases.
|
|
146
|
+
#
|
|
147
|
+
# Adding +NEAR+ or any new operator that a caller legitimately needs
|
|
148
|
+
# is a one-line change here; character-level blocklists would need to
|
|
149
|
+
# be re-audited against every FTS5 release.
|
|
150
|
+
#
|
|
151
|
+
# @see https://www.sqlite.org/fts5.html FTS5 query syntax
|
|
152
|
+
# @see https://www.mail-archive.com/sqlite-users@mailinglists.sqlite.org/msg118320.html
|
|
153
|
+
# Dan Kennedy's canonical guidance on MATCH sanitization
|
|
154
|
+
FTS5_PASSTHROUGH_OPERATORS = Set.new(%w[AND OR NOT NEAR]).freeze
|
|
155
|
+
private_constant :FTS5_PASSTHROUGH_OPERATORS
|
|
156
|
+
|
|
157
|
+
# Sanitizes user input for FTS5 MATCH safety by quote-wrapping each
|
|
158
|
+
# token. Logical operators ({FTS5_PASSTHROUGH_OPERATORS}) pass through
|
|
159
|
+
# so callers that intentionally build +word1 OR word2+ queries still
|
|
160
|
+
# get boolean behavior.
|
|
161
|
+
#
|
|
162
|
+
# A query that collapses to operators only (e.g. user typed "and or
|
|
163
|
+
# not") has no operands and would trigger an FTS5 syntax error, so we
|
|
164
|
+
# return an empty string and let {#call} short-circuit via
|
|
165
|
+
# +@terms.blank?+.
|
|
145
166
|
#
|
|
146
167
|
# @param raw [String]
|
|
147
168
|
# @return [String] safe FTS5 query
|
|
148
169
|
def sanitize_query(raw)
|
|
149
170
|
return "" unless raw
|
|
150
171
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
tokens = raw.scan(/"[^"]*"|\S+/).filter_map { |token| sanitize_token(token) }
|
|
173
|
+
return "" if tokens.all? { |t| FTS5_PASSTHROUGH_OPERATORS.include?(t) }
|
|
174
|
+
|
|
175
|
+
tokens.join(" ")
|
|
154
176
|
end
|
|
155
177
|
|
|
178
|
+
# @param token [String] one whitespace-delimited chunk of user input
|
|
179
|
+
# @return [String, nil] nil when the token is empty after cleanup
|
|
156
180
|
def sanitize_token(token)
|
|
157
|
-
return token if
|
|
181
|
+
return token if FTS5_PASSTHROUGH_OPERATORS.include?(token)
|
|
182
|
+
return rewrap_phrase(token) if token.start_with?('"')
|
|
183
|
+
|
|
184
|
+
quote_as_phrase(token)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Rebalances a user-supplied phrase so a stray or doubled quote can't
|
|
188
|
+
# leave the overall query syntactically broken.
|
|
189
|
+
#
|
|
190
|
+
# @param token [String] token starting with +"+
|
|
191
|
+
# @return [String, nil]
|
|
192
|
+
def rewrap_phrase(token)
|
|
193
|
+
inner = token.delete_prefix('"').delete_suffix('"').strip
|
|
194
|
+
inner.empty? ? nil : quote_as_phrase(inner)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param text [String] any token treated as literal content
|
|
198
|
+
# @return [String, nil]
|
|
199
|
+
def quote_as_phrase(text)
|
|
200
|
+
return nil if text.empty?
|
|
158
201
|
|
|
159
|
-
|
|
160
|
-
cleaned.empty? ? nil : cleaned
|
|
202
|
+
%("#{text.gsub('"', '""')}")
|
|
161
203
|
end
|
|
162
204
|
|
|
163
205
|
def connection
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Finish-line tool for {Mneme::RecallRunner}. The muse calls this when
|
|
6
|
+
# she's done her work — whether she surfaced memories or decided
|
|
7
|
+
# nothing was worth carrying forward. Having a single finish line makes
|
|
8
|
+
# every recall run explicit: silence is intentional, not a timeout.
|
|
9
|
+
#
|
|
10
|
+
# Mirror of {EverythingOk} for the eviction runner.
|
|
11
|
+
class NothingToSurface < ::Tools::Base
|
|
12
|
+
def self.tool_name = "nothing_to_surface"
|
|
13
|
+
|
|
14
|
+
def self.description = "Finish the recall run. Call this when you're done — whether you surfaced memories or decided nothing was worth surfacing right now. Silence is a valid answer when older memory wouldn't help."
|
|
15
|
+
|
|
16
|
+
def self.input_schema
|
|
17
|
+
{type: "object", properties: {}, required: []}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execute(_input)
|
|
21
|
+
"Acknowledged."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -20,7 +20,7 @@ module Mneme
|
|
|
20
20
|
properties: {
|
|
21
21
|
text: {
|
|
22
22
|
type: "string",
|
|
23
|
-
maxLength: Anima::Settings.mneme_max_tokens *
|
|
23
|
+
maxLength: Anima::Settings.mneme_max_tokens * TokenEstimation::BYTES_PER_TOKEN
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
required: %w[text]
|
|
@@ -46,19 +46,11 @@ module Mneme
|
|
|
46
46
|
text: text,
|
|
47
47
|
from_message_id: @from_message_id,
|
|
48
48
|
to_message_id: @to_message_id,
|
|
49
|
-
level: @level
|
|
50
|
-
token_count: estimate_tokens(text)
|
|
49
|
+
level: @level
|
|
51
50
|
)
|
|
52
51
|
|
|
53
52
|
"Snapshot saved (id: #{snapshot.id}, messages #{@from_message_id}..#{@to_message_id})"
|
|
54
53
|
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
# @return [Integer] estimated token count for the summary text
|
|
59
|
-
def estimate_tokens(text)
|
|
60
|
-
[(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
61
|
-
end
|
|
62
54
|
end
|
|
63
55
|
end
|
|
64
56
|
end
|