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,696 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "cgi"
5
+ require "set"
6
+
7
+ module Chocomint
8
+ # /edit AI エディタの HTTP ハンドラ群 (Server に include して使う)。
9
+ #
10
+ # 提供機能:
11
+ # GET /edit エディタ画面 (メニュー/ファイル一覧/Monaco/AIチャット/コンソール)
12
+ # GET /edit/assets/* Monaco / xterm.js のローカル同梱アセット配信
13
+ # GET /edit/static/* 自前の CSS/JS 配信 (public/edit/)
14
+ # GET /edit/files workspace のファイル一覧 (JSON)
15
+ # GET /edit/file?path= ファイル内容 (JSON)
16
+ # POST /edit/save ファイル保存 (PathGuard で workspace 内に限定)
17
+ # POST /edit/chat 自然言語指示で AI 編集 (Planner が提案→実行→検証→再試行)
18
+ #
19
+ # ファイルアクセスはすべて @path_guard 経由で許可ディレクトリ内に制限する。
20
+ module EditHandlers
21
+ # workspace ルート (allowed_roots の先頭)。表示・列挙の起点にする。
22
+ def edit_root
23
+ @path_guard.allowed_roots.first
24
+ end
25
+
26
+ # ---- ファイル一覧 -------------------------------------------------------
27
+
28
+ def handle_edit_files(req, res)
29
+ return edit_method_guard(res) unless req.request_method == "GET"
30
+
31
+ entries = list_workspace_files
32
+ edit_json(res, 200, "files" => entries, "dirs" => list_workspace_empty_dirs(entries),
33
+ "root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
34
+ rescue Chocomint::Error => e
35
+ edit_json(res, 500, "error" => e.message)
36
+ end
37
+
38
+ # workspace 配下を再帰列挙し、相対パスの配列で返す (ディレクトリは除く)。
39
+ MAX_LISTED_FILES = 2000
40
+
41
+ # 中身を展開しない VCS メタデータ・依存パッケージ等 (数千件で一覧を埋め尽くすため除外する)。
42
+ EXCLUDED_TREE_DIRS = %w[.git .hg .svn .venv venv node_modules .bundle vendor/bundle].freeze
43
+
44
+ def list_workspace_files
45
+ root = edit_root
46
+ return [] unless File.directory?(root)
47
+
48
+ files = []
49
+ # File::FNM_DOTMATCH で "." 始まりの隠しファイル/ディレクトリも列挙対象に含める。
50
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root).each do |rel|
51
+ rel = rel.tr("\\", "/")
52
+ # "." / ".." など自己・親参照は除外する (FNM_DOTMATCH で拾われるため)。
53
+ next if [".", ".."].include?(File.basename(rel))
54
+ # .git などの配下は展開しない (メタデータで一覧が埋まるのを防ぐ)。
55
+ next if EXCLUDED_TREE_DIRS.any? { |d| rel == d || rel.start_with?("#{d}/") }
56
+
57
+ abs = File.join(root, rel)
58
+ next unless File.file?(abs)
59
+
60
+ files << rel
61
+ break if files.size >= MAX_LISTED_FILES
62
+ end
63
+ files.sort
64
+ end
65
+
66
+ # files に1件も含まれないディレクトリ (空ディレクトリ、または空ディレクトリのみを含むディレクトリ) を列挙する。
67
+ # ファイル一覧からはツリー構築できないため、これらを別途フロントエンドに伝えてツリーに表示させる。
68
+ def list_workspace_empty_dirs(files)
69
+ root = edit_root
70
+ return [] unless File.directory?(root)
71
+
72
+ covered = files.each_with_object(Set.new) do |rel, set|
73
+ parts = rel.split("/")
74
+ parts.pop
75
+ prefix = +""
76
+ parts.each do |part|
77
+ prefix = prefix.empty? ? part : "#{prefix}/#{part}"
78
+ set << prefix
79
+ end
80
+ end
81
+
82
+ dirs = []
83
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: root).each do |rel|
84
+ rel = rel.tr("\\", "/")
85
+ next if [".", ".."].include?(File.basename(rel))
86
+ next if EXCLUDED_TREE_DIRS.any? { |d| rel == d || rel.start_with?("#{d}/") }
87
+ next if covered.include?(rel)
88
+
89
+ abs = File.join(root, rel)
90
+ next unless File.directory?(abs)
91
+
92
+ dirs << rel
93
+ break if dirs.size >= MAX_LISTED_FILES
94
+ end
95
+ dirs.sort
96
+ end
97
+
98
+ # ---- ファイル取得 -------------------------------------------------------
99
+
100
+ def handle_edit_file(req, res)
101
+ return edit_method_guard(res) unless req.request_method == "GET"
102
+
103
+ path = req.query["path"].to_s
104
+ return edit_json(res, 400, "error" => "path required") if path.empty?
105
+
106
+ abs = @path_guard.resolve(path)
107
+ return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
108
+ if File.size(abs) > @max_file_bytes
109
+ return edit_json(res, 413, "error" => "file too large")
110
+ end
111
+
112
+ raw = File.read(abs, mode: "rb")
113
+ # テキストとして開けない (バイナリ / 不正な UTF-8) ファイルは中身を返さずフラグで知らせる。
114
+ if binary_content?(raw)
115
+ return edit_json(res, 200, "path" => path, "binary" => true)
116
+ end
117
+
118
+ edit_json(res, 200, "path" => path, "content" => raw.force_encoding("UTF-8"))
119
+ rescue Chocomint::PathAccessError => e
120
+ edit_json(res, 403, "error" => e.message)
121
+ rescue Chocomint::Error => e
122
+ edit_json(res, 500, "error" => e.message)
123
+ end
124
+
125
+ # テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが
126
+ # 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。
127
+ BINARY_SNIFF_BYTES = 8192
128
+
129
+ def binary_content?(raw)
130
+ return false if raw.empty?
131
+
132
+ head = raw.byteslice(0, BINARY_SNIFF_BYTES)
133
+ return true if head.include?("\x00")
134
+
135
+ !head.dup.force_encoding("UTF-8").valid_encoding?
136
+ end
137
+
138
+ # ---- 保存 (人手編集) ----------------------------------------------------
139
+
140
+ def handle_edit_save(req, res)
141
+ return edit_method_guard(res) unless req.request_method == "POST"
142
+
143
+ body = JSON.parse(req.body.to_s)
144
+ path = body["path"].to_s
145
+ content = body["content"].to_s
146
+ return edit_json(res, 400, "error" => "path required") if path.empty?
147
+ if content.bytesize > @max_file_bytes
148
+ return edit_json(res, 413, "error" => "content exceeds max_file_bytes")
149
+ end
150
+
151
+ abs = @path_guard.resolve(path)
152
+ FileUtils.mkdir_p(File.dirname(abs))
153
+ File.write(abs, content)
154
+ edit_json(res, 200, "path" => path, "bytes" => content.bytesize)
155
+ rescue JSON::ParserError => e
156
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
157
+ rescue Chocomint::PathAccessError => e
158
+ edit_json(res, 403, "error" => e.message)
159
+ rescue Chocomint::Error => e
160
+ edit_json(res, 500, "error" => e.message)
161
+ end
162
+
163
+ # ---- AI 編集 (チャット) -------------------------------------------------
164
+
165
+ def handle_edit_chat(req, res)
166
+ return edit_method_guard(res) unless req.request_method == "POST"
167
+
168
+ body = JSON.parse(req.body.to_s)
169
+ instruction = body["instruction"].to_s.strip
170
+ path = body["path"].to_s
171
+ return edit_json(res, 400, "error" => "instruction required") if instruction.empty?
172
+
173
+ # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
174
+ return handle_edit_chat_stream(res, instruction, path) if body["stream"]
175
+
176
+ # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
177
+ if chat_instruction?(instruction)
178
+ return edit_json(res, 200, edit_chat_reply(instruction, path))
179
+ end
180
+
181
+ request = build_chat_request(path, instruction)
182
+ result = @planner.run(request, expectations: instruction)
183
+ edit_json(res, 200, edit_chat_result(result, path, instruction))
184
+ rescue JSON::ParserError => e
185
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
186
+ rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError => e
187
+ edit_json(res, 422, "status" => "FAIL", "error" => humanize_chat_error(e))
188
+ rescue Chocomint::RetryLimitExceededError => e
189
+ edit_json(res, 200, "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
190
+ rescue Chocomint::Error => e
191
+ edit_json(res, 500, "error" => e.message)
192
+ end
193
+
194
+ # SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、
195
+ # 最後に従来の JSON 結果を "result" event として送って締める。
196
+ # イベント: tool_start / tool_done / result / error (data は JSON 1 行)。
197
+ def handle_edit_chat_stream(res, instruction, path)
198
+ res.status = 200
199
+ res["content-type"] = "text/event-stream; charset=utf-8"
200
+ res["cache-control"] = "no-cache"
201
+ res.chunked = true
202
+ res.body = lambda do |out|
203
+ emit_sse = ->(event, data) { out.write("event: #{event}\ndata: #{JSON.generate(deep_scrub(data))}\n\n") }
204
+ begin
205
+ run_chat_stream(instruction, path, emit_sse)
206
+ rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError,
207
+ Chocomint::RetryLimitExceededError => e
208
+ emit_sse.call("result", "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
209
+ rescue Chocomint::Error => e
210
+ emit_sse.call("error", "error" => e.message)
211
+ end
212
+ end
213
+ end
214
+
215
+ # SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
216
+ def run_chat_stream(instruction, path, emit_sse)
217
+ # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
218
+ if chat_instruction?(instruction)
219
+ emit_sse.call("result", edit_chat_reply(instruction, path))
220
+ return
221
+ end
222
+
223
+ request = build_chat_request(path, instruction)
224
+ on_event = lambda do |ev|
225
+ case ev[:type]
226
+ when "step_start"
227
+ emit_sse.call("tool_start",
228
+ "step" => ev[:step],
229
+ "tool" => ev[:tool],
230
+ "label" => tool_label(ev[:tool]),
231
+ "target" => tool_target_summary(ev[:tool], ev[:arguments]))
232
+ when "step_done"
233
+ ok = ev[:status] == "PASS" && (result_field(ev[:result], :exit_code).nil? ||
234
+ result_field(ev[:result], :exit_code) == 0)
235
+ emit_sse.call("tool_done",
236
+ "step" => ev[:step],
237
+ "tool" => ev[:tool],
238
+ "label" => tool_label(ev[:tool]),
239
+ "target" => tool_target_summary(ev[:tool], ev[:arguments]),
240
+ "status" => ev[:status],
241
+ "ok" => ok,
242
+ "exit_code" => result_field(ev[:result], :exit_code),
243
+ # 実行結果 (stdout/stderr など) を人が読める形にして都度表示させる。
244
+ "output" => tool_output_text(ev[:result]))
245
+ end
246
+ end
247
+
248
+ result = @planner.run(request, expectations: instruction, on_event: on_event)
249
+ emit_sse.call("result", edit_chat_result(result, path, instruction))
250
+ end
251
+
252
+ # Planner の例外メッセージ (英語) を UI 向けの日本語に言い換える。
253
+ # 原文は握りつぶさず末尾に括弧書きで残す (デバッグの手がかりのため)。
254
+ def humanize_chat_error(err)
255
+ case err
256
+ when Chocomint::RetryLimitExceededError
257
+ "指示を達成できませんでした。指示をより具体的にするか、手順を分けて試してください。"
258
+ when Chocomint::InvalidProposalError
259
+ "AI が実行するツールをうまく選べませんでした。指示を言い換えて試してください。"
260
+ when Chocomint::UnknownToolError
261
+ "AI が存在しないツールを呼び出そうとしました。指示を言い換えて試してください。"
262
+ else
263
+ "エラーが発生しました。"
264
+ end + " (#{err.message})"
265
+ end
266
+
267
+ # ---- 会話ログの圧縮 (/compact) -----------------------------------------
268
+ #
269
+ # ブラウザから現在のチャット表示テキストを受け取り、AI に要点を要約させて返す。
270
+ # サーバー側は会話履歴を保持しないため、これは「表示ログを短い要約 1 件に畳む」
271
+ # ための機能 (AI の文脈には元々影響しない)。chat_client 未注入なら 503。
272
+
273
+ # 要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。
274
+ COMPACT_INPUT_LIMIT = 12_000
275
+
276
+ def handle_edit_compact(req, res)
277
+ return edit_method_guard(res) unless req.request_method == "POST"
278
+ return edit_json(res, 503, "error" => "chat client not configured") unless @chat_client
279
+
280
+ body = JSON.parse(req.body.to_s)
281
+ transcript = body["transcript"].to_s.strip
282
+ return edit_json(res, 400, "error" => "transcript required") if transcript.empty?
283
+
284
+ # 長すぎる場合は新しい方 (末尾) を優先して残す。
285
+ if transcript.length > COMPACT_INPUT_LIMIT
286
+ transcript = "(以前のやり取りは省略)\n#{transcript[-COMPACT_INPUT_LIMIT..]}"
287
+ end
288
+
289
+ summary = @chat_client.compact(transcript)
290
+ edit_json(res, 200, "summary" => summary)
291
+ rescue JSON::ParserError => e
292
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
293
+ rescue Chocomint::Error => e
294
+ edit_json(res, 500, "error" => e.message)
295
+ end
296
+
297
+ # chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
298
+ # chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
299
+ def chat_instruction?(instruction)
300
+ return false unless @chat_client
301
+
302
+ @chat_client.classify(instruction) == "chat"
303
+ rescue Chocomint::Error
304
+ # 分類に失敗したら安全側でタスク扱い (Planner に流す)。
305
+ false
306
+ end
307
+
308
+ # 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
309
+ # 開いているファイルがあれば内容を文脈として渡す。
310
+ def edit_chat_reply(instruction, path)
311
+ answer = @chat_client.answer(instruction, context: chat_file_context(path))
312
+ { "status" => "PASS", "reply" => answer, "steps" => [] }
313
+ rescue Chocomint::Error => e
314
+ { "status" => "FAIL", "error" => e.message, "steps" => [] }
315
+ end
316
+
317
+ # 会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。
318
+ CHAT_CONTEXT_LIMIT = 4000
319
+
320
+ def chat_file_context(path)
321
+ return nil if path.empty?
322
+
323
+ abs = @path_guard.resolve(path)
324
+ return nil unless File.file?(abs)
325
+
326
+ content = File.read(abs, mode: "rb").force_encoding("UTF-8")
327
+ content = "#{content[0, CHAT_CONTEXT_LIMIT]}…" if content.length > CHAT_CONTEXT_LIMIT
328
+ "現在開いているファイル #{path} の内容:\n```\n#{content}\n```"
329
+ rescue Chocomint::Error
330
+ nil
331
+ end
332
+
333
+ # 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
334
+ def build_chat_request(path, instruction)
335
+ if path.empty?
336
+ instruction
337
+ else
338
+ "ファイル #{path} に対して次の指示を反映してください: #{instruction}"
339
+ end
340
+ end
341
+
342
+ # Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。
343
+ # instruction: 要約生成のための元の要求文 (任意)。
344
+ def edit_chat_result(result, path, instruction = nil)
345
+ steps = result.steps.map do |s|
346
+ r = s[:result]
347
+ { "tool" => s[:tool],
348
+ # UI で読みやすい日本語ラベル (例: "コマンド実行")。
349
+ "label" => tool_label(s[:tool]),
350
+ "arguments" => s[:arguments],
351
+ # 引数の 1 行要約 (例: どのファイル / どのコマンドを対象にしたか)。
352
+ "target" => tool_target_summary(s[:tool], s[:arguments]),
353
+ "status" => s[:status],
354
+ # ツールの終了コード (0 = 成功)。シンボル/文字列どちらのキーでも拾う。
355
+ "exit_code" => result_field(r, :exit_code),
356
+ # ツールの実行結果 (stdout / 生成物など) を人間可読テキストにして返す。
357
+ "output" => tool_output_text(r) }
358
+ end
359
+ payload = {
360
+ "status" => result.status,
361
+ "trace_id" => result.trace_id,
362
+ "attempts" => result.attempts,
363
+ "steps" => steps
364
+ }
365
+ # 要約 (summary) と注記 (note) の振り分け。
366
+ # - ステップ無し + message: 聞き返し・会話的回答。message をそのまま要約枠に出す
367
+ # (実行内容が無いので summarize しない)。
368
+ # - ステップ有り + incomplete: 未完了の注記 (message) は黄色の注記枠へ。要約は別途生成する。
369
+ # - ステップ有り + 通常の finish message: それを要約として使う (summarize の代わり)。
370
+ # - それ以外のステップ有り: AI に実行内容を要約させる。
371
+ if !result.message.to_s.empty? && steps.empty?
372
+ payload["summary"] = result.message
373
+ elsif result.incomplete
374
+ payload["note"] = result.message unless result.message.to_s.empty?
375
+ if @chat_client && !steps.empty? && instruction
376
+ summary = @chat_client.summarize(instruction, steps, trace_id: result.trace_id)
377
+ payload["summary"] = summary if summary
378
+ end
379
+ elsif !result.message.to_s.empty? && !steps.empty?
380
+ payload["summary"] = result.message
381
+ elsif @chat_client && result.status == "PASS" && !steps.empty? && instruction
382
+ summary = @chat_client.summarize(instruction, steps, trace_id: result.trace_id)
383
+ payload["summary"] = summary if summary
384
+ end
385
+ # 対象ファイルがまだ存在すれば最新内容を返す (AI が編集した結果を反映)。
386
+ unless path.empty?
387
+ begin
388
+ abs = @path_guard.resolve(path)
389
+ payload["content"] = File.read(abs) if File.file?(abs)
390
+ rescue Chocomint::Error
391
+ # 解決できない・消えた等は content 無しで返す。
392
+ end
393
+ end
394
+ payload
395
+ end
396
+
397
+ # ツール実行結果を UI 表示用のテキストに整形する。ハッシュなら stdout/stderr など
398
+ # 人が読みたいキーを優先する。結果ハッシュのキーはツール実装ではシンボル
399
+ # ({ exit_code:, stdout:, ... })、テスト等では文字列で来るため両対応にする。
400
+ # 該当キーが空なら nil を返し (生 JSON は出さない)、UI 側で「(出力なし)」を表示させる。
401
+ # PowerShell 等の色付き出力に含まれる ANSI エスケープは、そのまま出すと文字化けに
402
+ # 見えるため除去する。
403
+ def tool_output_text(result)
404
+ text =
405
+ case result
406
+ when nil then nil
407
+ when String then result
408
+ when Hash
409
+ joined = %i[stdout stderr message content]
410
+ .map { |k| result_field(result, k) }
411
+ .compact.map(&:to_s).reject(&:empty?).join("\n")
412
+ joined.empty? ? nil : joined
413
+ else
414
+ result.to_s
415
+ end
416
+ text.nil? ? nil : strip_ansi(text)
417
+ end
418
+
419
+ # ANSI エスケープシーケンス (色・装飾。CSI \e[...m 等) を取り除く。
420
+ # 端末を経由しない chocomint の出力表示では色コードは無意味で、文字化けに見えるだけ。
421
+ ANSI_ESCAPE = /\e\[[0-9;?]*[ -\/]*[@-~]/
422
+
423
+ def strip_ansi(text)
424
+ text.gsub(ANSI_ESCAPE, "")
425
+ end
426
+
427
+ # 結果ハッシュからキー key の値を取り出す (シンボル/文字列どちらのキーでも拾う)。
428
+ def result_field(result, key)
429
+ return nil unless result.is_a?(Hash)
430
+
431
+ result.fetch(key) { result[key.to_s] }
432
+ end
433
+
434
+ # 内部ツール名を UI 向けの日本語ラベル (アイコン付き) に変換する。未知の名前は原文。
435
+ TOOL_LABELS = {
436
+ "write_file" => "📝 ファイル書き込み",
437
+ "read_file" => "📖 ファイル読み取り",
438
+ "append_file" => "➕ ファイル追記",
439
+ "delete_file" => "🗑 ファイル削除",
440
+ "edit" => "✏️ ファイル編集",
441
+ "make_dir" => "📁 ディレクトリ作成",
442
+ "file_info" => "ℹ️ ファイル情報",
443
+ "list_dir" => "📂 ディレクトリ一覧",
444
+ "ls" => "📂 一覧表示",
445
+ "glob" => "🔍 ファイル検索",
446
+ "grep" => "🔍 内容検索",
447
+ "run_command" => "▶️ コマンド実行",
448
+ "bash" => "▶️ コマンド実行",
449
+ "shell" => "▶️ コマンド実行"
450
+ }.freeze
451
+
452
+ def tool_label(tool)
453
+ TOOL_LABELS.fetch(tool.to_s, "🔧 #{tool}")
454
+ end
455
+
456
+ # 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
457
+ TARGET_SUMMARY_LIMIT = 120
458
+
459
+ def tool_target_summary(tool, args)
460
+ return nil unless args.is_a?(Hash)
461
+
462
+ value =
463
+ case tool.to_s
464
+ when "run_command", "bash", "shell"
465
+ args["command"] || args["cmd"] || args["script"]
466
+ when "grep", "glob"
467
+ args["pattern"] || args["path"]
468
+ else
469
+ args["path"] || args["file"] || args["dir"]
470
+ end
471
+ return nil if value.nil?
472
+
473
+ s = value.to_s.gsub(/\s+/, " ").strip
474
+ s.length > TARGET_SUMMARY_LIMIT ? "#{s[0, TARGET_SUMMARY_LIMIT]}…" : s
475
+ end
476
+
477
+ # ---- ファイル操作 (右クリックメニュー) ---------------------------------
478
+ #
479
+ # いずれも @path_guard.resolve を通し workspace 外を拒否する。相対パスは
480
+ # 一覧と同じく "/" 区切りで受け取る。
481
+
482
+ # ファイル or ディレクトリを削除する。
483
+ def handle_edit_fs_delete(req, res)
484
+ return edit_method_guard(res) unless req.request_method == "POST"
485
+
486
+ with_fs(res) do
487
+ path = fs_body_path(req)
488
+ abs = @path_guard.resolve(path)
489
+ raise Chocomint::Error, "workspace ルートは削除できません" if abs == edit_root
490
+ raise Chocomint::Error, "no such path" unless File.exist?(abs)
491
+
492
+ FileUtils.rm_rf(abs)
493
+ edit_json(res, 200, "path" => path, "deleted" => true)
494
+ end
495
+ end
496
+
497
+ # ファイル or ディレクトリの名前変更 (移動)。to は同一 workspace 内に限る。
498
+ def handle_edit_fs_rename(req, res)
499
+ return edit_method_guard(res) unless req.request_method == "POST"
500
+
501
+ with_fs(res) do
502
+ body = JSON.parse(req.body.to_s)
503
+ from = normalize_fs_path(body["from"])
504
+ to = normalize_fs_path(body["to"])
505
+ raise Chocomint::Error, "from/to required" if from.empty? || to.empty?
506
+
507
+ src = @path_guard.resolve(from)
508
+ dst = @path_guard.resolve(to)
509
+ raise Chocomint::Error, "no such path" unless File.exist?(src)
510
+ raise Chocomint::Error, "移動先が既に存在します" if File.exist?(dst)
511
+
512
+ FileUtils.mkdir_p(File.dirname(dst))
513
+ FileUtils.mv(src, dst)
514
+ edit_json(res, 200, "from" => from, "to" => to)
515
+ end
516
+ end
517
+
518
+ # ディレクトリを新規作成する (親が無ければ一緒に作る)。
519
+ def handle_edit_fs_mkdir(req, res)
520
+ return edit_method_guard(res) unless req.request_method == "POST"
521
+
522
+ with_fs(res) do
523
+ path = fs_body_path(req)
524
+ abs = @path_guard.resolve(path)
525
+ raise Chocomint::Error, "既に存在します" if File.exist?(abs)
526
+
527
+ FileUtils.mkdir_p(abs)
528
+ edit_json(res, 200, "path" => path, "created" => true)
529
+ end
530
+ end
531
+
532
+ # 空ファイルを新規作成する (親ディレクトリが無ければ作る)。
533
+ def handle_edit_fs_touch(req, res)
534
+ return edit_method_guard(res) unless req.request_method == "POST"
535
+
536
+ with_fs(res) do
537
+ path = fs_body_path(req)
538
+ abs = @path_guard.resolve(path)
539
+ raise Chocomint::Error, "既に存在します" if File.exist?(abs)
540
+
541
+ FileUtils.mkdir_p(File.dirname(abs))
542
+ File.write(abs, "")
543
+ edit_json(res, 200, "path" => path, "created" => true)
544
+ end
545
+ end
546
+
547
+ # ---- ファイル操作の補助 -------------------------------------------------
548
+
549
+ # JSON ボディから正規化済みの "path" を取り出す (空なら 400 相当の例外)。
550
+ def fs_body_path(req)
551
+ body = JSON.parse(req.body.to_s)
552
+ path = normalize_fs_path(body["path"])
553
+ raise Chocomint::Error, "path required" if path.empty?
554
+
555
+ path
556
+ end
557
+
558
+ # バックスラッシュを "/" に統一し前後空白を除く。
559
+ def normalize_fs_path(value)
560
+ value.to_s.tr("\\", "/").strip
561
+ end
562
+
563
+ # ファイル操作系ハンドラ共通の例外→HTTP 変換。
564
+ def with_fs(res)
565
+ yield
566
+ rescue JSON::ParserError => e
567
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
568
+ rescue Chocomint::PathAccessError => e
569
+ edit_json(res, 403, "error" => e.message)
570
+ rescue Chocomint::Error => e
571
+ edit_json(res, 400, "error" => e.message)
572
+ rescue SystemCallError => e
573
+ edit_json(res, 500, "error" => e.message)
574
+ end
575
+
576
+ # ---- アセット配信 -------------------------------------------------------
577
+
578
+ ASSET_CONTENT_TYPES = {
579
+ ".js" => "application/javascript; charset=utf-8",
580
+ ".css" => "text/css; charset=utf-8",
581
+ ".ttf" => "font/ttf",
582
+ ".map" => "application/json",
583
+ ".svg" => "image/svg+xml",
584
+ ".html" => "text/html; charset=utf-8"
585
+ }.freeze
586
+
587
+ def handle_edit_asset(req, res)
588
+ return edit_method_guard(res) unless req.request_method == "GET"
589
+
590
+ # /edit/assets/<rel> の <rel> を取り出し、vendor 配下限定で配信する (Monaco / xterm.js)。
591
+ # vendor は不変の大容量アセットなので長期キャッシュしてよい。
592
+ serve_edit_static(req, res, prefix: "/edit/assets/", root: @vendor_dir,
593
+ cache: "public, max-age=86400")
594
+ end
595
+
596
+ def handle_edit_static(req, res)
597
+ return edit_method_guard(res) unless req.request_method == "GET"
598
+
599
+ # /edit/static/<rel> の <rel> を取り出し、public/edit 配下限定で配信する (自前の CSS/JS)。
600
+ # 自前の CSS/JS は開発中に頻繁に編集するため、キャッシュせず毎回最新を配信する。
601
+ serve_edit_static(req, res, prefix: "/edit/static/", root: @edit_static_dir,
602
+ cache: "no-cache")
603
+ end
604
+
605
+ # prefix を取り除いた相対パスを root 配下限定で解決して配信する共通ロジック。
606
+ def serve_edit_static(req, res, prefix:, root:, cache:)
607
+ rel = req.path.sub(%r{\A#{Regexp.escape(prefix)}}, "")
608
+ abs = File.expand_path(rel, root)
609
+ # traversal 防止: root の外を指したら拒否。
610
+ unless abs.start_with?(root + File::SEPARATOR) || abs == root
611
+ res.status = 403
612
+ res.body = "forbidden"
613
+ return
614
+ end
615
+ unless File.file?(abs)
616
+ res.status = 404
617
+ res.body = "not found"
618
+ return
619
+ end
620
+
621
+ res.status = 200
622
+ res["content-type"] = ASSET_CONTENT_TYPES.fetch(File.extname(abs), "application/octet-stream")
623
+ res["cache-control"] = cache
624
+ res.body = File.binread(abs)
625
+ end
626
+
627
+ # ---- エディタ画面 -------------------------------------------------------
628
+
629
+ def handle_edit(req, res)
630
+ return edit_method_guard(res) unless req.request_method == "GET"
631
+
632
+ res.status = 200
633
+ res["content-type"] = "text/html; charset=utf-8"
634
+ # HTML は常に最新を返す (ブラウザのヒューリスティックキャッシュで古い版が
635
+ # 残ると CSS/JS の参照や DOM 構造がずれるため)。
636
+ res["cache-control"] = "no-cache"
637
+ res.body = edit_html
638
+ end
639
+
640
+ # ---- 補助 ---------------------------------------------------------------
641
+
642
+ def relative_root
643
+ rel = edit_root.sub(/\A#{Regexp.escape(@base_dir)}[\\\/]?/, "")
644
+ rel.empty? ? "." : rel.tr("\\", "/")
645
+ end
646
+
647
+ def edit_json(res, status, hash)
648
+ res.status = status
649
+ res["content-type"] = "application/json"
650
+ # ツール出力 (外部プロセスの stdout など) は妥当な UTF-8 とは限らない。
651
+ # 不正バイトが混じると JSON.generate が例外を投げ、WEBrick が HTML の 500 を
652
+ # 返してしまう (フロントは JSON を期待して "Unexpected token '<'" になる)。
653
+ # 生成前に全文字列を UTF-8 として scrub し、無効バイトを U+FFFD に置換して守る。
654
+ res.body = JSON.generate(deep_scrub(hash))
655
+ end
656
+
657
+ # ハッシュ/配列/文字列を再帰的にたどり、文字列を妥当な UTF-8 に正規化する。
658
+ # UTF-8 以外のエンコーディング (ASCII-8BIT な外部プロセス出力等) も UTF-8 とみなして
659
+ # scrub し、変換不能なバイトは置換文字にする。ハッシュのキーも同様に正規化する。
660
+ def deep_scrub(value)
661
+ case value
662
+ when String
663
+ s = value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding("UTF-8")
664
+ s.valid_encoding? ? s : s.scrub("�")
665
+ when Hash
666
+ value.each_with_object({}) { |(k, v), h| h[deep_scrub(k)] = deep_scrub(v) }
667
+ when Array
668
+ value.map { |v| deep_scrub(v) }
669
+ else
670
+ value
671
+ end
672
+ end
673
+
674
+ def edit_method_guard(res)
675
+ res.status = 405
676
+ res["content-type"] = "application/json"
677
+ res.body = JSON.generate("error" => "method not allowed")
678
+ end
679
+
680
+ # WebSocket コンソールの接続先。ws_port 未設定ならコンソール無効。
681
+ def console_ws_config
682
+ return nil unless @ws_port
683
+
684
+ { "port" => @ws_port, "token" => @ws_token }
685
+ end
686
+
687
+ def edit_html
688
+ ws = console_ws_config
689
+ ws_json = ws ? JSON.generate(ws) : "null"
690
+ EDIT_HTML_TEMPLATE.sub("__WS_CONFIG__", ws_json)
691
+ end
692
+ end
693
+ end
694
+
695
+ require_relative "edit_html"
696
+ require "fileutils"