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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -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 +93 -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 +4 -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 +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
@@ -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
- # 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.
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
- assemble_messages(viewport_events(token_budget: token_budget, include_pending: false))
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
- # 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 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
@@ -5,7 +5,6 @@ default: &default
5
5
  workers:
6
6
  - queues: "*"
7
7
  threads: 3
8
- processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
8
  polling_interval: 0.1
10
9
 
11
10
  development:
@@ -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