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
@@ -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,30 @@
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
+ # **Immediate Persist (event_id provided):** The user event was already
9
+ # persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
10
+ # The job verifies LLM delivery — if the first API call fails, the
11
+ # event is deleted and a {Events::BounceBack} is emitted so clients
12
+ # can restore the text to the input field.
13
13
  #
14
- # @example Background execution (future Brain/TUI separation)
14
+ # **Standard (no event_id):** Processes already-persisted events (e.g.
15
+ # after pending message promotion). Uses ActiveJob retry/discard for
16
+ # error handling.
17
+ #
18
+ # @example Immediate Persist — event already saved by SessionChannel
19
+ # AgentRequestJob.perform_later(session.id, event_id: 42)
20
+ #
21
+ # @example Standard — pending message processing
15
22
  # AgentRequestJob.perform_later(session.id)
16
23
  class AgentRequestJob < ApplicationJob
17
24
  queue_as :default
18
25
 
26
+ # ActionCable action signaling clients to prompt for an API token.
27
+ AUTH_REQUIRED_ACTION = "authentication_required"
28
+
29
+ # Standard path only — immediate persist handles its own errors.
19
30
  retry_on Providers::Anthropic::TransientError,
20
31
  wait: :polynomially_longer, attempts: 5 do |job, error|
21
32
  Events::Bus.emit(Events::SystemMessage.new(
@@ -27,40 +38,41 @@ class AgentRequestJob < ApplicationJob
27
38
  discard_on ActiveRecord::RecordNotFound
28
39
  discard_on Providers::Anthropic::AuthenticationError do |job, error|
29
40
  session_id = job.arguments.first
30
- # Persistent system message for the event log
31
41
  Events::Bus.emit(Events::SystemMessage.new(
32
42
  content: "Authentication failed: #{error.message}",
33
43
  session_id: session_id
34
44
  ))
35
- # Transient signal to trigger TUI token setup popup (not persisted)
36
45
  ActionCable.server.broadcast(
37
46
  "session_#{session_id}",
38
- {"action" => "authentication_required", "message" => error.message}
47
+ {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
39
48
  )
40
49
  end
41
50
 
42
51
  # @param session_id [Integer] ID of the session to process
43
- def perform(session_id)
52
+ # @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
53
+ def perform(session_id, event_id: nil)
44
54
  session = Session.find(session_id)
45
55
 
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.
56
+ # Atomic: only one job processes a session at a time.
49
57
  return unless claim_processing(session_id)
50
58
 
51
- # Run analytical brain BEFORE the main agent on user messages so
52
- # activated skills are available for the current response.
53
59
  run_analytical_brain_blocking(session)
54
60
 
55
61
  agent_loop = AgentLoop.new(session: session)
56
- loop do
62
+
63
+ if event_id
64
+ deliver_persisted_event(session, event_id, agent_loop)
65
+ else
57
66
  agent_loop.run
67
+ end
68
+
69
+ # Process any pending messages queued while we were busy.
70
+ loop do
58
71
  promoted = session.promote_pending_messages!
59
72
  break if promoted == 0
73
+ agent_loop.run
60
74
  end
61
75
 
62
- # Non-blocking analytical brain run after agent completes —
63
- # handles post-response updates (renaming, skill changes).
64
76
  session.schedule_analytical_brain!
65
77
  ensure
66
78
  release_processing(session_id)
@@ -70,6 +82,61 @@ class AgentRequestJob < ApplicationJob
70
82
 
71
83
  private
72
84
 
85
+ # Verifies LLM delivery for a pre-persisted user event.
86
+ #
87
+ # The event was already created and broadcast by the caller, so
88
+ # the user sees their message immediately. This method makes the
89
+ # first LLM API call — if it fails, the event is deleted and a
90
+ # {Events::BounceBack} notifies clients to remove the phantom
91
+ # message and restore the text to the input field. For
92
+ # {Providers::Anthropic::AuthenticationError}, an additional
93
+ # +authentication_required+ broadcast prompts the client to show
94
+ # the token entry dialog.
95
+ #
96
+ # Unlike the standard path (which uses +retry_on+ / +discard_on+),
97
+ # all errors here are caught and swallowed after emitting a
98
+ # BounceBack — the job completes normally so ActiveJob does not
99
+ # retry a message the user will re-send manually.
100
+ #
101
+ # After successful delivery, continues the agent loop (tool
102
+ # execution, subsequent API calls).
103
+ #
104
+ # @param session [Session] the conversation session
105
+ # @param event_id [Integer] database ID of the pre-persisted user event
106
+ # @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
107
+ def deliver_persisted_event(session, event_id, agent_loop)
108
+ event = Event.find_by(id: event_id, session_id: session.id)
109
+ # Event may have been deleted between SessionChannel#speak and job
110
+ # execution (e.g. user recalled the message). Exit silently — there
111
+ # is nothing to deliver or bounce back.
112
+ return unless event
113
+
114
+ content = event.payload["content"]
115
+
116
+ begin
117
+ agent_loop.deliver!
118
+ rescue => error
119
+ event.destroy!
120
+ Events::Bus.emit(Events::BounceBack.new(
121
+ content: content,
122
+ error: error.message,
123
+ session_id: session.id,
124
+ event_id: event_id
125
+ ))
126
+ broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
127
+ return
128
+ end
129
+
130
+ agent_loop.run
131
+ end
132
+
133
+ def broadcast_auth_required(session_id, error)
134
+ ActionCable.server.broadcast(
135
+ "session_#{session_id}",
136
+ {"action" => AUTH_REQUIRED_ACTION, "message" => error.message}
137
+ )
138
+ end
139
+
73
140
  # Runs the analytical brain synchronously before the main agent loop.
74
141
  # Respects the blocking_on_user_message setting and session guards
75
142
  # (skips sub-agents and sessions with too few messages).
@@ -88,18 +155,21 @@ class AgentRequestJob < ApplicationJob
88
155
 
89
156
  # Sets the session's processing flag atomically. Returns true if this
90
157
  # job claimed the lock, false if another job already holds it.
158
+ # Broadcasts the state change to the parent session's HUD.
91
159
  def claim_processing(session_id)
92
- Session.where(id: session_id, processing: false).update_all(processing: true) == 1
160
+ claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
161
+ Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
162
+ claimed
93
163
  end
94
164
 
95
165
  # Clears the processing flag so the session can accept new jobs.
166
+ # Broadcasts the state change to the parent session's HUD.
96
167
  def release_processing(session_id)
97
168
  Session.where(id: session_id).update_all(processing: false)
169
+ Session.find_by(id: session_id)&.broadcast_children_update_to_parent
98
170
  end
99
171
 
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.
172
+ # Safety-net clearing of the interrupt flag.
103
173
  def clear_interrupt(session_id)
104
174
  Session.where(id: session_id, interrupt_requested: true).update_all(interrupt_requested: false)
105
175
  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
@@ -76,6 +76,10 @@ module Event::Broadcasting
76
76
  evicted_ids = session.recalculate_viewport!
77
77
  broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
78
78
 
79
+ # The nil? branch fires on every broadcast until boundary initializes, but
80
+ # schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
81
+ session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
82
+
79
83
  ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
80
84
  end
81
85
  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