aiko 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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class Base
6
+ OUTPUT_LIMIT = 50_000
7
+
8
+ def initialize(workdir:)
9
+ @workdir = workdir
10
+ end
11
+
12
+ def name
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def description
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def input_schema
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def call(arguments)
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def requires_approval?(_arguments)
29
+ false
30
+ end
31
+
32
+ def approval_message(arguments)
33
+ "#{name}(#{arguments.inspect})"
34
+ end
35
+
36
+ protected
37
+
38
+ attr_reader :workdir
39
+
40
+ def resolve_path(relative)
41
+ resolved = File.expand_path(relative, workdir)
42
+ unless resolved == workdir || resolved.start_with?(workdir + File::SEPARATOR)
43
+ raise ToolError, "path escapes the working directory: #{relative}"
44
+ end
45
+ resolved
46
+ end
47
+
48
+ def truncate(text, limit = OUTPUT_LIMIT)
49
+ return text if text.length <= limit
50
+
51
+ "#{text[0, limit]}\n[出力が長いため切り詰めました]"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class EditFile < Base
6
+ def name
7
+ "edit_file"
8
+ end
9
+
10
+ def description
11
+ "Replace a unique occurrence of old_string with new_string in a file. " \
12
+ "old_string must match exactly one location in the file; read the file first."
13
+ end
14
+
15
+ def input_schema
16
+ {
17
+ "type" => "object",
18
+ "properties" => {
19
+ "path" => {
20
+ "type" => "string",
21
+ "description" => "File path relative to the working directory"
22
+ },
23
+ "old_string" => {
24
+ "type" => "string",
25
+ "description" => "Exact text to replace (must be unique in the file)"
26
+ },
27
+ "new_string" => {
28
+ "type" => "string",
29
+ "description" => "Replacement text"
30
+ }
31
+ },
32
+ "required" => %w[path old_string new_string]
33
+ }
34
+ end
35
+
36
+ def call(arguments)
37
+ path = resolve_path(arguments.fetch("path"))
38
+ return "Error: no such file: #{arguments["path"]}" unless File.file?(path)
39
+
40
+ old_string = arguments.fetch("old_string")
41
+ new_string = arguments.fetch("new_string")
42
+ content = File.read(path)
43
+
44
+ count = count_occurrences(content, old_string)
45
+ return "Error: old_string not found" if count.zero?
46
+ return "Error: old_string is not unique (#{count} matches)" if count > 1
47
+
48
+ index = content.index(old_string)
49
+ File.write(path, content[0, index] + new_string + content[(index + old_string.length)..])
50
+ "Edited #{arguments["path"]}"
51
+ end
52
+
53
+ def requires_approval?(_arguments)
54
+ true
55
+ end
56
+
57
+ def approval_message(arguments)
58
+ "edit_file: #{arguments["path"]} を編集します\n--- 置換前 ---\n#{arguments["old_string"]}\n--- 置換後 ---\n#{arguments["new_string"]}"
59
+ end
60
+
61
+ private
62
+
63
+ # 正規表現メタ文字を含むold_stringでも誤動作しないよう、プレーン文字列として数える
64
+ def count_occurrences(content, old_string)
65
+ return 0 if old_string.empty?
66
+
67
+ count = 0
68
+ index = 0
69
+ while (index = content.index(old_string, index))
70
+ count += 1
71
+ index += 1
72
+ end
73
+ count
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class ListFiles < Base
6
+ EXCLUDED_DIRS = %w[.git node_modules vendor tmp].freeze
7
+ MAX_ENTRIES = 1_000
8
+
9
+ def name
10
+ "list_files"
11
+ end
12
+
13
+ def description
14
+ "List files and directories. Directories have a trailing '/'. " \
15
+ "Set recursive to true to list the whole tree (hidden files and " \
16
+ "directories like .git/node_modules are skipped when recursive)."
17
+ end
18
+
19
+ def input_schema
20
+ {
21
+ "type" => "object",
22
+ "properties" => {
23
+ "path" => {
24
+ "type" => "string",
25
+ "description" => "Directory path relative to the working directory (default: working directory)"
26
+ },
27
+ "recursive" => {
28
+ "type" => "boolean",
29
+ "description" => "List entries recursively (default: false)"
30
+ }
31
+ },
32
+ "required" => []
33
+ }
34
+ end
35
+
36
+ def call(arguments)
37
+ base = resolve_path(arguments["path"] || ".")
38
+ return "Error: no such directory: #{arguments["path"]}" unless File.directory?(base)
39
+
40
+ entries = arguments["recursive"] ? collect_recursive(base) : collect_flat(base)
41
+ entries.sort!
42
+ return "(empty)" if entries.empty?
43
+
44
+ if entries.size > MAX_ENTRIES
45
+ entries = entries.first(MAX_ENTRIES)
46
+ entries << "[#{MAX_ENTRIES}エントリを超えたため省略しました]"
47
+ end
48
+ truncate(entries.join("\n"))
49
+ end
50
+
51
+ private
52
+
53
+ def collect_flat(base)
54
+ Dir.children(base).map do |entry|
55
+ File.directory?(File.join(base, entry)) ? "#{entry}/" : entry
56
+ end
57
+ end
58
+
59
+ def collect_recursive(base, prefix = "")
60
+ entries = []
61
+ Dir.children(base).each do |entry|
62
+ next if entry.start_with?(".") || EXCLUDED_DIRS.include?(entry)
63
+
64
+ full = File.join(base, entry)
65
+ relative = prefix.empty? ? entry : "#{prefix}/#{entry}"
66
+ if File.directory?(full)
67
+ entries << "#{relative}/"
68
+ entries.concat(collect_recursive(full, relative))
69
+ else
70
+ entries << relative
71
+ end
72
+ end
73
+ entries
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class ReadFile < Base
6
+ def name
7
+ "read_file"
8
+ end
9
+
10
+ def description
11
+ "Read the contents of a text file. Returns the content with line numbers " \
12
+ "(e.g. \"1: ...\"). Use this before editing a file."
13
+ end
14
+
15
+ def input_schema
16
+ {
17
+ "type" => "object",
18
+ "properties" => {
19
+ "path" => {
20
+ "type" => "string",
21
+ "description" => "File path relative to the working directory"
22
+ }
23
+ },
24
+ "required" => ["path"]
25
+ }
26
+ end
27
+
28
+ def call(arguments)
29
+ path = resolve_path(arguments.fetch("path"))
30
+ return "Error: no such file: #{arguments["path"]}" unless File.exist?(path)
31
+ return "Error: is a directory: #{arguments["path"]}" if File.directory?(path)
32
+ return "Error: binary file: #{arguments["path"]}" if binary?(path)
33
+
34
+ numbered = File.read(path).each_line.with_index(1).map do |line, i|
35
+ format("%d: %s", i, line)
36
+ end.join
37
+ truncate(numbered)
38
+ end
39
+
40
+ private
41
+
42
+ def binary?(path)
43
+ head = File.binread(path, 8192) || ""
44
+ head.include?("\x00")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class Registry
6
+ def initialize
7
+ @tools = {}
8
+ end
9
+
10
+ def register(tool)
11
+ raise ArgumentError, "tool already registered: #{tool.name}" if @tools.key?(tool.name)
12
+
13
+ @tools[tool.name] = tool
14
+ end
15
+
16
+ def schemas
17
+ @tools.values.map do |tool|
18
+ {
19
+ "type" => "function",
20
+ "function" => {
21
+ "name" => tool.name,
22
+ "description" => tool.description,
23
+ "parameters" => tool.input_schema
24
+ }
25
+ }
26
+ end
27
+ end
28
+
29
+ def dispatch(name, arguments, callbacks:)
30
+ tool = @tools[name]
31
+ return "Error: unknown tool '#{name}'" unless tool
32
+
33
+ if tool.requires_approval?(arguments) && !callbacks.confirm?(tool.approval_message(arguments))
34
+ return "User declined to run this tool."
35
+ end
36
+
37
+ begin
38
+ tool.call(arguments)
39
+ rescue ToolError, SystemCallError => e
40
+ "Error: #{e.message}"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Aiko
6
+ module Tools
7
+ class RunCommand < Base
8
+ DEFAULT_TIMEOUT = 60
9
+ MAX_TIMEOUT = 600
10
+
11
+ def name
12
+ "run_command"
13
+ end
14
+
15
+ def description
16
+ "Run a shell command in the working directory and return its combined " \
17
+ "stdout/stderr with the exit status. Use this to run tests, scripts, " \
18
+ "or inspect the environment."
19
+ end
20
+
21
+ def input_schema
22
+ {
23
+ "type" => "object",
24
+ "properties" => {
25
+ "command" => {
26
+ "type" => "string",
27
+ "description" => "Shell command to execute"
28
+ },
29
+ "timeout" => {
30
+ "type" => "integer",
31
+ "description" => "Timeout in seconds (default: 60, max: 600)"
32
+ }
33
+ },
34
+ "required" => ["command"]
35
+ }
36
+ end
37
+
38
+ def call(arguments)
39
+ command = arguments.fetch("command")
40
+ timeout = [(arguments["timeout"] || DEFAULT_TIMEOUT).to_i, MAX_TIMEOUT].min
41
+ timeout = DEFAULT_TIMEOUT unless timeout.positive?
42
+
43
+ output, status = run_with_timeout(command, timeout)
44
+ return "Error: command timed out after #{timeout} seconds" unless status
45
+
46
+ truncate("exit status: #{status.exitstatus}\n--- output ---\n#{output}")
47
+ end
48
+
49
+ def requires_approval?(_arguments)
50
+ true
51
+ end
52
+
53
+ def approval_message(arguments)
54
+ "run_command: #{arguments["command"]}"
55
+ end
56
+
57
+ private
58
+
59
+ def run_with_timeout(command, timeout)
60
+ Open3.popen2e(command, chdir: workdir) do |stdin, stdout_err, wait_thread|
61
+ stdin.close
62
+ reader = Thread.new { stdout_err.read }
63
+ if wait_thread.join(timeout)
64
+ [reader.value, wait_thread.value]
65
+ else
66
+ Process.kill("KILL", wait_thread.pid)
67
+ wait_thread.join
68
+ [nil, nil]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ module Tools
5
+ class Search < Base
6
+ MAX_MATCHES = 200
7
+
8
+ def name
9
+ "search"
10
+ end
11
+
12
+ def description
13
+ "Search file contents with a Ruby regular expression (like grep). " \
14
+ "Returns matching lines as 'path:line_number: line'. " \
15
+ "Optionally restrict to a subdirectory and/or a filename glob like '*.rb'."
16
+ end
17
+
18
+ def input_schema
19
+ {
20
+ "type" => "object",
21
+ "properties" => {
22
+ "pattern" => {
23
+ "type" => "string",
24
+ "description" => "Ruby regular expression to search for"
25
+ },
26
+ "path" => {
27
+ "type" => "string",
28
+ "description" => "Directory to search, relative to the working directory (default: working directory)"
29
+ },
30
+ "glob" => {
31
+ "type" => "string",
32
+ "description" => "Filename glob filter, e.g. '*.rb'"
33
+ }
34
+ },
35
+ "required" => ["pattern"]
36
+ }
37
+ end
38
+
39
+ def call(arguments)
40
+ regexp = begin
41
+ Regexp.new(arguments.fetch("pattern"))
42
+ rescue RegexpError => e
43
+ return "Error: invalid regexp: #{e.message}"
44
+ end
45
+
46
+ base = resolve_path(arguments["path"] || ".")
47
+ return "Error: no such directory: #{arguments["path"]}" unless File.directory?(base)
48
+
49
+ matches = search_files(base, regexp, arguments["glob"])
50
+ return "(no matches)" if matches.empty?
51
+
52
+ truncated = matches.size > MAX_MATCHES
53
+ matches = matches.first(MAX_MATCHES)
54
+ matches << "[#{MAX_MATCHES}マッチを超えたため省略しました]" if truncated
55
+ truncate(matches.join("\n"))
56
+ end
57
+
58
+ private
59
+
60
+ def search_files(base, regexp, glob)
61
+ matches = []
62
+ each_text_file(base) do |path|
63
+ next if glob && !File.fnmatch?(glob, File.basename(path))
64
+
65
+ relative = path.delete_prefix(workdir + File::SEPARATOR)
66
+ begin
67
+ File.foreach(path).with_index(1) do |line, number|
68
+ matches << "#{relative}:#{number}: #{line.chomp}" if regexp.match?(line)
69
+ break if matches.size > MAX_MATCHES
70
+ end
71
+ rescue ArgumentError
72
+ # 非UTF-8バイト列を含むファイルはスキップ
73
+ end
74
+ break if matches.size > MAX_MATCHES
75
+ end
76
+ matches
77
+ end
78
+
79
+ def each_text_file(dir, &block)
80
+ Dir.children(dir).sort.each do |entry|
81
+ next if entry.start_with?(".") || ListFiles::EXCLUDED_DIRS.include?(entry)
82
+
83
+ full = File.join(dir, entry)
84
+ if File.directory?(full)
85
+ each_text_file(full, &block)
86
+ elsif File.file?(full) && !binary?(full)
87
+ block.call(full)
88
+ end
89
+ end
90
+ end
91
+
92
+ def binary?(path)
93
+ head = File.binread(path, 8192) || ""
94
+ head.include?("\x00")
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Aiko
6
+ module Tools
7
+ class WriteFile < Base
8
+ def name
9
+ "write_file"
10
+ end
11
+
12
+ def description
13
+ "Create a new file or overwrite an existing file with the given content. " \
14
+ "Parent directories are created automatically."
15
+ end
16
+
17
+ def input_schema
18
+ {
19
+ "type" => "object",
20
+ "properties" => {
21
+ "path" => {
22
+ "type" => "string",
23
+ "description" => "File path relative to the working directory"
24
+ },
25
+ "content" => {
26
+ "type" => "string",
27
+ "description" => "Full content to write to the file"
28
+ }
29
+ },
30
+ "required" => %w[path content]
31
+ }
32
+ end
33
+
34
+ def call(arguments)
35
+ path = resolve_path(arguments.fetch("path"))
36
+ content = arguments.fetch("content")
37
+ FileUtils.mkdir_p(File.dirname(path))
38
+ File.write(path, content)
39
+ "Wrote #{content.bytesize} bytes to #{arguments["path"]}"
40
+ end
41
+
42
+ def requires_approval?(_arguments)
43
+ true
44
+ end
45
+
46
+ def approval_message(arguments)
47
+ path = resolve_path(arguments.fetch("path"))
48
+ content = arguments.fetch("content")
49
+ action = File.exist?(path) ? "上書き" : "新規作成"
50
+ preview = content.lines.first(5).join
51
+ "write_file: #{arguments["path"]} を#{action}します(#{content.bytesize} bytes)\n--- 内容(先頭5行) ---\n#{preview}"
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/aiko/ui.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ class UI
5
+ TOOL_ARGS_WIDTH = 120
6
+ RESULT_PREVIEW_LINES = 5
7
+
8
+ def initialize(input: $stdin, output: $stdout, error: $stderr, auto_approve: false)
9
+ @input = input
10
+ @output = output
11
+ @error = error
12
+ @auto_approve = auto_approve
13
+ end
14
+
15
+ def on_assistant_text(text)
16
+ @output.puts(text)
17
+ end
18
+
19
+ def on_request_start
20
+ return unless @output.tty?
21
+
22
+ @output.print(dim("考え中…"))
23
+ @output.flush
24
+ end
25
+
26
+ def on_request_end
27
+ return unless @output.tty?
28
+
29
+ @output.print("\r\e[K")
30
+ @output.flush
31
+ end
32
+
33
+ def on_tool_start(name, arguments)
34
+ args = arguments.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
35
+ line = "⏺ #{bold(name)}(#{args})"
36
+ line = "#{line[0, TOOL_ARGS_WIDTH]}…" if line.length > TOOL_ARGS_WIDTH
37
+ @output.puts(line)
38
+ end
39
+
40
+ def on_tool_result(_name, result)
41
+ lines = result.lines.first(RESULT_PREVIEW_LINES)
42
+ lines.each { |line| @output.puts(dim(" #{line.chomp}")) }
43
+ end
44
+
45
+ def confirm?(message)
46
+ return true if @auto_approve
47
+ return false unless @input.tty?
48
+
49
+ @output.puts(message)
50
+ @output.print("実行しますか? [y/N] ")
51
+ answer = @input.gets
52
+ !answer.nil? && %w[y Y].include?(answer.strip)
53
+ end
54
+
55
+ def show_error(message)
56
+ @error.puts("Error: #{message}")
57
+ end
58
+
59
+ def puts(text = "")
60
+ @output.puts(text)
61
+ end
62
+
63
+ private
64
+
65
+ def bold(text)
66
+ @output.tty? ? "\e[1m#{text}\e[0m" : text
67
+ end
68
+
69
+ def dim(text)
70
+ @output.tty? ? "\e[2m#{text}\e[0m" : text
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aiko
4
+ VERSION = "0.1.0"
5
+ end
data/lib/aiko.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "aiko/version"
4
+ require_relative "aiko/errors"
5
+ require_relative "aiko/config"
6
+ require_relative "aiko/tools/base"
7
+ require_relative "aiko/tools/registry"
8
+ require_relative "aiko/tools/read_file"
9
+ require_relative "aiko/tools/write_file"
10
+ require_relative "aiko/tools/edit_file"
11
+ require_relative "aiko/tools/list_files"
12
+ require_relative "aiko/tools/run_command"
13
+ require_relative "aiko/tools/search"
14
+ require_relative "aiko/llm/client"
15
+ require_relative "aiko/llm/response"
16
+ require_relative "aiko/llm/openai_compatible"
17
+ require_relative "aiko/conversation"
18
+ require_relative "aiko/system_prompt"
19
+ require_relative "aiko/agent"
20
+ require_relative "aiko/ui"
21
+ require_relative "aiko/cli"