chocomint 1.1.1 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf5b5e18513023f06550376aa901ada01d10efd621f53fc01c4f1671b94e3f1d
4
- data.tar.gz: 8fa2badc83ba90c08fd8bcbc5a2a2632a4342993befafb3721457e10c39ee168
3
+ metadata.gz: e8b684c264c0c8d2449165ef471d439f3816b59732464d1a80d773d65327838b
4
+ data.tar.gz: c9a98fd3654496f6db1c634771e81a05093ffd21af91e90e7e95c12bc3d8fb4f
5
5
  SHA512:
6
- metadata.gz: 6be492f0a271ab2d6978da7c22c6740522983f933f167b96fcdc7800482b341aadc48156d4cb062612abadda090dcc34220a4f0e21745e2b835c51e0a658f8fb
7
- data.tar.gz: b5408738a6734a31b1d311fa455c4f2374f176c375cd6fddb7136011353c8fe7dddd54461851baf2c7ed625c6091f7a3d36e90e30ea71a6d918eda31a66ebd0d
6
+ metadata.gz: a9909618b16c7063b1d98b72a61bf666db38081bdd712a46595c20a97434c2087f30f265658a4b99ef614f9d38d391a9e2889866bbd406a41efb03f83a4ac526
7
+ data.tar.gz: 2771ba70535d038cc64987496ea410c4da908af05fd61147f05af15c0492a51ee7b717564aa2b748d75edd8af96d2c4b5ea77091af82d115880655cf5e45efd9
data/README.md CHANGED
@@ -125,6 +125,38 @@ Invoke-RestMethod -Uri http://127.0.0.1:9210/v1/messages -Method Post `
125
125
  bundle exec rspec
126
126
  ```
127
127
 
128
+ ## リリース(gem 公開)
129
+
130
+ RubyGems に公開済み。バージョンは `lib/chocomint.rb` の `Chocomint::VERSION`
131
+ 1 か所で定義し、gemspec がそれを参照する。
132
+
133
+ ```powershell
134
+ # 1. バージョンを更新(lib/chocomint.rb の VERSION を書き換える)
135
+ # 例: "1.1.1" -> "1.1.2"
136
+
137
+ # 2. 更新をコミット
138
+ git add lib/chocomint.rb
139
+ git commit -m 'release: vX.Y.Z'
140
+
141
+ # 3. gem をビルド
142
+ gem build chocomint.gemspec # -> chocomint-X.Y.Z.gem が生成される
143
+
144
+ # 4. RubyGems へ push(要認証 + OTP。下記の注意を参照)
145
+ gem push chocomint-X.Y.Z.gem
146
+ ```
147
+
148
+ > **push には認証と OTP が必要**: gemspec で `rubygems_mfa_required = true` を
149
+ > 設定しているため、`gem push` には RubyGems の認証(`gem signin` か環境変数
150
+ > `GEM_HOST_API_KEY`)と、多要素認証の OTP コード入力が必要。この操作は対話が
151
+ > 発生するため手元で実行すること。
152
+
153
+ > ⚠️ **公開前のエンドツーエンド検証**: 実行ファイル(`bin/*`)では
154
+ > `$PROGRAM_NAME == __FILE__` ガードを**使わない**。RubyGems の実行ラッパーは
155
+ > 本体を `load` 経由で呼ぶため、このガードがあると gem インストール経由では
156
+ > `main` が発動せず「何もせず終了」する不具合になる。`ruby bin/xxx` の直接実行
157
+ > だけでは見逃すため、公開前後はソースツリー外で `gem install` した実体
158
+ > (`.bat` ラッパー経由)で必ず動作確認する。
159
+
128
160
  ## 監査ログ
129
161
 
130
162
  全試行が `logs/execution.db` (SQLite) の `executions` テーブルに記録される(DESIGN §12)。
@@ -269,8 +269,14 @@ module Chocomint
269
269
 
270
270
  head = raw.byteslice(0, BINARY_SNIFF_BYTES)
271
271
  return true if head.include?("\x00")
272
+ return false if head.dup.force_encoding("UTF-8").valid_encoding?
272
273
 
273
- !head.dup.force_encoding("UTF-8").valid_encoding?
274
+ # 先頭ブロックが有効な UTF-8 でないとき、それが「末尾のマルチバイト文字が
275
+ # スニフ境界 (8192B) で途中まで切れた」だけなら、テキストを誤ってバイナリと
276
+ # みなさない。scrub で不正バイトを除いた結果が末尾数バイトの欠落だけであれば
277
+ # (=不正箇所が末尾に限られる) テキストとして扱う。UTF-8 の 1 文字は最大 4 バイト。
278
+ scrubbed = head.dup.force_encoding("UTF-8").scrub("")
279
+ head.bytesize - scrubbed.bytesize > 3
274
280
  end
275
281
 
276
282
  # ---- 保存 (人手編集) ----------------------------------------------------
@@ -314,11 +320,8 @@ module Chocomint
314
320
  # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
315
321
  return handle_edit_chat_stream(res, instruction, path, history) if body["stream"]
316
322
 
317
- # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
318
- if chat_instruction?(instruction)
319
- return edit_json(res, 200, edit_chat_reply(instruction, path, history))
320
- end
321
-
323
+ # 全指示を Planner に流し、必要なツールを自律的に実行させて解決する。
324
+ # 純粋な会話・情報不足の聞き返しは、Planner がツールを使わず finish で応答する。
322
325
  request = build_chat_request(path, instruction)
323
326
  result = @planner.run(request, expectations: instruction, history: history)
324
327
  edit_json(res, 200, edit_chat_result(result, path, instruction))
@@ -355,12 +358,8 @@ module Chocomint
355
358
 
356
359
  # SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
357
360
  def run_chat_stream(instruction, path, history, emit_sse)
358
- # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
359
- if chat_instruction?(instruction)
360
- emit_sse.call("result", edit_chat_reply(instruction, path, history))
361
- return
362
- end
363
-
361
+ # 全指示を Planner に流す。ツールを使わない会話・聞き返しは、Planner が
362
+ # ツール実行なし (進捗イベントなし) で finish し、最終 result だけが届く。
364
363
  request = build_chat_request(path, instruction)
365
364
  on_event = lambda do |ev|
366
365
  case ev[:type]
@@ -511,42 +510,6 @@ module Chocomint
511
510
  []
512
511
  end
513
512
 
514
- # chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
515
- # chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
516
- def chat_instruction?(instruction)
517
- return false unless @chat_client
518
-
519
- @chat_client.classify(instruction) == "chat"
520
- rescue Chocomint::Error
521
- # 分類に失敗したら安全側でタスク扱い (Planner に流す)。
522
- false
523
- end
524
-
525
- # 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
526
- # 開いているファイルがあれば内容を文脈として渡す。
527
- def edit_chat_reply(instruction, path, history = [])
528
- answer = @chat_client.answer(instruction, context: chat_file_context(path), history: history)
529
- { "status" => "PASS", "reply" => answer, "steps" => [] }
530
- rescue Chocomint::Error => e
531
- { "status" => "FAIL", "error" => e.message, "steps" => [] }
532
- end
533
-
534
- # 会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。
535
- CHAT_CONTEXT_LIMIT = 4000
536
-
537
- def chat_file_context(path)
538
- return nil if path.empty?
539
-
540
- abs = @path_guard.resolve(path)
541
- return nil unless File.file?(abs)
542
-
543
- content = File.read(abs, mode: "rb").force_encoding("UTF-8")
544
- content = "#{content[0, CHAT_CONTEXT_LIMIT]}…" if content.length > CHAT_CONTEXT_LIMIT
545
- "現在開いているファイル #{path} の内容:\n```\n#{content}\n```"
546
- rescue Chocomint::Error
547
- nil
548
- end
549
-
550
513
  # 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
551
514
  # 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため
552
515
  # (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
@@ -101,7 +101,8 @@ module Chocomint
101
101
  max_tokens: config.llm.fetch("max_tokens", 1024),
102
102
  timeout: config.llm_timeout_sec,
103
103
  payload_logger: logger,
104
- role: "chat"
104
+ role: "chat",
105
+ provider: config.llm_provider
105
106
  )
106
107
  LLM::ChatClient.new(base_client: client)
107
108
  end
@@ -114,7 +115,8 @@ module Chocomint
114
115
  max_tokens: config.llm.fetch("max_tokens", 1024),
115
116
  timeout: config.llm_timeout_sec,
116
117
  payload_logger: logger,
117
- role: "primary"
118
+ role: "primary",
119
+ provider: config.llm_provider
118
120
  )
119
121
  LLM::PrimaryClient.new(base_client: client,
120
122
  tool_definitions: registry.tool_definitions)
@@ -132,7 +134,8 @@ module Chocomint
132
134
  max_tokens: config.verifier.fetch("max_tokens", 1024),
133
135
  timeout: config.llm_timeout_sec,
134
136
  payload_logger: logger,
135
- role: "verifier"
137
+ role: "verifier",
138
+ provider: config.llm_provider
136
139
  )
137
140
  LLM::VerifierClient.new(base_client: client)
138
141
  end
@@ -11,8 +11,11 @@ module Chocomint
11
11
  class BaseClient
12
12
  # payload_logger: LLM 送信内容を記録するロガー (record_llm_payload を持つ / 任意)。
13
13
  # role: ペイロードの識別子 ("primary" / "verifier")。ログ表示で区別するため。
14
+ # provider: "openrouter" / "ollama" 等。OpenRouter のときだけ prompt caching
15
+ # (cache_control) を明示的に付ける (Ollama はローカルでキャッシュ課金メリットが無く、
16
+ # cache_control 非対応の可能性もあるため付けない)。
14
17
  def initialize(base_url:, model:, api_key: nil, max_tokens: 1024, timeout: 60,
15
- payload_logger: nil, role: nil)
18
+ payload_logger: nil, role: nil, provider: nil)
16
19
  @base_url = base_url.to_s.chomp("/")
17
20
  @model = model
18
21
  @api_key = api_key
@@ -20,6 +23,7 @@ module Chocomint
20
23
  @timeout = timeout
21
24
  @payload_logger = payload_logger
22
25
  @role = role
26
+ @provider = provider
23
27
  end
24
28
 
25
29
  # messages: Anthropic 形式の配列。tools / system は任意。
@@ -31,8 +35,8 @@ module Chocomint
31
35
  "max_tokens" => @max_tokens,
32
36
  "messages" => messages
33
37
  }
34
- body["tools"] = tools if tools
35
- body["system"] = system if system
38
+ body["tools"] = with_cached_tools(tools) if tools
39
+ body["system"] = with_cached_system(system) if system
36
40
 
37
41
  headers = {
38
42
  "content-type" => "application/json",
@@ -102,6 +106,40 @@ module Chocomint
102
106
  warn "llm response logging failed: #{e.message}"
103
107
  end
104
108
 
109
+ # OpenRouter のときだけ prompt caching を有効にする。
110
+ # レンダリング順は tools → system → messages。安定した接頭辞 (tools と system) を
111
+ # キャッシュすれば、毎回同じ system/tools を送るこのアプリでは大きな節約になる。
112
+ def caching_enabled? = @provider.to_s == "openrouter"
113
+
114
+ EPHEMERAL = { "type" => "ephemeral" }.freeze
115
+
116
+ # tools 配列の最後のツール定義に cache_control を付ける。
117
+ # ブレークポイントはそのツールブロックまで (= tools 全体) をキャッシュ対象にする。
118
+ def with_cached_tools(tools)
119
+ return tools unless caching_enabled? && tools.is_a?(Array) && !tools.empty?
120
+
121
+ cached_last = tools.last.merge("cache_control" => EPHEMERAL)
122
+ tools[0...-1] + [cached_last]
123
+ end
124
+
125
+ # system は文字列で渡ってくる。Anthropic 形式では text ブロックの配列にでき、
126
+ # 最後のブロックに cache_control を付けると system (と直前の tools) がキャッシュされる。
127
+ # 既に配列で渡された場合はその最後のブロックに付ける。
128
+ def with_cached_system(system)
129
+ return system unless caching_enabled?
130
+
131
+ case system
132
+ when String
133
+ [{ "type" => "text", "text" => system, "cache_control" => EPHEMERAL }]
134
+ when Array
135
+ return system if system.empty?
136
+
137
+ system[0...-1] + [system.last.merge("cache_control" => EPHEMERAL)]
138
+ else
139
+ system
140
+ end
141
+ end
142
+
105
143
  def connection
106
144
  @connection ||= Faraday.new do |f|
107
145
  f.options.timeout = @timeout
@@ -5,46 +5,16 @@ require_relative "../errors"
5
5
 
6
6
  module Chocomint
7
7
  module LLM
8
- # /edit チャットの「普通の質問」応答を担う軽量クライアント。
8
+ # /edit チャットの補助 LLM 処理 (要約・抽出・圧縮) を担う軽量クライアント。
9
9
  #
10
- # 役割は 2 つ:
11
- # 1. classifyユーザー指示が「ファイル操作/コマンド実行を要するタスク」か、
12
- # 「ツール不要の普通の質問・会話」かを 1 語で判定する。
13
- # 2. answer会話と判定された指示に、ツールを使わず自然文で直接答える。
10
+ # 現在の役割:
11
+ # - summarizePlanner が実行したツール列とその結果を自然文にまとめる。
12
+ # - extract — fetch_url が取得したページ本文から目的の情報を抽出する。
13
+ # - compact会話履歴を短い要約に畳み込む (/compact 用)。
14
14
  #
15
- # タスクと判定された指示は呼び出し側 (EditHandlers) Planner に渡す。
15
+ # かつて指示を task/chat に振り分けていた classify / answer は、全指示を Planner
16
+ # 流す方針 (ツールを自律実行して解決) に変更したため削除済み。
16
17
  class ChatClient
17
- # 分類プロンプト。ローカル小型モデルでも安定するよう、出力を 1 語に強制する。
18
- CLASSIFY_SYSTEM_PROMPT = <<~PROMPT
19
- あなたはユーザーの指示を 2 種類に分類する分類器です。
20
- - "task": 実際の操作が必要なもの。ファイルの作成・編集・削除、
21
- コマンド・プログラムの実行、外部ツールでの調査 (ping・実行・ビルド・
22
- テスト・検索・一覧取得など) を伴うもの。「〜して」「〜を実行して」
23
- 「〜した結果をまとめて/教えて」のように、動かして結果を得る依頼はすべて task。
24
- 具体的な URL が示されていて、その内容を参照・要約する依頼も task。
25
- - "chat": 操作を一切伴わず、言葉の説明・質問・相談・雑談だけで済むもの。
26
- 知識の説明や意見を求めるものはこちら。
27
- また、今日の天気・最新ニュース・現在の株価のように「その場では調べる対象
28
- (場所・URL など) が特定できない、リアルタイム情報を尋ねる質問」も chat。
29
- これらは会話として聞き返し、必要な情報を確認するのが適切なため。
30
- 迷ったら task を選ぶこと (操作が必要なのに chat と誤ると何も実行されないため)。
31
- 出力は task または chat のいずれか 1 語のみ。他の文字は一切出力しないこと。
32
- PROMPT
33
-
34
- # 会話応答プロンプト。エディタ上のアシスタントとして簡潔に日本語で答えさせる。
35
- # 現在日時・OS・シェルの情報は answer 実行時に environment_prompt を付加する。
36
- ANSWER_SYSTEM_PROMPT = <<~PROMPT
37
- あなたはコードエディタに組み込まれた親切なアシスタントです。
38
- ユーザーの質問や相談に、簡潔で正確な日本語で答えてください。
39
- 実際のファイル操作やコマンド実行は行いません (この応答では行えません)。
40
- 操作が必要な場合は、その旨と具体的な手順を説明してください。
41
- 今日の天気・最新ニュースなどリアルタイム情報は、あなた自身は把握していません。
42
- 推測で答えず、参照したいウェブページの URL を教えてもらえれば内容を調べられる旨を
43
- 案内し、必要なら場所や対象を聞き返してください。
44
- コマンド例を示すときは PowerShell (pwsh) の構文を優先してください。
45
- 絵文字は一切使わないでください。
46
- PROMPT
47
-
48
18
  # 実行結果の要約プロンプト。実行済みのツール列とその結果をもとに、
49
19
  # ユーザー要求に対して何を行い・どうなったかを自然文でまとめさせる。
50
20
  SUMMARIZE_SYSTEM_PROMPT = <<~PROMPT
@@ -82,51 +52,20 @@ module Chocomint
82
52
  @client = base_client
83
53
  end
84
54
 
85
- # 指示を "task" / "chat" に分類する。判定不能時は安全側の "task" を返す
86
- # (タスクを会話扱いして取りこぼすより、会話をタスク扱いして Planner に流す方が無難)。
87
- def classify(instruction, trace_id: nil)
88
- response = @client.create_message(
89
- system: CLASSIFY_SYSTEM_PROMPT,
90
- messages: [{ "role" => "user", "content" => instruction.to_s }],
91
- trace_id: trace_id
92
- )
93
- text = extract_text(response).to_s.downcase
94
- text.include?("chat") ? "chat" : "task"
95
- end
96
-
97
- # 会話としての自然文回答を返す。現在日時・OS・シェルの情報を毎回付加する
98
- # (ローカルモデルが日付や実行環境を誤解しないようにするため)。
99
- # history: これまでのやり取り [{ "role" => "user"/"assistant", "content" => String }, ...]
100
- # (直近の指示より前の文脈として messages の先頭に積む)。
101
- def answer(instruction, context: nil, history: nil, trace_id: nil)
102
- content = context.to_s.empty? ? instruction.to_s : "#{context}\n\n#{instruction}"
103
- system_prompt = "#{ANSWER_SYSTEM_PROMPT}\n#{PrimaryClient.environment_prompt}"
104
- messages = Array(history) + [{ "role" => "user", "content" => content }]
105
- response = @client.create_message(
106
- system: system_prompt,
107
- messages: messages,
108
- trace_id: trace_id
109
- )
110
- text = strip_emoji(extract_text(response))
111
- raise Chocomint::Error, "chat response is empty" if text.nil? || text.empty?
112
-
113
- text
114
- end
115
-
116
55
  # 実行済みステップ列を自然文でまとめる。失敗しても要約なしで続行できるよう
117
56
  # 例外時は nil を返す (呼び出し側は生の結果表示にフォールバックする)。
118
- # steps: [{ tool:, arguments:, status:, output: }, ...] 形式。
57
+ # steps: [{ tool:, label:, target:, status:, exit_code:, output: }, ...] 形式。
58
+ #
59
+ # かつては全ステップの生出力を 1 本の巨大な user メッセージに連結し、その後は
60
+ # 要求と各ステップを別々の user メッセージに分割していた。だが Messages API は
61
+ # user/assistant が交互になる形を想定しており、user メッセージの連投は素直でない。
62
+ # そこで「ユーザー要求」と「実行トレース (ステップの構造化 JSON 配列)」を、
63
+ # 1 本の user メッセージにまとめて渡す (要求とトレースは見出しで切り分ける)。
119
64
  def summarize(request, steps, trace_id: nil)
120
- content = <<~MSG
121
- ユーザー要求:
122
- #{request}
123
-
124
- 実行したステップ:
125
- #{format_steps(steps)}
126
- MSG
65
+ messages = build_summarize_messages(request, steps)
127
66
  response = @client.create_message(
128
67
  system: SUMMARIZE_SYSTEM_PROMPT,
129
- messages: [{ "role" => "user", "content" => content }],
68
+ messages: messages,
130
69
  trace_id: trace_id
131
70
  )
132
71
  text = strip_emoji(extract_text(response))
@@ -184,15 +123,41 @@ module Chocomint
184
123
 
185
124
  SUMMARY_OUTPUT_LIMIT = 500
186
125
 
187
- def format_steps(steps)
188
- Array(steps).each_with_index.map do |s, i|
189
- tool = s[:tool] || s["tool"]
190
- status = s[:status] || s["status"]
191
- out = (s[:output] || s["output"]).to_s.strip
192
- out = "#{out[0, SUMMARY_OUTPUT_LIMIT]}…" if out.length > SUMMARY_OUTPUT_LIMIT
193
- line = "#{i + 1}. #{tool} (#{status})"
194
- out.empty? ? line : "#{line}\n 結果: #{out}"
195
- end.join("\n")
126
+ # 「ユーザー要求」と「実行トレース」を 1 本の user メッセージにまとめて積む。
127
+ # トレースは各ステップ (番号・ツール・対象・状態・出力要点) を要素とする JSON
128
+ # 配列にして、LLM が構造を読み取りやすいようにする。ステップが無ければ要求だけを渡す。
129
+ def build_summarize_messages(request, steps)
130
+ content = +"ユーザー要求:\n#{request}"
131
+ list = Array(steps)
132
+ unless list.empty?
133
+ trace = list.each_with_index.map { |s, i| format_step(s, i) }
134
+ content << "\n\n実行したステップ:\n#{JSON.pretty_generate(trace)}"
135
+ end
136
+ [{ "role" => "user", "content" => content }]
137
+ end
138
+
139
+ # 1 ステップを要約向けの構造化オブジェクトに整える。呼び出し側 (edit_handlers) が
140
+ # 用意した label / target / exit_code を活かし、生出力は要点だけに圧縮する。
141
+ def format_step(step, index)
142
+ obj = {
143
+ "step" => index + 1,
144
+ "tool" => step[:tool] || step["tool"],
145
+ "label" => step[:label] || step["label"],
146
+ "target" => step[:target] || step["target"],
147
+ "status" => step[:status] || step["status"],
148
+ "exit_code" => step[:exit_code] || step["exit_code"]
149
+ }.reject { |_, v| v.nil? || v == "" }
150
+ out = clean_output(step[:output] || step["output"])
151
+ obj["output"] = out unless out.empty?
152
+ obj
153
+ end
154
+
155
+ # ツール出力の生ノイズを取り除いて要点化する。改行コード (\r\n) を正規化し、
156
+ # 空行を畳んでから上限で切り詰める (生 JSON や README 全文をそのまま流さない)。
157
+ def clean_output(output)
158
+ text = output.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
159
+ text = text.gsub(/\n{3,}/, "\n\n").strip
160
+ text.length > SUMMARY_OUTPUT_LIMIT ? "#{text[0, SUMMARY_OUTPUT_LIMIT]}…" : text
196
161
  end
197
162
 
198
163
  def extract_text(response)
data/lib/chocomint.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chocomint
4
- VERSION = "1.1.1"
4
+ VERSION = "1.1.2"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
data/public/edit/edit.css CHANGED
@@ -360,7 +360,7 @@ html, body { margin: 0; height: 100%;
360
360
  .markdown h4, .markdown h5, .markdown h6 { margin: 0.5rem 0 0.3rem; line-height: 1.3; }
361
361
  .markdown h1 { font-size: 1.15rem; } .markdown h2 { font-size: 1.05rem; }
362
362
  .markdown h3 { font-size: 0.95rem; }
363
- .markdown ul { margin: 0.3rem 0; padding-left: 1.3rem; }
363
+ .markdown ul, .markdown ol { margin: 0.3rem 0; padding-left: 1.5rem; }
364
364
  .markdown li { margin: 0.15rem 0; }
365
365
  .markdown code { font-family: "Cascadia Code", "Noto Sans JP", monospace;
366
366
  font-size: 0.85em; background: #f0f0f3; padding: 0.05rem 0.3rem;
data/public/edit/edit.js CHANGED
@@ -1586,14 +1586,24 @@ function renderMarkdown(src) {
1586
1586
  codeBlocks.push(code.replace(/^\n/, ''));
1587
1587
  return ' CB' + (codeBlocks.length - 1) + ' ';
1588
1588
  });
1589
+ // ローカル LLM は番号付きリストを改行せず「1. … 2. … 3. …」と 1 行で返すことがある。
1590
+ // 行頭以外に現れる番号マーカー ( "2." など。前が空白/全角空白) の直前で改行して、
1591
+ // 各項目を独立した行に分割する (箇条書きとして描画できるようにするため)。
1592
+ text = text.replace(/[  ]+(?=\d+\.[  ])/g, '\n');
1593
+
1589
1594
  const lines = text.split('\n');
1590
1595
  let html = '';
1591
- let inList = false;
1592
- const closeList = () => { if (inList) { html += '</ul>'; inList = false; } };
1596
+ let listTag = null; // 開いているリストの種類 ('ul' / 'ol' / null)
1597
+ const closeList = () => { if (listTag) { html += '</' + listTag + '>'; listTag = null; } };
1598
+ const openList = (tag) => {
1599
+ if (listTag !== tag) { closeList(); html += '<' + tag + '>'; listTag = tag; }
1600
+ };
1593
1601
  for (let i = 0; i < lines.length; i++) {
1594
1602
  const line = lines[i];
1595
1603
  const h = line.match(/^(#{1,6})\s+(.*)$/);
1596
1604
  const li = line.match(/^\s*[-*]\s+(.*)$/);
1605
+ // 番号付きリスト: 「1. 内容」「2) 内容」など。
1606
+ const oli = line.match(/^\s*\d+[.)][  ]+(.*)$/);
1597
1607
  // GFM テーブル: 「| 見出し |」+ 次行が区切り (|---|---|) なら表として処理。
1598
1608
  if (isTableRow(line) && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
1599
1609
  closeList();
@@ -1607,8 +1617,11 @@ function renderMarkdown(src) {
1607
1617
  const lvl = h[1].length;
1608
1618
  html += '<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>';
1609
1619
  } else if (li) {
1610
- if (!inList) { html += '<ul>'; inList = true; }
1620
+ openList('ul');
1611
1621
  html += '<li>' + inline(li[1]) + '</li>';
1622
+ } else if (oli) {
1623
+ openList('ol');
1624
+ html += '<li>' + inline(oli[1]) + '</li>';
1612
1625
  } else if (line.trim() === '') {
1613
1626
  closeList();
1614
1627
  } else if (/^ CB\d+ $/.test(line)) {
@@ -1849,13 +1862,22 @@ async function compactChat() {
1849
1862
  }
1850
1863
  }
1851
1864
 
1865
+ // メッセージ要素から「発言そのもの」のテキストだけを取り出す。
1866
+ // 実行時間 (.msg-elapsed) やステータスバッジ (.badge) などの UI 注記は
1867
+ // AI への履歴・要約に混ぜたくないので、複製から取り除いてから textContent を取る。
1868
+ function messageText(m) {
1869
+ const clone = m.cloneNode(true);
1870
+ clone.querySelectorAll('.msg-elapsed, .badge').forEach(el => el.remove());
1871
+ return (clone.textContent || '').replace(/\s+/g, ' ').trim();
1872
+ }
1873
+
1852
1874
  // チャットログを AI へ渡す 1 本のプレーンテキストに書き出す (要約入力用)。
1853
1875
  // ユーザー発言 / AI 発言 / 実行要約 を role 付きの行にして連結する。
1854
1876
  function collectTranscript() {
1855
1877
  const lines = [];
1856
1878
  chatLog.querySelectorAll('.msg').forEach(m => {
1857
1879
  const role = m.classList.contains('user') ? 'ユーザー' : 'アシスタント';
1858
- const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
1880
+ const text = messageText(m);
1859
1881
  if (text) lines.push(role + ': ' + text);
1860
1882
  });
1861
1883
  return lines.join('\n');
@@ -1869,7 +1891,7 @@ function collectChatHistory() {
1869
1891
  // pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
1870
1892
  if (m.querySelector('.pending-head')) return;
1871
1893
  const role = m.classList.contains('user') ? 'user' : 'assistant';
1872
- const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
1894
+ const text = messageText(m);
1873
1895
  if (text) history.push({ role, content: text });
1874
1896
  });
1875
1897
  return history;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chocomint
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - ひかり (Himeyama)