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
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reverse_markdown"
4
+ require "toon"
5
+
6
+ # Transforms {Tools::WebGet} responses for LLM consumption by detecting
7
+ # the Content-Type header and applying format-specific conversion.
8
+ #
9
+ # Content-Type maps to a method name via simple string normalization:
10
+ # "application/json" → {#application_json}
11
+ # "text/html" → {#text_html}
12
+ # "text/plain" → method_missing → passthrough
13
+ #
14
+ # Adding a new format = adding one method. Unknown types fall through
15
+ # {#method_missing} and pass through unchanged.
16
+ #
17
+ # @example
18
+ # decorator = WebGetToolDecorator.new
19
+ # decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
20
+ # #=> "[Converted: HTML → Markdown]\n\n# Hi"
21
+ class WebGetToolDecorator < ToolDecorator
22
+ # HTML elements that carry no useful content for an LLM.
23
+ NOISE_TAGS = %w[script style nav footer aside form noscript iframe
24
+ svg header menu menuitem].freeze
25
+
26
+ # @param result [Hash] `{body: String, content_type: String}`
27
+ # @return [String] LLM-optimized content with conversion metadata tag
28
+ def call(result)
29
+ return result.to_s unless result.is_a?(Hash) && result.key?(:body)
30
+
31
+ body = result[:body].to_s
32
+ content_type = result[:content_type] || "text/plain"
33
+ decorated = decorate(body, content_type: content_type)
34
+
35
+ assemble(**decorated)
36
+ end
37
+
38
+ # Dispatches to the format-specific method derived from Content-Type.
39
+ #
40
+ # @param body [String] raw response body
41
+ # @param content_type [String] HTTP Content-Type header value
42
+ # @return [Hash] `{text: String, meta: String|nil}`
43
+ def decorate(body, content_type:)
44
+ method_name = content_type.split(";").first.strip.tr("/", "_").tr("-", "_")
45
+ public_send(method_name, body)
46
+ end
47
+
48
+ # Compresses JSON using TOON (Token-Optimized Object Notation) for
49
+ # ~40% token savings on typical JSON arrays.
50
+ #
51
+ # @param body [String] JSON response body
52
+ # @return [Hash] `{text: String, meta: String}`
53
+ def application_json(body)
54
+ parsed = JSON.parse(body)
55
+ {text: Toon.encode(parsed), meta: "[Converted: JSON → TOON]"}
56
+ rescue JSON::ParserError
57
+ {text: body, meta: nil}
58
+ end
59
+
60
+ # Strips noise elements (scripts, styles, nav, ads) and converts
61
+ # semantic HTML to Markdown for clean LLM consumption.
62
+ #
63
+ # @param body [String] HTML response body
64
+ # @return [Hash] `{text: String, meta: String}`
65
+ def text_html(body)
66
+ markdown = html_to_markdown(body)
67
+ {text: markdown, meta: "[Converted: HTML → Markdown]"}
68
+ end
69
+
70
+ # Passthrough for unregistered content types.
71
+ #
72
+ # @return [Hash] `{text: String, meta: nil}`
73
+ def method_missing(_method_name, body, *)
74
+ {text: body, meta: nil}
75
+ end
76
+
77
+ def respond_to_missing?(*, **)
78
+ true
79
+ end
80
+
81
+ private
82
+
83
+ # Strips noise HTML elements then converts to Markdown.
84
+ #
85
+ # @param html [String] raw HTML
86
+ # @return [String] clean Markdown
87
+ def html_to_markdown(html)
88
+ doc = Nokogiri::HTML(html)
89
+ doc.css(NOISE_TAGS.join(", ")).remove
90
+ clean_html = doc.at("body")&.inner_html || doc.to_html
91
+ markdown = ReverseMarkdown.convert(clean_html, unknown_tags: :bypass, github_flavored: true)
92
+ collapse_whitespace(markdown)
93
+ end
94
+
95
+ # Collapses excessive blank lines down to a single blank line.
96
+ #
97
+ # @param text [String]
98
+ # @return [String]
99
+ def collapse_whitespace(text)
100
+ text.gsub(/\n{3,}/, "\n\n").strip
101
+ end
102
+ end
@@ -3,19 +3,28 @@
3
3
  # Executes an LLM agent loop as a background job with retry logic
4
4
  # for transient failures (network errors, rate limits, server errors).
5
5
  #
6
- # Emits events via {Events::Bus} as it progresses, making results visible
7
- # to any subscriber (TUI, WebSocket clients). All retry and failure
8
- # notifications are emitted as {Events::SystemMessage} to avoid polluting
9
- # the LLM context window.
6
+ # Supports two modes:
10
7
  #
11
- # @example Inline execution (TUI)
12
- # AgentRequestJob.perform_now(session.id)
8
+ # **Bounce Back (content provided):** Persists the user event and verifies
9
+ # LLM delivery inside a single transaction. If the first API call fails,
10
+ # the transaction rolls back (event never existed) and a {Events::BounceBack}
11
+ # is emitted so clients can restore the text to the input field.
13
12
  #
14
- # @example Background execution (future Brain/TUI separation)
13
+ # **Standard (no content):** Processes already-persisted events (e.g. after
14
+ # pending message promotion). Uses ActiveJob retry/discard for error handling.
15
+ #
16
+ # @example Bounce Back — event-driven via AgentDispatcher
17
+ # AgentRequestJob.perform_later(session.id, content: "hello")
18
+ #
19
+ # @example Standard — pending message processing
15
20
  # AgentRequestJob.perform_later(session.id)
16
21
  class AgentRequestJob < ApplicationJob
17
22
  queue_as :default
18
23
 
24
+ # ActionCable action signaling clients to prompt for an API token.
25
+ AUTH_REQUIRED_ACTION = "authentication_required"
26
+
27
+ # Standard path only — bounce back handles its own errors.
19
28
  retry_on Providers::Anthropic::TransientError,
20
29
  wait: :polynomially_longer, attempts: 5 do |job, error|
21
30
  Events::Bus.emit(Events::SystemMessage.new(
@@ -27,40 +36,41 @@ class AgentRequestJob < ApplicationJob
27
36
  discard_on ActiveRecord::RecordNotFound
28
37
  discard_on Providers::Anthropic::AuthenticationError do |job, error|
29
38
  session_id = job.arguments.first
30
- # Persistent system message for the event log
31
39
  Events::Bus.emit(Events::SystemMessage.new(
32
40
  content: "Authentication failed: #{error.message}",
33
41
  session_id: session_id
34
42
  ))
35
- # Transient signal to trigger TUI token setup popup (not persisted)
36
43
  ActionCable.server.broadcast(
37
44
  "session_#{session_id}",
38
- {"action" => "authentication_required", "message" => error.message}
45
+ {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
39
46
  )
40
47
  end
41
48
 
42
49
  # @param session_id [Integer] ID of the session to process
43
- def perform(session_id)
50
+ # @param content [String, nil] user message text (triggers Bounce Back when present)
51
+ def perform(session_id, content: nil)
44
52
  session = Session.find(session_id)
45
53
 
46
- # Atomic: only one job processes a session at a time. If another job
47
- # is already running, this one exits — the running job will pick up
48
- # any pending messages after its current loop completes.
54
+ # Atomic: only one job processes a session at a time.
49
55
  return unless claim_processing(session_id)
50
56
 
51
- # Run analytical brain BEFORE the main agent on user messages so
52
- # activated skills are available for the current response.
53
57
  run_analytical_brain_blocking(session)
54
58
 
55
59
  agent_loop = AgentLoop.new(session: session)
56
- loop do
60
+
61
+ if content
62
+ deliver_with_bounce_back(session, content, agent_loop)
63
+ else
57
64
  agent_loop.run
65
+ end
66
+
67
+ # Process any pending messages queued while we were busy.
68
+ loop do
58
69
  promoted = session.promote_pending_messages!
59
70
  break if promoted == 0
71
+ agent_loop.run
60
72
  end
61
73
 
62
- # Non-blocking analytical brain run after agent completes —
63
- # handles post-response updates (renaming, skill changes).
64
74
  session.schedule_analytical_brain!
65
75
  ensure
66
76
  release_processing(session_id)
@@ -70,6 +80,60 @@ class AgentRequestJob < ApplicationJob
70
80
 
71
81
  private
72
82
 
83
+ # Persists the user event and verifies LLM delivery atomically.
84
+ #
85
+ # Inside a transaction: creates the event record, broadcasts it for
86
+ # optimistic UI, and makes the first LLM API call. If the call fails,
87
+ # a {Events::BounceBack} is emitted and the exception re-raised to
88
+ # trigger rollback — the event never existed in the database.
89
+ #
90
+ # After commit: continues the agent loop (tool execution, subsequent
91
+ # API calls) outside the transaction so tool events broadcast in
92
+ # real time.
93
+ #
94
+ # @param session [Session] the conversation session
95
+ # @param content [String] user message text
96
+ # @param agent_loop [AgentLoop] agent loop instance (reused after commit)
97
+ def deliver_with_bounce_back(session, content, agent_loop)
98
+ event_id = nil
99
+
100
+ ActiveRecord::Base.transaction do
101
+ event = persist_user_event(session, content)
102
+ event_id = event.id
103
+ event.broadcast_now!
104
+
105
+ agent_loop.deliver!
106
+ rescue => error
107
+ Events::Bus.emit(Events::BounceBack.new(
108
+ content: content,
109
+ error: error.message,
110
+ session_id: session.id,
111
+ event_id: event_id
112
+ ))
113
+ raise
114
+ end
115
+
116
+ # Transaction committed — first call succeeded.
117
+ # Continue processing (tool execution, etc.) outside the transaction.
118
+ agent_loop.run
119
+ rescue => error
120
+ # Bounce already emitted inside the transaction rescue.
121
+ # Also trigger auth popup for authentication errors.
122
+ broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
123
+ end
124
+
125
+ # @see Session#create_user_event
126
+ def persist_user_event(session, content)
127
+ session.create_user_event(content)
128
+ end
129
+
130
+ def broadcast_auth_required(session_id, error)
131
+ ActionCable.server.broadcast(
132
+ "session_#{session_id}",
133
+ {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
134
+ )
135
+ end
136
+
73
137
  # Runs the analytical brain synchronously before the main agent loop.
74
138
  # Respects the blocking_on_user_message setting and session guards
75
139
  # (skips sub-agents and sessions with too few messages).
@@ -88,18 +152,21 @@ class AgentRequestJob < ApplicationJob
88
152
 
89
153
  # Sets the session's processing flag atomically. Returns true if this
90
154
  # job claimed the lock, false if another job already holds it.
155
+ # Broadcasts the state change to the parent session's HUD.
91
156
  def claim_processing(session_id)
92
- Session.where(id: session_id, processing: false).update_all(processing: true) == 1
157
+ claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
158
+ Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
159
+ claimed
93
160
  end
94
161
 
95
162
  # Clears the processing flag so the session can accept new jobs.
163
+ # Broadcasts the state change to the parent session's HUD.
96
164
  def release_processing(session_id)
97
165
  Session.where(id: session_id).update_all(processing: false)
166
+ Session.find_by(id: session_id)&.broadcast_children_update_to_parent
98
167
  end
99
168
 
100
- # Safety-net clearing of the interrupt flag. The primary clear happens in
101
- # {LLM::Client#clear_interrupt!} after handling the interrupt; this ensures
102
- # the flag is reset even if the job crashes before reaching that code path.
169
+ # Safety-net clearing of the interrupt flag.
103
170
  def clear_interrupt(session_id)
104
171
  Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
105
172
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs the Mneme memory department — a phantom LLM loop that observes
4
+ # the main session and creates summaries of conversation context before
5
+ # it evicts from the viewport.
6
+ #
7
+ # Triggered when the terminal event ({Session#mneme_boundary_event_id})
8
+ # leaves the viewport, indicating that meaningful context is about to
9
+ # be lost.
10
+ #
11
+ # After L1 snapshot creation, checks whether enough uncovered L1 snapshots
12
+ # have accumulated to trigger Level 2 compression.
13
+ #
14
+ # @example
15
+ # MnemeJob.perform_later(session.id)
16
+ class MnemeJob < ApplicationJob
17
+ queue_as :default
18
+
19
+ retry_on Providers::Anthropic::TransientError,
20
+ wait: :polynomially_longer, attempts: 3
21
+
22
+ discard_on ActiveRecord::RecordNotFound
23
+ discard_on Providers::Anthropic::AuthenticationError
24
+
25
+ # @param session_id [Integer] the main Session to summarize
26
+ def perform(session_id)
27
+ session = Session.find(session_id)
28
+ log.info("job started for session=#{session_id}")
29
+ Mneme::Runner.new(session).call
30
+ check_l2_compression(session)
31
+ rescue => error
32
+ log.error("FAILED session=#{session_id}: #{error.class}: #{error.message}")
33
+ raise
34
+ end
35
+
36
+ private
37
+
38
+ # Triggers L2 compression when enough uncovered L1 snapshots accumulate.
39
+ # Runs inline (same job) since L2 compression is a small, fast LLM call.
40
+ def check_l2_compression(session)
41
+ uncovered = session.snapshots.for_level(1).not_covered_by_l2.count
42
+ threshold = Anima::Settings.mneme_l2_snapshot_threshold
43
+
44
+ if uncovered >= threshold
45
+ log.info("session=#{session.id} — #{uncovered} uncovered L1 snapshots, triggering L2 compression")
46
+ Mneme::L2Runner.new(session).call
47
+ end
48
+ end
49
+
50
+ def log = Mneme.logger
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs passive recall after goal updates — searches event history for
4
+ # context relevant to active goals and caches results on the session
5
+ # for viewport injection.
6
+ #
7
+ # Idempotent: multiple enqueues for the same session safely overwrite
8
+ # each other's results — last one wins.
9
+ #
10
+ # @example
11
+ # PassiveRecallJob.perform_later(session.id)
12
+ class PassiveRecallJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ discard_on ActiveRecord::RecordNotFound
16
+
17
+ # @param session_id [Integer]
18
+ def perform(session_id)
19
+ session = Session.find(session_id)
20
+ results = Mneme::PassiveRecall.new(session).call
21
+
22
+ if results.any?
23
+ session.update_column(:recalled_event_ids, results.map(&:event_id))
24
+ Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
+ elsif session.recalled_event_ids.present?
26
+ session.update_column(:recalled_event_ids, [])
27
+ end
28
+ end
29
+ end
@@ -44,9 +44,23 @@ module Event::Broadcasting
44
44
  after_update_commit :broadcast_update
45
45
  end
46
46
 
47
+ # Broadcasts this event immediately, bypassing +after_create_commit+.
48
+ # Used inside a wrapping transaction where +after_create_commit+ is
49
+ # deferred until the outer transaction commits. Gives clients
50
+ # optimistic UI — the event appears right away and is removed via
51
+ # bounce if the transaction rolls back.
52
+ #
53
+ # Sets a flag so the deferred +after_create_commit+ callback skips
54
+ # the duplicate broadcast after the transaction commits.
55
+ def broadcast_now!
56
+ @already_broadcast = true
57
+ broadcast_event(action: ACTION_CREATE)
58
+ end
59
+
47
60
  private
48
61
 
49
62
  def broadcast_create
63
+ return if @already_broadcast
50
64
  broadcast_event(action: ACTION_CREATE)
51
65
  end
52
66
 
@@ -76,6 +90,10 @@ module Event::Broadcasting
76
90
  evicted_ids = session.recalculate_viewport!
77
91
  broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
78
92
 
93
+ # The nil? branch fires on every broadcast until boundary initializes, but
94
+ # schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
95
+ session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
96
+
79
97
  ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
80
98
  end
81
99
  end
data/app/models/event.rb CHANGED
@@ -21,6 +21,8 @@ class Event < ApplicationRecord
21
21
  TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
22
22
  LLM_TYPES = %w[user_message agent_message].freeze
23
23
  CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
24
+ CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
25
+ THINK_TOOL = "think"
24
26
  PENDING_STATUS = "pending"
25
27
 
26
28
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
@@ -29,6 +31,7 @@ class Event < ApplicationRecord
29
31
  BYTES_PER_TOKEN = 4
30
32
 
31
33
  belongs_to :session
34
+ has_many :pinned_events, dependent: :destroy
32
35
 
33
36
  validates :event_type, presence: true, inclusion: {in: TYPES}
34
37
  validates :payload, presence: true
@@ -78,6 +81,13 @@ class Event < ApplicationRecord
78
81
  status == PENDING_STATUS
79
82
  end
80
83
 
84
+ # @return [Boolean] true if this is a conversation event (user/agent/system message)
85
+ # or a think tool_call — the events Mneme treats as "conversation" for boundary tracking
86
+ def conversation_or_think?
87
+ event_type.in?(CONVERSATION_TYPES) ||
88
+ (event_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
89
+ end
90
+
81
91
  # Heuristic token estimate: ~4 bytes per token for English prose.
82
92
  # Tool events are estimated from the full payload JSON since tool_input
83
93
  # and tool metadata contribute to token count. Messages use content only.
data/app/models/goal.rb CHANGED
@@ -13,6 +13,8 @@ class Goal < ApplicationRecord
13
13
  belongs_to :session
14
14
  belongs_to :parent_goal, class_name: "Goal", optional: true
15
15
  has_many :sub_goals, -> { order(:created_at) }, class_name: "Goal", foreign_key: :parent_goal_id, dependent: :destroy
16
+ has_many :goal_pinned_events, dependent: :destroy
17
+ has_many :pinned_events, through: :goal_pinned_events
16
18
 
17
19
  validates :description, presence: true
18
20
  validates :status, inclusion: {in: STATUSES}
@@ -24,10 +26,14 @@ class Goal < ApplicationRecord
24
26
  scope :root, -> { where(parent_goal_id: nil) }
25
27
 
26
28
  after_commit :broadcast_goals_update
29
+ after_commit :schedule_passive_recall, on: [:create, :update]
27
30
 
28
31
  # @return [Boolean] true if this goal has been completed
29
32
  def completed? = status == "completed"
30
33
 
34
+ # @return [Boolean] true if this goal is still active
35
+ def active? = status == "active"
36
+
31
37
  # @return [Boolean] true if this is a root goal (no parent)
32
38
  def root? = !parent_goal_id
33
39
 
@@ -46,6 +52,17 @@ class Goal < ApplicationRecord
46
52
  sub_goals.active.update_all(status: "completed", completed_at: now, updated_at: now)
47
53
  end
48
54
 
55
+ # Releases pinned events that have no remaining active Goal references
56
+ # anywhere in the session. Called after goal (and cascade) completion —
57
+ # the orphaned scope checks all Goals, so pins shared with other active
58
+ # Goals survive automatically via reference counting.
59
+ #
60
+ # @return [Integer] number of released pins
61
+ def release_orphaned_pins!
62
+ orphaned = session.pinned_events.orphaned
63
+ orphaned.destroy_all.size
64
+ end
65
+
49
66
  # Serializes this goal for ActionCable broadcast and TUI display.
50
67
  # Includes nested sub-goals for root goals.
51
68
  #
@@ -76,6 +93,16 @@ class Goal < ApplicationRecord
76
93
  errors.add(:parent_goal, "cannot nest deeper than two levels")
77
94
  end
78
95
 
96
+ # Triggers passive recall when goals change so relevant memories
97
+ # surface in the viewport automatically.
98
+ #
99
+ # @return [void]
100
+ def schedule_passive_recall
101
+ return if session.sub_agent?
102
+
103
+ PassiveRecallJob.perform_later(session_id)
104
+ end
105
+
79
106
  # Broadcasts goal changes to all clients subscribed to this session.
80
107
  # Mirrors the Session#broadcast_active_skills_update pattern so the
81
108
  # TUI info panel updates reactively.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Join record linking a {Goal} to a {PinnedEvent}. Many-to-many: one event
4
+ # can be pinned to multiple Goals, and one Goal can reference multiple pins.
5
+ # When the last Goal referencing a pin completes, the pin is released.
6
+ class GoalPinnedEvent < ApplicationRecord
7
+ belongs_to :goal
8
+ belongs_to :pinned_event
9
+
10
+ validates :pinned_event_id, uniqueness: {scope: :goal_id}
11
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A conversation event pinned to one or more Goals by Mneme to protect it
4
+ # from viewport eviction. Pinned events appear in the Goals section of
5
+ # the viewport, giving the main agent access to critical context that
6
+ # would otherwise scroll out of the sliding window.
7
+ #
8
+ # Pinning is goal-scoped: when all Goals referencing a pin complete,
9
+ # the pin is automatically released (reference-counted cleanup).
10
+ #
11
+ # @!attribute display_text
12
+ # @return [String] truncated event content (~200 chars) shown in the Goals section
13
+ class PinnedEvent < ApplicationRecord
14
+ # Display text limit — enough to recognize content, cheap on tokens.
15
+ MAX_DISPLAY_TEXT_LENGTH = 200
16
+
17
+ belongs_to :event
18
+
19
+ has_many :goal_pinned_events, dependent: :destroy
20
+ has_many :goals, through: :goal_pinned_events
21
+
22
+ validates :display_text, presence: true, length: {maximum: MAX_DISPLAY_TEXT_LENGTH}
23
+ validates :event_id, uniqueness: true
24
+
25
+ # Pinned events with no remaining active goals — safe to release.
26
+ #
27
+ # @return [ActiveRecord::Relation]
28
+ scope :orphaned, -> {
29
+ where.not(
30
+ "EXISTS (SELECT 1 FROM goal_pinned_events gpe " \
31
+ "JOIN goals ON goals.id = gpe.goal_id " \
32
+ "WHERE gpe.pinned_event_id = pinned_events.id " \
33
+ "AND goals.status = 'active')"
34
+ )
35
+ }
36
+
37
+ # @return [Integer] token cost estimate for viewport budget accounting
38
+ def token_cost
39
+ [(display_text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
40
+ end
41
+ end