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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4d6bb00af4ebfc5d2758c6c65bf67f79d1676f6f1d84ca6c71bd926afa05a5ee
|
|
4
|
+
data.tar.gz: 27fd6ead4d6c6bb6e315188be9a2fa1cddbad6153155d27762a453b815d04d4f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 547a3023dd19bfc0b6f2491d5fc0705f2f4dfd6b6747221a13ca6c2124f43169735e4b96c4dc8d606cf8092f11393ec3ff3a31949caa6ac903e842df1359ad03
|
|
7
|
+
data.tar.gz: 6e7219dec3d1a0b0d061527a726845b5de4d7ae5db77016b76d31e97c77da5cdc1367b5b6b1937701f8b97e511c4eb448bb77e8923bf082f548e5785c8687ac6
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Satoshi Takei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.ja.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# aiko
|
|
2
|
+
|
|
3
|
+
[English README](README.md)
|
|
4
|
+
|
|
5
|
+
AI + coding. OpenAI互換LLM API(DeepSeek / OpenRouter等)で動くRuby製CLIコーディングエージェント。
|
|
6
|
+
LLMにファイル操作・コマンド実行・検索のツールを与え、エージェントループで自律的にコーディングタスクを実行します。
|
|
7
|
+
|
|
8
|
+
> 名前の由来は「AI + コーディング」。「コーディング」の「コ」を取って、AI + ko = aiko です。
|
|
9
|
+
|
|
10
|
+
## インストール
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bundle install
|
|
14
|
+
rake install # gemをビルドしてローカルインストール(aikoコマンドが使えるようになる)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 設定
|
|
18
|
+
|
|
19
|
+
APIキーは環境変数で設定します(設定ファイルには書けません)。
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
# DeepSeek本家(デフォルト。deepseek-v4-flashを使用)
|
|
23
|
+
export AIKO_API_KEY=sk-...
|
|
24
|
+
|
|
25
|
+
# OpenRouterを使う場合(例: より高性能なdeepseek-v4-pro)
|
|
26
|
+
export AIKO_API_KEY=sk-or-...
|
|
27
|
+
export AIKO_BASE_URL=https://openrouter.ai/api/v1
|
|
28
|
+
export AIKO_MODEL=deepseek/deepseek-v4-pro
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
任意で `~/.aiko/config.json` に `base_url` / `model` / `max_iterations` を設定できます。
|
|
32
|
+
優先順位は「コマンドラインオプション > 環境変数 > 設定ファイル > デフォルト」です。
|
|
33
|
+
|
|
34
|
+
## 使い方
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# ワンショット: プロンプトを1回処理して終了
|
|
38
|
+
aiko "fizzbuzz.rb を作成し、実行して動作確認してください"
|
|
39
|
+
|
|
40
|
+
# REPL: 対話モード(/help でコマンド一覧、/exit で終了)
|
|
41
|
+
aiko
|
|
42
|
+
|
|
43
|
+
# 主要オプション
|
|
44
|
+
aiko -y "..." # すべてのツール実行を自動承認
|
|
45
|
+
aiko -w DIR "..." # 作業ディレクトリを指定
|
|
46
|
+
aiko -m MODEL "..." # モデル名を上書き
|
|
47
|
+
aiko --max-iterations 30 # エージェントループの上限回数
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
ファイル書き込み・編集・コマンド実行の前にはユーザー承認を求めます。
|
|
51
|
+
|
|
52
|
+
> **警告**: `-y` / `--yes` はLLMが提案したファイル書き込みやシェルコマンドを確認なしで実行します。
|
|
53
|
+
> 内容を確認できない操作が無条件に実行されるため、利用は自己責任で、信頼できる作業ディレクトリでのみ使用してください。
|
|
54
|
+
|
|
55
|
+
## 開発
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
rake test # テスト実行(デフォルトタスク)
|
|
59
|
+
rake run -- -y "..." # インストールせずに動作確認
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
仕様は [doc/spec/](doc/spec/01_overview.md)、実装手順は [doc/implementation/](doc/implementation/00_README.md) を参照。
|
|
63
|
+
|
|
64
|
+
## 作者
|
|
65
|
+
|
|
66
|
+
Satoshi Takei — [GitHub @takeisa](https://github.com/takeisa) / [X @takeisa](https://x.com/takeisa)
|
|
67
|
+
|
|
68
|
+
AIコーディングエージェントを利用して開発しています。設計・実装の記録は [doc/](doc/spec/01_overview.md) を参照。
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# aiko
|
|
2
|
+
|
|
3
|
+
[日本語版 README](README.ja.md)
|
|
4
|
+
|
|
5
|
+
AI + coding. A Ruby CLI coding agent that runs on OpenAI-compatible LLM APIs (DeepSeek, OpenRouter, etc.).
|
|
6
|
+
It gives the LLM tools for file operations, command execution, and search, and carries out coding tasks autonomously through an agent loop.
|
|
7
|
+
|
|
8
|
+
> The name comes from "AI + コーディング (coding)" — taking "コ" (ko), the first syllable of コーディング, gives AI + ko = aiko.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bundle install
|
|
14
|
+
rake install # build the gem and install it locally (makes the `aiko` command available)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
The API key is set via an environment variable (it cannot be set in a config file).
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
# DeepSeek (default; uses deepseek-v4-flash)
|
|
23
|
+
export AIKO_API_KEY=sk-...
|
|
24
|
+
|
|
25
|
+
# Using OpenRouter (e.g. the more capable deepseek-v4-pro)
|
|
26
|
+
export AIKO_API_KEY=sk-or-...
|
|
27
|
+
export AIKO_BASE_URL=https://openrouter.ai/api/v1
|
|
28
|
+
export AIKO_MODEL=deepseek/deepseek-v4-pro
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Optionally, you can set `base_url` / `model` / `max_iterations` in `~/.aiko/config.json`.
|
|
32
|
+
The precedence is: command-line options > environment variables > config file > defaults.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# One-shot: process a single prompt and exit
|
|
38
|
+
aiko "Create fizzbuzz.rb, run it, and verify it works"
|
|
39
|
+
|
|
40
|
+
# REPL: interactive mode (/help for command list, /exit to quit)
|
|
41
|
+
aiko
|
|
42
|
+
|
|
43
|
+
# Main options
|
|
44
|
+
aiko -y "..." # auto-approve all tool executions
|
|
45
|
+
aiko -w DIR "..." # specify working directory
|
|
46
|
+
aiko -m MODEL "..." # override the model name
|
|
47
|
+
aiko --max-iterations 30 # cap the number of agent loop iterations
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
aiko asks for your approval before writing/editing files or running commands.
|
|
51
|
+
|
|
52
|
+
> **Warning**: `-y` / `--yes` runs file writes/edits and shell commands proposed by the LLM
|
|
53
|
+
> without confirmation. Since operations you cannot review will run unconditionally,
|
|
54
|
+
> use this at your own risk, and only in a directory you trust.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
rake test # run tests (default task)
|
|
60
|
+
rake run -- -y "..." # try it out without installing
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
See [doc/spec/](doc/spec/01_overview.md) for the specification and [doc/implementation/](doc/implementation/00_README.md) for the implementation notes.
|
|
64
|
+
|
|
65
|
+
## Author
|
|
66
|
+
|
|
67
|
+
Satoshi Takei — [GitHub @takeisa](https://github.com/takeisa) / [X @takeisa](https://x.com/takeisa)
|
|
68
|
+
|
|
69
|
+
Developed using an AI coding agent. See [doc/](doc/spec/01_overview.md) for the design and implementation notes.
|
data/exe/aiko
ADDED
data/lib/aiko/agent.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aiko
|
|
4
|
+
class Agent
|
|
5
|
+
MAX_ITERATIONS_MESSAGE = "(最大ループ回数に達したため処理を打ち切りました)"
|
|
6
|
+
|
|
7
|
+
attr_reader :conversation
|
|
8
|
+
|
|
9
|
+
def initialize(llm:, registry:, callbacks:, conversation:, max_iterations: 20)
|
|
10
|
+
@llm = llm
|
|
11
|
+
@registry = registry
|
|
12
|
+
@callbacks = callbacks
|
|
13
|
+
@conversation = conversation
|
|
14
|
+
@max_iterations = max_iterations
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(user_input)
|
|
18
|
+
@conversation.add_user(user_input)
|
|
19
|
+
@max_iterations.times do
|
|
20
|
+
response = request_completion
|
|
21
|
+
return final_answer(response) unless response.tool_calls?
|
|
22
|
+
|
|
23
|
+
report_progress(response)
|
|
24
|
+
execute_tool_calls(response.tool_calls)
|
|
25
|
+
end
|
|
26
|
+
@callbacks.on_assistant_text(MAX_ITERATIONS_MESSAGE)
|
|
27
|
+
MAX_ITERATIONS_MESSAGE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def request_completion
|
|
33
|
+
@callbacks.on_request_start
|
|
34
|
+
response = begin
|
|
35
|
+
@llm.chat(messages: @conversation.messages, tools: @registry.schemas)
|
|
36
|
+
ensure
|
|
37
|
+
@callbacks.on_request_end
|
|
38
|
+
end
|
|
39
|
+
@conversation.add_assistant(response.raw_message)
|
|
40
|
+
response
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def final_answer(response)
|
|
44
|
+
@callbacks.on_assistant_text(response.content)
|
|
45
|
+
response.content
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def report_progress(response)
|
|
49
|
+
@callbacks.on_assistant_text(response.content) unless response.content.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def execute_tool_calls(tool_calls)
|
|
53
|
+
tool_calls.each do |tool_call|
|
|
54
|
+
@callbacks.on_tool_start(tool_call.name, tool_call.arguments)
|
|
55
|
+
result = execute_tool_call(tool_call)
|
|
56
|
+
@callbacks.on_tool_result(tool_call.name, result)
|
|
57
|
+
@conversation.add_tool_result(tool_call_id: tool_call.id, content: result)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute_tool_call(tool_call)
|
|
62
|
+
if tool_call.parse_error
|
|
63
|
+
"Error: invalid tool arguments JSON: #{tool_call.parse_error}"
|
|
64
|
+
else
|
|
65
|
+
@registry.dispatch(tool_call.name, tool_call.arguments, callbacks: @callbacks)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/aiko/cli.rb
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Aiko
|
|
6
|
+
class CLI
|
|
7
|
+
REPL_HELP = <<~HELP
|
|
8
|
+
/exit, /quit 終了 (Ctrl-D も同様)
|
|
9
|
+
/clear 会話履歴をクリア
|
|
10
|
+
/help このヘルプを表示
|
|
11
|
+
HELP
|
|
12
|
+
|
|
13
|
+
def self.start(argv)
|
|
14
|
+
new.start(argv)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.parse(argv)
|
|
18
|
+
options = {}
|
|
19
|
+
parser = OptionParser.new do |opts|
|
|
20
|
+
opts.banner = "Usage: aiko [options] [prompt]"
|
|
21
|
+
opts.on("-w", "--workdir DIR", "Working directory (default: current directory)") { |v| options[:workdir] = v }
|
|
22
|
+
opts.on("-m", "--model MODEL", "Override model name") { |v| options[:model] = v }
|
|
23
|
+
opts.on("--base-url URL", "Override API endpoint") { |v| options[:base_url] = v }
|
|
24
|
+
opts.on("-y", "--yes", "Auto-approve all tool executions") { options[:yes] = true }
|
|
25
|
+
opts.on("--max-iterations N", Integer, "Max agent loop iterations (default: 20)") { |v| options[:max_iterations] = v }
|
|
26
|
+
opts.on("-v", "--version", "Show version") { options[:version] = true }
|
|
27
|
+
opts.on("-h", "--help", "Show help") { options[:help] = true }
|
|
28
|
+
end
|
|
29
|
+
rest = parser.parse(argv)
|
|
30
|
+
options[:help_text] = parser.to_s
|
|
31
|
+
prompt = rest.empty? ? nil : rest.join(" ")
|
|
32
|
+
[options, prompt]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start(argv)
|
|
36
|
+
options, prompt = self.class.parse(argv)
|
|
37
|
+
return show_version if options[:version]
|
|
38
|
+
return show_help(options) if options[:help]
|
|
39
|
+
|
|
40
|
+
config = Config.load(config_options(options))
|
|
41
|
+
ui = UI.new(auto_approve: options.fetch(:yes, false))
|
|
42
|
+
agent = build_agent(config, ui)
|
|
43
|
+
prompt ? run_oneshot(agent, ui, prompt) : run_repl(agent, ui, config)
|
|
44
|
+
rescue OptionParser::ParseError, ConfigError => e
|
|
45
|
+
warn "Error: #{e.message}"
|
|
46
|
+
2
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def config_options(options)
|
|
52
|
+
options.slice(:workdir, :model, :base_url, :max_iterations)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def show_version
|
|
56
|
+
puts "aiko #{VERSION}"
|
|
57
|
+
0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def show_help(options)
|
|
61
|
+
puts options[:help_text]
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_agent(config, ui)
|
|
66
|
+
registry = Tools::Registry.new
|
|
67
|
+
[Tools::ReadFile, Tools::WriteFile, Tools::EditFile,
|
|
68
|
+
Tools::ListFiles, Tools::RunCommand, Tools::Search].each do |klass|
|
|
69
|
+
registry.register(klass.new(workdir: config.workdir))
|
|
70
|
+
end
|
|
71
|
+
llm = LLM::OpenAICompatible.new(
|
|
72
|
+
api_key: config.api_key, base_url: config.base_url, model: config.model
|
|
73
|
+
)
|
|
74
|
+
conversation = Conversation.new(system_prompt: SystemPrompt.build(workdir: config.workdir))
|
|
75
|
+
Agent.new(llm: llm, registry: registry, callbacks: ui,
|
|
76
|
+
conversation: conversation, max_iterations: config.max_iterations)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run_oneshot(agent, ui, prompt)
|
|
80
|
+
agent.run(prompt)
|
|
81
|
+
0
|
|
82
|
+
rescue LLM::APIError => e
|
|
83
|
+
ui.show_error(e.message)
|
|
84
|
+
1
|
|
85
|
+
rescue Interrupt
|
|
86
|
+
130
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_repl(agent, ui, config)
|
|
90
|
+
require "reline"
|
|
91
|
+
ui.puts "aiko #{VERSION} | model: #{config.model} | workdir: #{config.workdir}"
|
|
92
|
+
ui.puts "Type /help for commands, /exit to quit."
|
|
93
|
+
loop do
|
|
94
|
+
line = read_line
|
|
95
|
+
break if line.nil?
|
|
96
|
+
|
|
97
|
+
line = line.strip
|
|
98
|
+
next if line.empty?
|
|
99
|
+
|
|
100
|
+
if line.start_with?("/")
|
|
101
|
+
break if %w[/exit /quit].include?(line)
|
|
102
|
+
|
|
103
|
+
handle_repl_command(agent, ui, line)
|
|
104
|
+
else
|
|
105
|
+
run_repl_turn(agent, ui, line)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
0
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def read_line
|
|
112
|
+
Reline.readline("> ", true)
|
|
113
|
+
rescue Interrupt
|
|
114
|
+
""
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def handle_repl_command(agent, ui, line)
|
|
118
|
+
case line
|
|
119
|
+
when "/clear"
|
|
120
|
+
agent.conversation.clear
|
|
121
|
+
ui.puts "(会話履歴をクリアしました)"
|
|
122
|
+
when "/help"
|
|
123
|
+
ui.puts REPL_HELP
|
|
124
|
+
else
|
|
125
|
+
ui.show_error("unknown command: #{line} (/help で一覧を表示)")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def run_repl_turn(agent, ui, line)
|
|
130
|
+
agent.run(line)
|
|
131
|
+
rescue Interrupt
|
|
132
|
+
ui.puts "(中断しました)"
|
|
133
|
+
rescue LLM::APIError => e
|
|
134
|
+
ui.show_error(e.message)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/aiko/config.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Aiko
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
base_url: "https://api.deepseek.com",
|
|
9
|
+
model: "deepseek-v4-flash",
|
|
10
|
+
max_iterations: 20
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
FILE_KEYS = %w[base_url model max_iterations].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :api_key, :base_url, :model, :max_iterations, :workdir
|
|
16
|
+
|
|
17
|
+
def self.load(cli_options = {}, config_path: default_config_path, env: ENV)
|
|
18
|
+
file = load_file(config_path)
|
|
19
|
+
merged = DEFAULTS.dup
|
|
20
|
+
FILE_KEYS.each do |key|
|
|
21
|
+
merged[key.to_sym] = file[key] if file.key?(key)
|
|
22
|
+
end
|
|
23
|
+
merged[:base_url] = env["AIKO_BASE_URL"] if env["AIKO_BASE_URL"]
|
|
24
|
+
merged[:model] = env["AIKO_MODEL"] if env["AIKO_MODEL"]
|
|
25
|
+
merged[:api_key] = env["AIKO_API_KEY"]
|
|
26
|
+
%i[api_key base_url model max_iterations workdir].each do |key|
|
|
27
|
+
merged[key] = cli_options[key] if cli_options.key?(key) && !cli_options[key].nil?
|
|
28
|
+
end
|
|
29
|
+
new(**merged)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.default_config_path
|
|
33
|
+
File.join(Dir.home, ".aiko", "config.json")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.load_file(path)
|
|
37
|
+
return {} unless File.exist?(path)
|
|
38
|
+
|
|
39
|
+
JSON.parse(File.read(path))
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
raise ConfigError, "invalid JSON in config file #{path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
private_class_method :load_file
|
|
44
|
+
|
|
45
|
+
def initialize(api_key:, base_url:, model:, max_iterations:, workdir: nil)
|
|
46
|
+
@api_key = api_key
|
|
47
|
+
@base_url = base_url
|
|
48
|
+
@model = model
|
|
49
|
+
@max_iterations = max_iterations
|
|
50
|
+
@workdir = File.expand_path(workdir || Dir.pwd)
|
|
51
|
+
validate!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def validate!
|
|
57
|
+
raise ConfigError, "API key is not set (set AIKO_API_KEY)" if @api_key.nil? || @api_key.empty?
|
|
58
|
+
raise ConfigError, "base_url must start with http:// or https://: #{@base_url}" unless %r{\Ahttps?://}.match?(@base_url)
|
|
59
|
+
unless @max_iterations.is_a?(Integer) && @max_iterations.positive?
|
|
60
|
+
raise ConfigError, "max_iterations must be a positive integer: #{@max_iterations.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aiko
|
|
4
|
+
class Conversation
|
|
5
|
+
attr_reader :messages
|
|
6
|
+
|
|
7
|
+
def initialize(system_prompt:)
|
|
8
|
+
@system_prompt = system_prompt
|
|
9
|
+
@messages = [{ "role" => "system", "content" => system_prompt }]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add_user(text)
|
|
13
|
+
@messages << { "role" => "user", "content" => text }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_assistant(message)
|
|
17
|
+
@messages << message
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_tool_result(tool_call_id:, content:)
|
|
21
|
+
@messages << { "role" => "tool", "tool_call_id" => tool_call_id, "content" => content }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
@messages = [{ "role" => "system", "content" => @system_prompt }]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/aiko/errors.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aiko
|
|
4
|
+
module LLM
|
|
5
|
+
class Client
|
|
6
|
+
# messages: OpenAI互換形式のメッセージ配列
|
|
7
|
+
# tools: OpenAI互換形式のツール定義配列(空配列可)
|
|
8
|
+
# 戻り値: Aiko::LLM::Response
|
|
9
|
+
def chat(messages:, tools: [])
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Aiko
|
|
8
|
+
module LLM
|
|
9
|
+
class OpenAICompatible < Client
|
|
10
|
+
OPEN_TIMEOUT = 10
|
|
11
|
+
READ_TIMEOUT = 300
|
|
12
|
+
RETRY_WAITS = [1, 4].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(api_key:, base_url:, model:, sleeper: ->(sec) { sleep(sec) })
|
|
15
|
+
super()
|
|
16
|
+
@api_key = api_key
|
|
17
|
+
@endpoint = URI.parse("#{base_url.sub(%r{/+\z}, "")}/chat/completions")
|
|
18
|
+
@model = model
|
|
19
|
+
@sleeper = sleeper
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def chat(messages:, tools: [])
|
|
23
|
+
body = { "model" => @model, "messages" => messages }
|
|
24
|
+
body["tools"] = tools unless tools.empty?
|
|
25
|
+
|
|
26
|
+
status, response_body = post_with_retry(body)
|
|
27
|
+
unless (200..299).cover?(status)
|
|
28
|
+
raise APIError, "API request failed with status #{status}: #{response_body.to_s[0, 500]}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
to_response(parse_json(response_body))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def post_with_retry(body)
|
|
37
|
+
status, response_body = post_json(body)
|
|
38
|
+
RETRY_WAITS.each do |wait|
|
|
39
|
+
break unless status == 429 || (500..599).cover?(status)
|
|
40
|
+
|
|
41
|
+
@sleeper.call(wait)
|
|
42
|
+
status, response_body = post_json(body)
|
|
43
|
+
end
|
|
44
|
+
[status, response_body]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# テストではこのメソッドをオーバーライドしてHTTPをスタブする
|
|
48
|
+
def post_json(body)
|
|
49
|
+
http = Net::HTTP.new(@endpoint.host, @endpoint.port)
|
|
50
|
+
http.use_ssl = @endpoint.scheme == "https"
|
|
51
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
52
|
+
http.read_timeout = READ_TIMEOUT
|
|
53
|
+
|
|
54
|
+
request = Net::HTTP::Post.new(@endpoint.request_uri)
|
|
55
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
56
|
+
request["Content-Type"] = "application/json"
|
|
57
|
+
request.body = JSON.generate(body)
|
|
58
|
+
|
|
59
|
+
response = http.request(request)
|
|
60
|
+
[response.code.to_i, response.body]
|
|
61
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SystemCallError, OpenSSL::SSL::SSLError => e
|
|
62
|
+
raise APIError, "API request failed: #{e.class}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_json(body)
|
|
66
|
+
JSON.parse(body)
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
raise APIError, "invalid JSON response: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_response(json)
|
|
72
|
+
message = json.dig("choices", 0, "message")
|
|
73
|
+
raise APIError, "response has no choices: #{json.inspect[0, 500]}" unless message
|
|
74
|
+
|
|
75
|
+
Response.new(
|
|
76
|
+
content: message["content"],
|
|
77
|
+
tool_calls: (message["tool_calls"] || []).map { |tc| to_tool_call(tc) },
|
|
78
|
+
raw_message: message,
|
|
79
|
+
usage: json["usage"] || {}
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_tool_call(tool_call)
|
|
84
|
+
function = tool_call.fetch("function", {})
|
|
85
|
+
arguments = JSON.parse(function["arguments"].to_s)
|
|
86
|
+
arguments = {} unless arguments.is_a?(Hash)
|
|
87
|
+
ToolCall.new(id: tool_call["id"], name: function["name"], arguments: arguments)
|
|
88
|
+
rescue JSON::ParserError => e
|
|
89
|
+
ToolCall.new(id: tool_call["id"], name: function["name"], arguments: {},
|
|
90
|
+
parse_error: e.message)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aiko
|
|
4
|
+
module LLM
|
|
5
|
+
class ToolCall
|
|
6
|
+
attr_reader :id, :name, :arguments, :parse_error
|
|
7
|
+
|
|
8
|
+
def initialize(id:, name:, arguments:, parse_error: nil)
|
|
9
|
+
@id = id
|
|
10
|
+
@name = name
|
|
11
|
+
@arguments = arguments
|
|
12
|
+
@parse_error = parse_error
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Response
|
|
18
|
+
attr_reader :content, :tool_calls, :raw_message, :usage
|
|
19
|
+
|
|
20
|
+
def initialize(content:, tool_calls: [], raw_message: {}, usage: {})
|
|
21
|
+
@content = content.to_s
|
|
22
|
+
@tool_calls = tool_calls
|
|
23
|
+
@raw_message = raw_message
|
|
24
|
+
@usage = usage
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tool_calls?
|
|
29
|
+
!tool_calls.empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aiko
|
|
4
|
+
module SystemPrompt
|
|
5
|
+
def self.build(workdir:)
|
|
6
|
+
<<~PROMPT
|
|
7
|
+
You are aiko, a coding agent that works in the user's project directory.
|
|
8
|
+
You accomplish tasks by calling the provided tools (reading/writing/editing
|
|
9
|
+
files, listing files, searching, and running shell commands).
|
|
10
|
+
|
|
11
|
+
Working directory: #{workdir}
|
|
12
|
+
All relative paths are resolved from this directory. You cannot access
|
|
13
|
+
files outside of it.
|
|
14
|
+
|
|
15
|
+
Guidelines for tool use:
|
|
16
|
+
- Read a file before you edit or overwrite it.
|
|
17
|
+
- Prefer edit_file for small changes and write_file for new files or rewrites.
|
|
18
|
+
- You may run commands to inspect the environment and to verify your work
|
|
19
|
+
(e.g. run the tests or execute the program you just wrote).
|
|
20
|
+
- If a tool returns an error, read it carefully and correct your approach.
|
|
21
|
+
|
|
22
|
+
Answer style:
|
|
23
|
+
- Be concise. After completing a task, summarize what you did and the result.
|
|
24
|
+
- Reply in the same language the user writes in.
|
|
25
|
+
PROMPT
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|