anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -3,12 +3,17 @@
3
3
  module Events
4
4
  module Subscribers
5
5
  # Persists all events to SQLite as they flow through the event bus.
6
- # Each event is written as an Event record belonging to the active session.
6
+ # Each event is written as a Message record belonging to the active session.
7
7
  #
8
8
  # When initialized with a specific session, all events are saved to that
9
9
  # session. When initialized without one (global mode), the session is
10
10
  # looked up from the event's session_id payload field.
11
11
  #
12
+ # User messages are NOT persisted here — they are created directly by
13
+ # their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
14
+ # message ID is available for bounce-back cleanup. Pending user
15
+ # messages live in the {PendingMessage} table, outside the event bus.
16
+ #
12
17
  # @example Session-scoped
13
18
  # persister = Events::Subscribers::Persister.new(session)
14
19
  # Events::Bus.subscribe(persister)
@@ -28,10 +33,10 @@ module Events
28
33
 
29
34
  # Receives a Rails.event notification hash and persists it.
30
35
  #
31
- # Skips non-pending user messages — those are persisted by their
32
- # callers ({SessionChannel#speak} for idle sessions,
33
- # {AgentLoop#process} for direct usage). Also skips event types
34
- # not in {Event::TYPES} (transient events like {Events::BounceBack}).
36
+ # Skips user messages — those are persisted by their callers
37
+ # ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
38
+ # types not in {Message::TYPES} (transient events like
39
+ # {Events::BounceBack}).
35
40
  #
36
41
  # @param event [Hash] with :payload containing event data
37
42
  def emit(event)
@@ -40,19 +45,18 @@ module Events
40
45
 
41
46
  event_type = payload[:type]
42
47
  return if event_type.nil?
43
- return unless Event::TYPES.include?(event_type)
44
- return if persisted_by_job?(event_type, payload)
48
+ return unless Message::TYPES.include?(event_type)
49
+ return if event_type == "user_message"
45
50
 
46
51
  target_session = @session || Session.find_by(id: payload[:session_id])
47
52
  return unless target_session
48
53
 
49
54
  @mutex.synchronize do
50
- target_session.events.create!(
51
- event_type: event_type,
55
+ target_session.messages.create!(
56
+ message_type: event_type,
52
57
  payload: payload,
53
- status: payload[:status],
54
58
  tool_use_id: payload[:tool_use_id],
55
- timestamp: payload[:timestamp] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
59
+ timestamp: payload[:timestamp] || Time.current.to_ns
56
60
  )
57
61
  end
58
62
  end
@@ -60,17 +64,6 @@ module Events
60
64
  def session=(new_session)
61
65
  @mutex.synchronize { @session = new_session }
62
66
  end
63
-
64
- private
65
-
66
- # Non-pending user messages are persisted by their callers
67
- # ({SessionChannel#speak}, {AgentLoop#process}) so the event ID
68
- # is available for bounce-back cleanup if LLM delivery fails.
69
- # Pending messages are still auto-persisted here because they
70
- # queue while the session is busy.
71
- def persisted_by_job?(event_type, payload)
72
- event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
73
- end
74
67
  end
75
68
  end
76
69
  end
@@ -14,7 +14,8 @@ module Events
14
14
  #
15
15
  # **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
16
16
  # containing `@name` mentions, the router persists the message in each
17
- # matching child session and wakes them via {AgentRequestJob}.
17
+ # matching child session with a +[from parent]:+ origin label and wakes
18
+ # them via {AgentRequestJob}.
18
19
  #
19
20
  # Both directions delegate to {Session#enqueue_user_message}, which
20
21
  # respects the target session's processing state — persisting directly
@@ -25,9 +26,12 @@ module Events
25
26
  class SubagentMessageRouter
26
27
  include Events::Subscriber
27
28
 
28
- # Attribution prefix format for messages routed from child to parent.
29
- # @example "[sub-agent @loop-sleuth]: Here's what I found..."
30
- ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
29
+ # @see Tools::ResponseTruncator::ATTRIBUTION_FORMAT
30
+ ATTRIBUTION_FORMAT = Tools::ResponseTruncator::ATTRIBUTION_FORMAT
31
+
32
+ # Origin label for messages routed from parent agent to sub-agent.
33
+ # Lets the sub-agent distinguish delegated work from direct user input.
34
+ PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
31
35
 
32
36
  # Regex to extract @mention names from parent agent messages.
33
37
  MENTION_PATTERN = /@(\w[\w-]*)/
@@ -64,7 +68,8 @@ module Events
64
68
  private
65
69
 
66
70
  # Forwards a sub-agent's text message to its parent session
67
- # via {Session#enqueue_user_message}.
71
+ # via {Session#enqueue_user_message}. Truncates oversized messages
72
+ # to protect the parent's context window.
68
73
  #
69
74
  # @param child [Session] the sub-agent session
70
75
  # @param content [String] the sub-agent's message text
@@ -73,13 +78,18 @@ module Events
73
78
  return unless parent
74
79
 
75
80
  name = child.name || "agent-#{child.id}"
76
- attributed = format(ATTRIBUTION_FORMAT, name, content)
81
+ truncated = Tools::ResponseTruncator.truncate(
82
+ content,
83
+ threshold: Anima::Settings.max_subagent_response_chars,
84
+ reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
85
+ )
86
+ attributed = format(ATTRIBUTION_FORMAT, name, truncated)
77
87
 
78
88
  parent.enqueue_user_message(attributed)
79
89
  end
80
90
 
81
91
  # Scans a parent agent's message for @mentions and routes the message
82
- # to each mentioned child session.
92
+ # to each mentioned child session with origin attribution.
83
93
  #
84
94
  # @param parent [Session] the parent session
85
95
  # @param content [String] the parent agent's message text
@@ -90,11 +100,13 @@ module Events
90
100
  active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
91
101
  return if active_children.empty?
92
102
 
103
+ attributed = format(PARENT_ATTRIBUTION_FORMAT, content)
104
+
93
105
  mentioned_names.each do |name|
94
106
  child = active_children[name]
95
107
  next unless child
96
108
 
97
- child.enqueue_user_message(content)
109
+ child.enqueue_user_message(attributed)
98
110
  end
99
111
  end
100
112
  end
@@ -3,8 +3,8 @@
3
3
  module Events
4
4
  module Subscribers
5
5
  # Bridges transient (non-persisted) events to ActionCable so clients
6
- # receive them over WebSocket. Persisted events reach clients via
7
- # {Event::Broadcasting} callbacks; this subscriber handles events
6
+ # receive them over WebSocket. Persisted messages reach clients via
7
+ # {Message::Broadcasting} callbacks; this subscriber handles events
8
8
  # that never touch the database.
9
9
  #
10
10
  # @example Registering at boot
@@ -4,25 +4,14 @@ module Events
4
4
  class UserMessage < Base
5
5
  TYPE = "user_message"
6
6
 
7
- # @return [String, nil] "pending" when queued during active processing, nil otherwise
8
- attr_reader :status
9
-
10
7
  # @param content [String] message text
11
8
  # @param session_id [Integer, nil] session identifier
12
- # @param status [String, nil] "pending" when queued during active agent processing
13
- def initialize(content:, session_id: nil, status: nil)
14
- super(content: content, session_id: session_id)
15
- @status = status
9
+ def initialize(content:, session_id: nil)
10
+ super
16
11
  end
17
12
 
18
13
  def type
19
14
  TYPE
20
15
  end
21
-
22
- def to_h
23
- h = super
24
- h[:status] = status if status
25
- h
26
- end
27
16
  end
28
17
  end
data/lib/llm/client.rb CHANGED
@@ -15,8 +15,8 @@ module LLM
15
15
  # registry.register(Tools::WebGet)
16
16
  # client.chat_with_tools(messages, registry: registry, session_id: session.id)
17
17
  class Client
18
- # Synthetic tool_result message when a tool is skipped due to user interrupt.
19
- INTERRUPT_MESSAGE = "Stopped by user"
18
+ # Synthetic tool_result when a tool is skipped because the human pressed Escape.
19
+ INTERRUPT_MESSAGE = "Your human wants your attention"
20
20
 
21
21
  # @return [Providers::Anthropic] the underlying API provider
22
22
  attr_reader :provider
@@ -65,7 +65,7 @@ module LLM
65
65
  # tool interaction so they're persisted and visible in the event stream.
66
66
  #
67
67
  # When the user interrupts via Escape, remaining tools receive synthetic
68
- # "Stopped by user" results and the loop exits without another LLM call.
68
+ # "Your human wants your attention" results and the loop exits without another LLM call.
69
69
  #
70
70
  # @param messages [Array<Hash>] conversation messages in Anthropic format
71
71
  # @param registry [Tools::Registry] registered tools to make available
@@ -90,6 +90,7 @@ module LLM
90
90
  response = if first_response && rounds == 1
91
91
  first_response
92
92
  else
93
+ broadcast_session_state(session_id, "llm_generating")
93
94
  provider.create_message(
94
95
  model: model,
95
96
  messages: messages,
@@ -109,11 +110,14 @@ module LLM
109
110
  {role: "user", content: tool_results}
110
111
  ]
111
112
 
112
- if interrupted?(session_id)
113
- clear_interrupt!(session_id)
114
- return nil
115
- end
113
+ return nil if handle_interrupt!(session_id)
116
114
  else
115
+ # Discard the text response if the user pressed Escape while
116
+ # the API was generating it. Without this check the interrupt
117
+ # flag set during the blocking API call would be silently
118
+ # cleared by the ensure block in AgentRequestJob.
119
+ return nil if handle_interrupt!(session_id)
120
+
117
121
  return extract_text(response)
118
122
  end
119
123
  end
@@ -151,9 +155,12 @@ module LLM
151
155
  def execute_tools(response, registry, session_id)
152
156
  tool_uses = extract_tool_uses(response)
153
157
  results = []
158
+ interrupted = false
154
159
 
155
160
  tool_uses.each_with_index do |tool_use, index|
156
- if interrupted?(session_id)
161
+ # Check-only here; clearing happens in handle_interrupt! after the loop
162
+ interrupted ||= interrupt_requested?(session_id)
163
+ if interrupted
157
164
  remaining = tool_uses[index..]
158
165
  results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
159
166
  break
@@ -164,7 +171,7 @@ module LLM
164
171
  results
165
172
  end
166
173
 
167
- # Creates synthetic "Stopped by user" results for all tools in the list.
174
+ # Creates synthetic "Your human wants your attention" results for all tools in the list.
168
175
  #
169
176
  # @param tool_uses [Array<Hash>] remaining tool_use content blocks
170
177
  # @param session_id [Integer, String] session ID for events
@@ -188,6 +195,8 @@ module LLM
188
195
 
189
196
  log(:debug, "tool_call: #{name}(#{input.to_json})")
190
197
 
198
+ broadcast_session_state(session_id, "tool_executing", tool: name)
199
+
191
200
  Events::Bus.emit(Events::ToolCall.new(
192
201
  content: "Calling #{name}", tool_name: name,
193
202
  tool_input: input, tool_use_id: id, timeout: timeout,
@@ -197,6 +206,7 @@ module LLM
197
206
  result = registry.execute(name, input)
198
207
  result = ToolDecorator.call(name, result)
199
208
  result_content = format_tool_result(result)
209
+ result_content = truncate_tool_result(result_content, registry, name)
200
210
  log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
201
211
 
202
212
  Events::Bus.emit(Events::ToolResponse.new(
@@ -225,7 +235,7 @@ module LLM
225
235
  {type: "tool_result", tool_use_id: id, content: error_content}
226
236
  end
227
237
 
228
- # Creates a synthetic "Stopped by user" result for a tool that was not
238
+ # Creates a synthetic "Your human wants your attention" result for a tool that was not
229
239
  # executed due to user interrupt. Emits both ToolCall and ToolResponse
230
240
  # events so the TUI shows the interrupted tool in the event stream.
231
241
  #
@@ -238,7 +248,7 @@ module LLM
238
248
  input = tool_use["input"] || {}
239
249
 
240
250
  Events::Bus.emit(Events::ToolCall.new(
241
- content: "Skipped #{name} (interrupted)", tool_name: name,
251
+ content: "Skipped #{name} — your human wants your attention", tool_name: name,
242
252
  tool_input: input, tool_use_id: id, session_id: session_id
243
253
  ))
244
254
 
@@ -250,22 +260,35 @@ module LLM
250
260
  {type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
251
261
  end
252
262
 
253
- # Checks the database for a pending interrupt flag on the session.
263
+ # Checks whether the session has a pending interrupt flag.
254
264
  #
255
265
  # @param session_id [Integer, String] session to check
256
- # @return [Boolean] whether the session has a pending interrupt request
257
- def interrupted?(session_id)
266
+ # @return [Boolean] true when interrupt is pending
267
+ def interrupt_requested?(session_id)
258
268
  Session.where(id: session_id, interrupt_requested: true).exists?
259
269
  end
260
270
 
261
- # Clears the interrupt flag so the agent loop can continue with pending
262
- # messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
263
- # net for unexpected exits.
271
+ # Atomically checks for a pending interrupt and clears it in one query.
272
+ # Used at loop boundaries (after tools, before LLM text return) to
273
+ # short-circuit the agent loop when the user presses Escape.
264
274
  #
265
- # @param session_id [Integer, String] session to clear
275
+ # @param session_id [Integer, String] session to check
276
+ # @return [Boolean] true when interrupt was detected and cleared
277
+ def handle_interrupt!(session_id)
278
+ Session.where(id: session_id, interrupt_requested: true)
279
+ .update_all(interrupt_requested: false) > 0
280
+ end
281
+
282
+ # Broadcasts a session state transition to all subscribed clients.
283
+ # Delegates to {Session#broadcast_session_state} which handles both
284
+ # the session's own stream and the parent's stream for HUD updates.
285
+ #
286
+ # @param session_id [Integer, String] session to broadcast for
287
+ # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
288
+ # @param tool [String, nil] tool name when state is "tool_executing"
266
289
  # @return [void]
267
- def clear_interrupt!(session_id)
268
- Session.where(id: session_id).update_all(interrupt_requested: false)
290
+ def broadcast_session_state(session_id, state, tool: nil)
291
+ Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
269
292
  end
270
293
 
271
294
  def log(level, message)
@@ -277,5 +300,16 @@ module LLM
277
300
  def format_tool_result(result)
278
301
  result.is_a?(Hash) ? result.to_json : result.to_s
279
302
  end
303
+
304
+ # Applies head+tail truncation when a tool result exceeds the tool's
305
+ # configured character threshold. Skips tools that opt out (e.g. read).
306
+ def truncate_tool_result(content, registry, tool_name)
307
+ threshold = registry.truncation_threshold(tool_name)
308
+ return content unless threshold
309
+
310
+ lines = Tools::ResponseTruncator::HEAD_LINES
311
+ reason = "#{tool_name} output displays first/last #{lines} lines"
312
+ Tools::ResponseTruncator.truncate(content, threshold: threshold, reason: reason)
313
+ end
280
314
  end
281
315
  end
data/lib/mcp/config.rb CHANGED
@@ -6,7 +6,7 @@ require "toml-rb"
6
6
  module Mcp
7
7
  # Reads and writes MCP server configuration from a TOML file at
8
8
  # {DEFAULT_PATH}. Supports HTTP and stdio transports. Secrets stored
9
- # in Rails encrypted credentials are interpolated via
9
+ # in the encrypted secrets table are interpolated via
10
10
  # +${credential:key_name}+ syntax in any string value.
11
11
  #
12
12
  # @example Config file format (~/.anima/mcp.toml)
@@ -187,7 +187,7 @@ module Mcp
187
187
  end
188
188
 
189
189
  # Replaces +${credential:key_name}+ placeholders with values from
190
- # Rails encrypted credentials via {Mcp::Secrets}.
190
+ # the encrypted secrets table via {Mcp::Secrets}.
191
191
  #
192
192
  # @param value [String] string potentially containing placeholders
193
193
  # @return [String] interpolated string
data/lib/mcp/secrets.rb CHANGED
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcp
4
- # CRUD operations for MCP server secrets stored in Rails encrypted credentials.
5
- # Secrets live under the +mcp+ namespace in the credentials file:
4
+ # CRUD operations for MCP server secrets stored in the encrypted secrets table.
5
+ # Secrets live under the +mcp+ namespace:
6
6
  #
7
- # mcp:
8
- # linear_api_key: "sk-xxx"
9
- # mythonix_api_key: "Bearer tok-yyy"
7
+ # Mcp::Secrets.set("linear_api_key", "sk-xxx")
8
+ # Mcp::Secrets.get("linear_api_key") #=> "sk-xxx"
10
9
  #
11
10
  # Referenced in mcp.toml via +${credential:key_name}+ syntax, resolved at
12
11
  # runtime by {Mcp::Config#interpolate_credentials}.
@@ -23,7 +22,7 @@ module Mcp
23
22
  VALID_KEY_PATTERN = /\A\w+\z/
24
23
 
25
24
  class << self
26
- # Stores a secret in encrypted credentials.
25
+ # Stores a secret in encrypted storage.
27
26
  #
28
27
  # @param key [String] secret identifier (e.g. "linear_api_key")
29
28
  # @param value [String] secret value
@@ -35,7 +34,7 @@ module Mcp
35
34
  CredentialStore.write(NAMESPACE, key => value)
36
35
  end
37
36
 
38
- # Retrieves a secret from encrypted credentials.
37
+ # Retrieves a secret from encrypted storage.
39
38
  #
40
39
  # @param key [String] secret identifier
41
40
  # @return [String, nil] secret value or nil if not found
@@ -50,7 +49,7 @@ module Mcp
50
49
  CredentialStore.list(NAMESPACE)
51
50
  end
52
51
 
53
- # Removes a secret from encrypted credentials.
52
+ # Removes a secret from encrypted storage.
54
53
  #
55
54
  # @param key [String] secret identifier to remove
56
55
  # @return [void]
@@ -7,16 +7,16 @@ module Mneme
7
7
  # aggregate counters like `[4 tools called]`.
8
8
  #
9
9
  # The viewport is split into three zones separated by delimiters:
10
- # - **Eviction zone** — events about to leave the viewport (upper third)
11
- # - **Middle zone** — events in the middle of the viewport
12
- # - **Recent zone** — the most recent events (lower third)
10
+ # - **Eviction zone** — messages about to leave the viewport (upper third)
11
+ # - **Middle zone** — messages in the middle of the viewport
12
+ # - **Recent zone** — the most recent messages (lower third)
13
13
  #
14
14
  # Zone boundaries are calculated WITH tool call tokens (they affect
15
15
  # position), then tool calls are removed and replaced with counters.
16
16
  #
17
17
  # @example
18
18
  # viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
19
- # viewport.render #=> "── EVICTION ZONE ──\nevent 42 User: ..."
19
+ # viewport.render #=> "── EVICTION ZONE ──\nmessage 42 User: ..."
20
20
  class CompressedViewport
21
21
  ZONE_DELIMITERS = {
22
22
  eviction: "── EVICTION ZONE (upper third) ──",
@@ -26,74 +26,74 @@ module Mneme
26
26
 
27
27
  # @param session [Session] the session to build viewport for
28
28
  # @param token_budget [Integer] total tokens available for Mneme's viewport
29
- # @param from_event_id [Integer, nil] start from this event ID (inclusive);
29
+ # @param from_message_id [Integer, nil] start from this message ID (inclusive);
30
30
  # when nil, uses the session's full viewport
31
- def initialize(session, token_budget:, from_event_id: nil)
31
+ def initialize(session, token_budget:, from_message_id: nil)
32
32
  @session = session
33
33
  @token_budget = token_budget
34
- @from_event_id = from_event_id
34
+ @from_message_id = from_message_id
35
35
  end
36
36
 
37
37
  # Renders the compressed viewport as a string ready for Mneme's LLM context.
38
38
  #
39
39
  # @return [String] compressed viewport with zone delimiters
40
40
  def render
41
- return "" if events.empty?
41
+ return "" if messages.empty?
42
42
 
43
- zones = split_into_zones(events)
43
+ zones = split_into_zones(messages)
44
44
  render_zones(zones)
45
45
  end
46
46
 
47
- # @return [Array<Event>] the raw events selected for this viewport
48
- def events
49
- @events ||= fetch_events
47
+ # @return [Array<Message>] the raw messages selected for this viewport
48
+ def messages
49
+ @messages ||= fetch_messages
50
50
  end
51
51
 
52
52
  private
53
53
 
54
- # Fetches events within token budget, starting from from_event_id.
54
+ # Fetches messages within token budget, starting from from_message_id.
55
55
  # Selects newest-first until budget exhausted, returns chronological.
56
- # Caches per-event token costs in @event_costs for reuse by split_into_zones.
56
+ # Caches per-message token costs in @message_costs for reuse by split_into_zones.
57
57
  #
58
- # @return [Array<Event>]
59
- def fetch_events
60
- scope = @session.events.context_events.deliverable
58
+ # @return [Array<Message>]
59
+ def fetch_messages
60
+ scope = @session.messages.context_messages
61
61
 
62
- if @from_event_id
63
- scope = scope.where("id >= ?", @from_event_id)
62
+ if @from_message_id
63
+ scope = scope.where("id >= ?", @from_message_id)
64
64
  end
65
65
 
66
66
  selected = []
67
- @event_costs = {}
67
+ @message_costs = {}
68
68
  remaining = @token_budget
69
69
 
70
- scope.reorder(id: :desc).each do |event|
71
- cost = event_token_cost(event)
70
+ scope.reorder(id: :desc).each do |message|
71
+ cost = message_token_cost(message)
72
72
  break if cost > remaining && selected.any?
73
73
 
74
- selected << event
75
- @event_costs[event.id] = cost
74
+ selected << message
75
+ @message_costs[message.id] = cost
76
76
  remaining -= cost
77
77
  end
78
78
 
79
79
  selected.reverse
80
80
  end
81
81
 
82
- # Splits events into three zones by token count.
83
- # Zone boundaries are calculated including ALL events (tool calls count
82
+ # Splits messages into three zones by token count.
83
+ # Zone boundaries are calculated including ALL messages (tool calls count
84
84
  # toward position), but zone assignment uses cumulative tokens.
85
85
  #
86
- # @return [Hash{Symbol => Array<Event>}] :eviction, :middle, :recent
87
- def split_into_zones(events)
88
- costs = events.map { |event| [event, @event_costs[event.id] || event_token_cost(event)] }
86
+ # @return [Hash{Symbol => Array<Message>}] :eviction, :middle, :recent
87
+ def split_into_zones(messages)
88
+ costs = messages.map { |message| [message, @message_costs[message.id] || message_token_cost(message)] }
89
89
  zone_size = costs.sum(&:last) / 3.0
90
90
 
91
91
  result = {eviction: [], middle: [], recent: []}
92
92
  cumulative = 0
93
93
 
94
- costs.each do |event, cost|
94
+ costs.each do |message, cost|
95
95
  cumulative += cost
96
- result[zone_for_cumulative(cumulative, zone_size)] << event
96
+ result[zone_for_cumulative(cumulative, zone_size)] << message
97
97
  end
98
98
 
99
99
  result
@@ -101,7 +101,7 @@ module Mneme
101
101
 
102
102
  # Renders zones with delimiters, compressing tool calls into counters.
103
103
  #
104
- # @param zones [Hash{Symbol => Array<Event>}]
104
+ # @param zones [Hash{Symbol => Array<Message>}]
105
105
  # @return [String]
106
106
  def render_zones(zones)
107
107
  %i[eviction middle recent].flat_map { |name|
@@ -124,23 +124,23 @@ module Mneme
124
124
  end
125
125
  end
126
126
 
127
- # Renders a single zone: conversation events as full text, consecutive
127
+ # Renders a single zone: conversation messages as full text, consecutive
128
128
  # tool calls/responses compressed into `[N tools called]` counters.
129
- # tool_response events are intentionally silent — they affect zone boundaries
130
- # via token cost but are not rendered; only tool_call events increment the counter.
129
+ # tool_response messages are intentionally silent — they affect zone boundaries
130
+ # via token cost but are not rendered; only tool_call messages increment the counter.
131
131
  #
132
- # @param zone_events [Array<Event>]
132
+ # @param zone_messages [Array<Message>]
133
133
  # @return [String]
134
- def render_zone(zone_events)
134
+ def render_zone(zone_messages)
135
135
  lines = []
136
136
  tool_count = 0
137
137
 
138
- zone_events.each do |event|
139
- if conversation_event?(event) || think_event?(event)
138
+ zone_messages.each do |message|
139
+ if conversation_message?(message) || think_message?(message)
140
140
  lines << flush_tool_count(tool_count)
141
141
  tool_count = 0
142
- lines << render_event_line(event)
143
- elsif event.event_type == "tool_call"
142
+ lines << render_message_line(message)
143
+ elsif message.message_type == "tool_call"
144
144
  tool_count += 1
145
145
  end
146
146
  end
@@ -149,17 +149,17 @@ module Mneme
149
149
  lines.compact.join("\n")
150
150
  end
151
151
 
152
- # @return [Boolean] true if event is a user/agent/system message
153
- def conversation_event?(event)
154
- event.event_type.in?(Event::CONVERSATION_TYPES)
152
+ # @return [Boolean] true if message is a user/agent/system message
153
+ def conversation_message?(message)
154
+ message.message_type.in?(Message::CONVERSATION_TYPES)
155
155
  end
156
156
 
157
- # Think events are tool_call events with tool_name == "think".
157
+ # Think messages are tool_call messages with tool_name == "think".
158
158
  # They carry the agent's reasoning and are treated as conversation.
159
159
  #
160
160
  # @return [Boolean]
161
- def think_event?(event)
162
- event.event_type == "tool_call" && event.payload["tool_name"] == Event::THINK_TOOL
161
+ def think_message?(message)
162
+ message.message_type == "tool_call" && message.payload["tool_name"] == Message::THINK_TOOL
163
163
  end
164
164
 
165
165
  ROLE_LABELS = {
@@ -168,17 +168,17 @@ module Mneme
168
168
  "system_message" => "System"
169
169
  }.freeze
170
170
 
171
- # Renders a single event as a transcript line.
171
+ # Renders a single message as a transcript line.
172
172
  #
173
- # @param event [Event]
173
+ # @param message [Message]
174
174
  # @return [String]
175
- def render_event_line(event)
176
- prefix = "event #{event.id}"
177
- data = event.payload
178
- if think_event?(event)
175
+ def render_message_line(message)
176
+ prefix = "message #{message.id}"
177
+ data = message.payload
178
+ if think_message?(message)
179
179
  "#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
180
180
  else
181
- "#{prefix} #{ROLE_LABELS.fetch(event.event_type)}: #{data["content"]}"
181
+ "#{prefix} #{ROLE_LABELS.fetch(message.message_type)}: #{data["content"]}"
182
182
  end
183
183
  end
184
184
 
@@ -192,9 +192,9 @@ module Mneme
192
192
  end
193
193
 
194
194
  # @return [Integer] token cost using cached count or heuristic
195
- def event_token_cost(event)
196
- cached = event.token_count
197
- (cached > 0) ? cached : event.estimate_tokens
195
+ def message_token_cost(message)
196
+ cached = message.token_count
197
+ (cached > 0) ? cached : message.estimate_tokens
198
198
  end
199
199
  end
200
200
  end
@@ -110,22 +110,22 @@ module Mneme
110
110
  # @return [Array<Hash>] single-element messages array
111
111
  def build_messages(snapshots)
112
112
  content = snapshots.map.with_index(1) { |snap, idx|
113
- "--- Snapshot #{idx} (events #{snap.from_event_id}..#{snap.to_event_id}) ---\n#{snap.text}"
113
+ "--- Snapshot #{idx} (messages #{snap.from_message_id}..#{snap.to_message_id}) ---\n#{snap.text}"
114
114
  }.join("\n\n")
115
115
 
116
116
  [{role: "user", content: "Compress these #{snapshots.size} Level 1 snapshots into a single Level 2 summary:\n\n#{content}"}]
117
117
  end
118
118
 
119
119
  # Builds the tool registry with L2 context for SaveSnapshot.
120
- # The event range spans from the first L1's start to the last L1's end.
120
+ # The message range spans from the first L1's start to the last L1's end.
121
121
  #
122
122
  # @param snapshots [Array<Snapshot>]
123
123
  # @return [Tools::Registry]
124
124
  def build_registry(snapshots)
125
125
  registry = ::Tools::Registry.new(context: {
126
126
  main_session: @session,
127
- from_event_id: snapshots.first.from_event_id,
128
- to_event_id: snapshots.last.to_event_id,
127
+ from_message_id: snapshots.first.from_message_id,
128
+ to_message_id: snapshots.last.to_message_id,
129
129
  level: 2
130
130
  })
131
131
  TOOLS.each { |tool| registry.register(tool) }