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 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
+ ![Architecture](docs/architecture.png)
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/akaitsume'
5
+
6
+ Akaitsume::CLI.start(ARGV)
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