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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.ja.md +68 -0
- data/README.md +69 -0
- data/exe/aiko +5 -0
- data/lib/aiko/agent.rb +69 -0
- data/lib/aiko/cli.rb +137 -0
- data/lib/aiko/config.rb +64 -0
- data/lib/aiko/conversation.rb +28 -0
- data/lib/aiko/errors.rb +14 -0
- data/lib/aiko/llm/client.rb +14 -0
- data/lib/aiko/llm/openai_compatible.rb +94 -0
- data/lib/aiko/llm/response.rb +33 -0
- data/lib/aiko/system_prompt.rb +28 -0
- data/lib/aiko/tools/base.rb +55 -0
- data/lib/aiko/tools/edit_file.rb +77 -0
- data/lib/aiko/tools/list_files.rb +77 -0
- data/lib/aiko/tools/read_file.rb +48 -0
- data/lib/aiko/tools/registry.rb +45 -0
- data/lib/aiko/tools/run_command.rb +74 -0
- data/lib/aiko/tools/search.rb +98 -0
- data/lib/aiko/tools/write_file.rb +55 -0
- data/lib/aiko/ui.rb +73 -0
- data/lib/aiko/version.rb +5 -0
- data/lib/aiko.rb +21 -0
- metadata +68 -0
|
@@ -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
|
data/lib/aiko/version.rb
ADDED
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"
|