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
data/app/models/session.rb
CHANGED
|
@@ -15,6 +15,8 @@ class Session < ApplicationRecord
|
|
|
15
15
|
|
|
16
16
|
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
17
17
|
has_many :goals, dependent: :destroy
|
|
18
|
+
has_many :snapshots, dependent: :destroy
|
|
19
|
+
has_many :pinned_events, through: :events
|
|
18
20
|
|
|
19
21
|
belongs_to :parent_session, class_name: "Session", optional: true
|
|
20
22
|
has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
|
|
@@ -42,6 +44,38 @@ class Session < ApplicationRecord
|
|
|
42
44
|
parent_session_id.present?
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
# Checks whether the Mneme terminal event has left the viewport and
|
|
48
|
+
# enqueues {MnemeJob} when it has. On the first event of a new session,
|
|
49
|
+
# initializes the boundary pointer.
|
|
50
|
+
#
|
|
51
|
+
# The terminal event is always a conversation event (user/agent message
|
|
52
|
+
# or think tool_call), never a bare tool_call/tool_response.
|
|
53
|
+
#
|
|
54
|
+
# @return [void]
|
|
55
|
+
def schedule_mneme!
|
|
56
|
+
return if sub_agent?
|
|
57
|
+
|
|
58
|
+
# Initialize boundary on first conversation event
|
|
59
|
+
if mneme_boundary_event_id.nil?
|
|
60
|
+
first_conversation = events.deliverable
|
|
61
|
+
.where(event_type: Event::CONVERSATION_TYPES)
|
|
62
|
+
.order(:id).first
|
|
63
|
+
first_conversation ||= events.deliverable
|
|
64
|
+
.where(event_type: "tool_call")
|
|
65
|
+
.detect { |e| e.payload["tool_name"] == Event::THINK_TOOL }
|
|
66
|
+
|
|
67
|
+
if first_conversation
|
|
68
|
+
update_column(:mneme_boundary_event_id, first_conversation.id)
|
|
69
|
+
end
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if boundary event has left the viewport
|
|
74
|
+
return if viewport_event_ids.include?(mneme_boundary_event_id)
|
|
75
|
+
|
|
76
|
+
MnemeJob.perform_later(id)
|
|
77
|
+
end
|
|
78
|
+
|
|
45
79
|
# Enqueues the analytical brain to perform background maintenance on
|
|
46
80
|
# this session. Currently handles session naming; future phases add
|
|
47
81
|
# skill activation, goal tracking, and memory.
|
|
@@ -201,16 +235,62 @@ class Session < ApplicationRecord
|
|
|
201
235
|
end
|
|
202
236
|
|
|
203
237
|
# Builds the message array expected by the Anthropic Messages API.
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
238
|
+
# Viewport layout (top to bottom):
|
|
239
|
+
# [L2 snapshots] [L1 snapshots] [pinned events] [recalled memories] [sliding window events]
|
|
240
|
+
#
|
|
241
|
+
# Snapshots appear ONLY after their source events have evicted from
|
|
242
|
+
# the sliding window. L1 snapshots drop once covered by an L2 snapshot.
|
|
243
|
+
# Pinned events are critical context attached to active Goals — they
|
|
244
|
+
# survive eviction intact until their Goals complete.
|
|
245
|
+
# Recalled memories surface relevant older events (passive recall via goals).
|
|
246
|
+
# Each layer has a fixed token budget fraction — snapshots, pins, and recall
|
|
247
|
+
# consume viewport space, reducing the sliding window size.
|
|
248
|
+
#
|
|
249
|
+
# Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent events directly).
|
|
209
250
|
#
|
|
210
251
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
211
252
|
# @return [Array<Hash>] Anthropic Messages API format
|
|
212
253
|
def messages_for_llm(token_budget: Anima::Settings.token_budget)
|
|
213
|
-
|
|
254
|
+
sliding_budget = token_budget
|
|
255
|
+
snapshot_messages = []
|
|
256
|
+
pinned_messages = []
|
|
257
|
+
recall_messages = []
|
|
258
|
+
|
|
259
|
+
unless sub_agent?
|
|
260
|
+
l2_budget = (token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
|
|
261
|
+
l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
|
|
262
|
+
pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
|
|
263
|
+
recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
|
|
264
|
+
sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
events = viewport_events(token_budget: sliding_budget, include_pending: false)
|
|
268
|
+
|
|
269
|
+
unless sub_agent?
|
|
270
|
+
first_event_id = events.first&.id
|
|
271
|
+
snapshot_messages = assemble_snapshot_messages(first_event_id, l2_budget: l2_budget, l1_budget: l1_budget)
|
|
272
|
+
pinned_messages = assemble_pinned_event_messages(first_event_id, budget: pinned_budget)
|
|
273
|
+
recall_messages = assemble_recall_messages(budget: recall_budget)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
snapshot_messages + pinned_messages + recall_messages + assemble_messages(events)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Creates a user message event record directly (bypasses EventBus+Persister).
|
|
280
|
+
# Used by {AgentRequestJob} (Bounce Back transaction), {AgentLoop#process},
|
|
281
|
+
# and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
|
|
282
|
+
# because the global {Events::Subscribers::Persister} skips non-pending user
|
|
283
|
+
# messages — these callers own the persistence lifecycle.
|
|
284
|
+
#
|
|
285
|
+
# @param content [String] user message text
|
|
286
|
+
# @return [Event] the persisted event record
|
|
287
|
+
def create_user_event(content)
|
|
288
|
+
now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
289
|
+
events.create!(
|
|
290
|
+
event_type: "user_message",
|
|
291
|
+
payload: {type: "user_message", content: content, session_id: id, timestamp: now},
|
|
292
|
+
timestamp: now
|
|
293
|
+
)
|
|
214
294
|
end
|
|
215
295
|
|
|
216
296
|
# Promotes all pending user messages to delivered status so they
|
|
@@ -227,6 +307,27 @@ class Session < ApplicationRecord
|
|
|
227
307
|
promoted
|
|
228
308
|
end
|
|
229
309
|
|
|
310
|
+
# Broadcasts child session list to all clients subscribed to the parent
|
|
311
|
+
# session. Called when a child session is created or its processing state
|
|
312
|
+
# changes so the HUD sub-agents section updates in real time.
|
|
313
|
+
#
|
|
314
|
+
# Queries children via FK directly (avoids loading the parent record) and
|
|
315
|
+
# selects only the columns needed for the HUD payload.
|
|
316
|
+
#
|
|
317
|
+
# @return [void]
|
|
318
|
+
def broadcast_children_update_to_parent
|
|
319
|
+
return unless parent_session_id
|
|
320
|
+
|
|
321
|
+
children = Session.where(parent_session_id: parent_session_id)
|
|
322
|
+
.order(:created_at)
|
|
323
|
+
.select(:id, :name, :processing)
|
|
324
|
+
ActionCable.server.broadcast("session_#{parent_session_id}", {
|
|
325
|
+
"action" => "children_updated",
|
|
326
|
+
"session_id" => parent_session_id,
|
|
327
|
+
"children" => children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
|
|
328
|
+
})
|
|
329
|
+
end
|
|
330
|
+
|
|
230
331
|
private
|
|
231
332
|
|
|
232
333
|
# Reads the soul file — the agent's self-authored identity.
|
|
@@ -396,6 +497,234 @@ class Session < ApplicationRecord
|
|
|
396
497
|
event_list
|
|
397
498
|
end
|
|
398
499
|
|
|
500
|
+
# Selects visible snapshots and formats them as Anthropic messages.
|
|
501
|
+
# Snapshots are visible when their source events have fully evicted.
|
|
502
|
+
# L1 snapshots are excluded when covered by an L2 snapshot.
|
|
503
|
+
#
|
|
504
|
+
# @param first_event_id [Integer, nil] first event ID in the sliding window
|
|
505
|
+
# @param l2_budget [Integer] token budget for L2 snapshots
|
|
506
|
+
# @param l1_budget [Integer] token budget for L1 snapshots
|
|
507
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
508
|
+
def assemble_snapshot_messages(first_event_id, l2_budget:, l1_budget:)
|
|
509
|
+
return [] unless first_event_id
|
|
510
|
+
|
|
511
|
+
l2_messages = select_snapshots_within_budget(
|
|
512
|
+
snapshots.for_level(2).source_events_evicted(first_event_id).chronological,
|
|
513
|
+
budget: l2_budget
|
|
514
|
+
).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
|
|
515
|
+
|
|
516
|
+
l1_messages = select_snapshots_within_budget(
|
|
517
|
+
snapshots.for_level(1).not_covered_by_l2.source_events_evicted(first_event_id).chronological,
|
|
518
|
+
budget: l1_budget
|
|
519
|
+
).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
|
|
520
|
+
|
|
521
|
+
l2_messages + l1_messages
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Walks snapshots chronologically, selecting until the token budget is exhausted.
|
|
525
|
+
# Always includes at least one snapshot even if it exceeds the budget, so the
|
|
526
|
+
# agent never loses all memory context.
|
|
527
|
+
#
|
|
528
|
+
# @param scope [ActiveRecord::Relation] snapshot scope to select from
|
|
529
|
+
# @param budget [Integer] maximum tokens to include
|
|
530
|
+
# @return [Array<Snapshot>]
|
|
531
|
+
def select_snapshots_within_budget(scope, budget:)
|
|
532
|
+
selected = []
|
|
533
|
+
remaining = budget
|
|
534
|
+
|
|
535
|
+
scope.each do |snapshot|
|
|
536
|
+
cost = snapshot.token_cost
|
|
537
|
+
break if cost > remaining && selected.any?
|
|
538
|
+
|
|
539
|
+
selected << snapshot
|
|
540
|
+
remaining -= cost
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
selected
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Formats a snapshot as an Anthropic user message with a memory label prefix.
|
|
547
|
+
#
|
|
548
|
+
# @param snapshot [Snapshot]
|
|
549
|
+
# @param label [String] human-readable label (e.g. "recent memory", "long-term memory")
|
|
550
|
+
# @return [Hash] Anthropic message format
|
|
551
|
+
def format_snapshot_message(snapshot, label:)
|
|
552
|
+
{role: "user", content: "[#{label}]\n#{snapshot.text}"}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Assembles pinned events as a Goals section message for the viewport.
|
|
556
|
+
# Only includes pinned events whose source event has evicted from the
|
|
557
|
+
# sliding window (same rule as snapshots — no duplication with live events).
|
|
558
|
+
#
|
|
559
|
+
# Deduplication: the first Goal referencing an event shows its truncated
|
|
560
|
+
# display_text; subsequent Goals show a bare `event N` ID to save tokens.
|
|
561
|
+
#
|
|
562
|
+
# @param first_event_id [Integer, nil] first event ID in the sliding window
|
|
563
|
+
# @param budget [Integer] token budget for pinned events
|
|
564
|
+
# @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
|
|
565
|
+
def assemble_pinned_event_messages(first_event_id, budget:)
|
|
566
|
+
return [] unless first_event_id
|
|
567
|
+
|
|
568
|
+
pins = pinned_events
|
|
569
|
+
.includes(:event, :goals)
|
|
570
|
+
.where("pinned_events.event_id < ?", first_event_id)
|
|
571
|
+
.order("pinned_events.event_id")
|
|
572
|
+
|
|
573
|
+
return [] if pins.empty?
|
|
574
|
+
|
|
575
|
+
selected = select_pins_within_budget(pins, budget)
|
|
576
|
+
return [] if selected.empty?
|
|
577
|
+
|
|
578
|
+
text = render_pinned_events_section(selected)
|
|
579
|
+
[{role: "user", content: "[pinned events]\n#{text}"}]
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Walks pinned events chronologically, selecting until the token budget
|
|
583
|
+
# is exhausted. Always includes at least one pin.
|
|
584
|
+
#
|
|
585
|
+
# @param pins [Array<PinnedEvent>]
|
|
586
|
+
# @param budget [Integer]
|
|
587
|
+
# @return [Array<PinnedEvent>]
|
|
588
|
+
def select_pins_within_budget(pins, budget)
|
|
589
|
+
selected = []
|
|
590
|
+
remaining = budget
|
|
591
|
+
|
|
592
|
+
pins.each do |pin|
|
|
593
|
+
cost = pin.token_cost
|
|
594
|
+
break if cost > remaining && selected.any?
|
|
595
|
+
|
|
596
|
+
selected << pin
|
|
597
|
+
remaining -= cost
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
selected
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Renders the pinned events section grouped by Goal.
|
|
604
|
+
# First Goal referencing a pin shows truncated text; subsequent Goals
|
|
605
|
+
# show bare `event N` ID to avoid token-expensive repetition.
|
|
606
|
+
#
|
|
607
|
+
# @param pins [Array<PinnedEvent>] selected pins with preloaded goals
|
|
608
|
+
# @return [String] formatted section text
|
|
609
|
+
def render_pinned_events_section(pins)
|
|
610
|
+
goal_pins = group_pins_by_active_goal(pins)
|
|
611
|
+
|
|
612
|
+
shown_events = Set.new
|
|
613
|
+
goal_pins.map { |goal, pin_list|
|
|
614
|
+
render_goal_pins(goal, pin_list, shown_events)
|
|
615
|
+
}.join("\n\n")
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Groups pins by their active Goals so the viewport renders
|
|
619
|
+
# one headed section per Goal.
|
|
620
|
+
#
|
|
621
|
+
# @param pins [Array<PinnedEvent>] pins with preloaded goals
|
|
622
|
+
# @return [Hash{Goal => Array<PinnedEvent>}]
|
|
623
|
+
def group_pins_by_active_goal(pins)
|
|
624
|
+
pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
|
|
625
|
+
pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Expands a single pin into [goal, pin] pairs for each active Goal
|
|
629
|
+
# referencing it. Uses in-memory filter on preloaded goals.
|
|
630
|
+
#
|
|
631
|
+
# @param pin [PinnedEvent]
|
|
632
|
+
# @return [Array<Array(Goal, PinnedEvent)>]
|
|
633
|
+
def active_goal_pin_pairs(pin)
|
|
634
|
+
pin.goals.select(&:active?).map { |goal| [goal, pin] }
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Renders one Goal's pinned events as a headed list.
|
|
638
|
+
#
|
|
639
|
+
# @param goal [Goal]
|
|
640
|
+
# @param pin_list [Array<PinnedEvent>]
|
|
641
|
+
# @param shown_events [Set<Integer>] tracks already-rendered event IDs for dedup
|
|
642
|
+
# @return [String]
|
|
643
|
+
def render_goal_pins(goal, pin_list, shown_events)
|
|
644
|
+
lines = ["📌 #{goal.description} (id: #{goal.id})"]
|
|
645
|
+
pin_list.each { |pin| lines << format_pin_line(pin, shown_events) }
|
|
646
|
+
lines.join("\n")
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Formats a single pin line with deduplication: first occurrence shows
|
|
650
|
+
# truncated text, subsequent occurrences show bare event ID only.
|
|
651
|
+
#
|
|
652
|
+
# @param pin [PinnedEvent]
|
|
653
|
+
# @param shown_events [Set<Integer>]
|
|
654
|
+
# @return [String]
|
|
655
|
+
def format_pin_line(pin, shown_events)
|
|
656
|
+
event_id = pin.event_id
|
|
657
|
+
if shown_events.add?(event_id)
|
|
658
|
+
" event #{event_id}: #{pin.display_text}"
|
|
659
|
+
else
|
|
660
|
+
" event #{event_id}"
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Assembles recalled memory messages from passive recall results.
|
|
665
|
+
# Recalled events are fetched by ID and formatted as compact snippets
|
|
666
|
+
# with session and event context for drill-down via the remember tool.
|
|
667
|
+
#
|
|
668
|
+
# @param budget [Integer] token budget for recall messages
|
|
669
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
670
|
+
def assemble_recall_messages(budget:)
|
|
671
|
+
return [] if recalled_event_ids.blank?
|
|
672
|
+
|
|
673
|
+
recalled_events = Event.where(id: recalled_event_ids)
|
|
674
|
+
.includes(:session)
|
|
675
|
+
.index_by(&:id)
|
|
676
|
+
|
|
677
|
+
snippets = []
|
|
678
|
+
remaining = budget
|
|
679
|
+
|
|
680
|
+
recalled_event_ids.each do |eid|
|
|
681
|
+
event = recalled_events[eid]
|
|
682
|
+
next unless event
|
|
683
|
+
|
|
684
|
+
text = format_recall_snippet(event)
|
|
685
|
+
cost = [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
686
|
+
break if cost > remaining && snippets.any?
|
|
687
|
+
|
|
688
|
+
snippets << text
|
|
689
|
+
remaining -= cost
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
return [] if snippets.empty?
|
|
693
|
+
|
|
694
|
+
[{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Formats a recalled event as a compact snippet with enough context
|
|
698
|
+
# for the agent to decide whether to drill down with the remember tool.
|
|
699
|
+
#
|
|
700
|
+
# @param event [Event] the recalled event
|
|
701
|
+
# @return [String] formatted snippet
|
|
702
|
+
def format_recall_snippet(event)
|
|
703
|
+
session_label = event.session.name || "session ##{event.session_id}"
|
|
704
|
+
content = extract_event_content(event).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Event::BYTES_PER_TOKEN)
|
|
705
|
+
"event #{event.id} (#{session_label}): #{content}"
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Extracts readable content from an event's payload.
|
|
709
|
+
#
|
|
710
|
+
# @param event [Event]
|
|
711
|
+
# @return [String]
|
|
712
|
+
def extract_event_content(event)
|
|
713
|
+
data = event.payload
|
|
714
|
+
case event.event_type
|
|
715
|
+
when "user_message", "agent_message", "system_message"
|
|
716
|
+
data["content"]
|
|
717
|
+
when "tool_call"
|
|
718
|
+
if data["tool_name"] == Event::THINK_TOOL
|
|
719
|
+
data.dig("tool_input", "thoughts")
|
|
720
|
+
else
|
|
721
|
+
"#{data["tool_name"]}(…)"
|
|
722
|
+
end
|
|
723
|
+
else
|
|
724
|
+
data["content"]
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
399
728
|
# Converts a chronological list of events into Anthropic wire-format messages.
|
|
400
729
|
# Prepends a compact timestamp to each user message for LLM time awareness.
|
|
401
730
|
# 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
|
|
@@ -4,7 +4,18 @@
|
|
|
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 AgentRequestJob.
|
|
10
|
+
Events::Bus.subscribe(Events::Subscribers::Persister.new)
|
|
11
|
+
|
|
12
|
+
# Schedules AgentRequestJob when a non-pending user message is emitted.
|
|
13
|
+
Events::Bus.subscribe(Events::Subscribers::AgentDispatcher.new)
|
|
14
|
+
|
|
15
|
+
# Bridges transient events (e.g. BounceBack) to ActionCable for client delivery.
|
|
16
|
+
Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
|
|
17
|
+
|
|
18
|
+
# Routes text messages between parent and sub-agent sessions via @mentions.
|
|
19
|
+
Events::Bus.subscribe(Events::Subscribers::SubagentMessageRouter.new)
|
|
20
|
+
end
|
|
10
21
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates an FTS5 virtual table for full-text search over event content.
|
|
4
|
+
# Indexes user messages, agent messages, and think events across all sessions.
|
|
5
|
+
#
|
|
6
|
+
# FTS5 is SQLite's built-in full-text search extension — zero external
|
|
7
|
+
# dependencies, fast keyword matching. The interface is abstracted behind
|
|
8
|
+
# Mneme::Search so a future semantic search backend can swap in without
|
|
9
|
+
# changing callers.
|
|
10
|
+
#
|
|
11
|
+
# Content is stored only in the events table (contentless FTS via content="").
|
|
12
|
+
# The FTS index is kept in sync by triggers on INSERT and DELETE.
|
|
13
|
+
class CreateEventsFtsIndex < ActiveRecord::Migration[8.1]
|
|
14
|
+
def up
|
|
15
|
+
# FTS5 virtual table — contentless (content="" means no duplicate storage).
|
|
16
|
+
# Columns: event_id (for joins), session_id (for scoping), searchable_text.
|
|
17
|
+
execute <<~SQL
|
|
18
|
+
CREATE VIRTUAL TABLE events_fts USING fts5(
|
|
19
|
+
searchable_text,
|
|
20
|
+
content='',
|
|
21
|
+
contentless_delete=1,
|
|
22
|
+
tokenize='porter unicode61'
|
|
23
|
+
);
|
|
24
|
+
SQL
|
|
25
|
+
|
|
26
|
+
# Populate from existing events: user/agent messages + think tool_calls.
|
|
27
|
+
execute <<~SQL
|
|
28
|
+
INSERT INTO events_fts(rowid, searchable_text)
|
|
29
|
+
SELECT e.id,
|
|
30
|
+
CASE
|
|
31
|
+
WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
32
|
+
THEN json_extract(e.payload, '$.content')
|
|
33
|
+
WHEN e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think'
|
|
34
|
+
THEN json_extract(e.payload, '$.tool_input.thoughts')
|
|
35
|
+
END
|
|
36
|
+
FROM events e
|
|
37
|
+
WHERE (e.event_type IN ('user_message', 'agent_message', 'system_message'))
|
|
38
|
+
OR (e.event_type = 'tool_call' AND json_extract(e.payload, '$.tool_name') = 'think');
|
|
39
|
+
SQL
|
|
40
|
+
|
|
41
|
+
# Auto-index new searchable events on INSERT.
|
|
42
|
+
execute <<~SQL
|
|
43
|
+
CREATE TRIGGER events_fts_insert AFTER INSERT ON events
|
|
44
|
+
WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
45
|
+
OR (NEW.event_type = 'tool_call' AND json_extract(NEW.payload, '$.tool_name') = 'think')
|
|
46
|
+
BEGIN
|
|
47
|
+
INSERT INTO events_fts(rowid, searchable_text)
|
|
48
|
+
VALUES (
|
|
49
|
+
NEW.id,
|
|
50
|
+
CASE
|
|
51
|
+
WHEN NEW.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
52
|
+
THEN json_extract(NEW.payload, '$.content')
|
|
53
|
+
WHEN NEW.event_type = 'tool_call'
|
|
54
|
+
THEN json_extract(NEW.payload, '$.tool_input.thoughts')
|
|
55
|
+
END
|
|
56
|
+
);
|
|
57
|
+
END;
|
|
58
|
+
SQL
|
|
59
|
+
|
|
60
|
+
# Remove from FTS index when events are deleted.
|
|
61
|
+
# contentless_delete=1 tables use DELETE FROM, not the insert-based delete command.
|
|
62
|
+
execute <<~SQL
|
|
63
|
+
CREATE TRIGGER events_fts_delete AFTER DELETE ON events
|
|
64
|
+
WHEN OLD.event_type IN ('user_message', 'agent_message', 'system_message')
|
|
65
|
+
OR (OLD.event_type = 'tool_call' AND json_extract(OLD.payload, '$.tool_name') = 'think')
|
|
66
|
+
BEGIN
|
|
67
|
+
DELETE FROM events_fts WHERE rowid = OLD.id;
|
|
68
|
+
END;
|
|
69
|
+
SQL
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def down
|
|
73
|
+
execute "DROP TRIGGER IF EXISTS events_fts_insert"
|
|
74
|
+
execute "DROP TRIGGER IF EXISTS events_fts_delete"
|
|
75
|
+
execute "DROP TABLE IF EXISTS events_fts"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stores passive recall results on the session for viewport injection.
|
|
4
|
+
# Cached here so the viewport assembly doesn't need to re-run search
|
|
5
|
+
# on every LLM call — only refreshed when goals change.
|
|
6
|
+
class AddRecalledEventIdsToSessions < ActiveRecord::Migration[8.1]
|
|
7
|
+
def change
|
|
8
|
+
add_column :sessions, :recalled_event_ids, :json, default: [], null: false
|
|
9
|
+
end
|
|
10
|
+
end
|