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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +47 -0
  4. data/README.md +60 -26
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +29 -10
  7. data/app/decorators/tool_call_decorator.rb +7 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +90 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +18 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +335 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/config/initializers/event_subscribers.rb +14 -3
  22. data/config/initializers/fts5_schema_dump.rb +21 -0
  23. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  24. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  25. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  26. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  27. data/lib/agent_loop.rb +63 -20
  28. data/lib/analytical_brain/runner.rb +158 -65
  29. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  30. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  31. data/lib/anima/cli.rb +2 -1
  32. data/lib/anima/installer.rb +11 -12
  33. data/lib/anima/settings.rb +41 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/events/bounce_back.rb +37 -0
  36. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  37. data/lib/events/subscribers/persister.rb +17 -0
  38. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  39. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  40. data/lib/llm/client.rb +16 -8
  41. data/lib/mneme/compressed_viewport.rb +200 -0
  42. data/lib/mneme/l2_runner.rb +138 -0
  43. data/lib/mneme/passive_recall.rb +69 -0
  44. data/lib/mneme/runner.rb +254 -0
  45. data/lib/mneme/search.rb +150 -0
  46. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  47. data/lib/mneme/tools/everything_ok.rb +24 -0
  48. data/lib/mneme/tools/save_snapshot.rb +68 -0
  49. data/lib/mneme.rb +29 -0
  50. data/lib/providers/anthropic.rb +57 -13
  51. data/lib/shell_session.rb +188 -59
  52. data/lib/tasks/fts5.rake +6 -0
  53. data/lib/tools/remember.rb +179 -0
  54. data/lib/tools/spawn_specialist.rb +21 -9
  55. data/lib/tools/spawn_subagent.rb +22 -11
  56. data/lib/tools/subagent_prompts.rb +20 -3
  57. data/lib/tools/web_get.rb +15 -6
  58. data/lib/tui/app.rb +222 -125
  59. data/lib/tui/decorators/base_decorator.rb +165 -0
  60. data/lib/tui/decorators/bash_decorator.rb +20 -0
  61. data/lib/tui/decorators/edit_decorator.rb +19 -0
  62. data/lib/tui/decorators/read_decorator.rb +24 -0
  63. data/lib/tui/decorators/think_decorator.rb +36 -0
  64. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  65. data/lib/tui/decorators/write_decorator.rb +19 -0
  66. data/lib/tui/flash.rb +139 -0
  67. data/lib/tui/formatting.rb +28 -0
  68. data/lib/tui/height_map.rb +93 -0
  69. data/lib/tui/message_store.rb +25 -1
  70. data/lib/tui/performance_logger.rb +90 -0
  71. data/lib/tui/screens/chat.rb +358 -133
  72. data/templates/config.toml +40 -0
  73. metadata +83 -4
  74. data/CHANGELOG.md +0 -80
  75. data/Gemfile +0 -17
  76. data/lib/tools/return_result.rb +0 -81
@@ -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
- # Includes user/agent messages and tool call/response events in
205
- # Anthropic's wire format. Consecutive tool_call events are grouped
206
- # into a single assistant message; consecutive tool_response events
207
- # are grouped into a single user message with tool_result blocks.
208
- # Pending messages are excluded they haven't been delivered yet.
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
- assemble_messages(viewport_events(token_budget: token_budget, include_pending: false))
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
- # Global persister handles events from all sessions (brain server, background jobs).
8
- # Skipped in test specs manage their own persisters for isolation.
9
- Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
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