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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Akaitsume
4
+ VERSION = '0.1.0'
5
+ 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