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
data/app/models/session.rb
CHANGED
|
@@ -11,10 +11,14 @@ class Session < ApplicationRecord
|
|
|
11
11
|
|
|
12
12
|
VIEW_MODES = %w[basic verbose debug].freeze
|
|
13
13
|
|
|
14
|
+
attribute :view_mode, :string, default: -> { Anima::Settings.default_view_mode }
|
|
15
|
+
|
|
14
16
|
serialize :granted_tools, coder: JSON
|
|
15
17
|
|
|
16
18
|
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
17
19
|
has_many :goals, dependent: :destroy
|
|
20
|
+
has_many :snapshots, dependent: :destroy
|
|
21
|
+
has_many :pinned_events, through: :events
|
|
18
22
|
|
|
19
23
|
belongs_to :parent_session, class_name: "Session", optional: true
|
|
20
24
|
has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
|
|
@@ -42,6 +46,38 @@ class Session < ApplicationRecord
|
|
|
42
46
|
parent_session_id.present?
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# Checks whether the Mneme terminal event has left the viewport and
|
|
50
|
+
# enqueues {MnemeJob} when it has. On the first event of a new session,
|
|
51
|
+
# initializes the boundary pointer.
|
|
52
|
+
#
|
|
53
|
+
# The terminal event is always a conversation event (user/agent message
|
|
54
|
+
# or think tool_call), never a bare tool_call/tool_response.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def schedule_mneme!
|
|
58
|
+
return if sub_agent?
|
|
59
|
+
|
|
60
|
+
# Initialize boundary on first conversation event
|
|
61
|
+
if mneme_boundary_event_id.nil?
|
|
62
|
+
first_conversation = events.deliverable
|
|
63
|
+
.where(event_type: Event::CONVERSATION_TYPES)
|
|
64
|
+
.order(:id).first
|
|
65
|
+
first_conversation ||= events.deliverable
|
|
66
|
+
.where(event_type: "tool_call")
|
|
67
|
+
.detect { |e| e.payload["tool_name"] == Event::THINK_TOOL }
|
|
68
|
+
|
|
69
|
+
if first_conversation
|
|
70
|
+
update_column(:mneme_boundary_event_id, first_conversation.id)
|
|
71
|
+
end
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if boundary event has left the viewport
|
|
76
|
+
return if viewport_event_ids.include?(mneme_boundary_event_id)
|
|
77
|
+
|
|
78
|
+
MnemeJob.perform_later(id)
|
|
79
|
+
end
|
|
80
|
+
|
|
45
81
|
# Enqueues the analytical brain to perform background maintenance on
|
|
46
82
|
# this session. Currently handles session naming; future phases add
|
|
47
83
|
# skill activation, goal tracking, and memory.
|
|
@@ -201,16 +237,105 @@ class Session < ApplicationRecord
|
|
|
201
237
|
end
|
|
202
238
|
|
|
203
239
|
# Builds the message array expected by the Anthropic Messages API.
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
240
|
+
# Viewport layout (top to bottom):
|
|
241
|
+
# [L2 snapshots] [L1 snapshots] [pinned events] [recalled memories] [sliding window events]
|
|
242
|
+
#
|
|
243
|
+
# Snapshots appear ONLY after their source events have evicted from
|
|
244
|
+
# the sliding window. L1 snapshots drop once covered by an L2 snapshot.
|
|
245
|
+
# Pinned events are critical context attached to active Goals — they
|
|
246
|
+
# survive eviction intact until their Goals complete.
|
|
247
|
+
# Recalled memories surface relevant older events (passive recall via goals).
|
|
248
|
+
# Each layer has a fixed token budget fraction — snapshots, pins, and recall
|
|
249
|
+
# consume viewport space, reducing the sliding window size.
|
|
250
|
+
#
|
|
251
|
+
# Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent events directly).
|
|
209
252
|
#
|
|
210
253
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
211
254
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
212
255
|
def messages_for_llm(token_budget: Anima::Settings.token_budget)
|
|
213
|
-
|
|
256
|
+
heal_orphaned_tool_calls!
|
|
257
|
+
|
|
258
|
+
sliding_budget = token_budget
|
|
259
|
+
snapshot_messages = []
|
|
260
|
+
pinned_messages = []
|
|
261
|
+
recall_messages = []
|
|
262
|
+
|
|
263
|
+
unless sub_agent?
|
|
264
|
+
l2_budget = (token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
|
|
265
|
+
l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
|
|
266
|
+
pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
|
|
267
|
+
recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
|
|
268
|
+
sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
events = viewport_events(token_budget: sliding_budget, include_pending: false)
|
|
272
|
+
|
|
273
|
+
unless sub_agent?
|
|
274
|
+
first_event_id = events.first&.id
|
|
275
|
+
snapshot_messages = assemble_snapshot_messages(first_event_id, l2_budget: l2_budget, l1_budget: l1_budget)
|
|
276
|
+
pinned_messages = assemble_pinned_event_messages(first_event_id, budget: pinned_budget)
|
|
277
|
+
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(events))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Detects orphaned tool_call events (those without a matching tool_response
|
|
284
|
+
# and whose timeout has expired) and creates synthetic error responses.
|
|
285
|
+
# An orphaned tool_call permanently breaks the session because the
|
|
286
|
+
# Anthropic API rejects conversations where a tool_use block has no
|
|
287
|
+
# matching tool_result.
|
|
288
|
+
#
|
|
289
|
+
# Respects the per-call timeout stored in the tool_call event payload —
|
|
290
|
+
# a tool_call is only healed after its deadline has passed. This avoids
|
|
291
|
+
# prematurely healing long-running tools that the agent intentionally
|
|
292
|
+
# gave an extended timeout.
|
|
293
|
+
#
|
|
294
|
+
# @return [Integer] number of synthetic responses created
|
|
295
|
+
def heal_orphaned_tool_calls!
|
|
296
|
+
now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
297
|
+
responded_ids = events.where(event_type: "tool_response").where.not(tool_use_id: nil).select(:tool_use_id)
|
|
298
|
+
unresponded = events.where(event_type: "tool_call").where.not(tool_use_id: nil)
|
|
299
|
+
.where.not(tool_use_id: responded_ids)
|
|
300
|
+
|
|
301
|
+
healed = 0
|
|
302
|
+
unresponded.find_each do |orphan|
|
|
303
|
+
timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
|
|
304
|
+
deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
|
|
305
|
+
next if now_ns < deadline_ns
|
|
306
|
+
|
|
307
|
+
events.create!(
|
|
308
|
+
event_type: "tool_response",
|
|
309
|
+
payload: {
|
|
310
|
+
"type" => "tool_response",
|
|
311
|
+
"content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
|
|
312
|
+
"tool_name" => orphan.payload["tool_name"],
|
|
313
|
+
"tool_use_id" => orphan.tool_use_id,
|
|
314
|
+
"success" => false
|
|
315
|
+
},
|
|
316
|
+
tool_use_id: orphan.tool_use_id,
|
|
317
|
+
timestamp: now_ns
|
|
318
|
+
)
|
|
319
|
+
healed += 1
|
|
320
|
+
end
|
|
321
|
+
healed
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Creates a user message event record directly (bypasses EventBus+Persister).
|
|
325
|
+
# Used by {SessionChannel#speak} (immediate display), {AgentLoop#process},
|
|
326
|
+
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
327
|
+
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
328
|
+
# messages — these callers own the persistence lifecycle.
|
|
329
|
+
#
|
|
330
|
+
# @param content [String] user message text
|
|
331
|
+
# @return [Event] the persisted event record
|
|
332
|
+
def create_user_event(content)
|
|
333
|
+
now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
334
|
+
events.create!(
|
|
335
|
+
event_type: "user_message",
|
|
336
|
+
payload: {type: "user_message", content: content, session_id: id, timestamp: now},
|
|
337
|
+
timestamp: now
|
|
338
|
+
)
|
|
214
339
|
end
|
|
215
340
|
|
|
216
341
|
# Promotes all pending user messages to delivered status so they
|
|
@@ -227,6 +352,27 @@ class Session < ApplicationRecord
|
|
|
227
352
|
promoted
|
|
228
353
|
end
|
|
229
354
|
|
|
355
|
+
# Broadcasts child session list to all clients subscribed to the parent
|
|
356
|
+
# session. Called when a child session is created or its processing state
|
|
357
|
+
# changes so the HUD sub-agents section updates in real time.
|
|
358
|
+
#
|
|
359
|
+
# Queries children via FK directly (avoids loading the parent record) and
|
|
360
|
+
# selects only the columns needed for the HUD payload.
|
|
361
|
+
#
|
|
362
|
+
# @return [void]
|
|
363
|
+
def broadcast_children_update_to_parent
|
|
364
|
+
return unless parent_session_id
|
|
365
|
+
|
|
366
|
+
children = Session.where(parent_session_id: parent_session_id)
|
|
367
|
+
.order(:created_at)
|
|
368
|
+
.select(:id, :name, :processing)
|
|
369
|
+
ActionCable.server.broadcast("session_#{parent_session_id}", {
|
|
370
|
+
"action" => "children_updated",
|
|
371
|
+
"session_id" => parent_session_id,
|
|
372
|
+
"children" => children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
|
|
373
|
+
})
|
|
374
|
+
end
|
|
375
|
+
|
|
230
376
|
private
|
|
231
377
|
|
|
232
378
|
# Reads the soul file — the agent's self-authored identity.
|
|
@@ -396,6 +542,256 @@ class Session < ApplicationRecord
|
|
|
396
542
|
event_list
|
|
397
543
|
end
|
|
398
544
|
|
|
545
|
+
# Ensures every tool_call in the event list has a matching tool_response
|
|
546
|
+
# (and vice versa) by removing unpaired events. The Anthropic API requires
|
|
547
|
+
# every tool_use block to have a tool_result — a missing partner causes
|
|
548
|
+
# a permanent API error. Token budget cutoffs can split pairs when the
|
|
549
|
+
# boundary falls between a tool_call and its tool_response.
|
|
550
|
+
#
|
|
551
|
+
# @param event_list [Array<Event>] chronologically ordered events
|
|
552
|
+
# @return [Array<Event>] events with unpaired tool events removed
|
|
553
|
+
def ensure_atomic_tool_pairs(event_list)
|
|
554
|
+
tool_events = event_list.select { |e| e.tool_use_id.present? }
|
|
555
|
+
return event_list if tool_events.empty?
|
|
556
|
+
|
|
557
|
+
paired = tool_events.group_by(&:tool_use_id)
|
|
558
|
+
complete_ids = paired.each_with_object(Set.new) do |(id, evts), set|
|
|
559
|
+
has_call = evts.any? { |e| e.event_type == "tool_call" }
|
|
560
|
+
has_response = evts.any? { |e| e.event_type == "tool_response" }
|
|
561
|
+
set << id if has_call && has_response
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
event_list.reject { |e| e.tool_use_id.present? && !complete_ids.include?(e.tool_use_id) }
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Selects visible snapshots and formats them as Anthropic messages.
|
|
568
|
+
# Snapshots are visible when their source events have fully evicted.
|
|
569
|
+
# L1 snapshots are excluded when covered by an L2 snapshot.
|
|
570
|
+
#
|
|
571
|
+
# @param first_event_id [Integer, nil] first event ID in the sliding window
|
|
572
|
+
# @param l2_budget [Integer] token budget for L2 snapshots
|
|
573
|
+
# @param l1_budget [Integer] token budget for L1 snapshots
|
|
574
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
575
|
+
def assemble_snapshot_messages(first_event_id, l2_budget:, l1_budget:)
|
|
576
|
+
return [] unless first_event_id
|
|
577
|
+
|
|
578
|
+
l2_messages = select_snapshots_within_budget(
|
|
579
|
+
snapshots.for_level(2).source_events_evicted(first_event_id).chronological,
|
|
580
|
+
budget: l2_budget
|
|
581
|
+
).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
|
|
582
|
+
|
|
583
|
+
l1_messages = select_snapshots_within_budget(
|
|
584
|
+
snapshots.for_level(1).not_covered_by_l2.source_events_evicted(first_event_id).chronological,
|
|
585
|
+
budget: l1_budget
|
|
586
|
+
).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
|
|
587
|
+
|
|
588
|
+
l2_messages + l1_messages
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Walks snapshots chronologically, selecting until the token budget is exhausted.
|
|
592
|
+
# Always includes at least one snapshot even if it exceeds the budget, so the
|
|
593
|
+
# agent never loses all memory context.
|
|
594
|
+
#
|
|
595
|
+
# @param scope [ActiveRecord::Relation] snapshot scope to select from
|
|
596
|
+
# @param budget [Integer] maximum tokens to include
|
|
597
|
+
# @return [Array<Snapshot>]
|
|
598
|
+
def select_snapshots_within_budget(scope, budget:)
|
|
599
|
+
selected = []
|
|
600
|
+
remaining = budget
|
|
601
|
+
|
|
602
|
+
scope.each do |snapshot|
|
|
603
|
+
cost = snapshot.token_cost
|
|
604
|
+
break if cost > remaining && selected.any?
|
|
605
|
+
|
|
606
|
+
selected << snapshot
|
|
607
|
+
remaining -= cost
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
selected
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Formats a snapshot as an Anthropic user message with a memory label prefix.
|
|
614
|
+
#
|
|
615
|
+
# @param snapshot [Snapshot]
|
|
616
|
+
# @param label [String] human-readable label (e.g. "recent memory", "long-term memory")
|
|
617
|
+
# @return [Hash] Anthropic message format
|
|
618
|
+
def format_snapshot_message(snapshot, label:)
|
|
619
|
+
{role: "user", content: "[#{label}]\n#{snapshot.text}"}
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Assembles pinned events as a Goals section message for the viewport.
|
|
623
|
+
# Only includes pinned events whose source event has evicted from the
|
|
624
|
+
# sliding window (same rule as snapshots — no duplication with live events).
|
|
625
|
+
#
|
|
626
|
+
# Deduplication: the first Goal referencing an event shows its truncated
|
|
627
|
+
# display_text; subsequent Goals show a bare `event N` ID to save tokens.
|
|
628
|
+
#
|
|
629
|
+
# @param first_event_id [Integer, nil] first event ID in the sliding window
|
|
630
|
+
# @param budget [Integer] token budget for pinned events
|
|
631
|
+
# @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
|
|
632
|
+
def assemble_pinned_event_messages(first_event_id, budget:)
|
|
633
|
+
return [] unless first_event_id
|
|
634
|
+
|
|
635
|
+
pins = pinned_events
|
|
636
|
+
.includes(:event, :goals)
|
|
637
|
+
.where("pinned_events.event_id < ?", first_event_id)
|
|
638
|
+
.order("pinned_events.event_id")
|
|
639
|
+
|
|
640
|
+
return [] if pins.empty?
|
|
641
|
+
|
|
642
|
+
selected = select_pins_within_budget(pins, budget)
|
|
643
|
+
return [] if selected.empty?
|
|
644
|
+
|
|
645
|
+
text = render_pinned_events_section(selected)
|
|
646
|
+
[{role: "user", content: "[pinned events]\n#{text}"}]
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Walks pinned events chronologically, selecting until the token budget
|
|
650
|
+
# is exhausted. Always includes at least one pin.
|
|
651
|
+
#
|
|
652
|
+
# @param pins [Array<PinnedEvent>]
|
|
653
|
+
# @param budget [Integer]
|
|
654
|
+
# @return [Array<PinnedEvent>]
|
|
655
|
+
def select_pins_within_budget(pins, budget)
|
|
656
|
+
selected = []
|
|
657
|
+
remaining = budget
|
|
658
|
+
|
|
659
|
+
pins.each do |pin|
|
|
660
|
+
cost = pin.token_cost
|
|
661
|
+
break if cost > remaining && selected.any?
|
|
662
|
+
|
|
663
|
+
selected << pin
|
|
664
|
+
remaining -= cost
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
selected
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Renders the pinned events section grouped by Goal.
|
|
671
|
+
# First Goal referencing a pin shows truncated text; subsequent Goals
|
|
672
|
+
# show bare `event N` ID to avoid token-expensive repetition.
|
|
673
|
+
#
|
|
674
|
+
# @param pins [Array<PinnedEvent>] selected pins with preloaded goals
|
|
675
|
+
# @return [String] formatted section text
|
|
676
|
+
def render_pinned_events_section(pins)
|
|
677
|
+
goal_pins = group_pins_by_active_goal(pins)
|
|
678
|
+
|
|
679
|
+
shown_events = Set.new
|
|
680
|
+
goal_pins.map { |goal, pin_list|
|
|
681
|
+
render_goal_pins(goal, pin_list, shown_events)
|
|
682
|
+
}.join("\n\n")
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Groups pins by their active Goals so the viewport renders
|
|
686
|
+
# one headed section per Goal.
|
|
687
|
+
#
|
|
688
|
+
# @param pins [Array<PinnedEvent>] pins with preloaded goals
|
|
689
|
+
# @return [Hash{Goal => Array<PinnedEvent>}]
|
|
690
|
+
def group_pins_by_active_goal(pins)
|
|
691
|
+
pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
|
|
692
|
+
pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Expands a single pin into [goal, pin] pairs for each active Goal
|
|
696
|
+
# referencing it. Uses in-memory filter on preloaded goals.
|
|
697
|
+
#
|
|
698
|
+
# @param pin [PinnedEvent]
|
|
699
|
+
# @return [Array<Array(Goal, PinnedEvent)>]
|
|
700
|
+
def active_goal_pin_pairs(pin)
|
|
701
|
+
pin.goals.select(&:active?).map { |goal| [goal, pin] }
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Renders one Goal's pinned events as a headed list.
|
|
705
|
+
#
|
|
706
|
+
# @param goal [Goal]
|
|
707
|
+
# @param pin_list [Array<PinnedEvent>]
|
|
708
|
+
# @param shown_events [Set<Integer>] tracks already-rendered event IDs for dedup
|
|
709
|
+
# @return [String]
|
|
710
|
+
def render_goal_pins(goal, pin_list, shown_events)
|
|
711
|
+
lines = ["📌 #{goal.description} (id: #{goal.id})"]
|
|
712
|
+
pin_list.each { |pin| lines << format_pin_line(pin, shown_events) }
|
|
713
|
+
lines.join("\n")
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Formats a single pin line with deduplication: first occurrence shows
|
|
717
|
+
# truncated text, subsequent occurrences show bare event ID only.
|
|
718
|
+
#
|
|
719
|
+
# @param pin [PinnedEvent]
|
|
720
|
+
# @param shown_events [Set<Integer>]
|
|
721
|
+
# @return [String]
|
|
722
|
+
def format_pin_line(pin, shown_events)
|
|
723
|
+
event_id = pin.event_id
|
|
724
|
+
if shown_events.add?(event_id)
|
|
725
|
+
" event #{event_id}: #{pin.display_text}"
|
|
726
|
+
else
|
|
727
|
+
" event #{event_id}"
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Assembles recalled memory messages from passive recall results.
|
|
732
|
+
# Recalled events are fetched by ID and formatted as compact snippets
|
|
733
|
+
# with session and event context for drill-down via the remember tool.
|
|
734
|
+
#
|
|
735
|
+
# @param budget [Integer] token budget for recall messages
|
|
736
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
737
|
+
def assemble_recall_messages(budget:)
|
|
738
|
+
return [] if recalled_event_ids.blank?
|
|
739
|
+
|
|
740
|
+
recalled_events = Event.where(id: recalled_event_ids)
|
|
741
|
+
.includes(:session)
|
|
742
|
+
.index_by(&:id)
|
|
743
|
+
|
|
744
|
+
snippets = []
|
|
745
|
+
remaining = budget
|
|
746
|
+
|
|
747
|
+
recalled_event_ids.each do |eid|
|
|
748
|
+
event = recalled_events[eid]
|
|
749
|
+
next unless event
|
|
750
|
+
|
|
751
|
+
text = format_recall_snippet(event)
|
|
752
|
+
cost = [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
753
|
+
break if cost > remaining && snippets.any?
|
|
754
|
+
|
|
755
|
+
snippets << text
|
|
756
|
+
remaining -= cost
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
return [] if snippets.empty?
|
|
760
|
+
|
|
761
|
+
[{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Formats a recalled event as a compact snippet with enough context
|
|
765
|
+
# for the agent to decide whether to drill down with the remember tool.
|
|
766
|
+
#
|
|
767
|
+
# @param event [Event] the recalled event
|
|
768
|
+
# @return [String] formatted snippet
|
|
769
|
+
def format_recall_snippet(event)
|
|
770
|
+
session_label = event.session.name || "session ##{event.session_id}"
|
|
771
|
+
content = extract_event_content(event).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Event::BYTES_PER_TOKEN)
|
|
772
|
+
"event #{event.id} (#{session_label}): #{content}"
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Extracts readable content from an event's payload.
|
|
776
|
+
#
|
|
777
|
+
# @param event [Event]
|
|
778
|
+
# @return [String]
|
|
779
|
+
def extract_event_content(event)
|
|
780
|
+
data = event.payload
|
|
781
|
+
case event.event_type
|
|
782
|
+
when "user_message", "agent_message", "system_message"
|
|
783
|
+
data["content"]
|
|
784
|
+
when "tool_call"
|
|
785
|
+
if data["tool_name"] == Event::THINK_TOOL
|
|
786
|
+
data.dig("tool_input", "thoughts")
|
|
787
|
+
else
|
|
788
|
+
"#{data["tool_name"]}(…)"
|
|
789
|
+
end
|
|
790
|
+
else
|
|
791
|
+
data["content"]
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
|
|
399
795
|
# Converts a chronological list of events into Anthropic wire-format messages.
|
|
400
796
|
# Prepends a compact timestamp to each user message for LLM time awareness.
|
|
401
797
|
# Groups consecutive tool_call events into one assistant message and
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A persisted summary of conversation context created by Mneme before
|
|
4
|
+
# events evict from the viewport. Snapshots capture the "gist" of what
|
|
5
|
+
# happened so the agent retains awareness of past context.
|
|
6
|
+
#
|
|
7
|
+
# Level 1 snapshots are created from raw events (messages + thinks).
|
|
8
|
+
# Level 2 snapshots compress multiple Level 1 snapshots (days/weeks scale).
|
|
9
|
+
# Both levels use the same event ID range tracking — an L2 snapshot's range
|
|
10
|
+
# is the union of its constituent L1 snapshots.
|
|
11
|
+
#
|
|
12
|
+
# @!attribute text
|
|
13
|
+
# @return [String] the summary text generated by Mneme
|
|
14
|
+
# @!attribute from_event_id
|
|
15
|
+
# @return [Integer] first event ID covered by this snapshot
|
|
16
|
+
# @!attribute to_event_id
|
|
17
|
+
# @return [Integer] last event ID covered by this snapshot
|
|
18
|
+
# @!attribute level
|
|
19
|
+
# @return [Integer] compression level (1 = from raw events, 2 = from L1 snapshots)
|
|
20
|
+
# @!attribute token_count
|
|
21
|
+
# @return [Integer] cached token count of the summary text
|
|
22
|
+
class Snapshot < ApplicationRecord
|
|
23
|
+
belongs_to :session
|
|
24
|
+
|
|
25
|
+
# 32KB — generous upper bound (~8K tokens). The LLM tool description advises
|
|
26
|
+
# a tighter limit (mneme_max_tokens), but this hard cap prevents unbounded storage.
|
|
27
|
+
MAX_TEXT_BYTES = 32_768
|
|
28
|
+
|
|
29
|
+
validates :text, presence: true, length: {maximum: MAX_TEXT_BYTES}
|
|
30
|
+
validates :from_event_id, presence: true
|
|
31
|
+
validates :to_event_id, presence: true
|
|
32
|
+
validates :level, presence: true, numericality: {greater_than: 0}
|
|
33
|
+
validates :token_count, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
|
|
34
|
+
validate :from_event_id_not_after_to_event_id
|
|
35
|
+
|
|
36
|
+
scope :for_level, ->(level) { where(level: level) }
|
|
37
|
+
scope :chronological, -> { order(:from_event_id) }
|
|
38
|
+
|
|
39
|
+
# L1 snapshots whose event range is NOT fully contained within any L2 snapshot.
|
|
40
|
+
# Used to determine which L1 snapshots are still "live" in the viewport.
|
|
41
|
+
scope :not_covered_by_l2, -> {
|
|
42
|
+
where.not(
|
|
43
|
+
"EXISTS (SELECT 1 FROM snapshots l2 " \
|
|
44
|
+
"WHERE l2.session_id = snapshots.session_id " \
|
|
45
|
+
"AND l2.level = 2 " \
|
|
46
|
+
"AND l2.from_event_id <= snapshots.from_event_id " \
|
|
47
|
+
"AND l2.to_event_id >= snapshots.to_event_id)"
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Snapshots whose source events have fully evicted from the sliding window.
|
|
52
|
+
# A snapshot is visible when its entire event range precedes the first
|
|
53
|
+
# event currently in the viewport.
|
|
54
|
+
#
|
|
55
|
+
# @param first_event_id [Integer] the first event ID in the sliding window
|
|
56
|
+
scope :source_events_evicted, ->(first_event_id) {
|
|
57
|
+
where("to_event_id < ?", first_event_id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# @return [Integer] token cost, using cached count or heuristic estimate
|
|
61
|
+
def token_cost
|
|
62
|
+
token_count.positive? ? token_count : estimate_tokens
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def from_event_id_not_after_to_event_id
|
|
68
|
+
return unless from_event_id && to_event_id
|
|
69
|
+
errors.add(:from_event_id, "must be <= to_event_id") if from_event_id > to_event_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Integer] estimated token count (at least 1)
|
|
73
|
+
def estimate_tokens
|
|
74
|
+
[(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
75
|
+
end
|
|
76
|
+
end
|
data/bin/jobs
CHANGED
|
@@ -3,4 +3,9 @@
|
|
|
3
3
|
require_relative "../config/environment"
|
|
4
4
|
require "solid_queue/cli"
|
|
5
5
|
|
|
6
|
+
# Run all Solid Queue components (worker, dispatcher, scheduler) as threads
|
|
7
|
+
# in a single process. Fork mode spawns child processes that can outlive
|
|
8
|
+
# the supervisor and run stale code after gem updates (#275).
|
|
9
|
+
ENV["SOLID_QUEUE_SUPERVISOR_MODE"] = "async"
|
|
10
|
+
|
|
6
11
|
SolidQueue::Cli.start(ARGV)
|
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
# Subscribers registered here receive all events regardless of which
|
|
5
5
|
# process emitted them (brain server, background job, etc.).
|
|
6
6
|
Rails.application.config.after_initialize do
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
unless Rails.env.test?
|
|
8
|
+
# Global persister handles events from all sessions (brain server, background jobs).
|
|
9
|
+
# Skips non-pending user messages — those are persisted by their callers
|
|
10
|
+
# (SessionChannel#speak for idle sessions, AgentLoop#process for direct usage).
|
|
11
|
+
Events::Bus.subscribe(Events::Subscribers::Persister.new)
|
|
12
|
+
|
|
13
|
+
# Bridges transient events (e.g. BounceBack) to ActionCable for client delivery.
|
|
14
|
+
Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
15
|
+
|
|
16
|
+
# Routes text messages between parent and sub-agent sessions via @mentions.
|
|
17
|
+
Events::Bus.subscribe(Events::Subscribers::SubagentMessageRouter.new)
|
|
18
|
+
end
|
|
10
19
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Skip FTS5 virtual tables during Ruby schema dump.
|
|
4
|
+
# Rails' schema dumper can't express contentless FTS5 tables — the
|
|
5
|
+
# migration handles creation. The schema.rb omits the virtual table,
|
|
6
|
+
# and db:prepare runs pending migrations to recreate it.
|
|
7
|
+
ActiveSupport.on_load(:active_record) do
|
|
8
|
+
require "active_record/connection_adapters/sqlite3/schema_dumper"
|
|
9
|
+
|
|
10
|
+
ActiveRecord::ConnectionAdapters::SQLite3::SchemaDumper.prepend(Module.new do
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def virtual_tables(stream)
|
|
14
|
+
# Intentionally empty — FTS5 tables are managed by migrations.
|
|
15
|
+
# The default implementation crashes on contentless FTS5 arguments.
|
|
16
|
+
end
|
|
17
|
+
end)
|
|
18
|
+
rescue LoadError
|
|
19
|
+
# Not using SQLite3 adapter — nothing to patch.
|
|
20
|
+
nil
|
|
21
|
+
end
|
data/config/queue.yml
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adds Mneme memory department infrastructure:
|
|
4
|
+
# - Terminal event tracking on sessions for viewport eviction detection
|
|
5
|
+
# - Snapshot pointers for tracking what Mneme has already summarized
|
|
6
|
+
# - Snapshots table for persisted summaries of evicted conversation context
|
|
7
|
+
class CreateMnemeSchema < ActiveRecord::Migration[8.1]
|
|
8
|
+
def change
|
|
9
|
+
# Terminal event trigger: the event ID that marks Mneme's boundary.
|
|
10
|
+
# When this event leaves the viewport, Mneme fires.
|
|
11
|
+
add_column :sessions, :mneme_boundary_event_id, :integer
|
|
12
|
+
|
|
13
|
+
# Snapshot range pointers: track which events Mneme has summarized.
|
|
14
|
+
add_column :sessions, :mneme_snapshot_first_event_id, :integer
|
|
15
|
+
add_column :sessions, :mneme_snapshot_last_event_id, :integer
|
|
16
|
+
|
|
17
|
+
create_table :snapshots do |t|
|
|
18
|
+
t.references :session, null: false, foreign_key: true
|
|
19
|
+
t.text :text, null: false
|
|
20
|
+
t.integer :from_event_id, null: false
|
|
21
|
+
t.integer :to_event_id, null: false
|
|
22
|
+
t.integer :level, null: false, default: 1
|
|
23
|
+
t.integer :token_count, null: false, default: 0
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_index :snapshots, [:session_id, :level]
|
|
29
|
+
add_index :snapshots, [:session_id, :from_event_id, :to_event_id],
|
|
30
|
+
name: "index_snapshots_on_session_and_event_range"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Goal-scoped event pinning: Mneme pins critical events to Goals via a
|
|
4
|
+
# many-to-many relationship. Pinned events float above the sliding window,
|
|
5
|
+
# protected from viewport eviction. When a Goal completes, events attached
|
|
6
|
+
# exclusively to it are automatically released (reference counting).
|
|
7
|
+
class CreatePinnedEvents < ActiveRecord::Migration[8.1]
|
|
8
|
+
def change
|
|
9
|
+
create_table :pinned_events do |t|
|
|
10
|
+
t.references :event, null: false, foreign_key: true, index: {unique: true}
|
|
11
|
+
t.text :display_text, null: false
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
create_table :goal_pinned_events do |t|
|
|
17
|
+
t.references :goal, null: false, foreign_key: true
|
|
18
|
+
t.references :pinned_event, null: false, foreign_key: true
|
|
19
|
+
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One event pinned to a goal at most once.
|
|
24
|
+
# (t.references already creates individual indexes on goal_id and pinned_event_id)
|
|
25
|
+
add_index :goal_pinned_events, [:goal_id, :pinned_event_id], unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|