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
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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mneme
|
|
4
|
+
module Tools
|
|
5
|
+
# Surfaces a past message into Aoide's next turn as a `from_mneme`
|
|
6
|
+
# phantom tool pair. Called by Mneme's recall loop when a search hit
|
|
7
|
+
# or a viewed message clears her relevance bar.
|
|
8
|
+
#
|
|
9
|
+
# The persisted {PendingMessage} carries the original +message_id+ in
|
|
10
|
+
# its +source_name+ (and through promotion ends up inside
|
|
11
|
+
# +tool_input.message_id+ of the phantom pair), so the same memory
|
|
12
|
+
# isn't re-surfaced on later cycles — Mneme::Search already excludes
|
|
13
|
+
# Aoide's viewport, and once a recall promotes it lives there.
|
|
14
|
+
#
|
|
15
|
+
# The muse explains +why+ she's surfacing this memory. The reason is
|
|
16
|
+
# logged but not shown to Aoide — keeping the surfaced content itself
|
|
17
|
+
# clean of meta-commentary.
|
|
18
|
+
class SurfaceMemory < ::Tools::Base
|
|
19
|
+
def self.tool_name = "surface_memory"
|
|
20
|
+
|
|
21
|
+
def self.description = "Surface a memory into Aoide's next turn. Use when a specific past message is genuinely useful for what she's working on now. Pass the message_id and a short reason — one sentence explaining why she needs this *now*."
|
|
22
|
+
|
|
23
|
+
def self.input_schema
|
|
24
|
+
{
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
message_id: {type: "integer"},
|
|
28
|
+
why: {type: "string", description: "One-sentence justification — kept for logs, not shown to Aoide."}
|
|
29
|
+
},
|
|
30
|
+
required: %w[message_id why]
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param main_session [Session] the session receiving the recall
|
|
35
|
+
def initialize(main_session:, **)
|
|
36
|
+
@main_session = main_session
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def execute(input)
|
|
40
|
+
message_id = input["message_id"].to_i
|
|
41
|
+
why = input["why"].to_s.strip
|
|
42
|
+
|
|
43
|
+
message = Message.find_by(id: message_id)
|
|
44
|
+
return {error: "Message #{message_id} not found"} unless message
|
|
45
|
+
return {error: "Reason cannot be blank"} if why.empty?
|
|
46
|
+
|
|
47
|
+
content = render_snippet(message)
|
|
48
|
+
|
|
49
|
+
@main_session.pending_messages.create!(
|
|
50
|
+
content: content,
|
|
51
|
+
source_type: "recall",
|
|
52
|
+
source_name: message_id.to_s,
|
|
53
|
+
message_type: "from_mneme"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Mneme.logger.info("session=#{@main_session.id} — surfaced message #{message_id}: #{why}")
|
|
57
|
+
|
|
58
|
+
"Surfaced message #{message_id}."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Formats the message as the text Aoide will read when the phantom
|
|
64
|
+
# pair promotes. Headed with origin metadata, bounded by the recall
|
|
65
|
+
# snippet-token budget so long messages don't blow out her viewport.
|
|
66
|
+
#
|
|
67
|
+
# @param message [Message]
|
|
68
|
+
# @return [String]
|
|
69
|
+
def render_snippet(message)
|
|
70
|
+
origin = message.session&.name.presence || "session ##{message.session_id}"
|
|
71
|
+
raw = extract_content(message)
|
|
72
|
+
max_chars = Anima::Settings.recall_max_snippet_tokens * TokenEstimation::BYTES_PER_TOKEN
|
|
73
|
+
"message #{message.id} (#{origin}): #{raw.truncate(max_chars)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_content(message)
|
|
77
|
+
payload = message.payload
|
|
78
|
+
case message.message_type
|
|
79
|
+
when "user_message", "agent_message", "system_message"
|
|
80
|
+
payload["content"].to_s
|
|
81
|
+
when "tool_call"
|
|
82
|
+
payload.dig("tool_input", "thoughts").to_s
|
|
83
|
+
else
|
|
84
|
+
payload["content"].to_s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/mneme.rb
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Mneme — the memory
|
|
4
|
-
# summaries before context is lost.
|
|
3
|
+
# Mneme — the muse of memory. Watches for viewport eviction and creates
|
|
4
|
+
# summaries before context is lost. One of the Three Muses: she remembers
|
|
5
|
+
# while Melete prepares and Aoide performs.
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# main session, creates snapshots, but leaves no trace of its own reasoning.
|
|
7
|
+
# Operates as a phantom LLM loop: observes the main session, creates
|
|
8
|
+
# snapshots, but leaves no trace of her own reasoning.
|
|
9
9
|
module Mneme
|
|
10
|
+
# Estimated token overhead for a synthetic +tool_use+/+tool_result+
|
|
11
|
+
# pair — the wrapper JSON that phantom promotions emit around their
|
|
12
|
+
# content (tool name, input hash, ids, framing). Added to the content's
|
|
13
|
+
# token estimate when sizing phantom pairs in the viewport.
|
|
14
|
+
TOOL_PAIR_OVERHEAD_TOKENS = 50
|
|
15
|
+
|
|
10
16
|
# Dev-only logger that writes to log/mneme.log.
|
|
11
17
|
# In non-development environments returns a null logger so
|
|
12
18
|
# call sites don't need conditionals.
|