akaitsume 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de11bca8eff9c9ffcba7f00f11d998f61ad726fa2ec11031e7489e38c4278464
4
- data.tar.gz: 259a7c03cd5e11b6b53c56431fe7df0b615b36362b07bf0af520d19b70960091
3
+ metadata.gz: f3fecc18ed7284344542f94ca9cc13d0489e5165a61ddb054c285f30a6b00c39
4
+ data.tar.gz: 553b5efd077b266ea0b9962f007ecf0caae5cc4fc06b51ae2bce7c818ececf5c
5
5
  SHA512:
6
- metadata.gz: 58e94b9fe21a74c9c8365579dd188b0af2fecfcb0ec3abf3f55fa64829dcc33ab78c65dd4948a0d591063777fe2ae750b3145f1cf855d2ddb4bc87f226d29cd8
7
- data.tar.gz: fe343f18ce0db6cf82aaea1a2828ac56b985fb7fed6b946dff3d2f5c94cf9c5b2246ece8af7466255deaf4991e7d1f2637a5583ff0265325e6d4834e9a8966c6
6
+ metadata.gz: 264c20941a3dd343fed396843aab2bac073f5759fafa491ba79fbcfdb86c4c4d97669ffb9700767f2aeee421a9e3c11228635fd05147956dcc653b8611c175f0
7
+ data.tar.gz: b569bb26ae8b60a6754b4892e4e56bcb88bfa584529f378c685c619272ede11e2bd6d0beb78c7b7c61c9fb87784daac74a22af68db773544831314cff1e260f0
data/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # Akaitsume
2
2
 
3
- 赤い爪 — A sharp, extensible AI agent framework for Ruby built on the Anthropic SDK.
3
+ 赤い爪 — A sharp, extensible AI agent framework for Ruby.
4
4
 
5
5
  ## Features
6
6
 
7
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
8
+ - **Tool system** — trait-based tools with auto-discovery and external plugin loading
9
+ - **Security** — dangerous command guard (Bash), SSRF protection (HTTP), path traversal prevention (Files)
9
10
  - **Memory backends** — FileStore (default) or SQLite, switchable via config
10
11
  - **Session management** — conversation continuity with token/cost tracking
11
12
  - **Hooks** — `before_tool`, `after_tool`, `on_response`, `on_error` callbacks
@@ -28,7 +29,8 @@ gem install akaitsume
28
29
  ## Quick start
29
30
 
30
31
  ```sh
31
- export ANTHROPIC_API_KEY=sk-...
32
+ # Set your API key (or use .env file with dotenv)
33
+ export ANTHROPIC_API_KEY=sk-ant-...
32
34
 
33
35
  # Single prompt
34
36
  akaitsume run "list files in the current directory"
@@ -76,20 +78,22 @@ Config is loaded from YAML, environment variables, or passed directly:
76
78
 
77
79
  ```yaml
78
80
  # config/agent.yml
79
- model: claude-sonnet-4-20250514
81
+ model: claude-haiku-4-5-20251001
80
82
  max_turns: 20
81
83
  max_tokens: 8096
82
84
  workspace: ~/.akaitsume/workspace
83
- memory_backend: file # or "sqlite"
85
+ memory_backend: file # or "sqlite"
84
86
  db_path: ~/.akaitsume/akaitsume.db
85
87
  log_level: info
88
+ tool_paths: # external plugin directories
89
+ - ~/.akaitsume/plugins
86
90
  ```
87
91
 
88
92
  ```sh
89
93
  akaitsume run "hello" --config config/agent.yml
90
94
  ```
91
95
 
92
- Environment: `ANTHROPIC_API_KEY` is required.
96
+ Environment: `ANTHROPIC_API_KEY` is required. Use a `.env` file with `dotenv` for convenience.
93
97
 
94
98
  ## Architecture
95
99
 
@@ -108,49 +112,63 @@ lib/akaitsume/
108
112
 
109
113
  ├── provider/
110
114
  │ ├── base.rb # Provider contract (module)
111
- │ ├── response.rb # Unified response value object
115
+ │ ├── response.rb # Immutable response (Data.define)
112
116
  │ └── anthropic.rb # Anthropic SDK wrapper
113
117
 
114
118
  ├── tool/
115
119
  │ ├── base.rb # Tool contract (module)
116
- │ ├── registry.rb # Tool registry
117
- │ ├── bash.rb # Shell execution
120
+ │ ├── registry.rb # Auto-discovery + plugin loading
121
+ │ ├── bash.rb # Shell execution (with dangerous command guard)
118
122
  │ ├── files.rb # File operations (with path traversal protection)
119
- │ ├── http.rb # HTTP requests via Faraday
123
+ │ ├── http.rb # HTTP requests (with SSRF protection)
120
124
  │ └── memory_tool.rb # LLM-facing memory read/write/search
121
125
 
122
126
  └── memory/
123
- ├── base.rb # Memory contract (module)
127
+ ├── base.rb # Memory contract (module) + Memory.build factory
124
128
  ├── file_store.rb # Markdown file backend (default)
125
129
  └── sqlite_store.rb # SQLite backend (opt-in)
126
130
  ```
127
131
 
128
132
  ## Extending
129
133
 
130
- ### Custom tool
134
+ ### Custom tool (plugin)
135
+
136
+ Drop a `.rb` file in a `tool_paths` directory:
131
137
 
132
138
  ```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"
139
+ # ~/.akaitsume/plugins/weather.rb
140
+ module Akaitsume
141
+ module Tool
142
+ class Weather
143
+ include Base
144
+
145
+ tool_name 'weather'
146
+ description 'Get current weather for a city'
147
+ input_schema({
148
+ type: 'object',
149
+ properties: {
150
+ city: { type: 'string', description: 'City name' }
151
+ },
152
+ required: ['city']
153
+ })
154
+
155
+ def call(input)
156
+ "Weather in #{input['city']}: 22C, sunny"
157
+ end
158
+ end
149
159
  end
150
160
  end
161
+ ```
151
162
 
152
- # Register it
153
- agent = Akaitsume::Agent.new
163
+ ```yaml
164
+ # config/agent.yml
165
+ tool_paths:
166
+ - ~/.akaitsume/plugins
167
+ ```
168
+
169
+ Or register manually:
170
+
171
+ ```ruby
154
172
  registry = Akaitsume::Tool::Registry.new
155
173
  registry.register(MyTool)
156
174
  agent = Akaitsume::Agent.new(tools: registry)
@@ -168,7 +186,7 @@ class OllamaProvider
168
186
  # Call Ollama API, return Provider::Response
169
187
  Akaitsume::Provider::Response.new(
170
188
  content: [...],
171
- stop_reason: 'end_turn',
189
+ stop_reason: :end_turn,
172
190
  model: model,
173
191
  usage: { input_tokens: 0, output_tokens: 0 }
174
192
  )
@@ -202,6 +220,7 @@ agent = Akaitsume::Agent.new(memory: RedisStore.new)
202
220
  | `faraday` | HTTP tool |
203
221
  | `sqlite3` | SQLite memory backend |
204
222
  | `thor` | CLI framework |
223
+ | `dotenv` | `.env` file loading |
205
224
 
206
225
  ## License
207
226
 
data/bin/akaitsume CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'dotenv/load'
4
5
  require_relative '../lib/akaitsume'
5
6
 
6
7
  Akaitsume::CLI.start(ARGV)
@@ -10,12 +10,11 @@ module Akaitsume
10
10
  provider: nil, tools: nil, memory: nil, logger: nil)
11
11
  @name = name
12
12
  @role = role
13
- @config = config
13
+ @config = config.ensure_directories!
14
14
  @provider = provider || Provider::Anthropic.new(api_key: config.api_key)
15
- @memory = memory || build_memory(config, name)
15
+ @memory = memory || Memory.build(config, agent_name: name)
16
16
  @tools = tools || Tool::Registry.default_for(config, memory: @memory)
17
17
  @logger = logger || Logger.new(level: config.log_level)
18
- init_hooks
19
18
  end
20
19
 
21
20
  # Spawn a sub-agent with its own tools and memory
@@ -26,7 +25,7 @@ module Akaitsume
26
25
  config: @config,
27
26
  provider: @provider,
28
27
  tools: tools,
29
- memory: build_memory(@config, name),
28
+ memory: Memory.build(@config, agent_name: name),
30
29
  logger: @logger
31
30
  )
32
31
  end
@@ -43,19 +42,17 @@ module Akaitsume
43
42
  loop do
44
43
  raise MaxTurnsError, "Exceeded max_turns (#{@config.max_turns})" if session.turn_count >= @config.max_turns
45
44
 
45
+ session.increment_turn
46
46
  response = call_provider(sys, session)
47
47
  session.add_assistant(response.content)
48
48
  session.track_usage(response)
49
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
50
+ next dispatch_tool_cycle(response, session) if response.tool_use?
51
+
52
+ text = extract_text(response.content)
53
+ fire(:on_response, text)
54
+ block&.call(text)
55
+ return text
59
56
  end
60
57
  rescue StandardError => e
61
58
  fire(:on_error, e)
@@ -64,13 +61,9 @@ module Akaitsume
64
61
 
65
62
  private
66
63
 
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
64
+ def dispatch_tool_cycle(response, session)
65
+ tool_results = dispatch_tools(response.content)
66
+ session.add_tool_results(tool_results)
74
67
  end
75
68
 
76
69
  def inject_memory_and_prompt(session, prompt)
@@ -110,7 +103,7 @@ module Akaitsume
110
103
 
111
104
  def dispatch_tools(content_blocks)
112
105
  content_blocks.filter_map do |block|
113
- next unless block.type == 'tool_use'
106
+ next unless block.type == :tool_use
114
107
 
115
108
  tool = @tools[block.name]
116
109
 
@@ -134,7 +127,7 @@ module Akaitsume
134
127
 
135
128
  def extract_text(content_blocks)
136
129
  content_blocks
137
- .select { |b| b.type == 'text' }
130
+ .select { |b| b.type == :text }
138
131
  .map(&:text)
139
132
  .join
140
133
  end
data/lib/akaitsume/cli.rb CHANGED
@@ -47,7 +47,7 @@ module Akaitsume
47
47
  desc 'tools', 'List registered tools'
48
48
  def tools
49
49
  cfg = load_config
50
- memory = build_memory(cfg)
50
+ memory = Memory.build(cfg)
51
51
  registry = Tool::Registry.default_for(cfg, memory: memory)
52
52
 
53
53
  registry.names.each { |n| say " \u2022 #{n}" }
@@ -60,14 +60,14 @@ module Akaitsume
60
60
  desc 'show [AGENT]', 'Show agent memory'
61
61
  def show(agent_name = 'akaitsume')
62
62
  cfg = parent_load_config
63
- store = parent_build_memory(cfg, agent_name)
63
+ store = Memory.build(cfg, agent_name: agent_name)
64
64
  say store.read || '(empty)'
65
65
  end
66
66
 
67
67
  desc 'search QUERY [AGENT]', 'Search agent memory'
68
68
  def search(query, agent_name = 'akaitsume')
69
69
  cfg = parent_load_config
70
- store = parent_build_memory(cfg, agent_name)
70
+ store = Memory.build(cfg, agent_name: agent_name)
71
71
  say store.search(query)
72
72
  end
73
73
 
@@ -76,15 +76,6 @@ module Akaitsume
76
76
  path = parent_options[:config]
77
77
  path ? Config.load(path: path) : Config.load
78
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
79
  end
89
80
  }
90
81
 
@@ -101,15 +92,6 @@ module Akaitsume
101
92
  end
102
93
  end
103
94
 
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
95
  def build_agent
114
96
  cfg = load_config
115
97
  Agent.new(config: cfg)
@@ -6,18 +6,20 @@ require 'fileutils'
6
6
  module Akaitsume
7
7
  class Config
8
8
  DEFAULTS = {
9
- model: 'claude-opus-4-6',
9
+ model: 'claude-haiku-4-5-20251001',
10
10
  max_turns: 20,
11
11
  max_tokens: 8096,
12
12
  workspace: Dir.home + '/.akaitsume/workspace',
13
13
  memory_dir: Dir.home + '/.akaitsume/memory',
14
14
  memory_backend: 'file',
15
15
  db_path: Dir.home + '/.akaitsume/akaitsume.db',
16
- log_level: 'info'
16
+ log_level: 'info',
17
+ tool_paths: []
17
18
  }.freeze
18
19
 
19
20
  attr_reader :model, :max_turns, :max_tokens, :workspace,
20
- :memory_dir, :memory_backend, :db_path, :log_level, :api_key
21
+ :memory_dir, :memory_backend, :db_path, :log_level, :api_key,
22
+ :tool_paths
21
23
 
22
24
  def self.load(path: nil)
23
25
  file_cfg = path ? YAML.safe_load_file(path, symbolize_names: true) : {}
@@ -35,9 +37,13 @@ module Akaitsume
35
37
  @memory_backend = cfg[:memory_backend].to_s
36
38
  @db_path = cfg[:db_path]
37
39
  @log_level = cfg[:log_level]
40
+ @tool_paths = Array(cfg[:tool_paths])
41
+ end
38
42
 
43
+ def ensure_directories!
39
44
  FileUtils.mkdir_p(@workspace)
40
45
  FileUtils.mkdir_p(@memory_dir)
46
+ self
41
47
  end
42
48
  end
43
49
  end
@@ -4,22 +4,20 @@ module Akaitsume
4
4
  module Hooks
5
5
  EVENTS = %i[before_tool after_tool on_response on_error].freeze
6
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
7
  EVENTS.each do |event|
14
8
  define_method(event) do |&block|
15
- @hooks[event] << block
9
+ hooks[event] << block
16
10
  end
17
11
  end
18
12
 
19
13
  private
20
14
 
15
+ def hooks
16
+ @hooks ||= EVENTS.to_h { |e| [e, []] }
17
+ end
18
+
21
19
  def fire(event, *args)
22
- @hooks.fetch(event, []).each { |h| h.call(*args) }
20
+ hooks[event].each { |h| h.call(*args) }
23
21
  end
24
22
  end
25
23
  end
@@ -7,8 +7,6 @@ module Akaitsume
7
7
  class FileStore
8
8
  include Base
9
9
 
10
- MEMORY_FILE = 'MEMORY.md'
11
-
12
10
  def initialize(dir:, agent_name: 'agent')
13
11
  @path = File.join(dir, "#{agent_name}.md")
14
12
  FileUtils.mkdir_p(dir)
@@ -12,13 +12,15 @@ module Akaitsume
12
12
  end
13
13
 
14
14
  def chat(messages:, system:, tools:, model:, max_tokens:)
15
- raw = @client.messages(
15
+ params = {
16
16
  model: model,
17
17
  max_tokens: max_tokens,
18
18
  system: system,
19
- tools: tools,
20
19
  messages: messages
21
- )
20
+ }
21
+ params[:tools] = tools if tools && !tools.empty?
22
+
23
+ raw = @client.messages.create(**params)
22
24
 
23
25
  Response.new(
24
26
  content: raw.content,
@@ -2,18 +2,13 @@
2
2
 
3
3
  module Akaitsume
4
4
  module Provider
5
- class Response
6
- attr_reader :content, :stop_reason, :model, :usage
7
-
5
+ Response = Data.define(:content, :stop_reason, :model, :usage) do
8
6
  def initialize(content:, stop_reason:, model:, usage: {})
9
- @content = content
10
- @stop_reason = stop_reason
11
- @model = model
12
- @usage = usage
7
+ super(content: content, stop_reason: stop_reason, model: model, usage: usage)
13
8
  end
14
9
 
15
10
  def tool_use?
16
- stop_reason == 'tool_use'
11
+ stop_reason == :tool_use
17
12
  end
18
13
 
19
14
  def input_tokens
@@ -19,6 +19,9 @@ module Akaitsume
19
19
 
20
20
  def add_assistant(content)
21
21
  @messages << { role: 'assistant', content: content }
22
+ end
23
+
24
+ def increment_turn
22
25
  @metadata[:turns] += 1
23
26
  end
24
27
 
@@ -38,9 +38,10 @@ module Akaitsume
38
38
  raise NotImplementedError, "#{self.class}#call not implemented"
39
39
  end
40
40
 
41
- # Wraps result into Anthropic tool_result content format
41
+ # Normalizes input keys to strings and wraps result into Anthropic tool_result content format
42
42
  def execute(input)
43
- result = call(input)
43
+ normalized = input.is_a?(Hash) ? input.transform_keys(&:to_s) : input
44
+ result = call(normalized)
44
45
  { type: 'text', text: result.to_s }
45
46
  rescue StandardError => e
46
47
  { type: 'text', text: "Error: #{e.message}" }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'timeout'
4
5
 
5
6
  module Akaitsume
6
7
  module Tool
@@ -28,28 +29,55 @@ module Akaitsume
28
29
  required: ['command']
29
30
  })
30
31
 
31
- def initialize(workspace:)
32
+ DANGEROUS_PATTERNS = [
33
+ %r{\brm\s+-rf\s+[/~]}, # rm -rf / or ~
34
+ /\bmkfs\b/,
35
+ %r{\bdd\s+.*of=/dev/},
36
+ %r{>\s*/dev/sd},
37
+ /\bshutdown\b/,
38
+ /\breboot\b/,
39
+ /\bchmod\s+-R\s+777/,
40
+ /\bcurl\b.*\|\s*\bsh\b/, # curl | sh
41
+ /\bwget\b.*\|\s*\bsh\b/ # wget | sh
42
+ ].freeze
43
+
44
+ def initialize(workspace:, blocked_commands: nil)
32
45
  @workspace = workspace
46
+ @blocked_commands = blocked_commands || DANGEROUS_PATTERNS
33
47
  end
34
48
 
35
49
  def call(input)
36
- cmd = input['command'] || input[:command]
37
- timeout = (input['timeout'] || input[:timeout] || 30).to_i
50
+ cmd = input['command']
51
+ timeout = (input['timeout'] || 30).to_i
52
+
53
+ if (violation = detect_dangerous(cmd))
54
+ return "BLOCKED: dangerous command pattern detected (#{violation}). " \
55
+ 'If this is intentional, use the files tool or modify blocked_commands config.'
56
+ end
38
57
 
39
- stdout, stderr, status = Open3.capture3(
40
- cmd,
41
- chdir: @workspace,
42
- timeout: timeout
43
- )
58
+ stdout, stderr, status = Timeout.timeout(timeout) do
59
+ Open3.capture3(cmd, chdir: @workspace)
60
+ end
44
61
 
45
62
  parts = []
46
63
  parts << stdout.strip unless stdout.strip.empty?
47
64
  parts << "[stderr] #{stderr.strip}" unless stderr.strip.empty?
48
65
  parts << "[exit #{status.exitstatus}]" unless status.success?
49
66
  parts.empty? ? '(no output)' : parts.join("\n")
67
+ rescue Timeout::Error
68
+ "Error: command timed out after #{(input['timeout'] || 30).to_i}s"
50
69
  rescue Errno::ENOENT => e
51
70
  "Error: #{e.message}"
52
71
  end
72
+
73
+ private
74
+
75
+ def detect_dangerous(cmd)
76
+ @blocked_commands.each do |pattern|
77
+ return pattern.source if pattern.match?(cmd)
78
+ end
79
+ nil
80
+ end
53
81
  end
54
82
  end
55
83
  end
@@ -38,10 +38,10 @@ module Akaitsume
38
38
  end
39
39
 
40
40
  def call(input)
41
- action = input['action'] || input[:action]
42
- path = input['path'] || input[:path]
43
- content = input['content'] || input[:content]
44
- pattern = input['pattern'] || input[:pattern] || '**/*'
41
+ action = input['action']
42
+ path = input['path']
43
+ content = input['content']
44
+ pattern = input['pattern'] || '**/*'
45
45
 
46
46
  case action
47
47
  when 'read' then read(path)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'json'
5
+ require 'resolv'
6
+ require 'uri'
5
7
 
6
8
  module Akaitsume
7
9
  module Tool
@@ -10,7 +12,8 @@ module Akaitsume
10
12
 
11
13
  tool_name 'http'
12
14
  description 'Make HTTP requests. Supports GET, POST, PUT, PATCH, DELETE. ' \
13
- 'Returns status code, response headers, and body (truncated to 4KB).'
15
+ 'Returns status code, response headers, and body (truncated to 4KB). ' \
16
+ 'Requests to private/internal networks are blocked by default.'
14
17
 
15
18
  input_schema({
16
19
  type: 'object',
@@ -43,12 +46,31 @@ module Akaitsume
43
46
 
44
47
  MAX_BODY = 4096
45
48
 
49
+ # CIDR ranges that are blocked by default (private/link-local/loopback)
50
+ BLOCKED_RANGES = [
51
+ IPAddr.new('127.0.0.0/8'), # loopback
52
+ IPAddr.new('10.0.0.0/8'), # private class A
53
+ IPAddr.new('172.16.0.0/12'), # private class B
54
+ IPAddr.new('192.168.0.0/16'), # private class C
55
+ IPAddr.new('169.254.0.0/16'), # link-local / cloud metadata
56
+ IPAddr.new('0.0.0.0/8'), # "this" network
57
+ IPAddr.new('::1/128'), # IPv6 loopback
58
+ IPAddr.new('fc00::/7'), # IPv6 unique local
59
+ IPAddr.new('fe80::/10') # IPv6 link-local
60
+ ].freeze
61
+
62
+ def initialize(blocked_ranges: nil)
63
+ @blocked_ranges = blocked_ranges || BLOCKED_RANGES
64
+ end
65
+
46
66
  def call(input)
47
- method = (input['method'] || input[:method]).downcase.to_sym
48
- url = input['url'] || input[:url]
49
- headers = input['headers'] || input[:headers] || {}
50
- body = input['body'] || input[:body]
51
- timeout = (input['timeout'] || input[:timeout] || 30).to_i
67
+ method = input['method'].downcase.to_sym
68
+ url = input['url']
69
+ headers = input['headers'] || {}
70
+ body = input['body']
71
+ timeout = (input['timeout'] || 30).to_i
72
+
73
+ validate_url!(url)
52
74
 
53
75
  conn = Faraday.new do |f|
54
76
  f.options.timeout = timeout
@@ -68,6 +90,29 @@ module Akaitsume
68
90
  rescue Faraday::Error => e
69
91
  "Error: #{e.class} - #{e.message}"
70
92
  end
93
+
94
+ private
95
+
96
+ def validate_url!(url)
97
+ uri = URI.parse(url)
98
+ host = uri.host
99
+
100
+ raise 'BLOCKED: invalid URL' unless host
101
+ raise 'BLOCKED: only http/https allowed' unless %w[http https].include?(uri.scheme)
102
+
103
+ # Resolve hostname to IP and check against blocked ranges
104
+ ips = Resolv.getaddresses(host)
105
+ raise "BLOCKED: cannot resolve host '#{host}'" if ips.empty?
106
+
107
+ ips.each do |ip_str|
108
+ ip = IPAddr.new(ip_str)
109
+ if @blocked_ranges.any? { |range| range.include?(ip) }
110
+ raise "BLOCKED: requests to private/internal networks are not allowed (#{host} -> #{ip_str})"
111
+ end
112
+ end
113
+ rescue URI::InvalidURIError
114
+ raise 'BLOCKED: malformed URL'
115
+ end
71
116
  end
72
117
  end
73
118
  end
@@ -36,30 +36,25 @@ module Akaitsume
36
36
  end
37
37
 
38
38
  def call(input)
39
- action = input['action'] || input[:action]
40
-
41
- case action
39
+ case input['action']
42
40
  when 'read'
43
41
  @memory.read || '(empty memory)'
44
42
  when 'store'
45
- content = input['content'] || input[:content]
46
- return 'Error: content is required for store' unless content
43
+ return 'Error: content is required for store' unless input['content']
47
44
 
48
- @memory.store(content)
45
+ @memory.store(input['content'])
49
46
  'Stored to memory.'
50
47
  when 'search'
51
- query = input['query'] || input[:query]
52
- return 'Error: query is required for search' unless query
48
+ return 'Error: query is required for search' unless input['query']
53
49
 
54
- @memory.search(query)
50
+ @memory.search(input['query'])
55
51
  when 'replace'
56
- content = input['content'] || input[:content]
57
- return 'Error: content is required for replace' unless content
52
+ return 'Error: content is required for replace' unless input['content']
58
53
 
59
- @memory.replace(content)
54
+ @memory.replace(input['content'])
60
55
  'Memory replaced.'
61
56
  else
62
- "Error: unknown action '#{action}'"
57
+ "Error: unknown action '#{input['action']}'"
63
58
  end
64
59
  end
65
60
  end
@@ -15,7 +15,7 @@ module Akaitsume
15
15
 
16
16
  def [](name)
17
17
  entry = @tools[name] || raise(ToolNotFoundError, "Tool '#{name}' not registered")
18
- entry[:klass].new(**entry[:init_args])
18
+ entry[:instance] ||= entry[:klass].new(**entry[:init_args])
19
19
  end
20
20
 
21
21
  def api_definitions
@@ -26,16 +26,65 @@ module Akaitsume
26
26
  @tools.keys
27
27
  end
28
28
 
29
- # Default registry with all built-in tools.
30
- # Pass memory: to enable the MemoryTool.
29
+ # Auto-discover and register all built-in Tool classes.
30
+ # Resolves constructor args from the provided context.
31
31
  def self.default_for(config, memory: nil)
32
+ context = { workspace: config.workspace, memory: memory }
33
+
32
34
  new.tap do |r|
33
- r.register(Akaitsume::Tool::Bash, workspace: config.workspace)
34
- r.register(Akaitsume::Tool::Files, workspace: config.workspace)
35
- r.register(Akaitsume::Tool::Http)
36
- r.register(Akaitsume::Tool::MemoryTool, memory: memory) if memory
35
+ discover_tools.each do |klass|
36
+ args = resolve_args(klass, context)
37
+ next if args.nil? # skip if required args not available
38
+
39
+ r.register(klass, **args)
40
+ end
41
+
42
+ # Load external tools from config paths
43
+ load_external(config.tool_paths).each do |klass|
44
+ args = resolve_args(klass, context) || {}
45
+ r.register(klass, **args)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Find all classes in Akaitsume::Tool that include Tool::Base
51
+ def self.discover_tools
52
+ Tool.constants
53
+ .map { |name| Tool.const_get(name) }
54
+ .select { |klass| klass.is_a?(Class) && klass < Base }
55
+ end
56
+
57
+ # Match a tool's initialize params to the available context
58
+ def self.resolve_args(klass, context)
59
+ params = klass.instance_method(:initialize).parameters
60
+ return {} if params.empty?
61
+
62
+ args = {}
63
+ params.each do |type, name|
64
+ if context.key?(name)
65
+ args[name] = context[name]
66
+ elsif %i[keyreq req].include?(type)
67
+ return nil # required arg missing — skip this tool
68
+ end
37
69
  end
70
+ args
38
71
  end
72
+
73
+ # Load .rb files from external paths, return tool classes defined in them
74
+ def self.load_external(paths)
75
+ return [] if paths.empty?
76
+
77
+ before = Tool.constants.dup
78
+ paths.each do |path|
79
+ Dir.glob(File.join(path, '*.rb')).each { |f| require f }
80
+ end
81
+ new_constants = Tool.constants - before
82
+ new_constants
83
+ .map { |name| Tool.const_get(name) }
84
+ .select { |klass| klass.is_a?(Class) && klass < Base }
85
+ end
86
+
87
+ private_class_method :discover_tools, :resolve_args, :load_external
39
88
  end
40
89
  end
41
90
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Akaitsume
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/akaitsume.rb CHANGED
@@ -12,4 +12,16 @@ module Akaitsume
12
12
  class MaxTurnsError < Error; end
13
13
  class ToolNotFoundError < Error; end
14
14
  class ConfigError < Error; end
15
+
16
+ module Memory
17
+ # Factory: builds a memory store based on config
18
+ def self.build(config, agent_name: 'akaitsume')
19
+ case config.memory_backend
20
+ when 'sqlite'
21
+ SqliteStore.new(db_path: config.db_path, agent_name: agent_name)
22
+ else
23
+ FileStore.new(dir: config.memory_dir, agent_name: agent_name)
24
+ end
25
+ end
26
+ end
15
27
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: akaitsume
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mateusz Palak
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dotenv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: faraday
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -167,7 +181,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
181
  - !ruby/object:Gem::Version
168
182
  version: '0'
169
183
  requirements: []
170
- rubygems_version: 3.6.9
184
+ rubygems_version: 4.0.9
171
185
  specification_version: 4
172
186
  summary: 赤い爪 — A sharp, extensible AI agent for Ruby
173
187
  test_files: []