anima-core 1.0.1 → 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 +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- 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/20260316094817_add_interrupt_requested_to_sessions.rb +5 -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 +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- 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 +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- 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 +99 -14
- 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/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- 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 +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -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,48 +36,104 @@ 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)
|
|
77
|
+
clear_interrupt(session_id)
|
|
67
78
|
agent_loop&.finalize
|
|
68
79
|
end
|
|
69
80
|
|
|
70
81
|
private
|
|
71
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
|
+
|
|
72
137
|
# Runs the analytical brain synchronously before the main agent loop.
|
|
73
138
|
# Respects the blocking_on_user_message setting and session guards
|
|
74
139
|
# (skips sub-agents and sessions with too few messages).
|
|
@@ -87,13 +152,23 @@ class AgentRequestJob < ApplicationJob
|
|
|
87
152
|
|
|
88
153
|
# Sets the session's processing flag atomically. Returns true if this
|
|
89
154
|
# job claimed the lock, false if another job already holds it.
|
|
155
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
90
156
|
def claim_processing(session_id)
|
|
91
|
-
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
|
|
92
160
|
end
|
|
93
161
|
|
|
94
162
|
# Clears the processing flag so the session can accept new jobs.
|
|
163
|
+
# Broadcasts the state change to the parent session's HUD.
|
|
95
164
|
def release_processing(session_id)
|
|
96
165
|
Session.where(id: session_id).update_all(processing: false)
|
|
166
|
+
Session.find_by(id: session_id)&.broadcast_children_update_to_parent
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Safety-net clearing of the interrupt flag.
|
|
170
|
+
def clear_interrupt(session_id)
|
|
171
|
+
Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
|
|
97
172
|
end
|
|
98
173
|
|
|
99
174
|
# Emits a system message before each retry so the user sees
|
|
@@ -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
|