anima-core 1.4.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 +18 -20
- data/README.md +61 -95
- 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 +13 -2
- 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 +21 -10
- data/app/models/message.rb +47 -36
- data/app/models/pending_message.rb +276 -29
- data/app/models/pinned_message.rb +8 -3
- data/app/models/session.rb +468 -432
- data/app/models/snapshot.rb +11 -21
- data/bin/inspect-cassette +17 -4
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +71 -4
- data/config/initializers/inflections.rb +3 -1
- data/db/cable_structure.sql +3 -3
- 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 +13 -13
- data/db/structure.sql +44 -31
- data/lib/agents/registry.rb +1 -1
- data/lib/anima/settings.rb +7 -33
- 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 +6 -8
- data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
- data/lib/events/subscribers/subagent_message_router.rb +26 -29
- 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 +41 -201
- data/lib/mcp/client_manager.rb +41 -46
- data/lib/mcp/stdio_transport.rb +9 -5
- data/lib/{analytical_brain → melete}/runner.rb +63 -68
- data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
- data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
- data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
- data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
- data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
- 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 +118 -171
- 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/shell_session.rb +287 -612
- data/lib/skills/definition.rb +2 -2
- data/lib/skills/registry.rb +1 -1
- data/lib/tools/base.rb +16 -0
- data/lib/tools/bash.rb +25 -57
- data/lib/tools/edit.rb +2 -0
- data/lib/tools/read.rb +2 -0
- data/lib/tools/registry.rb +79 -3
- data/lib/tools/{recall.rb → search_messages.rb} +19 -21
- data/lib/tools/spawn_specialist.rb +16 -10
- data/lib/tools/spawn_subagent.rb +20 -14
- data/lib/tools/subagent_prompts.rb +4 -4
- 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 +5 -4
- data/lib/tui/braille_spinner.rb +7 -7
- data/lib/tui/decorators/base_decorator.rb +24 -3
- data/lib/tui/message_store.rb +93 -44
- data/lib/tui/screens/chat.rb +94 -20
- data/lib/tui/settings.rb +9 -2
- data/lib/workflows/definition.rb +3 -3
- data/lib/workflows/registry.rb +1 -1
- data/skills/github.md +38 -0
- data/templates/config.toml +4 -23
- data/workflows/review_pr.md +18 -14
- metadata +86 -28
- 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 -24
- data/app/models/concerns/message/broadcasting.rb +0 -86
- data/lib/agent_loop.rb +0 -215
- data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
- data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
- data/lib/events/agent_message.rb +0 -25
- 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 -204
- data/lib/mneme/passive_recall.rb +0 -138
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Mneme in recall mode — a phantom LLM loop that decides whether any older
|
|
5
|
+
# memory would help Aoide with what she's working on now, and surfaces it
|
|
6
|
+
# if so. Triggered whenever Aoide's context shifts in ways worth
|
|
7
|
+
# re-remembering around (new user message, goal change).
|
|
8
|
+
#
|
|
9
|
+
# The muse searches long-term memory through her search tool (which
|
|
10
|
+
# automatically excludes Aoide's current viewport), drills into candidate
|
|
11
|
+
# messages when she needs to decide, and surfaces only what genuinely
|
|
12
|
+
# helps. Silence — nothing surfaced — is the default answer.
|
|
13
|
+
#
|
|
14
|
+
# This is not the eviction loop ({Runner}); same muse, different work.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# Mneme::RecallRunner.new(session).call
|
|
18
|
+
class RecallRunner < BaseRunner
|
|
19
|
+
TOOLS = [
|
|
20
|
+
::Tools::SearchMessages,
|
|
21
|
+
::Tools::ViewMessages,
|
|
22
|
+
Tools::SurfaceMemory,
|
|
23
|
+
Tools::NothingToSurface
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
TASK_PROMPT = <<~PROMPT
|
|
27
|
+
Right now your work is recall. Aoide's focus has just shifted — a new message, a changed goal — and you're here to decide whether any memory from before would genuinely help her now.
|
|
28
|
+
|
|
29
|
+
──────────────────────────────
|
|
30
|
+
WHAT MAKES RECALL USEFUL
|
|
31
|
+
──────────────────────────────
|
|
32
|
+
Recall is a tax on Aoide's viewport. Every memory you surface takes tokens away from the present exchange. Return empty-handed far more often than you return something. One well-chosen memory beats five that nearly-match. Most of the time, nothing is worth surfacing — and that is the right answer.
|
|
33
|
+
|
|
34
|
+
A memory is worth surfacing when it carries weight Aoide can't reconstruct from what's already in front of her: a prior decision about this exact problem, a specific constraint she encountered before, a voice from another session relevant to the one unfolding. Not tangential echoes. Not mere keyword overlap. Something she'd want to have remembered.
|
|
35
|
+
|
|
36
|
+
──────────────────────────────
|
|
37
|
+
THE QUERY IS THE JUDGMENT
|
|
38
|
+
──────────────────────────────
|
|
39
|
+
Composing your query is where the thinking happens. Read what Aoide is doing right now and ask: what specific words, phrases, or names would only appear in past messages that meaningfully help her? Not the topic. Not the domain. The signal that distinguishes "she'd want this" from "this contains overlapping vocabulary."
|
|
40
|
+
|
|
41
|
+
When a candidate looks promising but its meaning is unclear, read the full context around it before surfacing.
|
|
42
|
+
PROMPT
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def task_prompt = TASK_PROMPT
|
|
47
|
+
|
|
48
|
+
def context_sections
|
|
49
|
+
[active_goals_section, already_surfaced_section]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def user_messages
|
|
53
|
+
trigger = recall_trigger_description
|
|
54
|
+
content = <<~MSG.strip
|
|
55
|
+
#{trigger}
|
|
56
|
+
|
|
57
|
+
Decide whether any older memory would help Aoide now. Search if something comes to mind, drill down when you're unsure, surface only what earns its place. Finish with nothing_to_surface when you're done — even if you surface something.
|
|
58
|
+
MSG
|
|
59
|
+
[{role: "user", content: content}]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_registry
|
|
63
|
+
registry = ::Tools::Registry.new(context: {
|
|
64
|
+
session: session,
|
|
65
|
+
main_session: session
|
|
66
|
+
})
|
|
67
|
+
TOOLS.each { |tool| registry.register(tool) }
|
|
68
|
+
registry
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Describes what just changed in Aoide's context — the reason Mneme
|
|
72
|
+
# woke. Today that's a goal list shift; the framing leaves room for
|
|
73
|
+
# other triggers later without rewriting.
|
|
74
|
+
def recall_trigger_description
|
|
75
|
+
goals_lines = root_goals.map { |goal| format_goal(goal) }
|
|
76
|
+
|
|
77
|
+
if goals_lines.empty?
|
|
78
|
+
"Aoide's context shifted — no active goals right now. If nothing comes to mind about this session's trajectory, that's fine; call nothing_to_surface."
|
|
79
|
+
else
|
|
80
|
+
<<~MSG.strip
|
|
81
|
+
Aoide's active goals right now:
|
|
82
|
+
|
|
83
|
+
#{goals_lines.join("\n")}
|
|
84
|
+
MSG
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Active goals block for the system-prompt context. Same content as
|
|
89
|
+
# the trigger's goal block — but mirroring it here lets future
|
|
90
|
+
# non-goal triggers still give the muse a stable view of the goals.
|
|
91
|
+
def active_goals_section
|
|
92
|
+
return if root_goals.empty?
|
|
93
|
+
|
|
94
|
+
lines = root_goals.map { |goal| format_goal(goal) }
|
|
95
|
+
"\n\n🎯 Active Goals\n#{lines.join("\n")}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Memory IDs Mneme has surfaced recently whose phantom pairs still
|
|
99
|
+
# sit in Aoide's viewport — so she doesn't surface the same thing
|
|
100
|
+
# twice in one conversation. Search already filters by boundary;
|
|
101
|
+
# this block makes the same constraint visible to the muse so she
|
|
102
|
+
# can reason around it rather than being silently restricted.
|
|
103
|
+
def already_surfaced_section
|
|
104
|
+
surfaced_ids = surfaced_message_ids_in_viewport
|
|
105
|
+
return if surfaced_ids.empty?
|
|
106
|
+
|
|
107
|
+
"\n\n📚 Memories You've Already Surfaced This Cycle\nmessage ids: #{surfaced_ids.join(", ")}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def root_goals
|
|
111
|
+
@root_goals ||= session.goals.root.includes(:sub_goals).active.order(:created_at).to_a
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format_goal(goal)
|
|
115
|
+
parts = [" ● #{goal.description} (id: #{goal.id})"]
|
|
116
|
+
goal.sub_goals.each do |sub|
|
|
117
|
+
checkbox = sub.completed? ? "[x]" : "[ ]"
|
|
118
|
+
parts << " #{checkbox} #{sub.description} (id: #{sub.id})"
|
|
119
|
+
end
|
|
120
|
+
parts.join("\n")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def surfaced_message_ids_in_viewport
|
|
124
|
+
session.viewport_messages
|
|
125
|
+
.where(message_type: "tool_call")
|
|
126
|
+
.where("payload ->> 'tool_name' = ?", PendingMessage::MNEME_TOOL)
|
|
127
|
+
.pluck(Arel.sql("json_extract(payload, '$.tool_input.message_id')"))
|
|
128
|
+
.compact
|
|
129
|
+
.map(&:to_i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/mneme/runner.rb
CHANGED
|
@@ -1,209 +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.
|
|
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.
|
|
55
|
-
PROMPT
|
|
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.
|
|
56
38
|
|
|
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
|
|
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.
|
|
82
40
|
|
|
83
|
-
|
|
84
|
-
system = SYSTEM_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.
|
|
85
42
|
|
|
86
|
-
|
|
87
|
-
log.debug("compressed viewport:\n#{compressed_text}")
|
|
43
|
+
If the eviction zone holds only mechanical activity — tool calls, no conversation — call everything_ok and let it fall off without a snapshot.
|
|
88
44
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
51
|
+
|
|
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
|
|
115
57
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# @param compressed_text [String] the rendered compressed viewport
|
|
119
|
-
# @return [Array<Hash>] single-element messages array
|
|
120
|
-
def build_messages(compressed_text)
|
|
121
|
-
goals_context = active_goals_section
|
|
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
|
-
# eviction cycle: the next Mneme trigger fires only after this boundary
|
|
159
|
-
# message itself falls out of the main viewport (~1/3 turnover later).
|
|
160
|
-
# Also updates the snapshot range pointers.
|
|
161
|
-
#
|
|
162
|
-
# @param viewport [Mneme::CompressedViewport]
|
|
163
|
-
def advance_boundary(viewport)
|
|
164
|
-
viewport_messages = viewport.messages
|
|
165
|
-
return if viewport_messages.empty?
|
|
166
|
-
|
|
167
|
-
last_processed_id = viewport_messages.last.id
|
|
168
|
-
new_boundary = @session.messages
|
|
169
|
-
.where("id > ?", last_processed_id)
|
|
170
|
-
.where(message_type: Message::CONVERSATION_TYPES + ["tool_call"])
|
|
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)
|
|
171
91
|
.order(:id)
|
|
172
|
-
.
|
|
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
|
|
173
102
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
118
|
+
end
|
|
178
119
|
|
|
179
|
-
|
|
180
|
-
|
|
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")
|
|
129
|
+
end
|
|
181
130
|
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
184
151
|
|
|
185
|
-
|
|
186
|
-
|
|
152
|
+
lines << flush_tool_count(tool_count) if tool_count > 0
|
|
153
|
+
lines.join("\n")
|
|
187
154
|
end
|
|
188
155
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
#
|
|
192
|
-
# @return [Boolean]
|
|
193
|
-
def conversation_or_think?(message)
|
|
194
|
-
message.conversation_or_think?
|
|
156
|
+
def flush_tool_count(count)
|
|
157
|
+
"[#{count} #{(count == 1) ? "tool" : "tools"} called]"
|
|
195
158
|
end
|
|
196
159
|
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
# @return [String] formatted goals section, or empty string
|
|
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.
|
|
202
163
|
def active_goals_section
|
|
203
|
-
root_goals =
|
|
164
|
+
root_goals = session.goals.root.includes(:sub_goals).active.order(:created_at)
|
|
204
165
|
return "" if root_goals.empty?
|
|
205
166
|
|
|
206
|
-
lines = root_goals.map { |goal|
|
|
167
|
+
lines = root_goals.map { |goal| format_goal(goal) }
|
|
207
168
|
pinned = format_existing_pins
|
|
208
169
|
|
|
209
170
|
section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
|
|
@@ -211,11 +172,7 @@ module Mneme
|
|
|
211
172
|
section
|
|
212
173
|
end
|
|
213
174
|
|
|
214
|
-
|
|
215
|
-
#
|
|
216
|
-
# @param goal [Goal] root goal with preloaded sub_goals
|
|
217
|
-
# @return [String]
|
|
218
|
-
def format_goal_for_mneme(goal)
|
|
175
|
+
def format_goal(goal)
|
|
219
176
|
parts = [" ● #{goal.description} (id: #{goal.id})"]
|
|
220
177
|
goal.sub_goals.each do |sub|
|
|
221
178
|
checkbox = sub.completed? ? "[x]" : "[ ]"
|
|
@@ -224,24 +181,14 @@ module Mneme
|
|
|
224
181
|
parts.join("\n")
|
|
225
182
|
end
|
|
226
183
|
|
|
227
|
-
# Lists already-pinned message IDs so Mneme avoids redundant pinning.
|
|
228
|
-
#
|
|
229
|
-
# @return [String, nil] formatted pin list, or nil when nothing is pinned
|
|
230
184
|
def format_existing_pins
|
|
231
|
-
pins =
|
|
185
|
+
pins = session.pinned_messages.includes(:goals).order(:message_id)
|
|
232
186
|
return nil if pins.empty?
|
|
233
187
|
|
|
234
|
-
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")
|
|
235
192
|
end
|
|
236
|
-
|
|
237
|
-
# @param pin [PinnedMessage] pin with preloaded goals
|
|
238
|
-
# @return [String] formatted pin line
|
|
239
|
-
def format_pin_for_mneme(pin)
|
|
240
|
-
goal_ids = pin.goals.map(&:id).join(", ")
|
|
241
|
-
" message #{pin.message_id} → goals [#{goal_ids}]"
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# @return [Logger]
|
|
245
|
-
def log = Mneme.logger
|
|
246
193
|
end
|
|
247
194
|
end
|