akaitsume 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 +7 -0
- data/README.md +208 -0
- data/bin/akaitsume +6 -0
- data/docs/architecture.png +0 -0
- data/lib/akaitsume/agent.rb +142 -0
- data/lib/akaitsume/cli.rb +119 -0
- data/lib/akaitsume/config.rb +43 -0
- data/lib/akaitsume/hooks.rb +25 -0
- data/lib/akaitsume/logger.rb +35 -0
- data/lib/akaitsume/memory/base.rb +27 -0
- data/lib/akaitsume/memory/file_store.rb +46 -0
- data/lib/akaitsume/memory/sqlite_store.rb +63 -0
- data/lib/akaitsume/provider/anthropic.rb +35 -0
- data/lib/akaitsume/provider/base.rb +24 -0
- data/lib/akaitsume/provider/response.rb +32 -0
- data/lib/akaitsume/session.rb +42 -0
- data/lib/akaitsume/tool/base.rb +50 -0
- data/lib/akaitsume/tool/bash.rb +55 -0
- data/lib/akaitsume/tool/files.rb +102 -0
- data/lib/akaitsume/tool/http.rb +73 -0
- data/lib/akaitsume/tool/memory_tool.rb +67 -0
- data/lib/akaitsume/tool/registry.rb +41 -0
- data/lib/akaitsume/version.rb +5 -0
- data/lib/akaitsume.rb +15 -0
- metadata +173 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: de11bca8eff9c9ffcba7f00f11d998f61ad726fa2ec11031e7489e38c4278464
|
|
4
|
+
data.tar.gz: 259a7c03cd5e11b6b53c56431fe7df0b615b36362b07bf0af520d19b70960091
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 58e94b9fe21a74c9c8365579dd188b0af2fecfcb0ec3abf3f55fa64829dcc33ab78c65dd4948a0d591063777fe2ae750b3145f1cf855d2ddb4bc87f226d29cd8
|
|
7
|
+
data.tar.gz: fe343f18ce0db6cf82aaea1a2828ac56b985fb7fed6b946dff3d2f5c94cf9c5b2246ece8af7466255deaf4991e7d1f2637a5583ff0265325e6d4834e9a8966c6
|
data/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Akaitsume
|
|
2
|
+
|
|
3
|
+
赤い爪 — A sharp, extensible AI agent framework for Ruby built on the Anthropic SDK.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Provider abstraction** — swap LLM backends without changing agent code (Anthropic built-in, OpenAI/Ollama ready to add)
|
|
8
|
+
- **Tool system** — trait-based tools with auto-discovery: Bash, Files, HTTP, Memory
|
|
9
|
+
- **Memory backends** — FileStore (default) or SQLite, switchable via config
|
|
10
|
+
- **Session management** — conversation continuity with token/cost tracking
|
|
11
|
+
- **Hooks** — `before_tool`, `after_tool`, `on_response`, `on_error` callbacks
|
|
12
|
+
- **Structured logging** — JSON logs with token counts and tool durations
|
|
13
|
+
- **Thor CLI** — `run`, `chat`, `tools`, `memory` commands with auto-generated help
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem 'akaitsume'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
gem install akaitsume
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
export ANTHROPIC_API_KEY=sk-...
|
|
32
|
+
|
|
33
|
+
# Single prompt
|
|
34
|
+
akaitsume run "list files in the current directory"
|
|
35
|
+
|
|
36
|
+
# Interactive chat (with conversation memory)
|
|
37
|
+
akaitsume chat
|
|
38
|
+
|
|
39
|
+
# List available tools
|
|
40
|
+
akaitsume tools
|
|
41
|
+
|
|
42
|
+
# Show/search agent memory
|
|
43
|
+
akaitsume memory show
|
|
44
|
+
akaitsume memory search "ruby"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage from Ruby
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require 'akaitsume'
|
|
51
|
+
|
|
52
|
+
agent = Akaitsume::Agent.new
|
|
53
|
+
|
|
54
|
+
# Simple run
|
|
55
|
+
result = agent.run("What files are in the workspace?")
|
|
56
|
+
puts result
|
|
57
|
+
|
|
58
|
+
# With hooks
|
|
59
|
+
agent.before_tool { |name, input| puts "Calling #{name}..." }
|
|
60
|
+
agent.on_response { |text| puts "Got: #{text}" }
|
|
61
|
+
agent.run("Create a hello.txt file")
|
|
62
|
+
|
|
63
|
+
# Chat with session continuity
|
|
64
|
+
session = Akaitsume::Session.new
|
|
65
|
+
agent.run("Remember: my name is Mateusz", session: session)
|
|
66
|
+
agent.run("What's my name?", session: session)
|
|
67
|
+
|
|
68
|
+
# Sub-agents
|
|
69
|
+
researcher = agent.spawn(name: "researcher", role: :researcher)
|
|
70
|
+
researcher.run("Find all TODO comments in the codebase")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Config is loaded from YAML, environment variables, or passed directly:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
# config/agent.yml
|
|
79
|
+
model: claude-sonnet-4-20250514
|
|
80
|
+
max_turns: 20
|
|
81
|
+
max_tokens: 8096
|
|
82
|
+
workspace: ~/.akaitsume/workspace
|
|
83
|
+
memory_backend: file # or "sqlite"
|
|
84
|
+
db_path: ~/.akaitsume/akaitsume.db
|
|
85
|
+
log_level: info
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
akaitsume run "hello" --config config/agent.yml
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Environment: `ANTHROPIC_API_KEY` is required.
|
|
93
|
+
|
|
94
|
+
## Architecture
|
|
95
|
+
|
|
96
|
+

|
|
97
|
+
|
|
98
|
+
> Greyed-out elements (Web/Webhook entry points, streaming, retry policy, session persistence, feature flags) are planned for future phases.
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
lib/akaitsume/
|
|
102
|
+
├── agent.rb # Orchestrator (agentic loop)
|
|
103
|
+
├── cli.rb # Thor CLI
|
|
104
|
+
├── config.rb # YAML + ENV config
|
|
105
|
+
├── hooks.rb # Event system module
|
|
106
|
+
├── logger.rb # Structured JSON logging
|
|
107
|
+
├── session.rb # Conversation state + token tracking
|
|
108
|
+
│
|
|
109
|
+
├── provider/
|
|
110
|
+
│ ├── base.rb # Provider contract (module)
|
|
111
|
+
│ ├── response.rb # Unified response value object
|
|
112
|
+
│ └── anthropic.rb # Anthropic SDK wrapper
|
|
113
|
+
│
|
|
114
|
+
├── tool/
|
|
115
|
+
│ ├── base.rb # Tool contract (module)
|
|
116
|
+
│ ├── registry.rb # Tool registry
|
|
117
|
+
│ ├── bash.rb # Shell execution
|
|
118
|
+
│ ├── files.rb # File operations (with path traversal protection)
|
|
119
|
+
│ ├── http.rb # HTTP requests via Faraday
|
|
120
|
+
│ └── memory_tool.rb # LLM-facing memory read/write/search
|
|
121
|
+
│
|
|
122
|
+
└── memory/
|
|
123
|
+
├── base.rb # Memory contract (module)
|
|
124
|
+
├── file_store.rb # Markdown file backend (default)
|
|
125
|
+
└── sqlite_store.rb # SQLite backend (opt-in)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Extending
|
|
129
|
+
|
|
130
|
+
### Custom tool
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class MyTool
|
|
134
|
+
include Akaitsume::Tool::Base
|
|
135
|
+
|
|
136
|
+
tool_name 'weather'
|
|
137
|
+
description 'Get current weather for a city'
|
|
138
|
+
input_schema({
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
city: { type: 'string', description: 'City name' }
|
|
142
|
+
},
|
|
143
|
+
required: ['city']
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
def call(input)
|
|
147
|
+
# Your implementation here
|
|
148
|
+
"Weather in #{input['city']}: 22C, sunny"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Register it
|
|
153
|
+
agent = Akaitsume::Agent.new
|
|
154
|
+
registry = Akaitsume::Tool::Registry.new
|
|
155
|
+
registry.register(MyTool)
|
|
156
|
+
agent = Akaitsume::Agent.new(tools: registry)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Custom provider
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class OllamaProvider
|
|
163
|
+
include Akaitsume::Provider::Base
|
|
164
|
+
|
|
165
|
+
provider_name 'ollama'
|
|
166
|
+
|
|
167
|
+
def chat(messages:, system:, tools:, model:, max_tokens:)
|
|
168
|
+
# Call Ollama API, return Provider::Response
|
|
169
|
+
Akaitsume::Provider::Response.new(
|
|
170
|
+
content: [...],
|
|
171
|
+
stop_reason: 'end_turn',
|
|
172
|
+
model: model,
|
|
173
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
agent = Akaitsume::Agent.new(provider: OllamaProvider.new)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Custom memory backend
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
class RedisStore
|
|
185
|
+
include Akaitsume::Memory::Base
|
|
186
|
+
|
|
187
|
+
def read = # ...
|
|
188
|
+
def store(entry) = # ...
|
|
189
|
+
def replace(content) = # ...
|
|
190
|
+
def search(query) = # ...
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
agent = Akaitsume::Agent.new(memory: RedisStore.new)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Dependencies
|
|
197
|
+
|
|
198
|
+
| Gem | Purpose |
|
|
199
|
+
|-----|---------|
|
|
200
|
+
| `anthropic` | Anthropic Claude SDK |
|
|
201
|
+
| `zeitwerk` | Autoloading |
|
|
202
|
+
| `faraday` | HTTP tool |
|
|
203
|
+
| `sqlite3` | SQLite memory backend |
|
|
204
|
+
| `thor` | CLI framework |
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
data/bin/akaitsume
ADDED
|
Binary file
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
class Agent
|
|
5
|
+
include Hooks
|
|
6
|
+
|
|
7
|
+
attr_reader :name, :role, :config, :logger
|
|
8
|
+
|
|
9
|
+
def initialize(name: 'akaitsume', role: :orchestrator, config: Config.load,
|
|
10
|
+
provider: nil, tools: nil, memory: nil, logger: nil)
|
|
11
|
+
@name = name
|
|
12
|
+
@role = role
|
|
13
|
+
@config = config
|
|
14
|
+
@provider = provider || Provider::Anthropic.new(api_key: config.api_key)
|
|
15
|
+
@memory = memory || build_memory(config, name)
|
|
16
|
+
@tools = tools || Tool::Registry.default_for(config, memory: @memory)
|
|
17
|
+
@logger = logger || Logger.new(level: config.log_level)
|
|
18
|
+
init_hooks
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Spawn a sub-agent with its own tools and memory
|
|
22
|
+
def spawn(name:, role:, tools: nil)
|
|
23
|
+
self.class.new(
|
|
24
|
+
name: name,
|
|
25
|
+
role: role,
|
|
26
|
+
config: @config,
|
|
27
|
+
provider: @provider,
|
|
28
|
+
tools: tools,
|
|
29
|
+
memory: build_memory(@config, name),
|
|
30
|
+
logger: @logger
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Run the agent loop.
|
|
35
|
+
# Pass a Session for conversation continuity (chat mode).
|
|
36
|
+
# Without a session, creates a temporary one for this single run.
|
|
37
|
+
def run(prompt, system: nil, session: nil, &block)
|
|
38
|
+
session ||= Session.new(system_prompt: system || default_system_prompt)
|
|
39
|
+
sys = session.system_prompt || system || default_system_prompt
|
|
40
|
+
|
|
41
|
+
inject_memory_and_prompt(session, prompt)
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
raise MaxTurnsError, "Exceeded max_turns (#{@config.max_turns})" if session.turn_count >= @config.max_turns
|
|
45
|
+
|
|
46
|
+
response = call_provider(sys, session)
|
|
47
|
+
session.add_assistant(response.content)
|
|
48
|
+
session.track_usage(response)
|
|
49
|
+
|
|
50
|
+
if response.tool_use?
|
|
51
|
+
tool_results = dispatch_tools(response.content)
|
|
52
|
+
session.add_tool_results(tool_results)
|
|
53
|
+
else
|
|
54
|
+
text = extract_text(response.content)
|
|
55
|
+
fire(:on_response, text)
|
|
56
|
+
block&.call(text)
|
|
57
|
+
return text
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
fire(:on_error, e)
|
|
62
|
+
raise
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_memory(config, agent_name)
|
|
68
|
+
case config.memory_backend
|
|
69
|
+
when 'sqlite'
|
|
70
|
+
Memory::SqliteStore.new(db_path: config.db_path, agent_name: agent_name)
|
|
71
|
+
else
|
|
72
|
+
Memory::FileStore.new(dir: config.memory_dir, agent_name: agent_name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def inject_memory_and_prompt(session, prompt)
|
|
77
|
+
content = if (mem = @memory.read)
|
|
78
|
+
"<memory>\n#{mem}\n</memory>\n\n#{prompt}"
|
|
79
|
+
else
|
|
80
|
+
prompt
|
|
81
|
+
end
|
|
82
|
+
session.add_user(content)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call_provider(sys, session)
|
|
86
|
+
@logger.debug('api_call', model: @config.model, messages: session.messages.size)
|
|
87
|
+
|
|
88
|
+
response = @provider.chat(
|
|
89
|
+
model: @config.model,
|
|
90
|
+
max_tokens: @config.max_tokens,
|
|
91
|
+
system: sys,
|
|
92
|
+
tools: @tools.api_definitions,
|
|
93
|
+
messages: session.messages
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@logger.info('api_response',
|
|
97
|
+
stop_reason: response.stop_reason,
|
|
98
|
+
input_tokens: response.input_tokens,
|
|
99
|
+
output_tokens: response.output_tokens)
|
|
100
|
+
|
|
101
|
+
response
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def default_system_prompt
|
|
105
|
+
parts = ["You are #{@name}, a #{@role} AI agent."]
|
|
106
|
+
parts << 'You can delegate tasks to sub-agents.' if @role == :orchestrator
|
|
107
|
+
parts << 'Be concise, precise, and always complete your task.'
|
|
108
|
+
parts.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def dispatch_tools(content_blocks)
|
|
112
|
+
content_blocks.filter_map do |block|
|
|
113
|
+
next unless block.type == 'tool_use'
|
|
114
|
+
|
|
115
|
+
tool = @tools[block.name]
|
|
116
|
+
|
|
117
|
+
fire(:before_tool, block.name, block.input)
|
|
118
|
+
@logger.debug('tool_call', tool: block.name)
|
|
119
|
+
|
|
120
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
result = tool.execute(block.input)
|
|
122
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
123
|
+
|
|
124
|
+
fire(:after_tool, block.name, result)
|
|
125
|
+
@logger.debug('tool_result', tool: block.name, duration_ms: duration_ms)
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
type: 'tool_result',
|
|
129
|
+
tool_use_id: block.id,
|
|
130
|
+
content: [result]
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_text(content_blocks)
|
|
136
|
+
content_blocks
|
|
137
|
+
.select { |b| b.type == 'text' }
|
|
138
|
+
.map(&:text)
|
|
139
|
+
.join
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
|
|
5
|
+
module Akaitsume
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
class_option :config, type: :string, desc: 'Path to config YAML file'
|
|
8
|
+
|
|
9
|
+
desc 'run PROMPT', 'Run agent with a single prompt'
|
|
10
|
+
method_option :model, type: :string, desc: 'Override model name'
|
|
11
|
+
def run_task(prompt)
|
|
12
|
+
agent = build_agent
|
|
13
|
+
agent.before_tool { |name, input| say " \u2192 [#{name}] #{input.inspect}", :cyan }
|
|
14
|
+
agent.after_tool { |name, _result| say " \u2190 [#{name}]", :cyan }
|
|
15
|
+
|
|
16
|
+
say "\n\xF0\x9F\x94\xB4 akaitsume\n\n"
|
|
17
|
+
result = agent.run(prompt)
|
|
18
|
+
say result
|
|
19
|
+
end
|
|
20
|
+
map 'run' => :run_task
|
|
21
|
+
|
|
22
|
+
desc 'chat', 'Interactive chat mode'
|
|
23
|
+
def chat
|
|
24
|
+
agent = build_agent
|
|
25
|
+
session = Session.new
|
|
26
|
+
|
|
27
|
+
agent.before_tool { |name, _input| $stdout.print " \u2192 [#{name}] " }
|
|
28
|
+
agent.after_tool { |name, _result| $stdout.puts "\u2190 [#{name}]" }
|
|
29
|
+
|
|
30
|
+
say "\xF0\x9F\x94\xB4 akaitsume chat (Ctrl+C or 'exit' to quit)\n\n"
|
|
31
|
+
|
|
32
|
+
loop do
|
|
33
|
+
print '> '
|
|
34
|
+
input = $stdin.gets&.chomp
|
|
35
|
+
break if input.nil? || input == 'exit'
|
|
36
|
+
next if input.empty?
|
|
37
|
+
|
|
38
|
+
result = agent.run(input, session: session)
|
|
39
|
+
say "\n#{result}\n\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
say "\nSession: #{session.turn_count} turns, #{session.total_tokens} tokens"
|
|
43
|
+
rescue Interrupt
|
|
44
|
+
say "\n\nSession: #{session.turn_count} turns, #{session.total_tokens} tokens"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
desc 'tools', 'List registered tools'
|
|
48
|
+
def tools
|
|
49
|
+
cfg = load_config
|
|
50
|
+
memory = build_memory(cfg)
|
|
51
|
+
registry = Tool::Registry.default_for(cfg, memory: memory)
|
|
52
|
+
|
|
53
|
+
registry.names.each { |n| say " \u2022 #{n}" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc 'memory SUBCOMMAND', 'Memory operations'
|
|
57
|
+
subcommand 'memory', Class.new(Thor) {
|
|
58
|
+
namespace 'memory'
|
|
59
|
+
|
|
60
|
+
desc 'show [AGENT]', 'Show agent memory'
|
|
61
|
+
def show(agent_name = 'akaitsume')
|
|
62
|
+
cfg = parent_load_config
|
|
63
|
+
store = parent_build_memory(cfg, agent_name)
|
|
64
|
+
say store.read || '(empty)'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
desc 'search QUERY [AGENT]', 'Search agent memory'
|
|
68
|
+
def search(query, agent_name = 'akaitsume')
|
|
69
|
+
cfg = parent_load_config
|
|
70
|
+
store = parent_build_memory(cfg, agent_name)
|
|
71
|
+
say store.search(query)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
no_commands do
|
|
75
|
+
def parent_load_config
|
|
76
|
+
path = parent_options[:config]
|
|
77
|
+
path ? Config.load(path: path) : Config.load
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parent_build_memory(cfg, agent_name = 'akaitsume')
|
|
81
|
+
case cfg.memory_backend
|
|
82
|
+
when 'sqlite'
|
|
83
|
+
Memory::SqliteStore.new(db_path: cfg.db_path, agent_name: agent_name)
|
|
84
|
+
else
|
|
85
|
+
Memory::FileStore.new(dir: cfg.memory_dir, agent_name: agent_name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
no_commands do
|
|
92
|
+
def load_config
|
|
93
|
+
path = options[:config]
|
|
94
|
+
cfg_hash = {}
|
|
95
|
+
cfg_hash[:model] = options[:model] if options[:model]
|
|
96
|
+
|
|
97
|
+
if path
|
|
98
|
+
Config.load(path: path)
|
|
99
|
+
else
|
|
100
|
+
Config.new(cfg_hash)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_memory(cfg, agent_name = 'akaitsume')
|
|
105
|
+
case cfg.memory_backend
|
|
106
|
+
when 'sqlite'
|
|
107
|
+
Memory::SqliteStore.new(db_path: cfg.db_path, agent_name: agent_name)
|
|
108
|
+
else
|
|
109
|
+
Memory::FileStore.new(dir: cfg.memory_dir, agent_name: agent_name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_agent
|
|
114
|
+
cfg = load_config
|
|
115
|
+
Agent.new(config: cfg)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Akaitsume
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
model: 'claude-opus-4-6',
|
|
10
|
+
max_turns: 20,
|
|
11
|
+
max_tokens: 8096,
|
|
12
|
+
workspace: Dir.home + '/.akaitsume/workspace',
|
|
13
|
+
memory_dir: Dir.home + '/.akaitsume/memory',
|
|
14
|
+
memory_backend: 'file',
|
|
15
|
+
db_path: Dir.home + '/.akaitsume/akaitsume.db',
|
|
16
|
+
log_level: 'info'
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :model, :max_turns, :max_tokens, :workspace,
|
|
20
|
+
:memory_dir, :memory_backend, :db_path, :log_level, :api_key
|
|
21
|
+
|
|
22
|
+
def self.load(path: nil)
|
|
23
|
+
file_cfg = path ? YAML.safe_load_file(path, symbolize_names: true) : {}
|
|
24
|
+
new(file_cfg)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(overrides = {})
|
|
28
|
+
cfg = DEFAULTS.merge(overrides)
|
|
29
|
+
@api_key = ENV.fetch('ANTHROPIC_API_KEY') { raise ConfigError, 'ANTHROPIC_API_KEY not set' }
|
|
30
|
+
@model = cfg[:model]
|
|
31
|
+
@max_turns = cfg[:max_turns].to_i
|
|
32
|
+
@max_tokens = cfg[:max_tokens].to_i
|
|
33
|
+
@workspace = cfg[:workspace]
|
|
34
|
+
@memory_dir = cfg[:memory_dir]
|
|
35
|
+
@memory_backend = cfg[:memory_backend].to_s
|
|
36
|
+
@db_path = cfg[:db_path]
|
|
37
|
+
@log_level = cfg[:log_level]
|
|
38
|
+
|
|
39
|
+
FileUtils.mkdir_p(@workspace)
|
|
40
|
+
FileUtils.mkdir_p(@memory_dir)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Hooks
|
|
5
|
+
EVENTS = %i[before_tool after_tool on_response on_error].freeze
|
|
6
|
+
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.define_method(:init_hooks) do
|
|
9
|
+
@hooks = EVENTS.each_with_object({}) { |e, h| h[e] = [] }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
EVENTS.each do |event|
|
|
14
|
+
define_method(event) do |&block|
|
|
15
|
+
@hooks[event] << block
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def fire(event, *args)
|
|
22
|
+
@hooks.fetch(event, []).each { |h| h.call(*args) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Akaitsume
|
|
7
|
+
class Logger
|
|
8
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(level: :info, output: $stderr)
|
|
11
|
+
@level = LEVELS.fetch(level.to_sym, 1)
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def debug(message, **context) = log(:debug, message, **context)
|
|
16
|
+
def info(message, **context) = log(:info, message, **context)
|
|
17
|
+
def warn(message, **context) = log(:warn, message, **context)
|
|
18
|
+
def error(message, **context) = log(:error, message, **context)
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def log(level, message, **context)
|
|
23
|
+
return if LEVELS[level] < @level
|
|
24
|
+
|
|
25
|
+
entry = {
|
|
26
|
+
ts: Time.now.iso8601,
|
|
27
|
+
level: level,
|
|
28
|
+
msg: message
|
|
29
|
+
}
|
|
30
|
+
entry.merge!(context) unless context.empty?
|
|
31
|
+
|
|
32
|
+
@output.puts(JSON.generate(entry))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Memory
|
|
5
|
+
module Base
|
|
6
|
+
# Returns full memory content or nil if empty.
|
|
7
|
+
def read
|
|
8
|
+
raise NotImplementedError, "#{self.class}#read not implemented"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Appends an entry to memory.
|
|
12
|
+
def store(entry)
|
|
13
|
+
raise NotImplementedError, "#{self.class}#store not implemented"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Replaces entire memory content.
|
|
17
|
+
def replace(content)
|
|
18
|
+
raise NotImplementedError, "#{self.class}#replace not implemented"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Searches memory for a query string, returns matching results.
|
|
22
|
+
def search(query)
|
|
23
|
+
raise NotImplementedError, "#{self.class}#search not implemented"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Akaitsume
|
|
6
|
+
module Memory
|
|
7
|
+
class FileStore
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
MEMORY_FILE = 'MEMORY.md'
|
|
11
|
+
|
|
12
|
+
def initialize(dir:, agent_name: 'agent')
|
|
13
|
+
@path = File.join(dir, "#{agent_name}.md")
|
|
14
|
+
FileUtils.mkdir_p(dir)
|
|
15
|
+
FileUtils.touch(@path) unless File.exist?(@path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns full memory content (injected into system prompt)
|
|
19
|
+
def read
|
|
20
|
+
content = File.read(@path).strip
|
|
21
|
+
content.empty? ? nil : content
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Appends a timestamped entry
|
|
25
|
+
def store(entry)
|
|
26
|
+
File.open(@path, 'a') do |f|
|
|
27
|
+
f.puts "\n## #{Time.now.strftime('%Y-%m-%d %H:%M')}\n#{entry.strip}\n"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replaces entire memory (for summarization)
|
|
32
|
+
def replace(content)
|
|
33
|
+
File.write(@path, content.to_s.strip + "\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Simple keyword search
|
|
37
|
+
def search(query)
|
|
38
|
+
lines = File.readlines(@path)
|
|
39
|
+
matches = lines.each_with_object([]).with_index do |(line, acc), i|
|
|
40
|
+
acc << "L#{i + 1}: #{line.chomp}" if line.downcase.include?(query.downcase)
|
|
41
|
+
end
|
|
42
|
+
matches.empty? ? "(no matches for '#{query}')" : matches.join("\n")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|