chocomint 1.0.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 +21 -0
- data/README.md +224 -0
- data/THIRD_PARTY_LICENSES.md +45 -0
- data/bin/chocomint +37 -0
- data/bin/chocomint-console-worker +35 -0
- data/bin/chocomint-server +43 -0
- data/config/config.yml +37 -0
- data/lib/chocomint/config.rb +74 -0
- data/lib/chocomint/console/conpty.rb +296 -0
- data/lib/chocomint/console/server.rb +164 -0
- data/lib/chocomint/console/worker.rb +88 -0
- data/lib/chocomint/console/worker_process.rb +254 -0
- data/lib/chocomint/edit_git_handlers.rb +397 -0
- data/lib/chocomint/edit_handlers.rb +696 -0
- data/lib/chocomint/edit_html.rb +9 -0
- data/lib/chocomint/errors.rb +18 -0
- data/lib/chocomint/factory.rb +145 -0
- data/lib/chocomint/llm/base_client.rb +113 -0
- data/lib/chocomint/llm/chat_client.rb +206 -0
- data/lib/chocomint/llm/primary_client.rb +320 -0
- data/lib/chocomint/llm/verifier_client.rb +127 -0
- data/lib/chocomint/logger/sqlite_logger.rb +245 -0
- data/lib/chocomint/orchestrator.rb +175 -0
- data/lib/chocomint/planner.rb +241 -0
- data/lib/chocomint/security/path_guard.rb +84 -0
- data/lib/chocomint/server.rb +863 -0
- data/lib/chocomint/tools/append_file.rb +50 -0
- data/lib/chocomint/tools/base.rb +88 -0
- data/lib/chocomint/tools/delete_file.rb +42 -0
- data/lib/chocomint/tools/edit.rb +65 -0
- data/lib/chocomint/tools/fetch_url.rb +156 -0
- data/lib/chocomint/tools/file_info.rb +54 -0
- data/lib/chocomint/tools/glob.rb +58 -0
- data/lib/chocomint/tools/grep.rb +78 -0
- data/lib/chocomint/tools/list_dir.rb +41 -0
- data/lib/chocomint/tools/ls.rb +51 -0
- data/lib/chocomint/tools/make_dir.rb +44 -0
- data/lib/chocomint/tools/read_file.rb +45 -0
- data/lib/chocomint/tools/registry.rb +29 -0
- data/lib/chocomint/tools/run_command.rb +96 -0
- data/lib/chocomint/tools/shell.rb +169 -0
- data/lib/chocomint/tools/write_file.rb +54 -0
- data/lib/chocomint/validator/machine_validator.rb +65 -0
- data/lib/chocomint/validator/semantic_validator.rb +52 -0
- data/lib/chocomint.rb +51 -0
- data/public/edit/edit.css +414 -0
- data/public/edit/edit.js +2137 -0
- data/public/edit/index.html +251 -0
- data/public/vendor/monaco/LICENSE.txt +23 -0
- data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- data/public/vendor/monaco/vs/loader.js +11 -0
- data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
- data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- data/public/vendor/xterm/LICENSE +25 -0
- data/public/vendor/xterm/addon-fit.js +2 -0
- data/public/vendor/xterm/xterm.css +218 -0
- data/public/vendor/xterm/xterm.js +2 -0
- metadata +318 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内のファイルに追記する。ファイルが無ければ新規作成。
|
|
8
|
+
class AppendFile < Base
|
|
9
|
+
def initialize(path_guard:, max_file_bytes:)
|
|
10
|
+
@path_guard = path_guard
|
|
11
|
+
@max_file_bytes = max_file_bytes
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name = "append_file"
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"指定パスの末尾にテキストを追記する。無ければ新規作成。path は許可ディレクトリ内であること。"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def schema
|
|
21
|
+
{
|
|
22
|
+
"type" => "object",
|
|
23
|
+
"properties" => {
|
|
24
|
+
"path" => { "type" => "string" },
|
|
25
|
+
"content" => { "type" => "string" }
|
|
26
|
+
},
|
|
27
|
+
"required" => %w[path content],
|
|
28
|
+
"additionalProperties" => false
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(args)
|
|
33
|
+
measure do
|
|
34
|
+
content = args["content"].to_s
|
|
35
|
+
abs = @path_guard.resolve(args["path"])
|
|
36
|
+
|
|
37
|
+
current = File.exist?(abs) ? File.size(abs) : 0
|
|
38
|
+
if current + content.bytesize > @max_file_bytes
|
|
39
|
+
next fail(stderr: "resulting file exceeds max_file_bytes (#{@max_file_bytes})")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
require "fileutils"
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(abs))
|
|
44
|
+
File.open(abs, "a") { |f| f.write(content) }
|
|
45
|
+
ok(stdout: "appended #{content.bytesize} bytes to #{args['path']}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chocomint
|
|
4
|
+
module Tools
|
|
5
|
+
# ツールの共通インターフェース (DESIGN §7)。
|
|
6
|
+
# call は必ず { exit_code:, stdout:, stderr:, duration_ms: } を返す。
|
|
7
|
+
class Base
|
|
8
|
+
def name
|
|
9
|
+
raise NotImplementedError
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# json_schemer 用の JSON Schema (arguments の検証に使う)。
|
|
13
|
+
def schema
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(_args)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Anthropic Messages API の tools 配列に渡す形式。
|
|
22
|
+
def to_tool_definition
|
|
23
|
+
{
|
|
24
|
+
"name" => name,
|
|
25
|
+
"description" => description,
|
|
26
|
+
"input_schema" => schema
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def description
|
|
31
|
+
""
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
protected
|
|
35
|
+
|
|
36
|
+
# 経過時間を測りつつ結果ハッシュを組み立てるヘルパ。
|
|
37
|
+
def measure
|
|
38
|
+
started = now_ms
|
|
39
|
+
body = yield
|
|
40
|
+
body.merge(duration_ms: now_ms - started)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
{
|
|
43
|
+
exit_code: 1,
|
|
44
|
+
stdout: "",
|
|
45
|
+
stderr: e.message,
|
|
46
|
+
duration_ms: now_ms - started
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ok(stdout: "", stderr: "")
|
|
51
|
+
{ exit_code: 0, stdout: stdout, stderr: stderr }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fail(stderr:, stdout: "")
|
|
55
|
+
{ exit_code: 1, stdout: stdout, stderr: stderr }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def now_ms
|
|
59
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 外部プロセスの出力 (ASCII-8BIT) を妥当な UTF-8 文字列に正規化する。
|
|
63
|
+
# Windows の python 等は UTF-8 ではなく OS ロケール (日本語なら CP932) で
|
|
64
|
+
# 日本語を出力するため、UTF-8 として不正なら CP932 からの変換を試みる。
|
|
65
|
+
# それも失敗した場合のみ、無効バイトを置換文字にフォールバックする。
|
|
66
|
+
# こうしないと CP932 の日本語が UTF-8 誤認で全て "□" 化けしてしまう。
|
|
67
|
+
def to_utf8(text)
|
|
68
|
+
return text if text.nil?
|
|
69
|
+
|
|
70
|
+
s = text.dup.force_encoding("UTF-8")
|
|
71
|
+
return s if s.valid_encoding?
|
|
72
|
+
|
|
73
|
+
# OS の既定エンコーディング (Windows-日本語なら Windows-31J=CP932) を優先し、
|
|
74
|
+
# 無ければ CP932 を試す。undef/invalid は置換しつつも大半の日本語を救う。
|
|
75
|
+
[Encoding.default_external, Encoding::CP932].uniq.each do |enc|
|
|
76
|
+
next if enc == Encoding::UTF_8
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
return text.dup.force_encoding(enc).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
80
|
+
rescue EncodingError, ArgumentError
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
s.scrub("�")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内のファイルを削除する。ディレクトリは対象外。
|
|
8
|
+
class DeleteFile < Base
|
|
9
|
+
def initialize(path_guard:)
|
|
10
|
+
@path_guard = path_guard
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name = "delete_file"
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
"指定パスのファイルを削除する。path は許可ディレクトリ内であること。ディレクトリは削除しない。"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def schema
|
|
20
|
+
{
|
|
21
|
+
"type" => "object",
|
|
22
|
+
"properties" => {
|
|
23
|
+
"path" => { "type" => "string" }
|
|
24
|
+
},
|
|
25
|
+
"required" => %w[path],
|
|
26
|
+
"additionalProperties" => false
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(args)
|
|
31
|
+
measure do
|
|
32
|
+
abs = @path_guard.resolve(args["path"])
|
|
33
|
+
next fail(stderr: "no such file: #{args['path']}") unless File.exist?(abs)
|
|
34
|
+
next fail(stderr: "not a file: #{args['path']}") unless File.file?(abs)
|
|
35
|
+
|
|
36
|
+
File.delete(abs)
|
|
37
|
+
ok(stdout: "deleted #{args['path']}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内のファイルで文字列置換を行う (Claude Code の Edit 相当)。
|
|
8
|
+
# old_string は一意でなければならない (replace_all=true で全置換)。
|
|
9
|
+
class Edit < Base
|
|
10
|
+
def initialize(path_guard:, max_file_bytes:)
|
|
11
|
+
@path_guard = path_guard
|
|
12
|
+
@max_file_bytes = max_file_bytes
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name = "edit"
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
"ファイル内の old_string を new_string に置換する。既定では old_string が一意である" \
|
|
19
|
+
"必要があり、replace_all=true で全置換。path は許可ディレクトリ内であること。"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def schema
|
|
23
|
+
{
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"properties" => {
|
|
26
|
+
"path" => { "type" => "string" },
|
|
27
|
+
"old_string" => { "type" => "string" },
|
|
28
|
+
"new_string" => { "type" => "string" },
|
|
29
|
+
"replace_all" => { "type" => "boolean" }
|
|
30
|
+
},
|
|
31
|
+
"required" => %w[path old_string new_string],
|
|
32
|
+
"additionalProperties" => false
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(args)
|
|
37
|
+
measure do
|
|
38
|
+
old_str = args["old_string"].to_s
|
|
39
|
+
new_str = args["new_string"].to_s
|
|
40
|
+
replace_all = args["replace_all"] == true
|
|
41
|
+
|
|
42
|
+
abs = @path_guard.resolve(args["path"])
|
|
43
|
+
next fail(stderr: "no such file: #{args['path']}") unless File.file?(abs)
|
|
44
|
+
next fail(stderr: "old_string and new_string are identical") if old_str == new_str
|
|
45
|
+
|
|
46
|
+
content = File.read(abs)
|
|
47
|
+
count = content.scan(Regexp.new(Regexp.escape(old_str))).size
|
|
48
|
+
next fail(stderr: "old_string not found") if count.zero?
|
|
49
|
+
if count > 1 && !replace_all
|
|
50
|
+
next fail(stderr: "old_string is not unique (#{count} matches); use replace_all")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
updated = replace_all ? content.gsub(old_str, new_str) : content.sub(old_str, new_str)
|
|
54
|
+
if updated.bytesize > @max_file_bytes
|
|
55
|
+
next fail(stderr: "resulting file exceeds max_file_bytes (#{@max_file_bytes})")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
replaced = replace_all ? count : 1
|
|
59
|
+
File.write(abs, updated)
|
|
60
|
+
ok(stdout: "replaced #{replaced} occurrence(s) in #{args['path']}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module Chocomint
|
|
8
|
+
module Tools
|
|
9
|
+
# 指定 URL のウェブページを取得し、本文テキストを抽出したうえで、
|
|
10
|
+
# 別途 LLM (ChatClient#extract) に「ユーザーの目的に沿った情報」を要約・抽出させる。
|
|
11
|
+
#
|
|
12
|
+
# 取得は標準ライブラリ net/http のみ (依存追加なし)。http/https に限定し、
|
|
13
|
+
# リダイレクトを数回だけ追い、応答サイズに上限を設ける。生 HTML をそのまま
|
|
14
|
+
# LLM に流すとノイズが多く読み取りにくいため、タグ除去で本文テキスト化してから渡す。
|
|
15
|
+
class FetchUrl < Base
|
|
16
|
+
MAX_REDIRECTS = 5
|
|
17
|
+
# 取得を許す本文サイズの上限 (バイト)。巨大ページで LLM 入力が溢れるのを防ぐ。
|
|
18
|
+
DEFAULT_MAX_BYTES = 2_000_000
|
|
19
|
+
# LLM に渡す抽出後テキストの上限 (文字)。小型ローカルモデルの文脈長に配慮する。
|
|
20
|
+
EXTRACT_TEXT_LIMIT = 12_000
|
|
21
|
+
|
|
22
|
+
# chat_client: ChatClient (extract を持つ)。timeout_sec: 取得タイムアウト。
|
|
23
|
+
def initialize(chat_client:, timeout_sec: 30, max_bytes: DEFAULT_MAX_BYTES)
|
|
24
|
+
@chat_client = chat_client
|
|
25
|
+
@timeout_sec = timeout_sec
|
|
26
|
+
@max_bytes = max_bytes
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name = "fetch_url"
|
|
30
|
+
|
|
31
|
+
def description
|
|
32
|
+
"指定した URL のウェブページを取得し、その内容から目的 (query) に沿った情報を" \
|
|
33
|
+
"抽出して返す。最新情報の調査や外部ドキュメントの参照に使う。" \
|
|
34
|
+
"url は http:// または https:// で始まること。"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def schema
|
|
38
|
+
{
|
|
39
|
+
"type" => "object",
|
|
40
|
+
"properties" => {
|
|
41
|
+
"url" => { "type" => "string", "description" => "参照する http/https の URL" },
|
|
42
|
+
"query" => {
|
|
43
|
+
"type" => "string",
|
|
44
|
+
"description" => "そのページから知りたいこと (任意。未指定ならページの要点をまとめる)"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required" => %w[url],
|
|
48
|
+
"additionalProperties" => false
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call(args)
|
|
53
|
+
measure do
|
|
54
|
+
url = args["url"].to_s
|
|
55
|
+
uri = parse_http_uri(url)
|
|
56
|
+
next fail(stderr: "url must be an http/https URL: #{url}") if uri.nil?
|
|
57
|
+
|
|
58
|
+
body, err = fetch(uri)
|
|
59
|
+
next fail(stderr: err) if body.nil?
|
|
60
|
+
|
|
61
|
+
text = html_to_text(body)
|
|
62
|
+
if text.empty?
|
|
63
|
+
next fail(stderr: "no readable text extracted from #{url}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
summary = @chat_client.extract(args["query"], text[0, EXTRACT_TEXT_LIMIT], url: url)
|
|
67
|
+
ok(stdout: summary)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def parse_http_uri(url)
|
|
74
|
+
uri = URI.parse(url)
|
|
75
|
+
return uri if uri.is_a?(URI::HTTP) && uri.host
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
rescue URI::InvalidURIError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# リダイレクトを MAX_REDIRECTS 回まで追って本文を取得する。
|
|
83
|
+
# 戻り値: [body_or_nil, error_or_nil]。
|
|
84
|
+
def fetch(uri, redirects = 0)
|
|
85
|
+
return [nil, "too many redirects"] if redirects > MAX_REDIRECTS
|
|
86
|
+
|
|
87
|
+
response = request(uri)
|
|
88
|
+
case response
|
|
89
|
+
when Net::HTTPSuccess
|
|
90
|
+
[read_limited(response), nil]
|
|
91
|
+
when Net::HTTPRedirection
|
|
92
|
+
location = response["location"]
|
|
93
|
+
return [nil, "redirect without location"] if location.nil?
|
|
94
|
+
|
|
95
|
+
nxt = parse_http_uri(URI.join(uri.to_s, location).to_s)
|
|
96
|
+
return [nil, "redirect to non-http URL"] if nxt.nil?
|
|
97
|
+
|
|
98
|
+
fetch(nxt, redirects + 1)
|
|
99
|
+
else
|
|
100
|
+
[nil, "HTTP #{response.code} #{response.message}"]
|
|
101
|
+
end
|
|
102
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
103
|
+
[nil, "timeout after #{@timeout_sec}s"]
|
|
104
|
+
rescue SocketError => e
|
|
105
|
+
[nil, "could not resolve or connect: #{e.message}"]
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
[nil, "fetch failed: #{e.message}"]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def request(uri)
|
|
111
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
112
|
+
http.use_ssl = uri.is_a?(URI::HTTPS)
|
|
113
|
+
http.open_timeout = @timeout_sec
|
|
114
|
+
http.read_timeout = @timeout_sec
|
|
115
|
+
|
|
116
|
+
req = Net::HTTP::Get.new(uri)
|
|
117
|
+
req["User-Agent"] = "chocomint-fetch_url/1.0"
|
|
118
|
+
req["Accept"] = "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8"
|
|
119
|
+
http.request(req)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# 応答本文を max_bytes まで読み、妥当な UTF-8 に正規化する。
|
|
123
|
+
def read_limited(response)
|
|
124
|
+
body = response.body.to_s
|
|
125
|
+
body = body.byteslice(0, @max_bytes) if body.bytesize > @max_bytes
|
|
126
|
+
to_utf8(body)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# HTML から本文テキストを素朴に抽出する。script/style/コメントを落とし、
|
|
130
|
+
# タグを除去して実体参照を復元する。完全なパーサではないが、LLM に渡す
|
|
131
|
+
# 読み取り用テキストとしては十分。
|
|
132
|
+
def html_to_text(html)
|
|
133
|
+
text = html.dup
|
|
134
|
+
text.gsub!(%r{<!--.*?-->}m, " ")
|
|
135
|
+
text.gsub!(%r{<script\b[^>]*>.*?</script>}mi, " ")
|
|
136
|
+
text.gsub!(%r{<style\b[^>]*>.*?</style>}mi, " ")
|
|
137
|
+
text.gsub!(%r{<(br|/p|/div|/li|/h[1-6]|/tr)\b[^>]*>}i, "\n")
|
|
138
|
+
text.gsub!(/<[^>]+>/m, " ")
|
|
139
|
+
text = unescape_entities(text)
|
|
140
|
+
text.gsub(/[ \t ]+/, " ").gsub(/\n{3,}/, "\n\n").strip
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
ENTITIES = {
|
|
144
|
+
"&" => "&", "<" => "<", ">" => ">",
|
|
145
|
+
""" => '"', "'" => "'", "'" => "'", " " => " "
|
|
146
|
+
}.freeze
|
|
147
|
+
|
|
148
|
+
def unescape_entities(text)
|
|
149
|
+
text = text.gsub(/&|<|>|"|'|'| /, ENTITIES)
|
|
150
|
+
# 数値文字参照 ({ / 😀) を復元する。
|
|
151
|
+
text = text.gsub(/&#(\d+);/) { [Regexp.last_match(1).to_i].pack("U") rescue " " }
|
|
152
|
+
text.gsub(/&#x([0-9a-fA-F]+);/) { [Regexp.last_match(1).to_i(16)].pack("U") rescue " " }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Chocomint
|
|
7
|
+
module Tools
|
|
8
|
+
# 許可ディレクトリ内のパスのメタ情報 (存在・種別・サイズ) を JSON で返す。
|
|
9
|
+
class FileInfo < Base
|
|
10
|
+
def initialize(path_guard:)
|
|
11
|
+
@path_guard = path_guard
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name = "file_info"
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"指定パスの存在・種別 (file/directory)・サイズを JSON で返す。path は許可ディレクトリ内であること。"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def schema
|
|
21
|
+
{
|
|
22
|
+
"type" => "object",
|
|
23
|
+
"properties" => {
|
|
24
|
+
"path" => { "type" => "string" }
|
|
25
|
+
},
|
|
26
|
+
"required" => %w[path],
|
|
27
|
+
"additionalProperties" => false
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(args)
|
|
32
|
+
measure do
|
|
33
|
+
abs = @path_guard.resolve(args["path"])
|
|
34
|
+
info = {
|
|
35
|
+
"path" => args["path"],
|
|
36
|
+
"exists" => File.exist?(abs),
|
|
37
|
+
"type" => file_type(abs),
|
|
38
|
+
"size" => (File.size(abs) if File.exist?(abs))
|
|
39
|
+
}
|
|
40
|
+
ok(stdout: JSON.generate(info))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def file_type(abs)
|
|
47
|
+
return "file" if File.file?(abs)
|
|
48
|
+
return "directory" if File.directory?(abs)
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内で glob パターンに一致するファイルを列挙する。
|
|
8
|
+
class Glob < Base
|
|
9
|
+
def initialize(path_guard:, base_dir: Dir.pwd)
|
|
10
|
+
@path_guard = path_guard
|
|
11
|
+
@base_dir = base_dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name = "glob"
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"glob パターン (例: **/*.rb) に一致するファイルを列挙する。base は許可ディレクトリ内であること。"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def schema
|
|
21
|
+
{
|
|
22
|
+
"type" => "object",
|
|
23
|
+
"properties" => {
|
|
24
|
+
"pattern" => { "type" => "string" },
|
|
25
|
+
"base" => { "type" => "string" }
|
|
26
|
+
},
|
|
27
|
+
"required" => %w[pattern],
|
|
28
|
+
"additionalProperties" => false
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(args)
|
|
33
|
+
measure do
|
|
34
|
+
base_rel = args["base"] || allowed_root
|
|
35
|
+
base_abs = @path_guard.resolve(base_rel)
|
|
36
|
+
next fail(stderr: "no such directory: #{base_rel}") unless File.directory?(base_abs)
|
|
37
|
+
|
|
38
|
+
matches = Dir.glob(File.join(base_abs, args["pattern"].to_s), File::FNM_PATHNAME)
|
|
39
|
+
.select { |p| File.file?(p) && @path_guard.allowed?(p) }
|
|
40
|
+
.map { |p| relative(p) }
|
|
41
|
+
.sort
|
|
42
|
+
ok(stdout: matches.join("\n"))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# 許可ルートの先頭 (base 未指定時のデフォルト)。
|
|
49
|
+
def allowed_root
|
|
50
|
+
@path_guard.allowed_roots.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def relative(abs)
|
|
54
|
+
abs.delete_prefix(@base_dir.to_s + File::SEPARATOR)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内のファイル内容を正規表現で検索する (Claude Code の Grep 相当)。
|
|
8
|
+
class Grep < Base
|
|
9
|
+
def initialize(path_guard:, max_output_bytes:, base_dir: Dir.pwd)
|
|
10
|
+
@path_guard = path_guard
|
|
11
|
+
@max_output_bytes = max_output_bytes
|
|
12
|
+
@base_dir = base_dir
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name = "grep"
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
"許可ディレクトリ内のファイルを正規表現で検索し、一致行を file:line:text 形式で返す。" \
|
|
19
|
+
"glob (例: **/*.rb) で対象を絞れる。ignore_case で大文字小文字を無視。"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def schema
|
|
23
|
+
{
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"properties" => {
|
|
26
|
+
"pattern" => { "type" => "string" },
|
|
27
|
+
"base" => { "type" => "string" },
|
|
28
|
+
"glob" => { "type" => "string" },
|
|
29
|
+
"ignore_case" => { "type" => "boolean" }
|
|
30
|
+
},
|
|
31
|
+
"required" => %w[pattern],
|
|
32
|
+
"additionalProperties" => false
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(args)
|
|
37
|
+
measure do
|
|
38
|
+
base_rel = args["base"] || @path_guard.allowed_roots.first
|
|
39
|
+
base_abs = @path_guard.resolve(base_rel)
|
|
40
|
+
next fail(stderr: "no such directory: #{base_rel}") unless File.directory?(base_abs)
|
|
41
|
+
|
|
42
|
+
flags = args["ignore_case"] ? Regexp::IGNORECASE : 0
|
|
43
|
+
re = Regexp.new(args["pattern"].to_s, flags)
|
|
44
|
+
glob = args["glob"] || "**/*"
|
|
45
|
+
|
|
46
|
+
lines = search(base_abs, glob, re)
|
|
47
|
+
ok(stdout: truncate(lines.join("\n")))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def search(base_abs, glob, re)
|
|
54
|
+
Dir.glob(File.join(base_abs, glob), File::FNM_PATHNAME)
|
|
55
|
+
.select { |p| File.file?(p) && @path_guard.allowed?(p) }
|
|
56
|
+
.sort
|
|
57
|
+
.flat_map { |path| matches_in(path, re) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def matches_in(path, re)
|
|
61
|
+
rel = path.delete_prefix(@base_dir.to_s + File::SEPARATOR)
|
|
62
|
+
out = []
|
|
63
|
+
File.foreach(path).with_index(1) do |line, no|
|
|
64
|
+
out << "#{rel}:#{no}:#{line.chomp}" if re.match?(line)
|
|
65
|
+
end
|
|
66
|
+
out
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
[] # バイナリ等でエンコーディング不能な行はスキップ
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def truncate(text)
|
|
72
|
+
return text if text.bytesize <= @max_output_bytes
|
|
73
|
+
|
|
74
|
+
text.byteslice(0, @max_output_bytes)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Chocomint
|
|
6
|
+
module Tools
|
|
7
|
+
# 許可ディレクトリ内のエントリ一覧を改行区切りで stdout に返す。
|
|
8
|
+
class ListDir < Base
|
|
9
|
+
def initialize(path_guard:)
|
|
10
|
+
@path_guard = path_guard
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def name = "list_dir"
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
"指定ディレクトリのエントリ一覧を返す。path は許可ディレクトリ内であること。"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def schema
|
|
20
|
+
{
|
|
21
|
+
"type" => "object",
|
|
22
|
+
"properties" => {
|
|
23
|
+
"path" => { "type" => "string" }
|
|
24
|
+
},
|
|
25
|
+
"required" => %w[path],
|
|
26
|
+
"additionalProperties" => false
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(args)
|
|
31
|
+
measure do
|
|
32
|
+
abs = @path_guard.resolve(args["path"])
|
|
33
|
+
next fail(stderr: "no such directory: #{args['path']}") unless File.directory?(abs)
|
|
34
|
+
|
|
35
|
+
entries = Dir.children(abs).sort
|
|
36
|
+
ok(stdout: entries.join("\n"))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Chocomint
|
|
7
|
+
module Tools
|
|
8
|
+
# 許可ディレクトリ内のエントリを種別・サイズ付きで返す (Claude Code の LS 相当)。
|
|
9
|
+
# list_dir が名前のみを返すのに対し、こちらは各エントリの詳細を JSON 配列で返す。
|
|
10
|
+
class Ls < Base
|
|
11
|
+
def initialize(path_guard:)
|
|
12
|
+
@path_guard = path_guard
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name = "ls"
|
|
16
|
+
|
|
17
|
+
def description
|
|
18
|
+
"指定ディレクトリのエントリを name/type/size 付きの JSON 配列で返す。" \
|
|
19
|
+
"path は許可ディレクトリ内であること。"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def schema
|
|
23
|
+
{
|
|
24
|
+
"type" => "object",
|
|
25
|
+
"properties" => {
|
|
26
|
+
"path" => { "type" => "string" }
|
|
27
|
+
},
|
|
28
|
+
"required" => %w[path],
|
|
29
|
+
"additionalProperties" => false
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(args)
|
|
34
|
+
measure do
|
|
35
|
+
abs = @path_guard.resolve(args["path"])
|
|
36
|
+
next fail(stderr: "no such directory: #{args['path']}") unless File.directory?(abs)
|
|
37
|
+
|
|
38
|
+
entries = Dir.children(abs).sort.map do |name|
|
|
39
|
+
full = File.join(abs, name)
|
|
40
|
+
{
|
|
41
|
+
"name" => name,
|
|
42
|
+
"type" => File.directory?(full) ? "directory" : "file",
|
|
43
|
+
"size" => (File.size(full) if File.file?(full))
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
ok(stdout: JSON.generate(entries))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|