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.
Files changed (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. 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
+ "&amp;" => "&", "&lt;" => "<", "&gt;" => ">",
145
+ "&quot;" => '"', "&#39;" => "'", "&apos;" => "'", "&nbsp;" => " "
146
+ }.freeze
147
+
148
+ def unescape_entities(text)
149
+ text = text.gsub(/&amp;|&lt;|&gt;|&quot;|&#39;|&apos;|&nbsp;/, ENTITIES)
150
+ # 数値文字参照 (&#123; / &#x1F600;) を復元する。
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