openclacky 1.5.12 → 1.5.13

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
data/lib/clacky/client.rb CHANGED
@@ -44,6 +44,7 @@ module Clacky
44
44
  effective_api_format ||= "anthropic-messages" if anthropic_format
45
45
  resolved_type = Providers.api_type_for_model(provider_id, @model, user_override: effective_api_format)
46
46
  @use_anthropic_format = resolved_type == "anthropic-messages"
47
+ @use_responses_format = resolved_type == "openai-responses"
47
48
 
48
49
  # Remember the provider id so we can tune connection headers below
49
50
  # (OpenRouter's /v1/messages accepts either Bearer or x-api-key, but
@@ -66,6 +67,12 @@ module Clacky
66
67
  @use_anthropic_format && !@use_bedrock
67
68
  end
68
69
 
70
+ # Returns true when the client talks to the OpenAI Responses API
71
+ # (/v1/responses) instead of Chat Completions.
72
+ def responses_format?(model = nil)
73
+ @use_responses_format && !@use_bedrock
74
+ end
75
+
69
76
  # ── Connection test ───────────────────────────────────────────────────────
70
77
 
71
78
  # Test API connection by sending a minimal request.
@@ -81,6 +88,11 @@ module Clacky
81
88
  minimal_body = { model: api_model, max_tokens: 16,
82
89
  messages: [{ role: "user", content: "hi" }] }.to_json
83
90
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = minimal_body }
91
+ elsif responses_format?
92
+ minimal_body = MessageFormat::OpenAIResponses.build_request_body(
93
+ [{ role: "user", content: "hi" }], api_model, [], 16, false
94
+ ).to_json
95
+ response = openai_connection.post("responses") { |r| r.body = minimal_body }
84
96
  else
85
97
  minimal_body = { model: api_model, max_tokens: 16,
86
98
  messages: [{ role: "user", content: "hi" }] }.to_json
@@ -113,6 +125,10 @@ module Clacky
113
125
  body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
114
126
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
115
127
  parse_simple_anthropic_response(response)
128
+ elsif responses_format?
129
+ body = MessageFormat::OpenAIResponses.build_request_body(messages, api_model, [], max_tokens, false)
130
+ response = openai_connection.post("responses") { |r| r.body = body.to_json }
131
+ parse_simple_openai_responses_response(response)
116
132
  else
117
133
  body = MessageFormat::OpenAI.build_request_body(messages, api_model, [], max_tokens, false, reasoning_effort: reasoning_effort)
118
134
  response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
@@ -164,6 +180,9 @@ module Clacky
164
180
  elsif anthropic_format?
165
181
  streaming_used = !on_chunk.nil?
166
182
  send_anthropic_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
183
+ elsif responses_format?
184
+ streaming_used = !on_chunk.nil?
185
+ send_openai_responses_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
167
186
  else
168
187
  streaming_used = !on_chunk.nil?
169
188
  send_openai_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
@@ -206,6 +225,8 @@ module Clacky
206
225
  MessageFormat::Bedrock.format_tool_results(response, tool_results)
207
226
  elsif anthropic_format?
208
227
  MessageFormat::Anthropic.format_tool_results(response, tool_results)
228
+ elsif responses_format?
229
+ MessageFormat::OpenAIResponses.format_tool_results(response, tool_results)
209
230
  else
210
231
  MessageFormat::OpenAI.format_tool_results(response, tool_results)
211
232
  end
@@ -460,6 +481,92 @@ module Clacky
460
481
  content
461
482
  end
462
483
 
484
+ # ── OpenAI Responses API request / response ───────────────────────────────
485
+
486
+ def send_openai_responses_request(messages, model, tools, max_tokens, caching_enabled,
487
+ reasoning_effort: nil, on_chunk: nil, capability_model: nil)
488
+ # Override max_tokens when the model declares a higher output ceiling
489
+ model_for_limit = capability_model || model
490
+ model_limit = Providers.max_output_for(model_for_limit)
491
+ max_tokens = model_limit if model_limit
492
+
493
+ # Deliberately no apply_message_caching here: the Responses API does
494
+ # not recognize Anthropic-style cache_control markers, and OpenAI's
495
+ # Responses prompt caching is automatic server-side. Injecting
496
+ # cache_control would be silently ignored (or rejected by stricter
497
+ # endpoints).
498
+
499
+ cap_model = capability_model || model
500
+ body = MessageFormat::OpenAIResponses.build_request_body(
501
+ messages, model, tools, max_tokens, caching_enabled,
502
+ vision_supported: Providers.supports?(@provider_id, :vision, model_name: cap_model),
503
+ reasoning_effort: reasoning_effort
504
+ )
505
+ return send_openai_responses_stream_request(body, on_chunk) if on_chunk
506
+
507
+ response = openai_connection.post("responses") { |r| r.body = body.to_json }
508
+
509
+ raise_error(response) unless response.status == 200
510
+ check_html_response(response)
511
+
512
+ parsed_body = safe_json_parse(response.body, context: "LLM response")
513
+ MessageFormat::OpenAIResponses.parse_response(parsed_body)
514
+ end
515
+
516
+ # Streaming variant for the OpenAI Responses API.
517
+ # Posts to the "responses" endpoint with stream:true; the upstream returns
518
+ # typed SSE events (response.output_text.delta,
519
+ # response.function_call_arguments.delta, response.completed, etc.) that
520
+ # the aggregator reassembles into the non-streaming response shape.
521
+ private def send_openai_responses_stream_request(body, on_chunk)
522
+ stream_body = body.merge(stream: true)
523
+ aggregator = OpenAIResponsesStreamAggregator.new(on_chunk: on_chunk)
524
+ sse_buf = +""
525
+
526
+ response = openai_connection.post("responses") do |req|
527
+ req.headers["Accept"] = "text/event-stream"
528
+ req.body = stream_body.to_json
529
+ req.options.on_data = proc do |chunk, _bytes_received, _env|
530
+ sse_buf << chunk
531
+ drain_sse_frames(sse_buf) { |_event, data| aggregator.handle(data) }
532
+ end
533
+ end
534
+
535
+ unless response.status == 200
536
+ response.env.body = sse_buf if response.body.to_s.empty?
537
+ raise_error(response)
538
+ end
539
+
540
+ result = aggregator.to_h
541
+ log_stream_summary("openai-responses", aggregator, aggregator.saw_done? ? "completed" : nil)
542
+ # A complete Responses API stream always terminates with a
543
+ # response.completed / response.done (or response.incomplete) event.
544
+ # Its absence means the upstream cut the stream mid-response; retry
545
+ # rather than accept a silently truncated answer.
546
+ unless aggregator.saw_done?
547
+ raise Clacky::UpstreamTruncatedError,
548
+ "[LLM] Streaming response ended without response.completed (upstream cut the stream). Retrying..."
549
+ end
550
+ MessageFormat::OpenAIResponses.parse_response(result)
551
+ end
552
+
553
+ def parse_simple_openai_responses_response(response)
554
+ raise_error(response) unless response.status == 200
555
+ parsed_body = safe_json_parse(response.body, context: "LLM response")
556
+ result = MessageFormat::OpenAIResponses.parse_response(parsed_body)
557
+ content = result[:content]
558
+ if content.nil?
559
+ snippet = response.body.to_s[0, 1200]
560
+ if defined?(Clacky::Logger)
561
+ Clacky::Logger.warn("[parse_simple_openai_responses_response] no content. status=#{response.status} body=#{snippet}")
562
+ end
563
+ raise RetryableError,
564
+ "Upstream Responses API response missing text content. " \
565
+ "Body snippet: #{snippet}"
566
+ end
567
+ content
568
+ end
569
+
463
570
  # ── Prompt caching helpers ────────────────────────────────────────────────
464
571
 
465
572
  # Add cache_control markers to the last 2 messages in the array.
@@ -344,6 +344,9 @@ class ExtStudioExt < Clacky::ApiExtension
344
344
  ext_id = require_ext_id!
345
345
  version = presence(json_body["version"])
346
346
  error!("version required", status: 422) unless version
347
+ unless Clacky::ExtensionVerifier.valid_version?(version)
348
+ error!("version must use three numeric segments (for example, 1.0.0)", status: 422)
349
+ end
347
350
 
348
351
  result = Clacky::ExtensionLoader.load_all(force: false)
349
352
  container = Array(result.containers).find { |id, _| id == ext_id }&.last
@@ -627,8 +627,12 @@
627
627
  }
628
628
  }
629
629
 
630
+ function normalizeVersion(v) {
631
+ return String(v || "").replace(/[^0-9.]/g, "").split(".").slice(0, 3).join(".");
632
+ }
633
+
630
634
  function isSemver(v) {
631
- return /^\d+\.\d+\.\d+$/.test(v);
635
+ return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v);
632
636
  }
633
637
 
634
638
  // Unified publish flow: one modal that walks the creator from a release form
@@ -642,23 +646,14 @@
642
646
  const titleNew = isBrand ? t("pub.brand.title.new") : t("pub.title.new");
643
647
  const titleUpdate = isBrand ? t("pub.brand.title.update") : t("pub.title.update");
644
648
  const introText = isBrand ? t("pub.brand.intro", { name: ext.name }) : t("pub.intro", { name: ext.name });
645
- let currentVersion = ext.version || "";
649
+ let currentVersion = normalizeVersion(ext.version);
646
650
  let isUpdate = false;
647
651
 
648
652
  const verField = el("div", { class: "studio-field" });
649
653
  verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
650
- const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
651
- verInput.addEventListener("input", () => {
652
- currentVersion = verInput.value.trim();
653
- const valid = isSemver(currentVersion);
654
- verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
655
- publishBtn.disabled = !valid;
656
- publishBtn.textContent = valid ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: currentVersion || "?" });
657
- if (currentVersion && !valid) {
658
- setProgress(t("err.invalid_version"), true);
659
- } else {
660
- status.style.display = "none";
661
- }
654
+ const verInput = el("input", {
655
+ class: "studio-input", type: "text", value: currentVersion,
656
+ placeholder: "1.0.0", inputmode: "decimal", autocomplete: "off", spellcheck: "false",
662
657
  });
663
658
  verField.appendChild(verInput);
664
659
 
@@ -694,7 +689,8 @@
694
689
 
695
690
  const publishBtn = modal.footer.querySelector(".btn-primary");
696
691
  const cancelBtn = modal.footer.querySelector(".btn-secondary");
697
- if (!currentVersion) publishBtn.disabled = true;
692
+ verInput.addEventListener("input", () => syncVersionState({ normalize: true }));
693
+ syncVersionState();
698
694
 
699
695
  Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
700
696
  if (done || !prevVersion) return;
@@ -702,10 +698,8 @@
702
698
  const titleEl = modal.overlay.querySelector(".modal-title");
703
699
  if (titleEl) titleEl.textContent = titleUpdate;
704
700
  if (prevVersion !== currentVersion) {
705
- currentVersion = prevVersion;
706
- verInput.value = currentVersion;
707
- publishBtn.disabled = false;
708
- publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
701
+ verInput.value = normalizeVersion(prevVersion);
702
+ syncVersionState();
709
703
  }
710
704
  });
711
705
 
@@ -715,9 +709,34 @@
715
709
  status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
716
710
  }
717
711
 
712
+ function syncVersionState({ normalize = false, updateFeedback = true } = {}) {
713
+ if (normalize) {
714
+ const raw = verInput.value;
715
+ const cursor = verInput.selectionStart;
716
+ const normalized = normalizeVersion(raw);
717
+ if (normalized !== raw) {
718
+ verInput.value = normalized;
719
+ if (cursor != null) {
720
+ const normalizedCursor = normalizeVersion(raw.slice(0, cursor)).length;
721
+ verInput.setSelectionRange(normalizedCursor, normalizedCursor);
722
+ }
723
+ }
724
+ }
725
+
726
+ currentVersion = verInput.value.trim();
727
+ const valid = isSemver(currentVersion);
728
+ publishBtn.disabled = !valid;
729
+ publishBtn.textContent = t("pub.btn.publish", { ver: valid ? currentVersion : (currentVersion || "?") });
730
+
731
+ if (updateFeedback) {
732
+ verInput.classList.toggle("studio-input-error", !!currentVersion && !valid);
733
+ if (currentVersion && !valid) setProgress(t("err.invalid_version"), true);
734
+ else status.style.display = "none";
735
+ }
736
+ }
737
+
718
738
  function resetButtons() {
719
- publishBtn.disabled = !currentVersion;
720
- publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
739
+ syncVersionState({ updateFeedback: false });
721
740
  cancelBtn.disabled = false;
722
741
  notes.disabled = false;
723
742
  verInput.disabled = false;
@@ -1,7 +1,7 @@
1
1
  id: git
2
2
  name: Git
3
3
  display_name: Git
4
- display_name_zh: Git
4
+ display_name_zh: Git 管理
5
5
  description: Changes panel — a friendly view of what the AI changed in the working tree
6
6
  description_zh: 变更面板 — 直观查看 AI 在工作区的每一处修改
7
7
  version: "0.1.0"