anima-core 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +18 -0
- data/CHANGELOG.md +26 -0
- data/README.md +134 -19
- data/Rakefile +3 -0
- data/app/jobs/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +105 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +8 -0
- data/config/database.yml +33 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +4 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/anima/cli.rb +55 -0
- data/lib/anima/installer.rb +118 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +4 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +46 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +164 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +181 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +210 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +124 -4
- data/BRAINSTORM.md +0 -466
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f7039906ee15401ed13db1ceaa640b088540903b1f0b814463fe69d91f6fa02
|
|
4
|
+
data.tar.gz: dee3280a42efa74aa78b3499f8c6687a937287eda9099e2b7de09d4ccf761021
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ed4a8551e9ba05919e95a4879bf2366e9cd4986726ebe0ed5900007bd6d06a53639133be5a286d38e41a7f269db78b3575ff982c23d2b4096f804c3528b02a0
|
|
7
|
+
data.tar.gz: 65f6d5dbe475dd36af817a3f2eb88c6f6913604de6bfc9dd56775d18abf8e47e0269862e669a8f739ee57fa99a9aede77d343c692400a44a4e873d28ee482c7a
|
data/.reek.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
detectors:
|
|
3
|
+
# YARD docs cover module/class documentation; no need for reek to duplicate
|
|
4
|
+
IrresponsibleModule:
|
|
5
|
+
enabled: false
|
|
6
|
+
# Constructor injection (provider: nil) triggers false positives
|
|
7
|
+
ControlParameter:
|
|
8
|
+
enabled: false
|
|
9
|
+
# Bang methods don't need safe counterparts in this codebase
|
|
10
|
+
MissingSafeMethod:
|
|
11
|
+
enabled: false
|
|
12
|
+
# Private helpers don't need instance state to be valid
|
|
13
|
+
UtilityFunction:
|
|
14
|
+
public_methods_only: true
|
|
15
|
+
|
|
16
|
+
# TUI render methods receive (frame, tui) by design — RatatuiRuby convention
|
|
17
|
+
exclude_paths:
|
|
18
|
+
- lib/tui
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Added
|
|
4
|
+
- Session and event persistence to SQLite — conversations survive TUI restart
|
|
5
|
+
- `Session` model — owns an ordered event stream
|
|
6
|
+
- `Event` model — polymorphic type, JSON payload, auto-incrementing position
|
|
7
|
+
- `Events::Subscribers::Persister` — writes all events to SQLite as they flow through the bus
|
|
8
|
+
- TUI resumes last session on startup, `Ctrl+a > n` creates a new session
|
|
9
|
+
- `anima tui` now runs pending migrations automatically on launch
|
|
10
|
+
- Event system using Rails Structured Event Reporter (`Rails.event`)
|
|
11
|
+
- Five event types: `system_message`, `user_message`, `agent_message`, `tool_call`, `tool_response`
|
|
12
|
+
- `Events::Bus` — thin wrapper around `Rails.event` for emitting and subscribing to Anima events
|
|
13
|
+
- `Events::Subscribers::MessageCollector` — in-memory subscriber that collects displayable messages
|
|
14
|
+
- Chat screen refactored from raw array to event-driven architecture
|
|
15
|
+
- TUI chat screen with LLM integration — in-memory message array, threaded API calls
|
|
16
|
+
- Chat input with character validation, backspace, Enter to submit
|
|
17
|
+
- Loading indicator — "Thinking" status bar mode, grayed-out input during LLM calls
|
|
18
|
+
- New session command (`Ctrl+a > n`) clears conversation
|
|
19
|
+
- Error handling — API failures displayed inline as chat messages
|
|
20
|
+
- Anthropic API subscription token authentication
|
|
21
|
+
- LLM client (raw HTTP to Anthropic API)
|
|
22
|
+
- TUI scaffold with RatatuiRuby — tmux-style `Ctrl+a` command mode, sidebar, status bar
|
|
23
|
+
- Headless Rails 8.1 app (API-only, no views/assets/Action Cable)
|
|
24
|
+
- `anima install` command — creates ~/.anima/ tree, per-environment credentials, systemd user service
|
|
25
|
+
- `anima start` command — runs db:prepare and boots Rails
|
|
26
|
+
- SQLite databases, logs, tmp, and credentials stored in ~/.anima/
|
|
27
|
+
- Environment validation (development, test, production)
|
|
28
|
+
|
|
3
29
|
## [0.0.1] - 2026-03-06
|
|
4
30
|
|
|
5
31
|
- Initial gem scaffold with CI and RubyGems publishing
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**A soul engine for AI agents.**
|
|
4
4
|
|
|
5
5
|
Ruby framework for building AI agents with desires, personality, and personal growth.
|
|
6
|
-
|
|
6
|
+
Headless Rails 8.1 app distributed as a gem, with a TUI-first interface via RatatuiRuby.
|
|
7
7
|
|
|
8
8
|
## The Problem
|
|
9
9
|
|
|
@@ -61,26 +61,110 @@ Existing RL techniques apply at the starting point, then we gradually expand int
|
|
|
61
61
|
## Architecture
|
|
62
62
|
|
|
63
63
|
```
|
|
64
|
-
Anima Framework (Ruby,
|
|
64
|
+
Anima Framework (Ruby, Rails 8.1 headless)
|
|
65
65
|
├── Thymos — hormonal/desire system (stimulus → hormone vector)
|
|
66
66
|
├── Mneme — semantic memory (QMD-style, emotional recall)
|
|
67
67
|
├── Psyche — soul matrix (coefficient table, evolving through experience)
|
|
68
68
|
└── Nous — LLM integration (cortex, thinking, decision-making)
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### Tech Stack
|
|
72
|
+
|
|
73
|
+
| Component | Technology |
|
|
74
|
+
|-----------|-----------|
|
|
75
|
+
| Framework | Rails 8.1 (headless — no web views, no asset pipeline) |
|
|
76
|
+
| Database | SQLite (per environment, stored in `~/.anima/db/`) |
|
|
77
|
+
| Event system | Rails Structured Event Reporter |
|
|
78
|
+
| LLM integration | Raw HTTP to Anthropic API |
|
|
79
|
+
| Interface | TUI via RatatuiRuby |
|
|
80
|
+
| Distribution | RubyGems (`gem install anima-core`) |
|
|
81
|
+
|
|
82
|
+
### Distribution Model
|
|
83
|
+
|
|
84
|
+
Anima is a Rails app distributed as a gem, following Unix philosophy: immutable program separate from mutable data.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
gem install anima-core # Install the Rails app as a gem
|
|
88
|
+
anima install # Create ~/.anima/ directory structure
|
|
89
|
+
anima start # Launch — code from gem, state from ~/.anima/
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
State directory (`~/.anima/`):
|
|
93
|
+
```
|
|
94
|
+
~/.anima/
|
|
95
|
+
├── db/ # SQLite databases (production, development, test)
|
|
96
|
+
├── config/
|
|
97
|
+
│ ├── credentials/ # Rails encrypted credentials per environment
|
|
98
|
+
│ └── anima.yml # User configuration
|
|
99
|
+
├── log/
|
|
100
|
+
└── tmp/
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Updates: `gem update anima-core` — next launch runs pending migrations automatically.
|
|
104
|
+
|
|
105
|
+
### Authentication Setup
|
|
106
|
+
|
|
107
|
+
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
|
|
108
|
+
|
|
109
|
+
**1. Get a setup-token:**
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
claude setup-token
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
|
|
116
|
+
|
|
117
|
+
**2. Store the token in Anima credentials:**
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
|
|
121
|
+
bin/rails credentials:edit
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Add your token:
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
anthropic:
|
|
128
|
+
subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**3. Verify the token works:**
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
bin/rails runner "Providers::Anthropic.validate!"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If the token expires or is revoked, repeat steps 1-2 with a new token.
|
|
138
|
+
|
|
71
139
|
### Three Layers (mirroring biology)
|
|
72
140
|
|
|
73
141
|
1. **Endocrine system (Thymos)** — a lightweight background process. Reads recent events. Doesn't respond. Just updates hormone levels. Pure stimulus→response, like a biological gland.
|
|
74
142
|
|
|
75
|
-
2. **Homeostasis** — persistent state (
|
|
143
|
+
2. **Homeostasis** — persistent state (SQLite). Current hormone levels with decay functions. No intelligence, just state that changes over time.
|
|
76
144
|
|
|
77
145
|
3. **Cortex (Nous)** — the main LLM. Reads hormone state transformed into **desire descriptions**. Not "longing: 87" but "you want to see them". The LLM should NOT see raw numbers — humans don't see cortisol levels, they feel anxiety.
|
|
78
146
|
|
|
79
147
|
### Event-Driven Design
|
|
80
148
|
|
|
81
|
-
Built on
|
|
149
|
+
Built on Rails Structured Event Reporter — a native Rails 8.1 feature for structured event emission with typed payloads, subscriber patterns, and block-scoped context tagging.
|
|
150
|
+
|
|
151
|
+
Five event types form the agent's nervous system:
|
|
152
|
+
|
|
153
|
+
| Event | Purpose |
|
|
154
|
+
|-------|---------|
|
|
155
|
+
| `system_message` | Internal notifications |
|
|
156
|
+
| `user_message` | User input |
|
|
157
|
+
| `agent_message` | LLM response |
|
|
158
|
+
| `tool_call` | Tool invocation |
|
|
159
|
+
| `tool_response` | Tool result |
|
|
160
|
+
|
|
161
|
+
Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape. The system prompt is assembled separately for each LLM call — it is not an event.
|
|
162
|
+
|
|
163
|
+
### Context as Viewport, Not Tape
|
|
82
164
|
|
|
83
|
-
|
|
165
|
+
There is no linear chat history. There are only events attached to a session. The context window is a **viewport** — a sliding window over the event stream, assembled on demand for each LLM call within a configured token budget.
|
|
166
|
+
|
|
167
|
+
POC uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add multi-resolution compression with Draper decorators and associative recall from Mneme.
|
|
84
168
|
|
|
85
169
|
### Brain as Microservices on a Shared Event Bus
|
|
86
170
|
|
|
@@ -95,7 +179,7 @@ Event: "tool_call_failed"
|
|
|
95
179
|
├── Mneme subscriber: log failure context for future recall
|
|
96
180
|
└── Psyche subscriber: update coefficient (this agent handles errors calmly → low frustration_gain)
|
|
97
181
|
|
|
98
|
-
Event: "user_sent_message"
|
|
182
|
+
Event: "user_sent_message"
|
|
99
183
|
│
|
|
100
184
|
├── Thymos subscriber: oxytocin += 5 (bonding signal)
|
|
101
185
|
├── Thymos subscriber: dopamine += 3 (engagement signal)
|
|
@@ -104,7 +188,17 @@ Event: "user_sent_message"
|
|
|
104
188
|
|
|
105
189
|
Each subscriber is a microservice — independent, stateless, reacting to the same event bus. No orchestrator decides "now update frustration." The architecture IS the nervous system.
|
|
106
190
|
|
|
107
|
-
|
|
191
|
+
### Plugin Architecture
|
|
192
|
+
|
|
193
|
+
Both tools and feelings are distributed as gems on the event bus:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
anima add anima-tools-filesystem
|
|
197
|
+
anima add anima-tools-shell
|
|
198
|
+
anima add anima-feelings-frustration
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Tools provide MCP capabilities. Feelings are event subscribers that update hormonal state. Same mechanism, different namespace. In POC, tools are built-in; plugin extraction comes later.
|
|
108
202
|
|
|
109
203
|
### Semantic Memory (Mneme)
|
|
110
204
|
|
|
@@ -207,17 +301,38 @@ This single example demonstrates every core principle:
|
|
|
207
301
|
|
|
208
302
|
## Status
|
|
209
303
|
|
|
210
|
-
|
|
211
|
-
|
|
304
|
+
POC stage. Gem scaffold with CI and RubyGems publishing exists. Building toward a working conversational agent with event-driven architecture.
|
|
305
|
+
|
|
306
|
+
The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but deferred — POC focuses on getting the core agent loop working first.
|
|
307
|
+
|
|
308
|
+
## Development
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
git clone https://github.com/hoblin/anima.git
|
|
312
|
+
cd anima
|
|
313
|
+
bundle install
|
|
314
|
+
bundle exec rspec
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Running the TUI
|
|
318
|
+
|
|
319
|
+
The TUI requires a background job worker for async token counting (used by the sliding viewport). Start both in separate terminals:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
# Terminal 1: Start Solid Queue worker
|
|
323
|
+
RAILS_ENV=development bundle exec rake solid_queue:start
|
|
324
|
+
|
|
325
|
+
# Terminal 2: Launch the TUI
|
|
326
|
+
bundle exec anima tui
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
On first run, initialize the databases:
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
RAILS_ENV=development bundle exec rails db:migrate
|
|
333
|
+
RAILS_ENV=development bundle exec rails db:schema:load:queue
|
|
334
|
+
```
|
|
212
335
|
|
|
213
|
-
##
|
|
336
|
+
## License
|
|
214
337
|
|
|
215
|
-
|
|
216
|
-
- [ ] Research prior art in depth (affective computing, BDI architecture, virtual creature motivation)
|
|
217
|
-
- [ ] Design initial coefficient matrix schema (Psyche)
|
|
218
|
-
- [ ] Prototype Thymos: Rage event bus + JSON state + context injection into LLM thinking step
|
|
219
|
-
- [ ] Experiment: hormone names vs abstract parameter names in LLM prompts
|
|
220
|
-
- [ ] Set up Rage project skeleton with event bus
|
|
221
|
-
- [ ] Design full event taxonomy (what events does the agent's "nervous system" react to?)
|
|
222
|
-
- [ ] Build Mneme: semantic memory with emotional associations
|
|
223
|
-
- [ ] Write blog post introducing the concept
|
|
338
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Counts tokens in an event's payload via the Anthropic API and
|
|
4
|
+
# caches the result on the event record. Enqueued automatically
|
|
5
|
+
# after each LLM event is created.
|
|
6
|
+
class CountEventTokensJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
retry_on Providers::Anthropic::Error, wait: :exponentially_longer, attempts: 3
|
|
10
|
+
discard_on ActiveRecord::RecordNotFound
|
|
11
|
+
|
|
12
|
+
# @param event_id [Integer] the Event record to count tokens for
|
|
13
|
+
def perform(event_id)
|
|
14
|
+
event = Event.find(event_id)
|
|
15
|
+
return if event.token_count > 0
|
|
16
|
+
|
|
17
|
+
provider = Providers::Anthropic.new
|
|
18
|
+
messages = [{role: event.api_role, content: event.payload["content"].to_s}]
|
|
19
|
+
|
|
20
|
+
token_count = provider.count_tokens(
|
|
21
|
+
model: LLM::Client::DEFAULT_MODEL,
|
|
22
|
+
messages: messages
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Atomic update: only write if still uncounted (avoids race with parallel jobs).
|
|
26
|
+
Event.where(id: event.id, token_count: 0).update_all(token_count: token_count)
|
|
27
|
+
end
|
|
28
|
+
end
|
data/app/models/event.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A persisted record of something that happened during a session.
|
|
4
|
+
# Events are the single source of truth for conversation history —
|
|
5
|
+
# there is no separate chat log, only events attached to a session.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute event_type
|
|
8
|
+
# @return [String] one of {TYPES}: system_message, user_message,
|
|
9
|
+
# agent_message, tool_call, tool_response
|
|
10
|
+
# @!attribute payload
|
|
11
|
+
# @return [Hash] event-specific data (content, tool_name, tool_input, etc.)
|
|
12
|
+
# @!attribute timestamp
|
|
13
|
+
# @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
|
|
14
|
+
# @!attribute token_count
|
|
15
|
+
# @return [Integer] cached token count for this event's payload (0 until counted)
|
|
16
|
+
# @!attribute tool_use_id
|
|
17
|
+
# @return [String, nil] Anthropic-assigned ID correlating tool_call and tool_response
|
|
18
|
+
class Event < ApplicationRecord
|
|
19
|
+
TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
|
|
20
|
+
LLM_TYPES = %w[user_message agent_message].freeze
|
|
21
|
+
CONTEXT_TYPES = %w[user_message agent_message tool_call tool_response].freeze
|
|
22
|
+
|
|
23
|
+
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
24
|
+
|
|
25
|
+
belongs_to :session
|
|
26
|
+
|
|
27
|
+
validates :event_type, presence: true, inclusion: {in: TYPES}
|
|
28
|
+
validates :payload, presence: true
|
|
29
|
+
validates :timestamp, presence: true
|
|
30
|
+
|
|
31
|
+
after_create :schedule_token_count, if: :llm_message?
|
|
32
|
+
|
|
33
|
+
# @!method self.llm_messages
|
|
34
|
+
# Events that represent conversation turns sent to the LLM API.
|
|
35
|
+
# @return [ActiveRecord::Relation]
|
|
36
|
+
scope :llm_messages, -> { where(event_type: LLM_TYPES) }
|
|
37
|
+
|
|
38
|
+
# @!method self.context_events
|
|
39
|
+
# Events included in the LLM context window (messages + tool interactions).
|
|
40
|
+
# @return [ActiveRecord::Relation]
|
|
41
|
+
scope :context_events, -> { where(event_type: CONTEXT_TYPES) }
|
|
42
|
+
|
|
43
|
+
# Maps event_type to the Anthropic Messages API role.
|
|
44
|
+
# @return [String] "user" or "assistant"
|
|
45
|
+
def api_role
|
|
46
|
+
ROLE_MAP.fetch(event_type)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] true if this event represents an LLM conversation turn
|
|
50
|
+
def llm_message?
|
|
51
|
+
event_type.in?(LLM_TYPES)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Boolean] true if this event is part of the LLM context window
|
|
55
|
+
def context_event?
|
|
56
|
+
event_type.in?(CONTEXT_TYPES)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def schedule_token_count
|
|
62
|
+
CountEventTokensJob.perform_later(id)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A conversation session — the fundamental unit of agent interaction.
|
|
4
|
+
# Owns an ordered stream of {Event} records representing everything
|
|
5
|
+
# that happened: user messages, agent responses, tool calls, etc.
|
|
6
|
+
class Session < ApplicationRecord
|
|
7
|
+
# Claude Sonnet 4 context window minus system prompt reserve.
|
|
8
|
+
DEFAULT_TOKEN_BUDGET = 190_000
|
|
9
|
+
|
|
10
|
+
# Heuristic: average bytes per token for English prose.
|
|
11
|
+
BYTES_PER_TOKEN = 4
|
|
12
|
+
|
|
13
|
+
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
14
|
+
|
|
15
|
+
# Builds the message array expected by the Anthropic Messages API.
|
|
16
|
+
# Includes user/agent messages and tool call/response events in
|
|
17
|
+
# Anthropic's wire format. Consecutive tool_call events are grouped
|
|
18
|
+
# into a single assistant message; consecutive tool_response events
|
|
19
|
+
# are grouped into a single user message with tool_result blocks.
|
|
20
|
+
#
|
|
21
|
+
# Walks events newest-first and includes them until the token budget
|
|
22
|
+
# is exhausted. Events are full-size or excluded entirely.
|
|
23
|
+
#
|
|
24
|
+
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
25
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
26
|
+
def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
|
|
27
|
+
selected = []
|
|
28
|
+
remaining = token_budget
|
|
29
|
+
|
|
30
|
+
events.context_events.reorder(id: :desc).each do |event|
|
|
31
|
+
cost = (event.token_count > 0) ? event.token_count : estimate_tokens(event)
|
|
32
|
+
break if cost > remaining && selected.any?
|
|
33
|
+
|
|
34
|
+
selected << event
|
|
35
|
+
remaining -= cost
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
assemble_messages(selected.reverse)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Converts a chronological list of events into Anthropic wire-format messages.
|
|
44
|
+
# Groups consecutive tool_call events into one assistant message and
|
|
45
|
+
# consecutive tool_response events into one user message.
|
|
46
|
+
#
|
|
47
|
+
# @param events [Array<Event>]
|
|
48
|
+
# @return [Array<Hash>]
|
|
49
|
+
def assemble_messages(events)
|
|
50
|
+
events.each_with_object([]) do |event, messages|
|
|
51
|
+
case event.event_type
|
|
52
|
+
when "user_message"
|
|
53
|
+
messages << {role: "user", content: event.payload["content"].to_s}
|
|
54
|
+
when "agent_message"
|
|
55
|
+
messages << {role: "assistant", content: event.payload["content"].to_s}
|
|
56
|
+
when "tool_call"
|
|
57
|
+
append_grouped_block(messages, "assistant", tool_use_block(event.payload))
|
|
58
|
+
when "tool_response"
|
|
59
|
+
append_grouped_block(messages, "user", tool_result_block(event.payload))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Groups consecutive tool blocks into a single message of the given role.
|
|
65
|
+
def append_grouped_block(messages, role, block)
|
|
66
|
+
prev = messages.last
|
|
67
|
+
if prev&.dig(:role) == role && prev[:content].is_a?(Array)
|
|
68
|
+
prev[:content] << block
|
|
69
|
+
else
|
|
70
|
+
messages << {role: role, content: [block]}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_use_block(payload)
|
|
75
|
+
{
|
|
76
|
+
type: "tool_use",
|
|
77
|
+
id: payload["tool_use_id"],
|
|
78
|
+
name: payload["tool_name"],
|
|
79
|
+
input: payload["tool_input"] || {}
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tool_result_block(payload)
|
|
84
|
+
{
|
|
85
|
+
type: "tool_result",
|
|
86
|
+
tool_use_id: payload["tool_use_id"],
|
|
87
|
+
content: payload["content"].to_s
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Rough estimate for events not yet counted by the background job.
|
|
92
|
+
# For tool events, estimates from the full payload since tool_input
|
|
93
|
+
# and tool metadata contribute to token count.
|
|
94
|
+
#
|
|
95
|
+
# @param event [Event]
|
|
96
|
+
# @return [Integer] at least 1
|
|
97
|
+
def estimate_tokens(event)
|
|
98
|
+
text = if event.event_type.in?(%w[tool_call tool_response])
|
|
99
|
+
event.payload.to_json
|
|
100
|
+
else
|
|
101
|
+
event.payload["content"].to_s
|
|
102
|
+
end
|
|
103
|
+
[(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "boot"
|
|
4
|
+
|
|
5
|
+
require "rails"
|
|
6
|
+
require "active_model/railtie"
|
|
7
|
+
require "active_record/railtie"
|
|
8
|
+
require "active_job/railtie"
|
|
9
|
+
require "rails/test_unit/railtie"
|
|
10
|
+
require "solid_queue"
|
|
11
|
+
|
|
12
|
+
Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
|
|
13
|
+
|
|
14
|
+
module Anima
|
|
15
|
+
class Application < Rails::Application
|
|
16
|
+
config.load_defaults 8.1
|
|
17
|
+
config.api_only = true
|
|
18
|
+
|
|
19
|
+
config.autoload_lib(ignore: %w[anima])
|
|
20
|
+
config.active_job.queue_adapter = :solid_queue
|
|
21
|
+
config.solid_queue.connects_to = {database: {writing: :queue}}
|
|
22
|
+
|
|
23
|
+
anima_home = Pathname.new(File.expand_path("~/.anima"))
|
|
24
|
+
|
|
25
|
+
config.paths["log"] = [anima_home.join("log", "#{Rails.env}.log").to_s]
|
|
26
|
+
config.paths["tmp"] = [anima_home.join("tmp").to_s]
|
|
27
|
+
|
|
28
|
+
config.credentials.content_path = anima_home.join("config/credentials/#{Rails.env}.yml.enc")
|
|
29
|
+
config.credentials.key_path = anima_home.join("config/credentials/#{Rails.env}.key")
|
|
30
|
+
end
|
|
31
|
+
end
|
data/config/boot.rb
ADDED
data/config/database.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<% anima_home = File.expand_path("~/.anima") %>
|
|
2
|
+
|
|
3
|
+
default: &default
|
|
4
|
+
adapter: sqlite3
|
|
5
|
+
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
|
|
6
|
+
timeout: 5000
|
|
7
|
+
|
|
8
|
+
development:
|
|
9
|
+
primary:
|
|
10
|
+
<<: *default
|
|
11
|
+
database: <%= File.join(anima_home, "db", "development.sqlite3") %>
|
|
12
|
+
queue:
|
|
13
|
+
<<: *default
|
|
14
|
+
database: <%= File.join(anima_home, "db", "development_queue.sqlite3") %>
|
|
15
|
+
migrations_paths: db/queue_migrate
|
|
16
|
+
|
|
17
|
+
test:
|
|
18
|
+
primary:
|
|
19
|
+
<<: *default
|
|
20
|
+
database: <%= File.join(anima_home, "db", "test.sqlite3") %>
|
|
21
|
+
queue:
|
|
22
|
+
<<: *default
|
|
23
|
+
database: <%= File.join(anima_home, "db", "test_queue.sqlite3") %>
|
|
24
|
+
migrations_paths: db/queue_migrate
|
|
25
|
+
|
|
26
|
+
production:
|
|
27
|
+
primary:
|
|
28
|
+
<<: *default
|
|
29
|
+
database: <%= File.join(anima_home, "db", "production.sqlite3") %>
|
|
30
|
+
queue:
|
|
31
|
+
<<: *default
|
|
32
|
+
database: <%= File.join(anima_home, "db", "production_queue.sqlite3") %>
|
|
33
|
+
migrations_paths: db/queue_migrate
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Rails.application.configure do
|
|
4
|
+
config.enable_reloading = false
|
|
5
|
+
config.eager_load = false
|
|
6
|
+
config.consider_all_requests_local = true
|
|
7
|
+
config.active_support.deprecation = :stderr
|
|
8
|
+
config.active_job.queue_adapter = :test
|
|
9
|
+
end
|
data/config/queue.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
default: &default
|
|
2
|
+
dispatchers:
|
|
3
|
+
- polling_interval: 1
|
|
4
|
+
batch_size: 500
|
|
5
|
+
workers:
|
|
6
|
+
- queues: "*"
|
|
7
|
+
threads: 3
|
|
8
|
+
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
|
9
|
+
polling_interval: 0.1
|
|
10
|
+
|
|
11
|
+
development:
|
|
12
|
+
<<: *default
|
|
13
|
+
|
|
14
|
+
test:
|
|
15
|
+
<<: *default
|
|
16
|
+
|
|
17
|
+
production:
|
|
18
|
+
<<: *default
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# examples:
|
|
2
|
+
# periodic_cleanup:
|
|
3
|
+
# class: CleanSoftDeletedRecordsJob
|
|
4
|
+
# queue: background
|
|
5
|
+
# args: [ 1000, { batch_size: 500 } ]
|
|
6
|
+
# schedule: every hour
|
|
7
|
+
# periodic_cleanup_with_command:
|
|
8
|
+
# command: "SoftDeletedRecord.due.delete_all"
|
|
9
|
+
# priority: 2
|
|
10
|
+
# schedule: at 5am every day
|
|
11
|
+
|
|
12
|
+
production:
|
|
13
|
+
clear_solid_queue_finished_jobs:
|
|
14
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
15
|
+
schedule: every hour at minute 12
|
data/config/routes.rb
ADDED
data/db/migrate/.keep
ADDED
|
File without changes
|