anima-core 1.0.2 → 1.1.1
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/.gitattributes +1 -0
- data/.reek.yml +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +93 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +4 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +21 -10
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reverse_markdown"
|
|
4
|
+
require "toon"
|
|
5
|
+
|
|
6
|
+
# Transforms {Tools::WebGet} responses for LLM consumption by detecting
|
|
7
|
+
# the Content-Type header and applying format-specific conversion.
|
|
8
|
+
#
|
|
9
|
+
# Content-Type maps to a method name via simple string normalization:
|
|
10
|
+
# "application/json" → {#application_json}
|
|
11
|
+
# "text/html" → {#text_html}
|
|
12
|
+
# "text/plain" → method_missing → passthrough
|
|
13
|
+
#
|
|
14
|
+
# Adding a new format = adding one method. Unknown types fall through
|
|
15
|
+
# {#method_missing} and pass through unchanged.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# decorator = WebGetToolDecorator.new
|
|
19
|
+
# decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
|
|
20
|
+
# #=> "[Converted: HTML → Markdown]\n\n# Hi"
|
|
21
|
+
class WebGetToolDecorator < ToolDecorator
|
|
22
|
+
# HTML elements that carry no useful content for an LLM.
|
|
23
|
+
NOISE_TAGS = %w[script style nav footer aside form noscript iframe
|
|
24
|
+
svg header menu menuitem].freeze
|
|
25
|
+
|
|
26
|
+
# @param result [Hash] `{body: String, content_type: String}`
|
|
27
|
+
# @return [String] LLM-optimized content with conversion metadata tag
|
|
28
|
+
def call(result)
|
|
29
|
+
return result.to_s unless result.is_a?(Hash) && result.key?(:body)
|
|
30
|
+
|
|
31
|
+
body = result[:body].to_s
|
|
32
|
+
content_type = result[:content_type] || "text/plain"
|
|
33
|
+
decorated = decorate(body, content_type: content_type)
|
|
34
|
+
|
|
35
|
+
assemble(**decorated)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Dispatches to the format-specific method derived from Content-Type.
|
|
39
|
+
#
|
|
40
|
+
# @param body [String] raw response body
|
|
41
|
+
# @param content_type [String] HTTP Content-Type header value
|
|
42
|
+
# @return [Hash] `{text: String, meta: String|nil}`
|
|
43
|
+
def decorate(body, content_type:)
|
|
44
|
+
method_name = content_type.split(";").first.strip.tr("/", "_").tr("-", "_")
|
|
45
|
+
public_send(method_name, body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Compresses JSON using TOON (Token-Optimized Object Notation) for
|
|
49
|
+
# ~40% token savings on typical JSON arrays.
|
|
50
|
+
#
|
|
51
|
+
# @param body [String] JSON response body
|
|
52
|
+
# @return [Hash] `{text: String, meta: String}`
|
|
53
|
+
def application_json(body)
|
|
54
|
+
parsed = JSON.parse(body)
|
|
55
|
+
{text: Toon.encode(parsed), meta: "[Converted: JSON → TOON]"}
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
{text: body, meta: nil}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Strips noise elements (scripts, styles, nav, ads) and converts
|
|
61
|
+
# semantic HTML to Markdown for clean LLM consumption.
|
|
62
|
+
#
|
|
63
|
+
# @param body [String] HTML response body
|
|
64
|
+
# @return [Hash] `{text: String, meta: String}`
|
|
65
|
+
def text_html(body)
|
|
66
|
+
markdown = html_to_markdown(body)
|
|
67
|
+
{text: markdown, meta: "[Converted: HTML → Markdown]"}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Passthrough for unregistered content types.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash] `{text: String, meta: nil}`
|
|
73
|
+
def method_missing(_method_name, body, *)
|
|
74
|
+
{text: body, meta: nil}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def respond_to_missing?(*, **)
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Strips noise HTML elements then converts to Markdown.
|
|
84
|
+
#
|
|
85
|
+
# @param html [String] raw HTML
|
|
86
|
+
# @return [String] clean Markdown
|
|
87
|
+
def html_to_markdown(html)
|
|
88
|
+
doc = Nokogiri::HTML(html)
|
|
89
|
+
doc.css(NOISE_TAGS.join(", ")).remove
|
|
90
|
+
clean_html = doc.at("body")&.inner_html || doc.to_html
|
|
91
|
+
markdown = ReverseMarkdown.convert(clean_html, unknown_tags: :bypass, github_flavored: true)
|
|
92
|
+
collapse_whitespace(markdown)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Collapses excessive blank lines down to a single blank line.
|
|
96
|
+
#
|
|
97
|
+
# @param text [String]
|
|
98
|
+
# @return [String]
|
|
99
|
+
def collapse_whitespace(text)
|
|
100
|
+
text.gsub(/\n{3,}/, "\n\n").strip
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -3,19 +3,30 @@
|
|
|
3
3
|
# Executes an LLM agent loop as a background job with retry logic
|
|
4
4
|
# for transient failures (network errors, rate limits, server errors).
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# to any subscriber (TUI, WebSocket clients). All retry and failure
|
|
8
|
-
# notifications are emitted as {Events::SystemMessage} to avoid polluting
|
|
9
|
-
# the LLM context window.
|
|
6
|
+
# Supports two modes:
|
|
10
7
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
8
|
+
# **Immediate Persist (event_id provided):** The user event was already
|
|
9
|
+
# persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
|
|
10
|
+
# The job verifies LLM delivery — if the first API call fails, the
|
|
11
|
+
# event is deleted and a {Events::BounceBack} is emitted so clients
|
|
12
|
+
# can restore the text to the input field.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
14
|
+
# **Standard (no event_id):** Processes already-persisted events (e.g.
|
|
15
|
+
# after pending message promotion). Uses ActiveJob retry/discard for
|
|
16
|
+
# error handling.
|
|
17
|
+
#
|
|
18
|
+
# @example Immediate Persist — event already saved by SessionChannel
|
|
19
|
+
# AgentRequestJob.perform_later(session.id, event_id: 42)
|
|
20
|
+
#
|
|
21
|
+
# @example Standard — pending message processing
|
|
15
22
|
# AgentRequestJob.perform_later(session.id)
|
|
16
23
|
class AgentRequestJob < ApplicationJob
|
|
17
24
|
queue_as :default
|
|
18
25
|
|
|
26
|
+
# ActionCable action signaling clients to prompt for an API token.
|
|
27
|
+
AUTH_REQUIRED_ACTION = "authentication_required"
|
|
28
|
+
|
|
29
|
+
# Standard path only — immediate persist handles its own errors.
|
|
19
30
|
retry_on Providers::Anthropic::TransientError,
|
|
20
31
|
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
21
32
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
@@ -27,40 +38,41 @@ class AgentRequestJob < ApplicationJob
|
|
|
27
38
|
discard_on ActiveRecord::RecordNotFound
|
|
28
39
|
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
29
40
|
session_id = job.arguments.first
|
|
30
|
-
# Persistent system message for the event log
|
|
31
41
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
32
42
|
content: "Authentication failed: #{error.message}",
|
|
33
43
|
session_id: session_id
|
|
34
44
|
))
|
|
35
|
-
# Transient signal to trigger TUI token setup popup (not persisted)
|
|
36
45
|
ActionCable.server.broadcast(
|
|
37
46
|
"session_#{session_id}",
|
|
38
|
-
{"action" =>
|
|
47
|
+
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
39
48
|
)
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
# @param session_id [Integer] ID of the session to process
|
|
43
|
-
|
|
52
|
+
# @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
|
|
53
|
+
def perform(session_id, event_id: nil)
|
|
44
54
|
session = Session.find(session_id)
|
|
45
55
|
|
|
46
|
-
# Atomic: only one job processes a session at a time.
|
|
47
|
-
# is already running, this one exits — the running job will pick up
|
|
48
|
-
# any pending messages after its current loop completes.
|
|
56
|
+
# Atomic: only one job processes a session at a time.
|
|
49
57
|
return unless claim_processing(session_id)
|
|
50
58
|
|
|
51
|
-
# Run analytical brain BEFORE the main agent on user messages so
|
|
52
|
-
# activated skills are available for the current response.
|
|
53
59
|
run_analytical_brain_blocking(session)
|
|
54
60
|
|
|
55
61
|
agent_loop = AgentLoop.new(session: session)
|
|
56
|
-
|
|
62
|
+
|
|
63
|
+
if event_id
|
|
64
|
+
deliver_persisted_event(session, event_id, agent_loop)
|
|
65
|
+
else
|
|
57
66
|
agent_loop.run
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Process any pending messages queued while we were busy.
|
|
70
|
+
loop do
|
|
58
71
|
promoted = session.promote_pending_messages!
|
|
59
72
|
break if promoted == 0
|
|
73
|
+
agent_loop.run
|
|
60
74
|
end
|
|
61
75
|
|
|
62
|
-
# Non-blocking analytical brain run after agent completes —
|
|
63
|
-
# handles post-response updates (renaming, skill changes).
|
|
64
76
|
session.schedule_analytical_brain!
|
|
65
77
|
ensure
|
|
66
78
|
release_processing(session_id)
|
|
@@ -70,6 +82,61 @@ class AgentRequestJob < ApplicationJob
|
|
|
70
82
|
|
|
71
83
|
private
|
|
72
84
|
|
|
85
|
+
# Verifies LLM delivery for a pre-persisted user event.
|
|
86
|
+
#
|
|
87
|
+
# The event was already created and broadcast by the caller, so
|
|
88
|
+
# the user sees their message immediately. This method makes the
|
|
89
|
+
# first LLM API call — if it fails, the event is deleted and a
|
|
90
|
+
# {Events::BounceBack} notifies clients to remove the phantom
|
|
91
|
+
# message and restore the text to the input field. For
|
|
92
|
+
# {Providers::Anthropic::AuthenticationError}, an additional
|
|
93
|
+
# +authentication_required+ broadcast prompts the client to show
|
|
94
|
+
# the token entry dialog.
|
|
95
|
+
#
|
|
96
|
+
# Unlike the standard path (which uses +retry_on+ / +discard_on+),
|
|
97
|
+
# all errors here are caught and swallowed after emitting a
|
|
98
|
+
# BounceBack — the job completes normally so ActiveJob does not
|
|
99
|
+
# retry a message the user will re-send manually.
|
|
100
|
+
#
|
|
101
|
+
# After successful delivery, continues the agent loop (tool
|
|
102
|
+
# execution, subsequent API calls).
|
|
103
|
+
#
|
|
104
|
+
# @param session [Session] the conversation session
|
|
105
|
+
# @param event_id [Integer] database ID of the pre-persisted user event
|
|
106
|
+
# @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
|
|
107
|
+
def deliver_persisted_event(session, event_id, agent_loop)
|
|
108
|
+
event = Event.find_by(id: event_id, session_id: session.id)
|
|
109
|
+
# Event may have been deleted between SessionChannel#speak and job
|
|
110
|
+
# execution (e.g. user recalled the message). Exit silently — there
|
|
111
|
+
# is nothing to deliver or bounce back.
|
|
112
|
+
return unless event
|
|
113
|
+
|
|
114
|
+
content = event.payload["content"]
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
agent_loop.deliver!
|
|
118
|
+
rescue => error
|
|
119
|
+
event.destroy!
|
|
120
|
+
Events::Bus.emit(Events::BounceBack.new(
|
|
121
|
+
content: content,
|
|
122
|
+
error: error.message,
|
|
123
|
+
session_id: session.id,
|
|
124
|
+
event_id: event_id
|
|
125
|
+
))
|
|
126
|
+
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
agent_loop.run
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def broadcast_auth_required(session_id, error)
|
|
134
|
+
ActionCable.server.broadcast(
|
|
135
|
+
"session_#{session_id}",
|
|
136
|
+
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
73
140
|
# Runs the analytical brain synchronously before the main agent loop.
|
|
74
141
|
# Respects the blocking_on_user_message setting and session guards
|
|
75
142
|
# (skips sub-agents and sessions with too few messages).
|
|
@@ -88,18 +155,21 @@ class AgentRequestJob < ApplicationJob
|
|
|
88
155
|
|
|
89
156
|
# Sets the session's processing flag atomically. Returns true if this
|
|
90
157
|
# job claimed the lock, false if another job already holds it.
|
|
158
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
91
159
|
def claim_processing(session_id)
|
|
92
|
-
Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
160
|
+
claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
161
|
+
Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
|
|
162
|
+
claimed
|
|
93
163
|
end
|
|
94
164
|
|
|
95
165
|
# Clears the processing flag so the session can accept new jobs.
|
|
166
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
96
167
|
def release_processing(session_id)
|
|
97
168
|
Session.where(id: session_id).update_all(processing: false)
|
|
169
|
+
Session.find_by(id: session_id)&.broadcast_children_update_to_parent
|
|
98
170
|
end
|
|
99
171
|
|
|
100
|
-
# Safety-net clearing of the interrupt flag.
|
|
101
|
-
# {LLM::Client#clear_interrupt!} after handling the interrupt; this ensures
|
|
102
|
-
# the flag is reset even if the job crashes before reaching that code path.
|
|
172
|
+
# Safety-net clearing of the interrupt flag.
|
|
103
173
|
def clear_interrupt(session_id)
|
|
104
174
|
Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
|
|
105
175
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs the Mneme memory department — a phantom LLM loop that observes
|
|
4
|
+
# the main session and creates summaries of conversation context before
|
|
5
|
+
# it evicts from the viewport.
|
|
6
|
+
#
|
|
7
|
+
# Triggered when the terminal event ({Session#mneme_boundary_event_id})
|
|
8
|
+
# leaves the viewport, indicating that meaningful context is about to
|
|
9
|
+
# be lost.
|
|
10
|
+
#
|
|
11
|
+
# After L1 snapshot creation, checks whether enough uncovered L1 snapshots
|
|
12
|
+
# have accumulated to trigger Level 2 compression.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# MnemeJob.perform_later(session.id)
|
|
16
|
+
class MnemeJob < ApplicationJob
|
|
17
|
+
queue_as :default
|
|
18
|
+
|
|
19
|
+
retry_on Providers::Anthropic::TransientError,
|
|
20
|
+
wait: :polynomially_longer, attempts: 3
|
|
21
|
+
|
|
22
|
+
discard_on ActiveRecord::RecordNotFound
|
|
23
|
+
discard_on Providers::Anthropic::AuthenticationError
|
|
24
|
+
|
|
25
|
+
# @param session_id [Integer] the main Session to summarize
|
|
26
|
+
def perform(session_id)
|
|
27
|
+
session = Session.find(session_id)
|
|
28
|
+
log.info("job started for session=#{session_id}")
|
|
29
|
+
Mneme::Runner.new(session).call
|
|
30
|
+
check_l2_compression(session)
|
|
31
|
+
rescue => error
|
|
32
|
+
log.error("FAILED session=#{session_id}: #{error.class}: #{error.message}")
|
|
33
|
+
raise
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Triggers L2 compression when enough uncovered L1 snapshots accumulate.
|
|
39
|
+
# Runs inline (same job) since L2 compression is a small, fast LLM call.
|
|
40
|
+
def check_l2_compression(session)
|
|
41
|
+
uncovered = session.snapshots.for_level(1).not_covered_by_l2.count
|
|
42
|
+
threshold = Anima::Settings.mneme_l2_snapshot_threshold
|
|
43
|
+
|
|
44
|
+
if uncovered >= threshold
|
|
45
|
+
log.info("session=#{session.id} — #{uncovered} uncovered L1 snapshots, triggering L2 compression")
|
|
46
|
+
Mneme::L2Runner.new(session).call
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log = Mneme.logger
|
|
51
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Runs passive recall after goal updates — searches event history for
|
|
4
|
+
# context relevant to active goals and caches results on the session
|
|
5
|
+
# for viewport injection.
|
|
6
|
+
#
|
|
7
|
+
# Idempotent: multiple enqueues for the same session safely overwrite
|
|
8
|
+
# each other's results — last one wins.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# PassiveRecallJob.perform_later(session.id)
|
|
12
|
+
class PassiveRecallJob < ApplicationJob
|
|
13
|
+
queue_as :default
|
|
14
|
+
|
|
15
|
+
discard_on ActiveRecord::RecordNotFound
|
|
16
|
+
|
|
17
|
+
# @param session_id [Integer]
|
|
18
|
+
def perform(session_id)
|
|
19
|
+
session = Session.find(session_id)
|
|
20
|
+
results = Mneme::PassiveRecall.new(session).call
|
|
21
|
+
|
|
22
|
+
if results.any?
|
|
23
|
+
session.update_column(:recalled_event_ids, results.map(&:event_id))
|
|
24
|
+
Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
|
|
25
|
+
elsif session.recalled_event_ids.present?
|
|
26
|
+
session.update_column(:recalled_event_ids, [])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -76,6 +76,10 @@ module Event::Broadcasting
|
|
|
76
76
|
evicted_ids = session.recalculate_viewport!
|
|
77
77
|
broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
|
|
78
78
|
|
|
79
|
+
# The nil? branch fires on every broadcast until boundary initializes, but
|
|
80
|
+
# schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
|
|
81
|
+
session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
|
|
82
|
+
|
|
79
83
|
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
|
|
80
84
|
end
|
|
81
85
|
end
|
data/app/models/event.rb
CHANGED
|
@@ -21,6 +21,8 @@ class Event < ApplicationRecord
|
|
|
21
21
|
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
22
22
|
LLM_TYPES = %w[user_message agent_message].freeze
|
|
23
23
|
CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
24
|
+
CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
|
|
25
|
+
THINK_TOOL = "think"
|
|
24
26
|
PENDING_STATUS = "pending"
|
|
25
27
|
|
|
26
28
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
@@ -29,6 +31,7 @@ class Event < ApplicationRecord
|
|
|
29
31
|
BYTES_PER_TOKEN = 4
|
|
30
32
|
|
|
31
33
|
belongs_to :session
|
|
34
|
+
has_many :pinned_events, dependent: :destroy
|
|
32
35
|
|
|
33
36
|
validates :event_type, presence: true, inclusion: {in: TYPES}
|
|
34
37
|
validates :payload, presence: true
|
|
@@ -78,6 +81,13 @@ class Event < ApplicationRecord
|
|
|
78
81
|
status == PENDING_STATUS
|
|
79
82
|
end
|
|
80
83
|
|
|
84
|
+
# @return [Boolean] true if this is a conversation event (user/agent/system message)
|
|
85
|
+
# or a think tool_call — the events Mneme treats as "conversation" for boundary tracking
|
|
86
|
+
def conversation_or_think?
|
|
87
|
+
event_type.in?(CONVERSATION_TYPES) ||
|
|
88
|
+
(event_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
|
|
89
|
+
end
|
|
90
|
+
|
|
81
91
|
# Heuristic token estimate: ~4 bytes per token for English prose.
|
|
82
92
|
# Tool events are estimated from the full payload JSON since tool_input
|
|
83
93
|
# and tool metadata contribute to token count. Messages use content only.
|
data/app/models/goal.rb
CHANGED
|
@@ -13,6 +13,8 @@ class Goal < ApplicationRecord
|
|
|
13
13
|
belongs_to :session
|
|
14
14
|
belongs_to :parent_goal, class_name: "Goal", optional: true
|
|
15
15
|
has_many :sub_goals, -> { order(:created_at) }, class_name: "Goal", foreign_key: :parent_goal_id, dependent: :destroy
|
|
16
|
+
has_many :goal_pinned_events, dependent: :destroy
|
|
17
|
+
has_many :pinned_events, through: :goal_pinned_events
|
|
16
18
|
|
|
17
19
|
validates :description, presence: true
|
|
18
20
|
validates :status, inclusion: {in: STATUSES}
|
|
@@ -24,10 +26,14 @@ class Goal < ApplicationRecord
|
|
|
24
26
|
scope :root, -> { where(parent_goal_id: nil) }
|
|
25
27
|
|
|
26
28
|
after_commit :broadcast_goals_update
|
|
29
|
+
after_commit :schedule_passive_recall, on: [:create, :update]
|
|
27
30
|
|
|
28
31
|
# @return [Boolean] true if this goal has been completed
|
|
29
32
|
def completed? = status == "completed"
|
|
30
33
|
|
|
34
|
+
# @return [Boolean] true if this goal is still active
|
|
35
|
+
def active? = status == "active"
|
|
36
|
+
|
|
31
37
|
# @return [Boolean] true if this is a root goal (no parent)
|
|
32
38
|
def root? = !parent_goal_id
|
|
33
39
|
|
|
@@ -46,6 +52,17 @@ class Goal < ApplicationRecord
|
|
|
46
52
|
sub_goals.active.update_all(status: "completed", completed_at: now, updated_at: now)
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Releases pinned events that have no remaining active Goal references
|
|
56
|
+
# anywhere in the session. Called after goal (and cascade) completion —
|
|
57
|
+
# the orphaned scope checks all Goals, so pins shared with other active
|
|
58
|
+
# Goals survive automatically via reference counting.
|
|
59
|
+
#
|
|
60
|
+
# @return [Integer] number of released pins
|
|
61
|
+
def release_orphaned_pins!
|
|
62
|
+
orphaned = session.pinned_events.orphaned
|
|
63
|
+
orphaned.destroy_all.size
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Serializes this goal for ActionCable broadcast and TUI display.
|
|
50
67
|
# Includes nested sub-goals for root goals.
|
|
51
68
|
#
|
|
@@ -76,6 +93,16 @@ class Goal < ApplicationRecord
|
|
|
76
93
|
errors.add(:parent_goal, "cannot nest deeper than two levels")
|
|
77
94
|
end
|
|
78
95
|
|
|
96
|
+
# Triggers passive recall when goals change so relevant memories
|
|
97
|
+
# surface in the viewport automatically.
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def schedule_passive_recall
|
|
101
|
+
return if session.sub_agent?
|
|
102
|
+
|
|
103
|
+
PassiveRecallJob.perform_later(session_id)
|
|
104
|
+
end
|
|
105
|
+
|
|
79
106
|
# Broadcasts goal changes to all clients subscribed to this session.
|
|
80
107
|
# Mirrors the Session#broadcast_active_skills_update pattern so the
|
|
81
108
|
# TUI info panel updates reactively.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Join record linking a {Goal} to a {PinnedEvent}. Many-to-many: one event
|
|
4
|
+
# can be pinned to multiple Goals, and one Goal can reference multiple pins.
|
|
5
|
+
# When the last Goal referencing a pin completes, the pin is released.
|
|
6
|
+
class GoalPinnedEvent < ApplicationRecord
|
|
7
|
+
belongs_to :goal
|
|
8
|
+
belongs_to :pinned_event
|
|
9
|
+
|
|
10
|
+
validates :pinned_event_id, uniqueness: {scope: :goal_id}
|
|
11
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A conversation event pinned to one or more Goals by Mneme to protect it
|
|
4
|
+
# from viewport eviction. Pinned events appear in the Goals section of
|
|
5
|
+
# the viewport, giving the main agent access to critical context that
|
|
6
|
+
# would otherwise scroll out of the sliding window.
|
|
7
|
+
#
|
|
8
|
+
# Pinning is goal-scoped: when all Goals referencing a pin complete,
|
|
9
|
+
# the pin is automatically released (reference-counted cleanup).
|
|
10
|
+
#
|
|
11
|
+
# @!attribute display_text
|
|
12
|
+
# @return [String] truncated event content (~200 chars) shown in the Goals section
|
|
13
|
+
class PinnedEvent < ApplicationRecord
|
|
14
|
+
# Display text limit — enough to recognize content, cheap on tokens.
|
|
15
|
+
MAX_DISPLAY_TEXT_LENGTH = 200
|
|
16
|
+
|
|
17
|
+
belongs_to :event
|
|
18
|
+
|
|
19
|
+
has_many :goal_pinned_events, dependent: :destroy
|
|
20
|
+
has_many :goals, through: :goal_pinned_events
|
|
21
|
+
|
|
22
|
+
validates :display_text, presence: true, length: {maximum: MAX_DISPLAY_TEXT_LENGTH}
|
|
23
|
+
validates :event_id, uniqueness: true
|
|
24
|
+
|
|
25
|
+
# Pinned events with no remaining active goals — safe to release.
|
|
26
|
+
#
|
|
27
|
+
# @return [ActiveRecord::Relation]
|
|
28
|
+
scope :orphaned, -> {
|
|
29
|
+
where.not(
|
|
30
|
+
"EXISTS (SELECT 1 FROM goal_pinned_events gpe " \
|
|
31
|
+
"JOIN goals ON goals.id = gpe.goal_id " \
|
|
32
|
+
"WHERE gpe.pinned_event_id = pinned_events.id " \
|
|
33
|
+
"AND goals.status = 'active')"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# @return [Integer] token cost estimate for viewport budget accounting
|
|
38
|
+
def token_cost
|
|
39
|
+
[(display_text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
40
|
+
end
|
|
41
|
+
end
|