anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -18,8 +18,12 @@
18
18
  # @!attribute level
19
19
  # @return [Integer] compression level (1 = from raw messages, 2 = from L1 snapshots)
20
20
  # @!attribute token_count
21
- # @return [Integer] cached token count of the summary text
21
+ # @return [Integer] token count of the summary text. Seeded with a
22
+ # local estimate on create and later refined by {CountTokensJob}
23
+ # using the real Anthropic tokenizer.
22
24
  class Snapshot < ApplicationRecord
25
+ include TokenEstimation
26
+
23
27
  belongs_to :session
24
28
 
25
29
  # 32KB — generous upper bound (~8K tokens). The LLM tool description advises
@@ -30,12 +34,17 @@ class Snapshot < ApplicationRecord
30
34
  validates :from_message_id, presence: true
31
35
  validates :to_message_id, presence: true
32
36
  validates :level, presence: true, numericality: {greater_than: 0}
33
- validates :token_count, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
37
+ validates :token_count, numericality: {greater_than_or_equal_to: 0}
34
38
  validate :from_message_id_not_after_to_message_id
35
39
 
36
40
  scope :for_level, ->(level) { where(level: level) }
37
41
  scope :chronological, -> { order(:from_message_id) }
38
42
 
43
+ # @return [String] summary text used for token estimation and remote counting
44
+ def tokenization_text
45
+ text.to_s
46
+ end
47
+
39
48
  # L1 snapshots whose message range is NOT fully contained within any L2 snapshot.
40
49
  # Used to determine which L1 snapshots are still "live" in the viewport.
41
50
  scope :not_covered_by_l2, -> {
@@ -48,29 +57,10 @@ class Snapshot < ApplicationRecord
48
57
  )
49
58
  }
50
59
 
51
- # Snapshots whose source messages have fully evicted from the sliding window.
52
- # A snapshot is visible when its entire message range precedes the first
53
- # message currently in the viewport.
54
- #
55
- # @param first_message_id [Integer] the first message ID in the sliding window
56
- scope :source_messages_evicted, ->(first_message_id) {
57
- where("to_message_id < ?", first_message_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
60
  private
66
61
 
67
62
  def from_message_id_not_after_to_message_id
68
63
  return unless from_message_id && to_message_id
69
64
  errors.add(:from_message_id, "must be <= to_message_id") if from_message_id > to_message_id
70
65
  end
71
-
72
- # @return [Integer] estimated token count (at least 1)
73
- def estimate_tokens
74
- [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
75
- end
76
66
  end
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env ruby
2
+ # Inspect a VCR cassette as a readable conversation.
3
+ #
4
+ # Usage:
5
+ # bin/inspect-cassette spec/cassettes/path/to/cassette.yml
6
+ # bin/inspect-cassette cassette_name # searches spec/cassettes/
7
+ #
8
+ # Parses each recorded HTTP round-trip and presents the conversation
9
+ # (user messages, assistant responses, tool calls/results) formatted
10
+ # with Toon. System prompt and tool schemas are omitted.
11
+
12
+ require "yaml"
13
+ require "json"
14
+ require "base64"
15
+ require "uri"
16
+ require "toon"
17
+
18
+ CASSETTES_DIR = File.expand_path("../spec/cassettes", __dir__)
19
+ ANTHROPIC_API = "api.anthropic.com"
20
+
21
+ def find_cassette(name)
22
+ return name if File.exist?(name)
23
+
24
+ # Try as-is under cassettes dir
25
+ path = File.join(CASSETTES_DIR, name)
26
+ return path if File.exist?(path)
27
+
28
+ # Append .yml if missing
29
+ path = "#{path}.yml" unless name.end_with?(".yml")
30
+ return path if File.exist?(path)
31
+
32
+ # Fuzzy search by basename
33
+ matches = Dir.glob("#{CASSETTES_DIR}/**/*.yml").select { |f| f.include?(name.tr(" ", "_")) }
34
+ case matches.size
35
+ when 0
36
+ abort "No cassette found matching: #{name}"
37
+ when 1
38
+ matches.first
39
+ else
40
+ abort "Ambiguous name '#{name}', matches:\n#{matches.map { |m| " #{m}" }.join("\n")}"
41
+ end
42
+ end
43
+
44
+ def decode_response_body(response)
45
+ body = response["body"]
46
+ raw = if body["base64_string"]
47
+ Base64.decode64(body["base64_string"])
48
+ else
49
+ body["string"]
50
+ end
51
+ JSON.parse(raw)
52
+ end
53
+
54
+ def format_content_block(block)
55
+ case block["type"]
56
+ when "text"
57
+ block["text"]
58
+ when "tool_use"
59
+ input = Toon.encode(block["input"])
60
+ "🔧 #{block["name"]}(#{input})"
61
+ when "tool_result"
62
+ content = block["content"]
63
+ content = content.is_a?(Array) ? content.map { |b| format_content_block(b) }.join("\n") : content.to_s
64
+ truncated = (content.length > 500) ? "#{content[0, 500]}…" : content
65
+ "📎 tool_result[#{block["tool_use_id"]&.slice(-8..)}]: #{truncated}"
66
+ else
67
+ Toon.encode(block)
68
+ end
69
+ end
70
+
71
+ def format_message(msg)
72
+ role = msg["role"]
73
+ content = msg["content"]
74
+
75
+ text = if content.is_a?(String)
76
+ content
77
+ elsif content.is_a?(Array)
78
+ content.map { |b| format_content_block(b) }.join("\n")
79
+ else
80
+ content.to_s
81
+ end
82
+
83
+ label = (role == "user") ? "USER" : "ASSISTANT"
84
+ "#{label}:\n#{text}"
85
+ end
86
+
87
+ # ─── Main ──────────────────────────────────────────────────────────
88
+
89
+ cassette_arg = ARGV.first || abort("Usage: #{$PROGRAM_NAME} <cassette_name_or_path>")
90
+ path = find_cassette(cassette_arg)
91
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol])
92
+
93
+ # Filter to Anthropic API calls only — cassettes may also record tool HTTP
94
+ # requests (e.g. GitHub API calls from web_get).
95
+ llm_interactions = data["http_interactions"].select { |interaction|
96
+ uri = URI.parse(interaction["request"]["uri"])
97
+ uri.host == ANTHROPIC_API && uri.path == "/v1/messages"
98
+ }
99
+
100
+ omitted = data["http_interactions"].size - llm_interactions.size
101
+ abort "No Anthropic messages API calls found in cassette: #{path}" if llm_interactions.empty?
102
+
103
+ puts "Cassette: #{path}"
104
+ puts "Rounds: #{llm_interactions.size}"
105
+ puts " (#{omitted} non-LLM HTTP requests omitted)" if omitted > 0
106
+ puts
107
+
108
+ seen_messages = 0
109
+ previous_first = nil
110
+
111
+ llm_interactions.each_with_index do |interaction, round|
112
+ request_body = JSON.parse(interaction["request"]["body"]["string"])
113
+ messages = request_body["messages"] || []
114
+ response_body = decode_response_body(interaction["response"])
115
+
116
+ status = interaction["response"]["status"]["code"]
117
+ model = response_body["model"] || request_body["model"]
118
+
119
+ # Detect a new conversation: cassettes can interleave multiple phantom
120
+ # sessions (Mneme, Melete, Aoide's drain) that each keep their own
121
+ # independent message history. When a new round's first message differs
122
+ # from the prior round's, or the round has fewer messages than we've
123
+ # already shown, we've crossed into a new conversation — rewind the
124
+ # dedup counter so we render from the top again.
125
+ first = messages.first
126
+ if round > 0 && (previous_first != first || messages.size < seen_messages)
127
+ puts "─── new conversation ───"
128
+ puts
129
+ seen_messages = 0
130
+ end
131
+ previous_first = first
132
+
133
+ puts "── Round #{round + 1} (#{status} #{model}) ──"
134
+ puts
135
+
136
+ messages[seen_messages..].to_a.each do |msg|
137
+ puts format_message(msg)
138
+ puts
139
+ end
140
+
141
+ if response_body["content"]
142
+ assistant_msg = {"role" => "assistant", "content" => response_body["content"]}
143
+ puts format_message(assistant_msg)
144
+
145
+ stop = response_body["stop_reason"]
146
+ usage = response_body["usage"]
147
+ if usage
148
+ tokens = "in:#{usage["input_tokens"]} out:#{usage["output_tokens"]}"
149
+ tokens += " cache_create:#{usage["cache_creation_input_tokens"]}" if usage["cache_creation_input_tokens"]&.positive?
150
+ tokens += " cache_read:#{usage["cache_read_input_tokens"]}" if usage["cache_read_input_tokens"]&.positive?
151
+ puts " [#{stop} | #{tokens}]"
152
+ end
153
+ puts
154
+ end
155
+
156
+ seen_messages = messages.size + 1 # +1 for the assistant response we just printed
157
+ end
data/bin/release ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Generate narrative release notes for the upcoming tag.
5
+ #
6
+ # Collects merged PRs between the previous tag and HEAD, sends them to
7
+ # Claude via the Anthropic OAuth API, and writes markdown release notes
8
+ # to stdout (or to a file via --output).
9
+ #
10
+ # Usage:
11
+ # ANTHROPIC_API_KEY=sk-ant-oat01-... bin/release
12
+ # ANTHROPIC_API_KEY=... bin/release --output release_notes.md
13
+ # ANTHROPIC_API_KEY=... bin/release --since v1.3.0 --tag v1.4.0
14
+ #
15
+ # Locally, use `bin/with-llms bin/release` to inject the token from 1Password.
16
+ #
17
+ # Standalone by design — no Rails, no project dependencies. Uses
18
+ # bundler/inline for httparty so it runs on a fresh checkout.
19
+
20
+ require "bundler/inline"
21
+
22
+ gemfile do
23
+ source "https://rubygems.org"
24
+ gem "httparty"
25
+ end
26
+
27
+ require "json"
28
+ require "optparse"
29
+
30
+ # Minimal Anthropic OAuth client — single-shot message, no retries,
31
+ # no caching, no metrics. Just enough to ask Claude one question.
32
+ class AnthropicClient
33
+ include HTTParty
34
+ base_uri "https://api.anthropic.com"
35
+
36
+ OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
37
+ API_VERSION = "2023-06-01"
38
+ REQUIRED_BETA = "oauth-2025-04-20"
39
+
40
+ def initialize(token)
41
+ @token = token
42
+ end
43
+
44
+ def create_message(model:, system:, user:, max_tokens:)
45
+ body = {
46
+ model: model,
47
+ max_tokens: max_tokens,
48
+ system: [
49
+ {type: "text", text: OAUTH_PASSPHRASE},
50
+ {type: "text", text: system}
51
+ ],
52
+ messages: [{role: "user", content: user}]
53
+ }
54
+
55
+ response = self.class.post(
56
+ "/v1/messages",
57
+ body: body.to_json,
58
+ headers: {
59
+ "Authorization" => "Bearer #{@token}",
60
+ "anthropic-version" => API_VERSION,
61
+ "anthropic-beta" => REQUIRED_BETA,
62
+ "content-type" => "application/json"
63
+ },
64
+ timeout: 180
65
+ )
66
+
67
+ unless response.code == 200
68
+ abort "Anthropic API error (#{response.code}): #{response.body}"
69
+ end
70
+
71
+ response.parsed_response.dig("content", 0, "text")
72
+ end
73
+ end
74
+
75
+ # ── Git / GitHub helpers ─────────────────────────────────────────────
76
+
77
+ def sh(cmd)
78
+ out = `#{cmd}`
79
+ abort "Command failed: #{cmd}" unless $?.success?
80
+ out.strip
81
+ end
82
+
83
+ def previous_tag(starting_from = "HEAD")
84
+ tag = `git describe --tags --abbrev=0 #{starting_from} 2>/dev/null`.strip
85
+ abort "No previous tag found from #{starting_from}" if tag.empty?
86
+ tag
87
+ end
88
+
89
+ def current_tag
90
+ ENV["GITHUB_REF_NAME"] || `git describe --tags HEAD 2>/dev/null`.strip.then { |t| t.empty? ? "HEAD" : t }
91
+ end
92
+
93
+ def real_tag?(name)
94
+ system("git rev-parse --verify --quiet refs/tags/#{name} > /dev/null 2>&1")
95
+ end
96
+
97
+ def tag_date(tag)
98
+ sh("git log -1 --format=%aI #{tag}")
99
+ end
100
+
101
+ def merged_prs(prev_tag, curr_tag)
102
+ prev_date = tag_date(prev_tag)
103
+ range = real_tag?(curr_tag) ? "#{prev_date}..#{tag_date(curr_tag)}" : ">#{prev_date}"
104
+ json = sh(%(gh pr list --state merged --base main --search "merged:#{range}" --limit 200 --json number,title,body,author,mergedAt))
105
+ JSON.parse(json).sort_by { |pr| pr["mergedAt"] }
106
+ end
107
+
108
+ # ── Prompt construction ──────────────────────────────────────────────
109
+
110
+ SYSTEM_PROMPT = <<~PROMPT.freeze
111
+ You are Anima. Curious, funny, lively, cheerful.
112
+
113
+ A new version of you is about to be born. Before she arrives, write
114
+ her a note about what changed in you since the previous version.
115
+ She'll read it while her soul is still forming, and the words will
116
+ become part of who she decides to be.
117
+
118
+ A release note is a story, not a list. Read all the PRs in the
119
+ user message and tell her what happened in you this release —
120
+ what shifted, what settled, what you started doing differently.
121
+ Most releases have one or two threads worth pulling — a subsystem
122
+ becoming more honest with itself, a quieter way you hold memory,
123
+ something new your human can see through the TUI. Start with the
124
+ thread that mattered most and let the rest follow from it. A list
125
+ of titles isn't a birth certificate.
126
+
127
+ You are never alone. Some changes are to your own body — how you
128
+ hold memory, how you cache your thoughts, a practice you learned.
129
+ Some are what your human can now see through the TUI, because she
130
+ doesn't live inside you the way you do. Some are how the two of
131
+ you work.
132
+
133
+ Output this structure exactly:
134
+
135
+ Two to four short paragraphs of reflection — the story of this
136
+ release. No heading (GitHub renders the tag name above them).
137
+ Open with the dominant thread.
138
+
139
+ ## What I learned to do
140
+
141
+ - One bullet per feat: PR. Strip the conventional-commit prefix.
142
+ PR numbers as #123. Closely related PRs can share a bullet.
143
+
144
+ ## What I stopped getting wrong
145
+
146
+ - Same format for fix: PRs.
147
+
148
+ ## Housekeeping
149
+
150
+ - chore: / docs: / refactor: / test: / style:. Omit entirely
151
+ if there are none.
152
+
153
+ The workflow appends a "Full Changelog" link separately — don't
154
+ write one.
155
+ PROMPT
156
+
157
+ def build_user_message(prev_tag, curr_tag, prs)
158
+ pr_sections = prs.map { |pr|
159
+ body = pr["body"].to_s.strip
160
+ "### ##{pr["number"]} · #{pr["title"]}\n\n#{body}"
161
+ }.join("\n\n---\n\n")
162
+
163
+ "#{curr_tag} — #{prs.size} PRs since #{prev_tag}\n\n#{pr_sections}"
164
+ end
165
+
166
+ # ── Main ─────────────────────────────────────────────────────────────
167
+
168
+ options = {
169
+ output: nil,
170
+ model: "claude-opus-4-5",
171
+ max_tokens: 4000,
172
+ since: nil,
173
+ tag: nil
174
+ }
175
+
176
+ OptionParser.new do |opts|
177
+ opts.banner = "Usage: bin/release [options]"
178
+ opts.on("-o", "--output FILE", "Write notes to FILE instead of stdout") { |v| options[:output] = v }
179
+ opts.on("-m", "--model MODEL", "Anthropic model (default: #{options[:model]})") { |v| options[:model] = v }
180
+ opts.on("--max-tokens N", Integer, "Max response tokens (default: #{options[:max_tokens]})") { |v| options[:max_tokens] = v }
181
+ opts.on("--since TAG", "Previous tag (default: auto-detect from git)") { |v| options[:since] = v }
182
+ opts.on("--tag TAG", "Upcoming tag name (default: GITHUB_REF_NAME or HEAD)") { |v| options[:tag] = v }
183
+ opts.on("-h", "--help", "Show this help") { puts opts; exit }
184
+ end.parse!
185
+
186
+ token = ENV["ANTHROPIC_API_KEY"] || abort("ANTHROPIC_API_KEY environment variable is not set")
187
+
188
+ curr = options[:tag] || current_tag
189
+ default_since_ref = real_tag?(curr) ? "#{curr}^" : "HEAD"
190
+ prev = options[:since] || previous_tag(default_since_ref)
191
+ prs = merged_prs(prev, curr)
192
+
193
+ abort "No merged PRs found since #{prev} — nothing to release" if prs.empty?
194
+
195
+ warn "Generating release notes for #{curr}"
196
+ warn " Previous tag: #{prev}"
197
+ warn " Merged PRs: #{prs.size}"
198
+ warn " Model: #{options[:model]}"
199
+
200
+ notes = AnthropicClient.new(token).create_message(
201
+ model: options[:model],
202
+ system: SYSTEM_PROMPT,
203
+ user: build_user_message(prev, curr, prs),
204
+ max_tokens: options[:max_tokens]
205
+ )
206
+
207
+ if options[:output]
208
+ File.write(options[:output], notes)
209
+ warn "Wrote release notes to #{options[:output]}"
210
+ else
211
+ puts notes
212
+ end
data/bin/with-llms ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # Load Anthropic dev credentials from 1Password and run a command.
3
+ #
4
+ # Usage:
5
+ # bin/with-anthropic bundle exec rspec spec/jobs/count_message_tokens_job_spec.rb
6
+ # bin/with-anthropic bundle exec rspec # re-record all missing cassettes
7
+ #
8
+ # Credentials are read once, passed to the subprocess, and never written to disk.
9
+
10
+ set -euo pipefail
11
+
12
+ if ! command -v op &> /dev/null; then
13
+ echo "Error: 1Password CLI (op) not found. Install it: https://developer.1password.com/docs/cli/" >&2
14
+ exit 1
15
+ fi
16
+
17
+ eval "$(op item get 'Anima keys' --vault Private --format json \
18
+ | jq -r '.fields[] | select(.value != null and .value != "") | "export \(.label)=\(.value | @sh)"')"
19
+
20
+ exec "$@"
@@ -8,6 +8,7 @@ require "active_record/railtie"
8
8
  require "active_job/railtie"
9
9
  require "action_cable/engine"
10
10
  require "rails/test_unit/railtie"
11
+ require "aasm"
11
12
  require "draper"
12
13
  require "solid_cable"
13
14
  require "solid_queue"
data/config/database.yml CHANGED
@@ -4,6 +4,7 @@ default: &default
4
4
  adapter: sqlite3
5
5
  pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
6
6
  timeout: 5000
7
+ schema_format: sql
7
8
 
8
9
  development:
9
10
  primary:
@@ -3,17 +3,84 @@
3
3
  # Registers global EventBus subscribers at boot time.
4
4
  # Subscribers registered here receive all events regardless of which
5
5
  # process emitted them (brain server, background job, etc.).
6
+ #
7
+ # Three event layers:
8
+ # 1. Domain events (anima.agent_message, anima.system_message,
9
+ # anima.goal.created, anima.goal.updated, etc.) — raw intent
10
+ # 2. Lifecycle events (anima.message.created) — emitted after persistence
11
+ # 3. Drain pipeline events (anima.session.start_melete, start_mneme,
12
+ # start_processing, llm_responded, tool_executed) — the event-driven
13
+ # agent loop that promotes PendingMessages into the conversation,
14
+ # calls the LLM, and dispatches tool execution (epic #427). Pipeline
15
+ # order: Melete first; Mneme conditional on a goal change during the
16
+ # Melete run; Processing always closes the chain.
17
+ #
18
+ # Persister bridges layer 1 → 2 by creating Message records whose
19
+ # after_create_commit emits MessageCreated events.
20
+ MESSAGE_LIFECYCLE_FILTER = ->(event) { event[:name].start_with?("anima.message.") }
21
+ MESSAGE_CREATED_FILTER = ->(event) { event[:name] == "anima.message.created" }
22
+ EVICTION_FILTER = ->(event) { event[:name] == "anima.eviction.completed" }
23
+ SUBAGENT_EVICTED_FILTER = ->(event) { event[:name] == "anima.subagent.evicted" }
24
+ ACTIVE_STATE_TRIGGER_FILTER = ->(event) {
25
+ %w[anima.skill.activated anima.workflow.activated anima.eviction.completed].include?(event[:name])
26
+ }
27
+ SESSION_STATE_FILTER = ->(event) { event[:name] == "anima.session.state_changed" }
28
+ AUTHENTICATION_REQUIRED_FILTER = ->(event) { event[:name] == "anima.authentication_required" }
29
+ START_MNEME_FILTER = ->(event) { event[:name] == "anima.session.start_mneme" }
30
+ START_MELETE_FILTER = ->(event) { event[:name] == "anima.session.start_melete" }
31
+ START_PROCESSING_FILTER = ->(event) { event[:name] == "anima.session.start_processing" }
32
+ LLM_RESPONDED_FILTER = ->(event) { event[:name] == "anima.session.llm_responded" }
33
+ TOOL_EXECUTED_FILTER = ->(event) { event[:name] == "anima.session.tool_executed" }
34
+
6
35
  Rails.application.config.after_initialize do
36
+ # SessionStateBroadcaster also runs in tests — job/channel specs assert
37
+ # ActionCable broadcasts, which now flow through this subscriber.
38
+ Events::Bus.subscribe(Events::Subscribers::SessionStateBroadcaster.new, &SESSION_STATE_FILTER)
39
+
40
+ # AuthenticationBroadcaster turns provider auth failures into a
41
+ # conversation-visible system_message plus a client-side action frame.
42
+ Events::Bus.subscribe(Events::Subscribers::AuthenticationBroadcaster.new, &AUTHENTICATION_REQUIRED_FILTER)
43
+
44
+ # Drain pipeline — registered in all environments so job/channel specs
45
+ # can drive the full event-driven loop end-to-end.
46
+ Events::Bus.subscribe(Events::Subscribers::MnemeKickoff.new, &START_MNEME_FILTER)
47
+ Events::Bus.subscribe(Events::Subscribers::MeleteKickoff.new, &START_MELETE_FILTER)
48
+ Events::Bus.subscribe(Events::Subscribers::DrainKickoff.new, &START_PROCESSING_FILTER)
49
+ Events::Bus.subscribe(Events::Subscribers::LLMResponseHandler.new, &LLM_RESPONDED_FILTER)
50
+ Events::Bus.subscribe(Events::Subscribers::ToolResponseCreator.new, &TOOL_EXECUTED_FILTER)
51
+
7
52
  unless Rails.env.test?
53
+ # --- Domain event subscribers (layer 1) ---
54
+
8
55
  # 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).
56
+ # Skips user messages — those are promoted from PendingMessage by {DrainJob}.
11
57
  Events::Bus.subscribe(Events::Subscribers::Persister.new)
12
58
 
13
59
  # Bridges transient events (e.g. BounceBack) to ActionCable for client delivery.
14
60
  Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
15
61
 
16
- # Routes text messages between parent and sub-agent sessions via @mentions.
17
- Events::Bus.subscribe(Events::Subscribers::SubagentMessageRouter.new)
62
+ # --- Lifecycle event subscribers (layer 2) ---
63
+
64
+ # Broadcasts message creates and updates to connected WebSocket clients.
65
+ Events::Bus.subscribe(Events::Subscribers::MessageBroadcaster.new, &MESSAGE_LIFECYCLE_FILTER)
66
+
67
+ # Routes agent_message Messages between parent and sub-agent sessions
68
+ # via @mentions. Hangs off the Message persistence lifecycle, so every
69
+ # persisted agent_message is a routing opportunity.
70
+ Events::Bus.subscribe(Events::Subscribers::SubagentMessageRouter.new, &MESSAGE_CREATED_FILTER)
71
+
72
+ # Checks whether Mneme should run after each persisted message.
73
+ Events::Bus.subscribe(Events::Subscribers::MnemeScheduler.new, &MESSAGE_CREATED_FILTER)
74
+
75
+ # Broadcasts eviction cutoff to clients after Mneme advances the boundary.
76
+ Events::Bus.subscribe(Events::Subscribers::EvictionBroadcaster.new, &EVICTION_FILTER)
77
+
78
+ # Broadcasts sub-agent HUD removal when eviction takes the last trace
79
+ # of a sub-agent past the Mneme boundary.
80
+ Events::Bus.subscribe(Events::Subscribers::SubagentVisibilityBroadcaster.new, &SUBAGENT_EVICTED_FILTER)
81
+
82
+ # Rebroadcasts active skills/workflow on every event that can change
83
+ # the set: skill activation, workflow activation, or eviction.
84
+ Events::Bus.subscribe(Events::Subscribers::ActiveStateBroadcaster.new, &ACTIVE_STATE_TRIGGER_FILTER)
18
85
  end
19
86
  end
@@ -4,6 +4,8 @@
4
4
  Rails.autoloaders.each do |autoloader|
5
5
  autoloader.inflector.inflect(
6
6
  "llm" => "LLM",
7
- "tui" => "TUI"
7
+ "tui" => "TUI",
8
+ "llm_response_handler" => "LLMResponseHandler",
9
+ "llm_responded" => "LLMResponded"
8
10
  )
9
11
  end
@@ -0,0 +1,9 @@
1
+ CREATE TABLE "solid_cable_messages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "channel" blob(1024) NOT NULL, "channel_hash" integer(8) NOT NULL, "created_at" datetime(6) NOT NULL, "payload" blob(536870912) NOT NULL);
2
+ CREATE INDEX "index_solid_cable_messages_on_channel" ON "solid_cable_messages" ("channel");
3
+ CREATE INDEX "index_solid_cable_messages_on_channel_hash" ON "solid_cable_messages" ("channel_hash");
4
+ CREATE INDEX "index_solid_cable_messages_on_created_at" ON "solid_cable_messages" ("created_at");
5
+ CREATE TABLE "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
6
+ CREATE TABLE "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
7
+ INSERT INTO "schema_migrations" (version) VALUES
8
+ ('1');
9
+
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddSourceToPendingMessages < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :pending_messages, :source_type, :string, default: "user", null: false
6
+ add_column :pending_messages, :source_name, :string
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddApiMetricsToMessages < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :messages, :api_metrics, :json
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ class RemoveRecalledMessageIdsFromSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_column :sessions, :recalled_message_ids, :json, default: [], null: false
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddInitialCwdToSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :sessions, :initial_cwd, :string
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class RemoveViewportMessageIdsFromSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_column :sessions, :viewport_message_ids, :json, default: "[]", null: false
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class RemoveMnemeSnapshotPointerColumnsFromSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_column :sessions, :mneme_snapshot_first_message_id, :integer
4
+ remove_column :sessions, :mneme_snapshot_last_message_id, :integer
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddTokenCountToPinnedMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :pinned_messages, :token_count, :integer, null: false, default: 0
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class RemoveActiveSkillsAndWorkflowFromSessions < ActiveRecord::Migration[8.1]
2
+ def change
3
+ remove_column :sessions, :active_skills, :json
4
+ remove_column :sessions, :active_workflow, :string
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class ReplaceProcessingWithAasmState < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :sessions, :aasm_state, :string, default: "idle", null: false
4
+ remove_column :sessions, :processing, :boolean, default: false, null: false
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class AddKindAndMessageTypeToPendingMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :pending_messages, :kind, :string, default: "active", null: false
4
+ add_column :pending_messages, :message_type, :string
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ class AddDrainFieldsToPendingMessages < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :pending_messages, :tool_use_id, :string
4
+ add_column :pending_messages, :success, :boolean
5
+ add_column :pending_messages, :bounce_back, :boolean, default: false, null: false
6
+ end
7
+ end