anima-core 1.0.2 → 1.1.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/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -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 +90 -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 +18 -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 +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- 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 +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- 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 +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- 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 +15 -6
- 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 +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- 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,28 @@
|
|
|
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
|
+
# **Bounce Back (content provided):** Persists the user event and verifies
|
|
9
|
+
# LLM delivery inside a single transaction. If the first API call fails,
|
|
10
|
+
# the transaction rolls back (event never existed) and a {Events::BounceBack}
|
|
11
|
+
# is emitted so clients can restore the text to the input field.
|
|
13
12
|
#
|
|
14
|
-
#
|
|
13
|
+
# **Standard (no content):** Processes already-persisted events (e.g. after
|
|
14
|
+
# pending message promotion). Uses ActiveJob retry/discard for error handling.
|
|
15
|
+
#
|
|
16
|
+
# @example Bounce Back — event-driven via AgentDispatcher
|
|
17
|
+
# AgentRequestJob.perform_later(session.id, content: "hello")
|
|
18
|
+
#
|
|
19
|
+
# @example Standard — pending message processing
|
|
15
20
|
# AgentRequestJob.perform_later(session.id)
|
|
16
21
|
class AgentRequestJob < ApplicationJob
|
|
17
22
|
queue_as :default
|
|
18
23
|
|
|
24
|
+
# ActionCable action signaling clients to prompt for an API token.
|
|
25
|
+
AUTH_REQUIRED_ACTION = "authentication_required"
|
|
26
|
+
|
|
27
|
+
# Standard path only — bounce back handles its own errors.
|
|
19
28
|
retry_on Providers::Anthropic::TransientError,
|
|
20
29
|
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
21
30
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
@@ -27,40 +36,41 @@ class AgentRequestJob < ApplicationJob
|
|
|
27
36
|
discard_on ActiveRecord::RecordNotFound
|
|
28
37
|
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
29
38
|
session_id = job.arguments.first
|
|
30
|
-
# Persistent system message for the event log
|
|
31
39
|
Events::Bus.emit(Events::SystemMessage.new(
|
|
32
40
|
content: "Authentication failed: #{error.message}",
|
|
33
41
|
session_id: session_id
|
|
34
42
|
))
|
|
35
|
-
# Transient signal to trigger TUI token setup popup (not persisted)
|
|
36
43
|
ActionCable.server.broadcast(
|
|
37
44
|
"session_#{session_id}",
|
|
38
|
-
{"action" =>
|
|
45
|
+
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
39
46
|
)
|
|
40
47
|
end
|
|
41
48
|
|
|
42
49
|
# @param session_id [Integer] ID of the session to process
|
|
43
|
-
|
|
50
|
+
# @param content [String, nil] user message text (triggers Bounce Back when present)
|
|
51
|
+
def perform(session_id, content: nil)
|
|
44
52
|
session = Session.find(session_id)
|
|
45
53
|
|
|
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.
|
|
54
|
+
# Atomic: only one job processes a session at a time.
|
|
49
55
|
return unless claim_processing(session_id)
|
|
50
56
|
|
|
51
|
-
# Run analytical brain BEFORE the main agent on user messages so
|
|
52
|
-
# activated skills are available for the current response.
|
|
53
57
|
run_analytical_brain_blocking(session)
|
|
54
58
|
|
|
55
59
|
agent_loop = AgentLoop.new(session: session)
|
|
56
|
-
|
|
60
|
+
|
|
61
|
+
if content
|
|
62
|
+
deliver_with_bounce_back(session, content, agent_loop)
|
|
63
|
+
else
|
|
57
64
|
agent_loop.run
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Process any pending messages queued while we were busy.
|
|
68
|
+
loop do
|
|
58
69
|
promoted = session.promote_pending_messages!
|
|
59
70
|
break if promoted == 0
|
|
71
|
+
agent_loop.run
|
|
60
72
|
end
|
|
61
73
|
|
|
62
|
-
# Non-blocking analytical brain run after agent completes —
|
|
63
|
-
# handles post-response updates (renaming, skill changes).
|
|
64
74
|
session.schedule_analytical_brain!
|
|
65
75
|
ensure
|
|
66
76
|
release_processing(session_id)
|
|
@@ -70,6 +80,60 @@ class AgentRequestJob < ApplicationJob
|
|
|
70
80
|
|
|
71
81
|
private
|
|
72
82
|
|
|
83
|
+
# Persists the user event and verifies LLM delivery atomically.
|
|
84
|
+
#
|
|
85
|
+
# Inside a transaction: creates the event record, broadcasts it for
|
|
86
|
+
# optimistic UI, and makes the first LLM API call. If the call fails,
|
|
87
|
+
# a {Events::BounceBack} is emitted and the exception re-raised to
|
|
88
|
+
# trigger rollback — the event never existed in the database.
|
|
89
|
+
#
|
|
90
|
+
# After commit: continues the agent loop (tool execution, subsequent
|
|
91
|
+
# API calls) outside the transaction so tool events broadcast in
|
|
92
|
+
# real time.
|
|
93
|
+
#
|
|
94
|
+
# @param session [Session] the conversation session
|
|
95
|
+
# @param content [String] user message text
|
|
96
|
+
# @param agent_loop [AgentLoop] agent loop instance (reused after commit)
|
|
97
|
+
def deliver_with_bounce_back(session, content, agent_loop)
|
|
98
|
+
event_id = nil
|
|
99
|
+
|
|
100
|
+
ActiveRecord::Base.transaction do
|
|
101
|
+
event = persist_user_event(session, content)
|
|
102
|
+
event_id = event.id
|
|
103
|
+
event.broadcast_now!
|
|
104
|
+
|
|
105
|
+
agent_loop.deliver!
|
|
106
|
+
rescue => error
|
|
107
|
+
Events::Bus.emit(Events::BounceBack.new(
|
|
108
|
+
content: content,
|
|
109
|
+
error: error.message,
|
|
110
|
+
session_id: session.id,
|
|
111
|
+
event_id: event_id
|
|
112
|
+
))
|
|
113
|
+
raise
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Transaction committed — first call succeeded.
|
|
117
|
+
# Continue processing (tool execution, etc.) outside the transaction.
|
|
118
|
+
agent_loop.run
|
|
119
|
+
rescue => error
|
|
120
|
+
# Bounce already emitted inside the transaction rescue.
|
|
121
|
+
# Also trigger auth popup for authentication errors.
|
|
122
|
+
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @see Session#create_user_event
|
|
126
|
+
def persist_user_event(session, content)
|
|
127
|
+
session.create_user_event(content)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def broadcast_auth_required(session_id, error)
|
|
131
|
+
ActionCable.server.broadcast(
|
|
132
|
+
"session_#{session_id}",
|
|
133
|
+
{"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
73
137
|
# Runs the analytical brain synchronously before the main agent loop.
|
|
74
138
|
# Respects the blocking_on_user_message setting and session guards
|
|
75
139
|
# (skips sub-agents and sessions with too few messages).
|
|
@@ -88,18 +152,21 @@ class AgentRequestJob < ApplicationJob
|
|
|
88
152
|
|
|
89
153
|
# Sets the session's processing flag atomically. Returns true if this
|
|
90
154
|
# job claimed the lock, false if another job already holds it.
|
|
155
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
91
156
|
def claim_processing(session_id)
|
|
92
|
-
Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
157
|
+
claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
158
|
+
Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
|
|
159
|
+
claimed
|
|
93
160
|
end
|
|
94
161
|
|
|
95
162
|
# Clears the processing flag so the session can accept new jobs.
|
|
163
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
96
164
|
def release_processing(session_id)
|
|
97
165
|
Session.where(id: session_id).update_all(processing: false)
|
|
166
|
+
Session.find_by(id: session_id)&.broadcast_children_update_to_parent
|
|
98
167
|
end
|
|
99
168
|
|
|
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.
|
|
169
|
+
# Safety-net clearing of the interrupt flag.
|
|
103
170
|
def clear_interrupt(session_id)
|
|
104
171
|
Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
|
|
105
172
|
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
|
|
@@ -44,9 +44,23 @@ module Event::Broadcasting
|
|
|
44
44
|
after_update_commit :broadcast_update
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Broadcasts this event immediately, bypassing +after_create_commit+.
|
|
48
|
+
# Used inside a wrapping transaction where +after_create_commit+ is
|
|
49
|
+
# deferred until the outer transaction commits. Gives clients
|
|
50
|
+
# optimistic UI — the event appears right away and is removed via
|
|
51
|
+
# bounce if the transaction rolls back.
|
|
52
|
+
#
|
|
53
|
+
# Sets a flag so the deferred +after_create_commit+ callback skips
|
|
54
|
+
# the duplicate broadcast after the transaction commits.
|
|
55
|
+
def broadcast_now!
|
|
56
|
+
@already_broadcast = true
|
|
57
|
+
broadcast_event(action: ACTION_CREATE)
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
private
|
|
48
61
|
|
|
49
62
|
def broadcast_create
|
|
63
|
+
return if @already_broadcast
|
|
50
64
|
broadcast_event(action: ACTION_CREATE)
|
|
51
65
|
end
|
|
52
66
|
|
|
@@ -76,6 +90,10 @@ module Event::Broadcasting
|
|
|
76
90
|
evicted_ids = session.recalculate_viewport!
|
|
77
91
|
broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
|
|
78
92
|
|
|
93
|
+
# The nil? branch fires on every broadcast until boundary initializes, but
|
|
94
|
+
# schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
|
|
95
|
+
session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
|
|
96
|
+
|
|
79
97
|
ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
|
|
80
98
|
end
|
|
81
99
|
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
|