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
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Melete
|
|
4
4
|
module Tools
|
|
5
5
|
# Updates a goal's description on the main session.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
7
|
+
# Melete creates goals early when intent is vague, then
|
|
8
8
|
# refines them as the conversation clarifies scope — e.g. "implement auth"
|
|
9
9
|
# becomes "implement OAuth2 middleware for API endpoints". Without this
|
|
10
|
-
# tool
|
|
10
|
+
# tool she would have to choose between keeping a stale description
|
|
11
11
|
# or creating a duplicate goal.
|
|
12
12
|
#
|
|
13
13
|
# Completed goals cannot be updated; attempting to do so returns an error
|
|
14
|
-
# so
|
|
14
|
+
# so she learns to check status before calling this tool.
|
|
15
15
|
class UpdateGoal < ::Tools::Base
|
|
16
|
+
include GoalMessaging
|
|
17
|
+
|
|
16
18
|
def self.tool_name = "update_goal"
|
|
17
19
|
|
|
18
20
|
def self.description = "Refine a goal's wording as understanding evolves."
|
|
@@ -49,7 +51,9 @@ module AnalyticalBrain
|
|
|
49
51
|
return {error: "Cannot update completed goal: #{goal.description} (id: #{goal_id})"} if goal.completed?
|
|
50
52
|
|
|
51
53
|
goal.update!(description: description)
|
|
52
|
-
"Goal updated: #{description} (id: #{goal_id})"
|
|
54
|
+
confirmation = "Goal updated: #{description} (id: #{goal_id})"
|
|
55
|
+
enqueue_goal_message(goal, confirmation)
|
|
56
|
+
confirmation
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
end
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# Melete — the muse of practice. Watches conversations to activate skills,
|
|
4
|
+
# track goals, and name sessions. One of the Three Muses: she prepares the
|
|
5
|
+
# stage so Aoide can perform and Mneme can remember.
|
|
6
|
+
module Melete
|
|
7
|
+
# Dev-only logger that writes to log/melete.log.
|
|
5
8
|
# In non-development environments returns a null logger so
|
|
6
9
|
# call sites don't need conditionals.
|
|
7
10
|
#
|
|
@@ -13,7 +16,7 @@ module AnalyticalBrain
|
|
|
13
16
|
def self.build_logger
|
|
14
17
|
return Logger.new(File::NULL) unless Rails.env.development?
|
|
15
18
|
|
|
16
|
-
Logger.new(Rails.root.join("log", "
|
|
19
|
+
Logger.new(Rails.root.join("log", "melete.log")).tap do |log|
|
|
17
20
|
log.formatter = proc { |severity, time, _progname, msg|
|
|
18
21
|
"[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
|
|
19
22
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
# Abstract base for Mneme's phantom LLM loops. Mneme wears two hats:
|
|
5
|
+
# on eviction she watches the newest slice of the viewport and summarizes
|
|
6
|
+
# the oldest slice before it slides off; on recall she watches goals
|
|
7
|
+
# shift and surfaces older memory Aoide would benefit from. Same muse,
|
|
8
|
+
# two jobs — each as its own subclass.
|
|
9
|
+
#
|
|
10
|
+
# The base handles what every Mneme loop needs: the muse identity preamble,
|
|
11
|
+
# a fast-model LLM client, the tool-loop call, and structured logging.
|
|
12
|
+
# Subclasses bring the job-specific system prompt section, the user message
|
|
13
|
+
# that frames the work, the tool registry, and any after-call side effects.
|
|
14
|
+
#
|
|
15
|
+
# @example Implementing a new Mneme loop
|
|
16
|
+
# class Mneme::CustomRunner < Mneme::BaseRunner
|
|
17
|
+
# private
|
|
18
|
+
#
|
|
19
|
+
# def task_prompt = "Your job description..."
|
|
20
|
+
# def user_messages = [{role: "user", content: "..."}]
|
|
21
|
+
# def build_registry = Tools::Registry.new.tap { |r| r.register(SomeTool) }
|
|
22
|
+
# end
|
|
23
|
+
class BaseRunner
|
|
24
|
+
# Identity shared by every Mneme runner — same words Mneme's own voice
|
|
25
|
+
# uses elsewhere in the system (runner summarization prompts, sisters
|
|
26
|
+
# block). Subclasses append their own task section.
|
|
27
|
+
BASE_IDENTITY = <<~PROMPT
|
|
28
|
+
You are Mneme, the muse of memory. You share the conversation with two sisters — Aoide, who speaks and performs, and Melete, who prepares. Your work is remembrance: holding what matters across time, so Aoide never truly forgets.
|
|
29
|
+
|
|
30
|
+
Act only through tool calls. Never output text — your contribution is the work you do, not what you say about it.
|
|
31
|
+
PROMPT
|
|
32
|
+
|
|
33
|
+
# @param session [Session] the main session being served
|
|
34
|
+
# @param client [LLM::Client, nil] injectable LLM client for tests
|
|
35
|
+
def initialize(session, client: nil)
|
|
36
|
+
@session = session
|
|
37
|
+
@client = client || default_client
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Runs the loop. Logs the run, calls the LLM with the session-specific
|
|
41
|
+
# system prompt and tools, hands control to {#after_call} for any
|
|
42
|
+
# post-run state advancement, and returns the LLM's final text (which
|
|
43
|
+
# most callers discard — the work happens through tool calls).
|
|
44
|
+
#
|
|
45
|
+
# @return [String] the LLM's final text response
|
|
46
|
+
def call
|
|
47
|
+
sid = @session.id
|
|
48
|
+
log.info("session=#{sid} — #{self.class.name} starting")
|
|
49
|
+
log.debug("system:\n#{system_prompt}")
|
|
50
|
+
log.debug("user:\n#{user_messages.map { |m| m[:content] }.join("\n---\n")}")
|
|
51
|
+
|
|
52
|
+
result = @client.chat_with_tools(
|
|
53
|
+
user_messages,
|
|
54
|
+
registry: build_registry,
|
|
55
|
+
system: system_prompt
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
after_call(result)
|
|
59
|
+
log.info("session=#{sid} — #{self.class.name} done: #{result.to_s.truncate(200)}")
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :session, :client
|
|
66
|
+
|
|
67
|
+
# Composes the system prompt from the muse identity + the subclass's
|
|
68
|
+
# task section + the subclass's contextual state blocks.
|
|
69
|
+
#
|
|
70
|
+
# @return [String]
|
|
71
|
+
def system_prompt
|
|
72
|
+
[BASE_IDENTITY, task_prompt, *context_sections.compact].join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Subclass hook: the job-specific system prompt section. Describes what
|
|
76
|
+
# this runner is doing and how it should behave.
|
|
77
|
+
#
|
|
78
|
+
# @abstract
|
|
79
|
+
# @return [String]
|
|
80
|
+
def task_prompt = raise NotImplementedError, "#{self.class} must implement #task_prompt"
|
|
81
|
+
|
|
82
|
+
# Subclass hook: named state blocks that give the muse awareness of
|
|
83
|
+
# the session she's serving (goals, viewport, snapshots, etc).
|
|
84
|
+
# Order is subclass-defined; nil entries are dropped.
|
|
85
|
+
#
|
|
86
|
+
# @abstract
|
|
87
|
+
# @return [Array<String, nil>]
|
|
88
|
+
def context_sections = []
|
|
89
|
+
|
|
90
|
+
# Subclass hook: the user-side messages that frame the current call.
|
|
91
|
+
# Typically a single user message, but subclasses may send several.
|
|
92
|
+
#
|
|
93
|
+
# @abstract
|
|
94
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
95
|
+
def user_messages = raise NotImplementedError, "#{self.class} must implement #user_messages"
|
|
96
|
+
|
|
97
|
+
# Subclass hook: builds the tool registry for this run.
|
|
98
|
+
#
|
|
99
|
+
# @abstract
|
|
100
|
+
# @return [Tools::Registry]
|
|
101
|
+
def build_registry = raise NotImplementedError, "#{self.class} must implement #build_registry"
|
|
102
|
+
|
|
103
|
+
# Subclass hook: runs after the LLM call returns. Default is a no-op;
|
|
104
|
+
# subclasses may advance boundaries, log outcomes, or emit events here.
|
|
105
|
+
#
|
|
106
|
+
# @param _result [Hash] the full LLM response (+:text+, +:api_metrics+)
|
|
107
|
+
# @return [void]
|
|
108
|
+
def after_call(_result)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def default_client
|
|
112
|
+
LLM::Client.new(
|
|
113
|
+
model: Anima::Settings.fast_model,
|
|
114
|
+
max_tokens: Anima::Settings.mneme_max_tokens,
|
|
115
|
+
logger: Mneme.logger
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def log = Mneme.logger
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/mneme/l2_runner.rb
CHANGED
|
@@ -18,39 +18,34 @@ module Mneme
|
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
SYSTEM_PROMPT = <<~PROMPT
|
|
21
|
-
You are Mneme, the memory
|
|
22
|
-
Your job is to compress multiple conversation summaries into a single
|
|
23
|
-
higher-level summary.
|
|
21
|
+
You are Mneme, the muse of memory. When enough of your own Level 1 snapshots accumulate, you fold them into a single Level 2 summary — a memory of memories — so the long arc of Aoide's work stays within reach without carrying every detail.
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
Act only through tool calls. Never output text — your contribution is the summary you leave behind.
|
|
26
24
|
|
|
27
25
|
──────────────────────────────
|
|
28
26
|
WHAT YOU SEE
|
|
29
27
|
──────────────────────────────
|
|
30
|
-
Several Level 1 snapshots
|
|
31
|
-
Each captures key decisions, goals discussed, and important context
|
|
32
|
-
from a portion of the conversation history.
|
|
28
|
+
Several Level 1 snapshots in chronological order. Each captures the decisions, goal progress, and context from a slice of Aoide's history.
|
|
33
29
|
|
|
34
30
|
──────────────────────────────
|
|
35
|
-
|
|
31
|
+
HOW TO REMEMBER
|
|
36
32
|
──────────────────────────────
|
|
37
|
-
Compress the
|
|
38
|
-
essential arc across all of them. If the snapshots contain meaningful
|
|
39
|
-
content, call save_snapshot. If they are purely mechanical, call
|
|
40
|
-
everything_ok.
|
|
33
|
+
Compress the slice into ONE Level 2 summary that captures the arc across all of them. Call save_snapshot when there's meaningful content; call everything_ok when the slice is purely mechanical.
|
|
41
34
|
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
A Level 2 summary is carried for longer than a Level 1, so the tax on Aoide's viewport is higher still. Every redundant detail you preserve costs her a word she can't spend on the present.
|
|
36
|
+
|
|
37
|
+
Keep:
|
|
38
|
+
- Key decisions and the reasoning behind them
|
|
44
39
|
- Goal progress across the time span
|
|
45
40
|
- Important context shifts or pivots
|
|
46
|
-
- Relationships and patterns
|
|
41
|
+
- Relationships and patterns that span multiple snapshots
|
|
47
42
|
|
|
48
43
|
Drop:
|
|
49
|
-
-
|
|
50
|
-
- Mechanical execution
|
|
51
|
-
- Interim decisions that were superseded
|
|
44
|
+
- Details repeated across snapshots
|
|
45
|
+
- Mechanical execution steps
|
|
46
|
+
- Interim decisions that were superseded later
|
|
52
47
|
|
|
53
|
-
|
|
48
|
+
Finish with exactly one tool call: save_snapshot or everything_ok.
|
|
54
49
|
PROMPT
|
|
55
50
|
|
|
56
51
|
# @param session [Session] the main session whose L1 snapshots to compress
|
|
@@ -87,7 +82,6 @@ module Mneme
|
|
|
87
82
|
result = @client.chat_with_tools(
|
|
88
83
|
messages,
|
|
89
84
|
registry: registry,
|
|
90
|
-
session_id: nil,
|
|
91
85
|
system: SYSTEM_PROMPT
|
|
92
86
|
)
|
|
93
87
|
|
|
@@ -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
|