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
@@ -8,19 +8,15 @@ module AnalyticalBrain
8
8
  class ActivateSkill < ::Tools::Base
9
9
  def self.tool_name = "activate_skill"
10
10
 
11
- def self.description = "Activate a domain knowledge skill on the main session. " \
12
- "The skill's content will be injected into the agent's system prompt."
11
+ def self.description = "Give the agent domain knowledge relevant to the current conversation."
13
12
 
14
13
  def self.input_schema
15
14
  {
16
15
  type: "object",
17
16
  properties: {
18
- name: {
19
- type: "string",
20
- description: "Name of the skill to activate (from the available skills list)"
21
- }
17
+ skill_name: {type: "string"}
22
18
  },
23
- required: %w[name]
19
+ required: %w[skill_name]
24
20
  }
25
21
  end
26
22
 
@@ -29,11 +25,11 @@ module AnalyticalBrain
29
25
  @main_session = main_session
30
26
  end
31
27
 
32
- # @param input [Hash<String, Object>] with "name" key
28
+ # @param input [Hash<String, Object>] with "skill_name" key
33
29
  # @return [String] confirmation message with skill description
34
30
  # @return [Hash] with :error key on validation failure
35
31
  def execute(input)
36
- skill_name = input["name"].to_s.strip
32
+ skill_name = input["skill_name"].to_s.strip
37
33
  return {error: "Skill name cannot be blank"} if skill_name.empty?
38
34
 
39
35
  skill = @main_session.activate_skill(skill_name)
@@ -17,8 +17,7 @@ module AnalyticalBrain
17
17
 
18
18
  def self.tool_name = "assign_nickname"
19
19
 
20
- def self.description = "Assign a short, memorable nickname to this sub-agent. " \
21
- "The nickname is permanent — it will not change."
20
+ def self.description = "Assign a permanent nickname to this sub-agent."
22
21
 
23
22
  def self.input_schema
24
23
  {
@@ -26,8 +25,7 @@ module AnalyticalBrain
26
25
  properties: {
27
26
  nickname: {
28
27
  type: "string",
29
- description: "1-3 lowercase words joined by hyphens (e.g. 'loop-sleuth', 'api-scout'). " \
30
- "Evocative of the task, fun, easy to type after @."
28
+ description: "Lowercase, hyphenated (e.g. 'loop-sleuth')."
31
29
  }
32
30
  },
33
31
  required: %w[nickname]
@@ -7,19 +7,15 @@ module AnalyticalBrain
7
7
  class DeactivateSkill < ::Tools::Base
8
8
  def self.tool_name = "deactivate_skill"
9
9
 
10
- def self.description = "Deactivate a skill that is no longer relevant. " \
11
- "The skill's content will be removed from the agent's system prompt."
10
+ def self.description = "Remove domain knowledge that is no longer relevant."
12
11
 
13
12
  def self.input_schema
14
13
  {
15
14
  type: "object",
16
15
  properties: {
17
- name: {
18
- type: "string",
19
- description: "Name of the skill to deactivate (from the currently active skills list)"
20
- }
16
+ skill_name: {type: "string"}
21
17
  },
22
- required: %w[name]
18
+ required: %w[skill_name]
23
19
  }
24
20
  end
25
21
 
@@ -28,11 +24,11 @@ module AnalyticalBrain
28
24
  @main_session = main_session
29
25
  end
30
26
 
31
- # @param input [Hash<String, Object>] with "name" key
27
+ # @param input [Hash<String, Object>] with "skill_name" key
32
28
  # @return [String] confirmation message
33
29
  # @return [Hash] with :error key on validation failure
34
30
  def execute(input)
35
- skill_name = input["name"].to_s.strip
31
+ skill_name = input["skill_name"].to_s.strip
36
32
  return {error: "Skill name cannot be blank"} if skill_name.empty?
37
33
 
38
34
  @main_session.deactivate_skill(skill_name)
@@ -11,8 +11,7 @@ module AnalyticalBrain
11
11
  class EverythingIsReady < ::Tools::Base
12
12
  def self.tool_name = "everything_is_ready"
13
13
 
14
- def self.description = "Signal that no changes are needed. " \
15
- "Call this when the session name and active skills are already appropriate."
14
+ def self.description = "Nothing else to do."
16
15
 
17
16
  def self.input_schema
18
17
  {type: "object", properties: {}, required: []}
@@ -7,17 +7,13 @@ module AnalyticalBrain
7
7
  class FinishGoal < ::Tools::Base
8
8
  def self.tool_name = "finish_goal"
9
9
 
10
- def self.description = "Mark a goal as completed. " \
11
- "Use this when the main agent has finished the work described by the goal."
10
+ def self.description = "Mark a goal as completed."
12
11
 
13
12
  def self.input_schema
14
13
  {
15
14
  type: "object",
16
15
  properties: {
17
- goal_id: {
18
- type: "integer",
19
- description: "ID of the goal to mark as completed"
20
- }
16
+ goal_id: {type: "integer"}
21
17
  },
22
18
  required: %w[goal_id]
23
19
  }
@@ -49,7 +45,8 @@ module AnalyticalBrain
49
45
  # brain learns to check status before retrying.
50
46
  def complete(goal)
51
47
  id = goal.id
52
- return {error: "Goal already completed: #{goal.description} (id: #{id})"} if goal.completed?
48
+ desc = goal.description
49
+ return {error: "Goal already completed: #{desc} (id: #{id})"} if goal.completed?
53
50
 
54
51
  released = 0
55
52
  Goal.transaction do
@@ -58,7 +55,7 @@ module AnalyticalBrain
58
55
  released = goal.release_orphaned_pins!
59
56
  end
60
57
 
61
- msg = "Goal completed: #{goal.description} (id: #{id})"
58
+ msg = "Goal completed: #{desc} (id: #{id})"
62
59
  msg += " (released #{released} orphaned pins)" if released > 0
63
60
  msg
64
61
  end
@@ -9,19 +9,15 @@ module AnalyticalBrain
9
9
  class ReadWorkflow < ::Tools::Base
10
10
  def self.tool_name = "read_workflow"
11
11
 
12
- def self.description = "Read a workflow's full content and activate it on the session. " \
13
- "Use the content to create appropriate goals with set_goal."
12
+ def self.description = "Activate a workflow and return its content for goal planning."
14
13
 
15
14
  def self.input_schema
16
15
  {
17
16
  type: "object",
18
17
  properties: {
19
- name: {
20
- type: "string",
21
- description: "Name of the workflow to read (from the available workflows list)"
22
- }
18
+ workflow_name: {type: "string"}
23
19
  },
24
- required: %w[name]
20
+ required: %w[workflow_name]
25
21
  }
26
22
  end
27
23
 
@@ -30,11 +26,11 @@ module AnalyticalBrain
30
26
  @main_session = main_session
31
27
  end
32
28
 
33
- # @param input [Hash<String, Object>] with "name" key
29
+ # @param input [Hash<String, Object>] with "workflow_name" key
34
30
  # @return [String] workflow name, description, and full content
35
31
  # @return [Hash] with :error key on validation failure
36
32
  def execute(input)
37
- workflow_name = input["name"].to_s.strip
33
+ workflow_name = input["workflow_name"].to_s.strip
38
34
  return {error: "Workflow name cannot be blank"} if workflow_name.empty?
39
35
 
40
36
  workflow = @main_session.activate_workflow(workflow_name)
@@ -11,21 +11,14 @@ module AnalyticalBrain
11
11
  class RenameSession < ::Tools::Base
12
12
  def self.tool_name = "rename_session"
13
13
 
14
- def self.description = "Rename the conversation session. " \
15
- "Use one emoji followed by 1-3 descriptive words."
14
+ def self.description = "Rename the session."
16
15
 
17
16
  def self.input_schema
18
17
  {
19
18
  type: "object",
20
19
  properties: {
21
- emoji: {
22
- type: "string",
23
- description: "A single emoji representing the conversation topic"
24
- },
25
- name: {
26
- type: "string",
27
- description: "1-3 word descriptive name for the session"
28
- }
20
+ emoji: {type: "string"},
21
+ name: {type: "string", description: "1-3 words."}
29
22
  },
30
23
  required: %w[emoji name]
31
24
  }
@@ -8,8 +8,7 @@ module AnalyticalBrain
8
8
  class SetGoal < ::Tools::Base
9
9
  def self.tool_name = "set_goal"
10
10
 
11
- def self.description = "Create a goal on the main session. " \
12
- "Omit parent_goal_id for a root goal, or provide it to create a sub-goal (TODO item)."
11
+ def self.description = "Create a goal or sub-goal."
13
12
 
14
13
  def self.input_schema
15
14
  {
@@ -17,12 +16,9 @@ module AnalyticalBrain
17
16
  properties: {
18
17
  description: {
19
18
  type: "string",
20
- description: "What needs to be accomplished (1-2 sentences)"
19
+ description: "1 sentence."
21
20
  },
22
- parent_goal_id: {
23
- type: "integer",
24
- description: "ID of the parent goal (omit for root goals)"
25
- }
21
+ parent_goal_id: {type: "integer"}
26
22
  },
27
23
  required: %w[description]
28
24
  }
@@ -15,20 +15,16 @@ module AnalyticalBrain
15
15
  class UpdateGoal < ::Tools::Base
16
16
  def self.tool_name = "update_goal"
17
17
 
18
- def self.description = "Update a goal's description. " \
19
- "Use this to refine a goal as understanding evolves."
18
+ def self.description = "Refine a goal's wording as understanding evolves."
20
19
 
21
20
  def self.input_schema
22
21
  {
23
22
  type: "object",
24
23
  properties: {
25
- goal_id: {
26
- type: "integer",
27
- description: "ID of the goal to update"
28
- },
24
+ goal_id: {type: "integer"},
29
25
  description: {
30
26
  type: "string",
31
- description: "New description for the goal (1-2 sentences)"
27
+ description: "1 sentence."
32
28
  }
33
29
  },
34
30
  required: %w[goal_id description]
@@ -5,8 +5,8 @@ require "thor"
5
5
  module Anima
6
6
  class CLI < Thor
7
7
  class Mcp < Thor
8
- # CLI commands for managing MCP secrets stored in Rails encrypted
9
- # credentials. Secrets are referenced in mcp.toml via
8
+ # CLI commands for managing MCP secrets stored in the encrypted
9
+ # secrets table. Secrets are referenced in mcp.toml via
10
10
  # +${credential:key_name}+ syntax.
11
11
  #
12
12
  # @example Store a secret
@@ -22,7 +22,7 @@ module Anima
22
22
  true
23
23
  end
24
24
 
25
- desc "set KEY=VALUE", "Store an MCP secret in encrypted credentials"
25
+ desc "set KEY=VALUE", "Store an MCP secret in encrypted secrets"
26
26
  def set(pair)
27
27
  key, value = pair.split("=", 2)
28
28
  unless value
@@ -50,7 +50,7 @@ module Anima
50
50
  keys.each { |key| say " #{key}" }
51
51
  end
52
52
 
53
- desc "remove KEY", "Remove an MCP secret from encrypted credentials"
53
+ desc "remove KEY", "Remove an MCP secret from encrypted storage"
54
54
  def remove(key)
55
55
  secrets = require_mcp_secrets
56
56
  unless secrets.list.include?(key)
data/lib/anima/cli/mcp.rb CHANGED
@@ -12,7 +12,7 @@ module Anima
12
12
  true
13
13
  end
14
14
 
15
- desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted credentials"
15
+ desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted storage"
16
16
  subcommand "secrets", Secrets
17
17
 
18
18
  desc "list", "List configured MCP servers with health status"
@@ -40,14 +40,14 @@ module Anima
40
40
 
41
41
  Use -e KEY=VALUE to set environment variables (stdio servers).
42
42
  Use -H "Header: Value" to set HTTP headers (HTTP servers).
43
- Use -s KEY=VALUE to store a secret in encrypted credentials.
43
+ Use -s KEY=VALUE to store a secret in encrypted storage.
44
44
  DESC
45
45
  option :env, aliases: "-e", type: :string, repeatable: true, banner: "KEY=VALUE",
46
46
  desc: "Environment variables (repeatable)"
47
47
  option :header, aliases: "-H", type: :string, repeatable: true, banner: "Header: Value",
48
48
  desc: "HTTP headers (repeatable)"
49
49
  option :secret, aliases: "-s", type: :string, repeatable: true, banner: "KEY=VALUE",
50
- desc: "Store secret in encrypted credentials (repeatable)"
50
+ desc: "Store secret in encrypted storage (repeatable)"
51
51
  def add(name, *rest)
52
52
  if rest.empty?
53
53
  say "Error: missing server URL or command.", :red
@@ -87,7 +87,7 @@ module Anima
87
87
  ::Mcp::Config.new
88
88
  end
89
89
 
90
- # Stores secrets from -s KEY=VALUE flags in encrypted credentials.
90
+ # Stores secrets from -s KEY=VALUE flags in the encrypted secrets table.
91
91
  def store_secrets(secret_strings)
92
92
  return unless secret_strings&.any?
93
93
 
@@ -117,7 +117,13 @@ module Anima
117
117
  raise_if_missing_key: true
118
118
  )
119
119
 
120
- config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
120
+ config.write(<<~YAML)
121
+ secret_key_base: #{SecureRandom.hex(64)}
122
+ active_record_encryption:
123
+ primary_key: #{SecureRandom.base64(32)}
124
+ deterministic_key: #{SecureRandom.base64(32)}
125
+ key_derivation_salt: #{SecureRandom.base64(32)}
126
+ YAML
121
127
  File.chmod(0o600, content_str)
122
128
  say " created credentials for #{env}"
123
129
  end
@@ -60,6 +60,13 @@ module Anima
60
60
  self.config_path = nil
61
61
  end
62
62
 
63
+ # ─── Agent Identity ─────────────────────────────────────────────
64
+
65
+ # The agent's display name. Separates engine identity ("Anima") from
66
+ # agent identity — any agent running on Anima can name itself.
67
+ # @return [String]
68
+ def agent_name = get("agent", "name")
69
+
63
70
  # ─── LLM ───────────────────────────────────────────────────────
64
71
 
65
72
  # Primary model for conversations.
@@ -84,6 +91,11 @@ module Anima
84
91
  # @return [Integer]
85
92
  def token_budget = get("llm", "token_budget")
86
93
 
94
+ # Maximum character length for the Think tool's thoughts parameter.
95
+ # Sub-agents receive half this budget (their tasks are less complex).
96
+ # @return [Integer]
97
+ def thinking_budget = get("llm", "thinking_budget")
98
+
87
99
  # ─── Timeouts (seconds) ────────────────────────────────────────
88
100
 
89
101
  # LLM API request timeout.
@@ -107,6 +119,13 @@ module Anima
107
119
  # @return [Integer] seconds
108
120
  def tool_timeout = get("timeouts", "tool")
109
121
 
122
+ # Polling interval for user interrupt checks during long-running commands.
123
+ # Enforces a 0.5s floor to prevent busy-polling from misconfiguration.
124
+ # @return [Numeric] seconds (minimum 0.5)
125
+ def interrupt_check_interval
126
+ [get("timeouts", "interrupt_check"), 0.5].max
127
+ end
128
+
110
129
  # ─── Shell ──────────────────────────────────────────────────────
111
130
 
112
131
  # Maximum bytes of command output before truncation.
@@ -119,11 +138,11 @@ module Anima
119
138
  # @return [Integer]
120
139
  def max_file_size = get("tools", "max_file_size")
121
140
 
122
- # Maximum lines returned by the read tool.
141
+ # Maximum lines returned by the read_file tool.
123
142
  # @return [Integer]
124
143
  def max_read_lines = get("tools", "max_read_lines")
125
144
 
126
- # Maximum bytes returned by the read tool.
145
+ # Maximum bytes returned by the read_file tool.
127
146
  # @return [Integer]
128
147
  def max_read_bytes = get("tools", "max_read_bytes")
129
148
 
@@ -131,6 +150,20 @@ module Anima
131
150
  # @return [Integer]
132
151
  def max_web_response_bytes = get("tools", "max_web_response_bytes")
133
152
 
153
+ # Minimum characters of extracted web content before flagging as possibly incomplete.
154
+ # @return [Integer]
155
+ def min_web_content_chars = get("tools", "min_web_content_chars")
156
+
157
+ # Maximum characters of tool output before head+tail truncation.
158
+ # Full output saved to a temp file for paginated reading.
159
+ # @return [Integer]
160
+ def max_tool_response_chars = get("tools", "max_tool_response_chars")
161
+
162
+ # Maximum characters of sub-agent result before head+tail truncation.
163
+ # Higher than tool threshold because sub-agent output is already curated.
164
+ # @return [Integer]
165
+ def max_subagent_response_chars = get("tools", "max_subagent_response_chars")
166
+
134
167
  # ─── Session ────────────────────────────────────────────────────
135
168
 
136
169
  # View mode applied to new sessions: "basic", "verbose", or "debug".
@@ -191,9 +224,16 @@ module Anima
191
224
  # @return [Boolean]
192
225
  def analytical_brain_blocking_on_agent_message = get("analytical_brain", "blocking_on_agent_message")
193
226
 
194
- # Number of recent events to include in the analytical brain's context window.
227
+ # Number of recent messages to include in the analytical brain's context window.
228
+ # @return [Integer]
229
+ def analytical_brain_message_window = get("analytical_brain", "message_window")
230
+
231
+ # ─── Goals ──────────────────────────────────────────────────────
232
+
233
+ # Number of meaningful messages (user + agent turns) after completion
234
+ # before a completed goal is automatically evicted from context.
195
235
  # @return [Integer]
196
- def analytical_brain_event_window = get("analytical_brain", "event_window")
236
+ def completed_decay_messages = get("goals", "completed_decay_messages")
197
237
 
198
238
  # ─── Mneme (Memory Department) ────────────────────────────────
199
239
 
@@ -217,8 +257,8 @@ module Anima
217
257
  # @return [Integer]
218
258
  def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
219
259
 
220
- # Fraction of the main viewport token budget reserved for pinned events.
221
- # Pinned events appear between snapshots and the sliding window.
260
+ # Fraction of the main viewport token budget reserved for pinned messages.
261
+ # Pinned messages appear between snapshots and the sliding window.
222
262
  # @return [Float]
223
263
  def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
224
264
 
data/lib/anima/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "1.1.3"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/anima.rb CHANGED
@@ -12,7 +12,7 @@ module Anima
12
12
  end
13
13
 
14
14
  # Boots Rails when CLI commands need access to Rails-managed resources
15
- # like encrypted credentials. No-op if Rails is already loaded.
15
+ # like the encrypted secrets table. No-op if Rails is already loaded.
16
16
  def self.boot_rails!
17
17
  return if defined?(Rails)
18
18
 
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Low-level read/write operations on Rails encrypted credentials.
4
- # Wraps the merge-and-write pattern used by {SessionChannel#write_anthropic_token}
5
- # in a reusable helper. All namespacing (e.g. +mcp+, +anthropic+) is the
6
- # caller's responsibility this class operates on raw top-level keys.
3
+ # Read/write operations for runtime secrets (API tokens, MCP credentials).
4
+ # Backed by the {Secret} model with Active Record Encryption — values are
5
+ # encrypted at rest and always fresh (no caching, no file-path issues in
6
+ # forked Solid Queue workers).
7
+ #
8
+ # All namespacing (e.g. +mcp+, +anthropic+) is the caller's responsibility.
7
9
  #
8
10
  # @example Writing a nested credential
9
11
  # CredentialStore.write("mcp", "linear_api_key" => "sk-xxx")
@@ -13,91 +15,40 @@
13
15
  class CredentialStore
14
16
  class << self
15
17
  # Writes one or more key-value pairs under a top-level namespace.
16
- # Merges into existing credentials, preserving sibling keys.
18
+ # Upserts: existing keys are updated, new keys are created.
17
19
  #
18
- # @param namespace [String] top-level YAML key (e.g. "mcp", "anthropic")
20
+ # @param namespace [String] top-level grouping key (e.g. "mcp", "anthropic")
19
21
  # @param pairs [Hash<String, String>] key-value pairs to store
20
22
  # @return [void]
21
23
  def write(namespace, pairs)
22
- existing = load_credentials
23
- section = existing[namespace] ||= {}
24
- section.merge!(pairs)
25
- save_credentials(existing)
24
+ Secret.write(namespace, pairs)
26
25
  end
27
26
 
28
27
  # Reads a single credential value from a namespace.
29
- # Busts the Rails credentials cache first so cross-process writes
30
- # (e.g. token saved in the web process, read in the SolidQueue worker)
31
- # are always visible.
32
28
  #
33
- # @param namespace [String] top-level YAML key
29
+ # @param namespace [String] top-level grouping key
34
30
  # @param key [String] credential key within the namespace
35
31
  # @return [String, nil] credential value or nil if not found
36
32
  def read(namespace, key)
37
- bust_credentials_cache!
38
- Rails.application.credentials.dig(namespace.to_sym, key.to_sym)
33
+ Secret.read(namespace, key)
39
34
  end
40
35
 
41
- # Lists all keys under a namespace.
36
+ # Lists all keys under a namespace (not values).
42
37
  #
43
- # @param namespace [String] top-level YAML key
44
- # @return [Array<String>] credential keys (not values)
38
+ # @param namespace [String] top-level grouping key
39
+ # @return [Array<String>] credential keys
45
40
  def list(namespace)
46
- section = Rails.application.credentials.dig(namespace.to_sym)
47
- return [] unless section.is_a?(Hash)
48
-
49
- section.keys.map(&:to_s)
41
+ Secret.list(namespace)
50
42
  end
51
43
 
52
44
  # Removes a single key from a namespace.
53
45
  # No-op if the key does not exist.
54
46
  #
55
- # @param namespace [String] top-level YAML key
47
+ # @param namespace [String] top-level grouping key
56
48
  # @param key [String] credential key to remove
57
49
  # @return [void]
58
50
  def remove(namespace, key)
59
- existing = load_credentials
60
- section = existing[namespace]
61
- return unless section.is_a?(Hash)
62
- return unless section.key?(key)
63
-
64
- section.delete(key)
65
- existing.delete(namespace) if section.empty?
66
- save_credentials(existing)
67
- end
68
-
69
- private
70
-
71
- # Reads and parses the raw YAML from encrypted credentials.
72
- # Returns an empty hash when the credentials file does not exist yet.
73
- #
74
- # @return [Hash] parsed credentials
75
- def load_credentials
76
- creds = Rails.application.credentials
77
- YAML.safe_load(creds.read) || {}
78
- rescue ActiveSupport::EncryptedFile::MissingContentError
79
- {}
80
- end
81
-
82
- # Writes the full credentials hash back to the encrypted file and
83
- # invalidates the Rails memoization cache so subsequent reads see
84
- # fresh data.
85
- #
86
- # @param data [Hash] complete credentials hash to persist
87
- # @return [void]
88
- def save_credentials(data)
89
- creds = Rails.application.credentials
90
- creds.write(data.to_yaml)
91
- bust_credentials_cache!
92
- end
93
-
94
- # Clears the Rails credentials in-memory cache so the next access
95
- # re-reads and decrypts from disk. Required because Rails memoizes
96
- # the decrypted config in @config per-process.
97
- #
98
- # @return [void]
99
- def bust_credentials_cache!
100
- Rails.application.credentials.instance_variable_set(:@config, nil)
51
+ Secret.remove(namespace, key)
101
52
  end
102
53
  end
103
54
  end
data/lib/events/base.rb CHANGED
@@ -16,7 +16,7 @@ module Events
16
16
  def initialize(content:, session_id: nil)
17
17
  @content = content
18
18
  @session_id = session_id
19
- @timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
19
+ @timestamp = Time.current.to_ns
20
20
  end
21
21
 
22
22
  # @return [String] event type identifier
@@ -6,24 +6,24 @@ module Events
6
6
  # this event notifies clients to remove the phantom message and
7
7
  # restore the text to the input field.
8
8
  #
9
- # Not persisted — not included in {Event::TYPES}.
9
+ # Not persisted — not included in {Message::TYPES}.
10
10
  class BounceBack < Base
11
11
  TYPE = "bounce_back"
12
12
 
13
13
  # @return [String] human-readable error description
14
14
  attr_reader :error
15
15
 
16
- # @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
17
- attr_reader :event_id
16
+ # @return [Integer, nil] database ID of the rolled-back message (for client-side removal)
17
+ attr_reader :message_id
18
18
 
19
19
  # @param content [String] original user message text to restore to input
20
20
  # @param error [String] error description for the flash message
21
21
  # @param session_id [Integer] session the message was intended for
22
- # @param event_id [Integer, nil] ID of the event that was broadcast optimistically
23
- def initialize(content:, error:, session_id:, event_id: nil)
22
+ # @param message_id [Integer, nil] ID of the message that was broadcast optimistically
23
+ def initialize(content:, error:, session_id:, message_id: nil)
24
24
  super(content: content, session_id: session_id)
25
25
  @error = error
26
- @event_id = event_id
26
+ @message_id = message_id
27
27
  end
28
28
 
29
29
  def type
@@ -31,7 +31,7 @@ module Events
31
31
  end
32
32
 
33
33
  def to_h
34
- super.merge(error: error, event_id: event_id)
34
+ super.merge(error: error, message_id: message_id)
35
35
  end
36
36
  end
37
37
  end