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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
|
|
5
|
+
module Akaitsume
|
|
6
|
+
module Memory
|
|
7
|
+
class SqliteStore
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
def initialize(db_path:, agent_name: 'agent')
|
|
11
|
+
@agent = agent_name
|
|
12
|
+
@db = SQLite3::Database.new(db_path)
|
|
13
|
+
@db.results_as_hash = true
|
|
14
|
+
create_table
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def read
|
|
18
|
+
rows = @db.execute(
|
|
19
|
+
'SELECT content, created_at FROM memories WHERE agent = ? ORDER BY id ASC',
|
|
20
|
+
[@agent]
|
|
21
|
+
)
|
|
22
|
+
return nil if rows.empty?
|
|
23
|
+
|
|
24
|
+
rows.map { |r| "## #{r['created_at']}\n#{r['content']}" }.join("\n\n")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def store(entry)
|
|
28
|
+
@db.execute(
|
|
29
|
+
'INSERT INTO memories (agent, content) VALUES (?, ?)',
|
|
30
|
+
[@agent, entry.strip]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def replace(content)
|
|
35
|
+
@db.execute('DELETE FROM memories WHERE agent = ?', [@agent])
|
|
36
|
+
store(content) unless content.to_s.strip.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def search(query)
|
|
40
|
+
rows = @db.execute(
|
|
41
|
+
'SELECT content, created_at FROM memories WHERE agent = ? AND content LIKE ? ORDER BY id ASC',
|
|
42
|
+
[@agent, "%#{query}%"]
|
|
43
|
+
)
|
|
44
|
+
return "(no matches for '#{query}')" if rows.empty?
|
|
45
|
+
|
|
46
|
+
rows.map { |r| "[#{r['created_at']}] #{r['content']}" }.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def create_table
|
|
52
|
+
@db.execute(<<~SQL)
|
|
53
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
agent TEXT NOT NULL,
|
|
56
|
+
content TEXT NOT NULL,
|
|
57
|
+
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
|
58
|
+
)
|
|
59
|
+
SQL
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Provider
|
|
5
|
+
class Anthropic
|
|
6
|
+
include Base
|
|
7
|
+
|
|
8
|
+
provider_name 'anthropic'
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:)
|
|
11
|
+
@client = ::Anthropic::Client.new(api_key: api_key)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def chat(messages:, system:, tools:, model:, max_tokens:)
|
|
15
|
+
raw = @client.messages(
|
|
16
|
+
model: model,
|
|
17
|
+
max_tokens: max_tokens,
|
|
18
|
+
system: system,
|
|
19
|
+
tools: tools,
|
|
20
|
+
messages: messages
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
Response.new(
|
|
24
|
+
content: raw.content,
|
|
25
|
+
stop_reason: raw.stop_reason,
|
|
26
|
+
model: raw.model,
|
|
27
|
+
usage: {
|
|
28
|
+
input_tokens: raw.usage.input_tokens,
|
|
29
|
+
output_tokens: raw.usage.output_tokens
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Provider
|
|
5
|
+
module Base
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def provider_name(name = nil)
|
|
12
|
+
@provider_name = name if name
|
|
13
|
+
@provider_name || self.name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Send messages to the LLM and return a Provider::Response.
|
|
18
|
+
# Must be implemented by each provider.
|
|
19
|
+
def chat(messages:, system:, tools:, model:, max_tokens:)
|
|
20
|
+
raise NotImplementedError, "#{self.class}#chat not implemented"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Provider
|
|
5
|
+
class Response
|
|
6
|
+
attr_reader :content, :stop_reason, :model, :usage
|
|
7
|
+
|
|
8
|
+
def initialize(content:, stop_reason:, model:, usage: {})
|
|
9
|
+
@content = content
|
|
10
|
+
@stop_reason = stop_reason
|
|
11
|
+
@model = model
|
|
12
|
+
@usage = usage
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def tool_use?
|
|
16
|
+
stop_reason == 'tool_use'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def input_tokens
|
|
20
|
+
usage[:input_tokens] || 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def output_tokens
|
|
24
|
+
usage[:output_tokens] || 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def total_tokens
|
|
28
|
+
input_tokens + output_tokens
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Akaitsume
|
|
6
|
+
class Session
|
|
7
|
+
attr_reader :id, :messages, :system_prompt, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(system_prompt: nil)
|
|
10
|
+
@id = SecureRandom.hex(8)
|
|
11
|
+
@messages = []
|
|
12
|
+
@system_prompt = system_prompt
|
|
13
|
+
@metadata = { turns: 0, input_tokens: 0, output_tokens: 0 }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_user(content)
|
|
17
|
+
@messages << { role: 'user', content: content }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_assistant(content)
|
|
21
|
+
@messages << { role: 'assistant', content: content }
|
|
22
|
+
@metadata[:turns] += 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add_tool_results(results)
|
|
26
|
+
@messages << { role: 'user', content: results }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def track_usage(response)
|
|
30
|
+
@metadata[:input_tokens] += response.input_tokens
|
|
31
|
+
@metadata[:output_tokens] += response.output_tokens
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def turn_count
|
|
35
|
+
@metadata[:turns]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def total_tokens
|
|
39
|
+
@metadata[:input_tokens] + @metadata[:output_tokens]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Tool
|
|
5
|
+
module Base
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def tool_name(name = nil)
|
|
12
|
+
@tool_name = name if name
|
|
13
|
+
@tool_name || raise(NotImplementedError, "#{self}.tool_name not defined")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description(desc = nil)
|
|
17
|
+
@description = desc if desc
|
|
18
|
+
@description || raise(NotImplementedError, "#{self}.description not defined")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def input_schema(schema = nil)
|
|
22
|
+
@input_schema = schema if schema
|
|
23
|
+
@input_schema || { type: 'object', properties: {}, required: [] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the tool definition hash for Anthropic API
|
|
27
|
+
def to_api_definition
|
|
28
|
+
{
|
|
29
|
+
name: tool_name,
|
|
30
|
+
description: description,
|
|
31
|
+
input_schema: input_schema
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Instance method — override in each tool
|
|
37
|
+
def call(input)
|
|
38
|
+
raise NotImplementedError, "#{self.class}#call not implemented"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Wraps result into Anthropic tool_result content format
|
|
42
|
+
def execute(input)
|
|
43
|
+
result = call(input)
|
|
44
|
+
{ type: 'text', text: result.to_s }
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
{ type: 'text', text: "Error: #{e.message}" }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Akaitsume
|
|
6
|
+
module Tool
|
|
7
|
+
class Bash
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
tool_name 'bash'
|
|
11
|
+
description 'Execute a shell command and return stdout + stderr. ' \
|
|
12
|
+
'Use for file operations, running scripts, git commands, etc. ' \
|
|
13
|
+
'Commands run in the configured workspace directory.'
|
|
14
|
+
|
|
15
|
+
input_schema({
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
command: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'The shell command to execute'
|
|
21
|
+
},
|
|
22
|
+
timeout: {
|
|
23
|
+
type: 'integer',
|
|
24
|
+
description: 'Timeout in seconds (default: 30)',
|
|
25
|
+
default: 30
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
required: ['command']
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
def initialize(workspace:)
|
|
32
|
+
@workspace = workspace
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(input)
|
|
36
|
+
cmd = input['command'] || input[:command]
|
|
37
|
+
timeout = (input['timeout'] || input[:timeout] || 30).to_i
|
|
38
|
+
|
|
39
|
+
stdout, stderr, status = Open3.capture3(
|
|
40
|
+
cmd,
|
|
41
|
+
chdir: @workspace,
|
|
42
|
+
timeout: timeout
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
parts = []
|
|
46
|
+
parts << stdout.strip unless stdout.strip.empty?
|
|
47
|
+
parts << "[stderr] #{stderr.strip}" unless stderr.strip.empty?
|
|
48
|
+
parts << "[exit #{status.exitstatus}]" unless status.success?
|
|
49
|
+
parts.empty? ? '(no output)' : parts.join("\n")
|
|
50
|
+
rescue Errno::ENOENT => e
|
|
51
|
+
"Error: #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Tool
|
|
5
|
+
class Files
|
|
6
|
+
include Base
|
|
7
|
+
|
|
8
|
+
tool_name 'files'
|
|
9
|
+
description 'Read, write, list, or delete files in the workspace. ' \
|
|
10
|
+
'Actions: read, write, append, list, delete.'
|
|
11
|
+
|
|
12
|
+
input_schema({
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
action: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
enum: %w[read write append list delete],
|
|
18
|
+
description: 'Action to perform'
|
|
19
|
+
},
|
|
20
|
+
path: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Relative path within workspace'
|
|
23
|
+
},
|
|
24
|
+
content: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Content to write (for write/append actions)'
|
|
27
|
+
},
|
|
28
|
+
pattern: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Glob pattern for list action (default: **/*)'
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
required: ['action']
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
def initialize(workspace:)
|
|
37
|
+
@workspace = workspace
|
|
38
|
+
end
|
|
39
|
+
|
|
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] || '**/*'
|
|
45
|
+
|
|
46
|
+
case action
|
|
47
|
+
when 'read' then read(path)
|
|
48
|
+
when 'write' then write(path, content)
|
|
49
|
+
when 'append' then append(path, content)
|
|
50
|
+
when 'list' then list(pattern)
|
|
51
|
+
when 'delete' then delete(path)
|
|
52
|
+
else "Unknown action: #{action}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def full_path(rel)
|
|
59
|
+
path = File.expand_path(rel, @workspace)
|
|
60
|
+
raise 'Path traversal denied' unless path.start_with?(@workspace)
|
|
61
|
+
|
|
62
|
+
path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read(rel)
|
|
66
|
+
p = full_path(rel)
|
|
67
|
+
raise "File not found: #{rel}" unless File.exist?(p)
|
|
68
|
+
|
|
69
|
+
File.read(p)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write(rel, content)
|
|
73
|
+
p = full_path(rel)
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(p))
|
|
75
|
+
File.write(p, content.to_s)
|
|
76
|
+
"Written #{content.to_s.bytesize} bytes to #{rel}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def append(rel, content)
|
|
80
|
+
p = full_path(rel)
|
|
81
|
+
FileUtils.mkdir_p(File.dirname(p))
|
|
82
|
+
File.open(p, 'a') { |f| f.write(content.to_s) }
|
|
83
|
+
"Appended to #{rel}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def list(pattern)
|
|
87
|
+
files = Dir.glob(File.join(@workspace, pattern))
|
|
88
|
+
.map { |f| f.delete_prefix("#{@workspace}/") }
|
|
89
|
+
.reject { |f| File.directory?(File.join(@workspace, f)) }
|
|
90
|
+
files.empty? ? '(no files)' : files.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete(rel)
|
|
94
|
+
p = full_path(rel)
|
|
95
|
+
raise "File not found: #{rel}" unless File.exist?(p)
|
|
96
|
+
|
|
97
|
+
File.delete(p)
|
|
98
|
+
"Deleted #{rel}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Akaitsume
|
|
7
|
+
module Tool
|
|
8
|
+
class Http
|
|
9
|
+
include Base
|
|
10
|
+
|
|
11
|
+
tool_name 'http'
|
|
12
|
+
description 'Make HTTP requests. Supports GET, POST, PUT, PATCH, DELETE. ' \
|
|
13
|
+
'Returns status code, response headers, and body (truncated to 4KB).'
|
|
14
|
+
|
|
15
|
+
input_schema({
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
method: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
enum: %w[get post put patch delete],
|
|
21
|
+
description: 'HTTP method'
|
|
22
|
+
},
|
|
23
|
+
url: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Full URL to request'
|
|
26
|
+
},
|
|
27
|
+
headers: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
description: 'Request headers as key-value pairs'
|
|
30
|
+
},
|
|
31
|
+
body: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: 'Request body (for POST/PUT/PATCH)'
|
|
34
|
+
},
|
|
35
|
+
timeout: {
|
|
36
|
+
type: 'integer',
|
|
37
|
+
description: 'Timeout in seconds (default: 30)',
|
|
38
|
+
default: 30
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
required: %w[method url]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
MAX_BODY = 4096
|
|
45
|
+
|
|
46
|
+
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
|
|
52
|
+
|
|
53
|
+
conn = Faraday.new do |f|
|
|
54
|
+
f.options.timeout = timeout
|
|
55
|
+
f.options.open_timeout = timeout
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
response = conn.run_request(method, url, body, headers)
|
|
59
|
+
|
|
60
|
+
resp_body = response.body.to_s
|
|
61
|
+
truncated = resp_body.length > MAX_BODY
|
|
62
|
+
|
|
63
|
+
parts = []
|
|
64
|
+
parts << "[#{response.status}]"
|
|
65
|
+
parts << resp_body[0...MAX_BODY]
|
|
66
|
+
parts << "... (truncated, #{resp_body.length} bytes total)" if truncated
|
|
67
|
+
parts.join("\n")
|
|
68
|
+
rescue Faraday::Error => e
|
|
69
|
+
"Error: #{e.class} - #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Tool
|
|
5
|
+
class MemoryTool
|
|
6
|
+
include Base
|
|
7
|
+
|
|
8
|
+
tool_name 'memory'
|
|
9
|
+
description 'Read, store, and search your long-term memory. ' \
|
|
10
|
+
"Use 'store' to remember important facts. " \
|
|
11
|
+
"Use 'read' to recall everything. " \
|
|
12
|
+
"Use 'search' to find specific information."
|
|
13
|
+
|
|
14
|
+
input_schema({
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
action: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
enum: %w[read store search replace],
|
|
20
|
+
description: 'Memory operation to perform'
|
|
21
|
+
},
|
|
22
|
+
content: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Content to store or replace (required for store/replace)'
|
|
25
|
+
},
|
|
26
|
+
query: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Search query (required for search)'
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
required: %w[action]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
def initialize(memory:)
|
|
35
|
+
@memory = memory
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call(input)
|
|
39
|
+
action = input['action'] || input[:action]
|
|
40
|
+
|
|
41
|
+
case action
|
|
42
|
+
when 'read'
|
|
43
|
+
@memory.read || '(empty memory)'
|
|
44
|
+
when 'store'
|
|
45
|
+
content = input['content'] || input[:content]
|
|
46
|
+
return 'Error: content is required for store' unless content
|
|
47
|
+
|
|
48
|
+
@memory.store(content)
|
|
49
|
+
'Stored to memory.'
|
|
50
|
+
when 'search'
|
|
51
|
+
query = input['query'] || input[:query]
|
|
52
|
+
return 'Error: query is required for search' unless query
|
|
53
|
+
|
|
54
|
+
@memory.search(query)
|
|
55
|
+
when 'replace'
|
|
56
|
+
content = input['content'] || input[:content]
|
|
57
|
+
return 'Error: content is required for replace' unless content
|
|
58
|
+
|
|
59
|
+
@memory.replace(content)
|
|
60
|
+
'Memory replaced.'
|
|
61
|
+
else
|
|
62
|
+
"Error: unknown action '#{action}'"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Akaitsume
|
|
4
|
+
module Tool
|
|
5
|
+
class Registry
|
|
6
|
+
def initialize
|
|
7
|
+
@tools = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register(tool_class, **init_args)
|
|
11
|
+
name = tool_class.tool_name
|
|
12
|
+
@tools[name] = { klass: tool_class, init_args: init_args }
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def [](name)
|
|
17
|
+
entry = @tools[name] || raise(ToolNotFoundError, "Tool '#{name}' not registered")
|
|
18
|
+
entry[:klass].new(**entry[:init_args])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def api_definitions
|
|
22
|
+
@tools.values.map { |e| e[:klass].to_api_definition }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def names
|
|
26
|
+
@tools.keys
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Default registry with all built-in tools.
|
|
30
|
+
# Pass memory: to enable the MemoryTool.
|
|
31
|
+
def self.default_for(config, memory: nil)
|
|
32
|
+
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
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/akaitsume.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zeitwerk'
|
|
4
|
+
require 'anthropic'
|
|
5
|
+
|
|
6
|
+
loader = Zeitwerk::Loader.for_gem
|
|
7
|
+
loader.inflector.inflect('cli' => 'CLI')
|
|
8
|
+
loader.setup
|
|
9
|
+
|
|
10
|
+
module Akaitsume
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
class MaxTurnsError < Error; end
|
|
13
|
+
class ToolNotFoundError < Error; end
|
|
14
|
+
class ConfigError < Error; end
|
|
15
|
+
end
|