kward 0.66.0 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -3
- data/Gemfile.lock +2 -2
- data/README.md +5 -1
- data/doc/configuration.md +43 -1
- data/doc/memory.md +31 -9
- data/doc/rpc.md +41 -21
- data/doc/troubleshooting.md +55 -0
- data/doc/usage.md +41 -6
- data/lib/kward/cli.rb +1155 -195
- data/lib/kward/cli_transcript_formatter.rb +124 -0
- data/lib/kward/compaction/file_operation_tracker.rb +46 -0
- data/lib/kward/compactor.rb +3 -68
- data/lib/kward/config_files.rb +45 -69
- data/lib/kward/memory/manager.rb +66 -7
- data/lib/kward/model/client.rb +2 -195
- data/lib/kward/model/model_info.rb +9 -10
- data/lib/kward/model/payloads.rb +203 -0
- data/lib/kward/prompt_interface/banner.rb +77 -0
- data/lib/kward/prompt_interface.rb +220 -191
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/rpc/runtime_payloads.rb +79 -0
- data/lib/kward/rpc/server.rb +33 -34
- data/lib/kward/rpc/session_manager.rb +518 -159
- data/lib/kward/rpc/tool_event_normalizer.rb +12 -9
- data/lib/kward/rpc/transcript_normalizer.rb +31 -53
- data/lib/kward/session_store.rb +262 -20
- data/lib/kward/session_trash.rb +96 -0
- data/lib/kward/session_tree_renderer.rb +264 -0
- data/lib/kward/tools/registry.rb +3 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +10 -5
- metadata +9 -1
data/lib/kward/model/client.rb
CHANGED
|
@@ -6,13 +6,14 @@ require_relative "../auth/openai_oauth"
|
|
|
6
6
|
require_relative "../cancellation"
|
|
7
7
|
require_relative "../config_files"
|
|
8
8
|
require_relative "context_overflow"
|
|
9
|
-
require_relative "../image_attachments"
|
|
10
9
|
require_relative "model_info"
|
|
10
|
+
require_relative "payloads"
|
|
11
11
|
require_relative "../telemetry/logger"
|
|
12
12
|
require_relative "stream_parser"
|
|
13
13
|
|
|
14
14
|
module Kward
|
|
15
15
|
class Client
|
|
16
|
+
include ModelPayloads
|
|
16
17
|
OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
|
|
17
18
|
OPENROUTER_MODELS_URL = URI("https://openrouter.ai/api/v1/models")
|
|
18
19
|
CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
|
|
@@ -629,200 +630,6 @@ module Kward
|
|
|
629
630
|
end
|
|
630
631
|
end
|
|
631
632
|
|
|
632
|
-
def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
633
|
-
parts = build_context_parts(provider, messages, tools, model: model)
|
|
634
|
-
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
635
|
-
payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
636
|
-
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
637
|
-
payload
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
def validate_image_support!(provider, model, messages)
|
|
641
|
-
return if ModelInfo.supports_images?(provider, model)
|
|
642
|
-
return unless messages_include_images?(messages)
|
|
643
|
-
|
|
644
|
-
raise "Model '#{model}' does not support image inputs. Switch to a vision-capable model or remove the image attachment."
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
def messages_include_images?(messages)
|
|
648
|
-
messages.any? do |message|
|
|
649
|
-
content = message[:content] || message["content"]
|
|
650
|
-
content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
|
|
651
|
-
end
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
def chat_messages(messages)
|
|
655
|
-
messages.map do |message|
|
|
656
|
-
role = message[:role] || message["role"]
|
|
657
|
-
content = message[:content] || message["content"]
|
|
658
|
-
case role.to_s
|
|
659
|
-
when "compactionSummary"
|
|
660
|
-
{ role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
|
|
661
|
-
when "assistant"
|
|
662
|
-
api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
|
|
663
|
-
when "toolResult"
|
|
664
|
-
api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "toolCallId", :toolCallId, "name", :name, "toolName", :toolName])
|
|
665
|
-
when "tool"
|
|
666
|
-
api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "name", :name])
|
|
667
|
-
when "user"
|
|
668
|
-
api_message(message, role: "user", content: content.is_a?(Array) ? chat_user_content(content) : content, keys: ["name", :name])
|
|
669
|
-
else
|
|
670
|
-
api_message(message, role: role, content: content, keys: ["name", :name])
|
|
671
|
-
end
|
|
672
|
-
end
|
|
673
|
-
end
|
|
674
|
-
|
|
675
|
-
def api_message(message, role:, content:, keys: [])
|
|
676
|
-
result = { role: role, content: content }
|
|
677
|
-
keys.each_slice(2) do |string_key, symbol_key|
|
|
678
|
-
value = message[string_key] || message[symbol_key]
|
|
679
|
-
next if value.nil?
|
|
680
|
-
|
|
681
|
-
target_key = case string_key.to_s
|
|
682
|
-
when "toolCallId" then :tool_call_id
|
|
683
|
-
when "toolName" then :name
|
|
684
|
-
else string_key.to_sym
|
|
685
|
-
end
|
|
686
|
-
result[target_key] = value
|
|
687
|
-
end
|
|
688
|
-
result
|
|
689
|
-
end
|
|
690
|
-
|
|
691
|
-
def chat_user_content(content)
|
|
692
|
-
content.filter_map do |part|
|
|
693
|
-
type = part[:type] || part["type"]
|
|
694
|
-
if type == "text"
|
|
695
|
-
{ type: "text", text: part[:text] || part["text"] || "" }
|
|
696
|
-
elsif type == "image"
|
|
697
|
-
{ type: "image_url", image_url: { url: ImageAttachments.data_url(part) } }
|
|
698
|
-
end
|
|
699
|
-
end
|
|
700
|
-
end
|
|
701
|
-
|
|
702
|
-
def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
703
|
-
parts = build_context_parts("Codex", messages, tools, model: model)
|
|
704
|
-
payload = {
|
|
705
|
-
model: parts[:model],
|
|
706
|
-
instructions: parts[:instructions],
|
|
707
|
-
input: parts[:input],
|
|
708
|
-
tools: parts[:tools],
|
|
709
|
-
tool_choice: "auto",
|
|
710
|
-
parallel_tool_calls: false,
|
|
711
|
-
stream: true,
|
|
712
|
-
store: false,
|
|
713
|
-
include: []
|
|
714
|
-
}
|
|
715
|
-
payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
|
|
716
|
-
payload
|
|
717
|
-
end
|
|
718
|
-
|
|
719
|
-
def build_context_parts(provider, messages, tools, model: nil)
|
|
720
|
-
if provider == "CopilotResponses"
|
|
721
|
-
instructions, input = codex_messages(messages)
|
|
722
|
-
{
|
|
723
|
-
provider: provider,
|
|
724
|
-
model: model_for("Copilot", override_model: model),
|
|
725
|
-
instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
|
|
726
|
-
input: input,
|
|
727
|
-
tools: tools.map { |tool| codex_tool_schema(tool) }
|
|
728
|
-
}
|
|
729
|
-
elsif provider == "Codex"
|
|
730
|
-
instructions, input = codex_messages(messages)
|
|
731
|
-
{
|
|
732
|
-
provider: provider,
|
|
733
|
-
model: model_for(provider, override_model: model),
|
|
734
|
-
instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
|
|
735
|
-
input: input,
|
|
736
|
-
tools: tools.map { |tool| codex_tool_schema(tool) }
|
|
737
|
-
}
|
|
738
|
-
else
|
|
739
|
-
{
|
|
740
|
-
provider: provider,
|
|
741
|
-
model: model_for(provider, override_model: model),
|
|
742
|
-
messages: chat_messages(messages),
|
|
743
|
-
tools: tools
|
|
744
|
-
}
|
|
745
|
-
end
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
def codex_messages(messages)
|
|
749
|
-
instructions = []
|
|
750
|
-
input = []
|
|
751
|
-
|
|
752
|
-
messages.each do |message|
|
|
753
|
-
role = message[:role] || message["role"]
|
|
754
|
-
content = message[:content] || message["content"] || ""
|
|
755
|
-
case role.to_s
|
|
756
|
-
when "system"
|
|
757
|
-
instructions << plain_content(content).to_s
|
|
758
|
-
when "tool", "toolResult"
|
|
759
|
-
input << {
|
|
760
|
-
type: "function_call_output",
|
|
761
|
-
call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
|
|
762
|
-
output: plain_content(content).to_s
|
|
763
|
-
}
|
|
764
|
-
when "assistant"
|
|
765
|
-
content = plain_content(content)
|
|
766
|
-
input << codex_message("assistant", content.to_s) unless content.to_s.empty?
|
|
767
|
-
(message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
|
|
768
|
-
function = tool_call[:function] || tool_call["function"] || {}
|
|
769
|
-
input << {
|
|
770
|
-
type: "function_call",
|
|
771
|
-
call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
|
|
772
|
-
name: function[:name] || function["name"],
|
|
773
|
-
arguments: function[:arguments] || function["arguments"] || "{}"
|
|
774
|
-
}
|
|
775
|
-
end
|
|
776
|
-
when "compactionSummary"
|
|
777
|
-
summary = message[:summary] || message["summary"] || content
|
|
778
|
-
input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
|
|
779
|
-
else
|
|
780
|
-
input << codex_user_message(content)
|
|
781
|
-
end
|
|
782
|
-
end
|
|
783
|
-
|
|
784
|
-
[instructions.join("\n\n"), input]
|
|
785
|
-
end
|
|
786
|
-
|
|
787
|
-
def codex_user_message(content)
|
|
788
|
-
return codex_message("user", content.to_s) unless content.is_a?(Array)
|
|
789
|
-
|
|
790
|
-
parts = content.filter_map do |part|
|
|
791
|
-
type = part[:type] || part["type"]
|
|
792
|
-
if type == "text"
|
|
793
|
-
{ type: "input_text", text: part[:text] || part["text"] || "" }
|
|
794
|
-
elsif type == "image"
|
|
795
|
-
{ type: "input_image", image_url: ImageAttachments.data_url(part) }
|
|
796
|
-
end
|
|
797
|
-
end
|
|
798
|
-
{ type: "message", role: "user", content: parts }
|
|
799
|
-
end
|
|
800
|
-
|
|
801
|
-
def codex_message(role, text)
|
|
802
|
-
type = role == "assistant" ? "output_text" : "input_text"
|
|
803
|
-
{ type: "message", role: role, content: [{ type: type, text: text }] }
|
|
804
|
-
end
|
|
805
|
-
|
|
806
|
-
def plain_content(content)
|
|
807
|
-
return content unless content.is_a?(Array)
|
|
808
|
-
|
|
809
|
-
content.filter_map do |part|
|
|
810
|
-
type = part[:type] || part["type"]
|
|
811
|
-
part[:text] || part["text"] if type == "text"
|
|
812
|
-
end.join
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
def codex_tool_schema(tool)
|
|
816
|
-
function = tool[:function] || tool["function"] || {}
|
|
817
|
-
{
|
|
818
|
-
type: "function",
|
|
819
|
-
name: function[:name] || function["name"],
|
|
820
|
-
description: function[:description] || function["description"] || "",
|
|
821
|
-
parameters: function[:parameters] || function["parameters"] || {},
|
|
822
|
-
strict: false
|
|
823
|
-
}
|
|
824
|
-
end
|
|
825
|
-
|
|
826
633
|
def model_for(provider, override_model: nil)
|
|
827
634
|
ModelInfo.model_for(provider, config: @config, override_model: override_model || @model)
|
|
828
635
|
end
|
|
@@ -107,25 +107,25 @@ module Kward
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def provider_config_value(provider)
|
|
110
|
-
case provider.to_s
|
|
111
|
-
when "
|
|
112
|
-
when "
|
|
110
|
+
case provider.to_s.downcase
|
|
111
|
+
when "openrouter" then "openrouter"
|
|
112
|
+
when "copilot" then "copilot"
|
|
113
113
|
else "codex"
|
|
114
114
|
end
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
def config_key_for_provider(provider)
|
|
118
|
-
case provider.to_s
|
|
119
|
-
when "
|
|
120
|
-
when "
|
|
118
|
+
case provider.to_s.downcase
|
|
119
|
+
when "openrouter" then "openrouter_model"
|
|
120
|
+
when "copilot" then "copilot_model"
|
|
121
121
|
else "openai_model"
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
|
|
125
125
|
def reasoning_config_key_for_provider(provider)
|
|
126
|
-
case provider.to_s
|
|
127
|
-
when "
|
|
128
|
-
when "
|
|
126
|
+
case provider.to_s.downcase
|
|
127
|
+
when "openrouter" then "openrouter_reasoning_effort"
|
|
128
|
+
when "copilot" then "copilot_reasoning_effort"
|
|
129
129
|
else "openai_reasoning_effort"
|
|
130
130
|
end
|
|
131
131
|
end
|
|
@@ -166,7 +166,6 @@ module Kward
|
|
|
166
166
|
provider: provider,
|
|
167
167
|
id: id,
|
|
168
168
|
name: model["name"] || id,
|
|
169
|
-
model: model["model"] || id,
|
|
170
169
|
reasoning: reasoning,
|
|
171
170
|
reasoningEffort: reasoning_effort,
|
|
172
171
|
contextWindow: model["contextWindow"] || model["context_window"] || context_window(provider, id),
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
require_relative "../image_attachments"
|
|
2
|
+
require_relative "model_info"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
module ModelPayloads
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
9
|
+
parts = build_context_parts(provider, messages, tools, model: model)
|
|
10
|
+
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
11
|
+
payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
12
|
+
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
13
|
+
payload
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_image_support!(provider, model, messages)
|
|
17
|
+
return if ModelInfo.supports_images?(provider, model)
|
|
18
|
+
return unless messages_include_images?(messages)
|
|
19
|
+
|
|
20
|
+
raise "Model '#{model}' does not support image inputs. Switch to a vision-capable model or remove the image attachment."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def messages_include_images?(messages)
|
|
24
|
+
messages.any? do |message|
|
|
25
|
+
content = message[:content] || message["content"]
|
|
26
|
+
content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def chat_messages(messages)
|
|
31
|
+
messages.map do |message|
|
|
32
|
+
role = message[:role] || message["role"]
|
|
33
|
+
content = message[:content] || message["content"]
|
|
34
|
+
case role.to_s
|
|
35
|
+
when "compactionSummary"
|
|
36
|
+
{ role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
|
|
37
|
+
when "assistant"
|
|
38
|
+
api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
|
|
39
|
+
when "toolResult"
|
|
40
|
+
api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "toolCallId", :toolCallId, "name", :name, "toolName", :toolName])
|
|
41
|
+
when "tool"
|
|
42
|
+
api_message(message, role: "tool", content: plain_content(content).to_s, keys: ["tool_call_id", :tool_call_id, "name", :name])
|
|
43
|
+
when "user"
|
|
44
|
+
api_message(message, role: "user", content: content.is_a?(Array) ? chat_user_content(content) : content, keys: ["name", :name])
|
|
45
|
+
else
|
|
46
|
+
api_message(message, role: role, content: content, keys: ["name", :name])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def api_message(message, role:, content:, keys: [])
|
|
52
|
+
result = { role: role, content: content }
|
|
53
|
+
keys.each_slice(2) do |string_key, symbol_key|
|
|
54
|
+
value = message[string_key] || message[symbol_key]
|
|
55
|
+
next if value.nil?
|
|
56
|
+
|
|
57
|
+
target_key = case string_key.to_s
|
|
58
|
+
when "toolCallId" then :tool_call_id
|
|
59
|
+
when "toolName" then :name
|
|
60
|
+
else string_key.to_sym
|
|
61
|
+
end
|
|
62
|
+
result[target_key] = value
|
|
63
|
+
end
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def chat_user_content(content)
|
|
68
|
+
content.filter_map do |part|
|
|
69
|
+
type = part[:type] || part["type"]
|
|
70
|
+
if type == "text"
|
|
71
|
+
{ type: "text", text: part[:text] || part["text"] || "" }
|
|
72
|
+
elsif type == "image"
|
|
73
|
+
{ type: "image_url", image_url: { url: ImageAttachments.data_url(part) } }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
|
|
79
|
+
parts = build_context_parts("Codex", messages, tools, model: model)
|
|
80
|
+
payload = {
|
|
81
|
+
model: parts[:model],
|
|
82
|
+
instructions: parts[:instructions],
|
|
83
|
+
input: parts[:input],
|
|
84
|
+
tools: parts[:tools],
|
|
85
|
+
tool_choice: "auto",
|
|
86
|
+
parallel_tool_calls: false,
|
|
87
|
+
stream: true,
|
|
88
|
+
store: false,
|
|
89
|
+
include: []
|
|
90
|
+
}
|
|
91
|
+
payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
|
|
92
|
+
payload
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_context_parts(provider, messages, tools, model: nil)
|
|
96
|
+
if provider == "CopilotResponses"
|
|
97
|
+
instructions, input = codex_messages(messages)
|
|
98
|
+
{
|
|
99
|
+
provider: provider,
|
|
100
|
+
model: model_for("Copilot", override_model: model),
|
|
101
|
+
instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
|
|
102
|
+
input: input,
|
|
103
|
+
tools: tools.map { |tool| codex_tool_schema(tool) }
|
|
104
|
+
}
|
|
105
|
+
elsif provider == "Codex"
|
|
106
|
+
instructions, input = codex_messages(messages)
|
|
107
|
+
{
|
|
108
|
+
provider: provider,
|
|
109
|
+
model: model_for(provider, override_model: model),
|
|
110
|
+
instructions: instructions.empty? ? "You are a helpful assistant." : instructions,
|
|
111
|
+
input: input,
|
|
112
|
+
tools: tools.map { |tool| codex_tool_schema(tool) }
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
{
|
|
116
|
+
provider: provider,
|
|
117
|
+
model: model_for(provider, override_model: model),
|
|
118
|
+
messages: chat_messages(messages),
|
|
119
|
+
tools: tools
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def codex_messages(messages)
|
|
125
|
+
instructions = []
|
|
126
|
+
input = []
|
|
127
|
+
|
|
128
|
+
messages.each do |message|
|
|
129
|
+
role = message[:role] || message["role"]
|
|
130
|
+
content = message[:content] || message["content"] || ""
|
|
131
|
+
case role.to_s
|
|
132
|
+
when "system"
|
|
133
|
+
instructions << plain_content(content).to_s
|
|
134
|
+
when "tool", "toolResult"
|
|
135
|
+
input << {
|
|
136
|
+
type: "function_call_output",
|
|
137
|
+
call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
|
|
138
|
+
output: plain_content(content).to_s
|
|
139
|
+
}
|
|
140
|
+
when "assistant"
|
|
141
|
+
content = plain_content(content)
|
|
142
|
+
input << codex_message("assistant", content.to_s) unless content.to_s.empty?
|
|
143
|
+
(message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
|
|
144
|
+
function = tool_call[:function] || tool_call["function"] || {}
|
|
145
|
+
input << {
|
|
146
|
+
type: "function_call",
|
|
147
|
+
call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
|
|
148
|
+
name: function[:name] || function["name"],
|
|
149
|
+
arguments: function[:arguments] || function["arguments"] || "{}"
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
when "compactionSummary"
|
|
153
|
+
summary = message[:summary] || message["summary"] || content
|
|
154
|
+
input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
|
|
155
|
+
else
|
|
156
|
+
input << codex_user_message(content)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
[instructions.join("\n\n"), input]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def codex_user_message(content)
|
|
164
|
+
return codex_message("user", content.to_s) unless content.is_a?(Array)
|
|
165
|
+
|
|
166
|
+
parts = content.filter_map do |part|
|
|
167
|
+
type = part[:type] || part["type"]
|
|
168
|
+
if type == "text"
|
|
169
|
+
{ type: "input_text", text: part[:text] || part["text"] || "" }
|
|
170
|
+
elsif type == "image"
|
|
171
|
+
{ type: "input_image", image_url: ImageAttachments.data_url(part) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
{ type: "message", role: "user", content: parts }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def codex_message(role, text)
|
|
178
|
+
type = role == "assistant" ? "output_text" : "input_text"
|
|
179
|
+
{ type: "message", role: role, content: [{ type: type, text: text }] }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def plain_content(content)
|
|
183
|
+
return content unless content.is_a?(Array)
|
|
184
|
+
|
|
185
|
+
content.filter_map do |part|
|
|
186
|
+
type = part[:type] || part["type"]
|
|
187
|
+
part[:text] || part["text"] if type == "text"
|
|
188
|
+
end.join
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def codex_tool_schema(tool)
|
|
192
|
+
function = tool[:function] || tool["function"] || {}
|
|
193
|
+
{
|
|
194
|
+
type: "function",
|
|
195
|
+
name: function[:name] || function["name"],
|
|
196
|
+
description: function[:description] || function["description"] || "",
|
|
197
|
+
parameters: function[:parameters] || function["parameters"] || {},
|
|
198
|
+
strict: false
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require_relative "../ansi"
|
|
2
|
+
require_relative "../resources/avatar_kward_logo"
|
|
3
|
+
require_relative "../resources/pixel_logo"
|
|
4
|
+
|
|
5
|
+
module Kward
|
|
6
|
+
class PromptInterface
|
|
7
|
+
class Banner
|
|
8
|
+
LOGO_WIDTH = 32
|
|
9
|
+
LOGO_PIXEL_HEIGHT = 32
|
|
10
|
+
MIN_LOGO_HEIGHT = 4
|
|
11
|
+
LOGO_PIXELS = Kward::Resources::AvatarKwardLogo::PIXELS
|
|
12
|
+
MESSAGE = "State your business.".freeze
|
|
13
|
+
|
|
14
|
+
def initialize(message:, pixels:, screen_height:, minimum_composer_rows: 3)
|
|
15
|
+
@message = message.to_s
|
|
16
|
+
@pixels = pixels
|
|
17
|
+
@screen_height = screen_height
|
|
18
|
+
@minimum_composer_rows = minimum_composer_rows
|
|
19
|
+
@logo_cache = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rows(width)
|
|
23
|
+
return [] unless visible?(width)
|
|
24
|
+
|
|
25
|
+
rows = []
|
|
26
|
+
rows.concat(centered_image_rows(width)) if image_visible?(width)
|
|
27
|
+
rows << align_plain_row(@message, width) unless @message.empty?
|
|
28
|
+
rows << ""
|
|
29
|
+
rows
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def logo_rows(width)
|
|
33
|
+
logo_width, logo_height = logo_dimensions(width)
|
|
34
|
+
return [] unless @pixels && max_logo_height >= MIN_LOGO_HEIGHT
|
|
35
|
+
|
|
36
|
+
key = [logo_width, logo_height]
|
|
37
|
+
@logo_cache[key] ||= Kward::PixelLogo.half_block_rows_from_pixels(@pixels, width: logo_width, pixel_height: logo_height)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def visible?(width)
|
|
43
|
+
!@message.empty? || image_visible?(width)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def image_visible?(width)
|
|
47
|
+
!logo_rows(width).empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def centered_image_rows(width)
|
|
51
|
+
logo_width, = logo_dimensions(width)
|
|
52
|
+
padding = [[(width - logo_width) / 2, 0].max, width - 1].min
|
|
53
|
+
logo_rows(width).map { |row| (" " * padding) + row }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def logo_dimensions(width)
|
|
57
|
+
logo_width = [LOGO_WIDTH, [width - 2, 1].max].min
|
|
58
|
+
logo_height = [LOGO_PIXEL_HEIGHT, max_logo_height * 2].min
|
|
59
|
+
[logo_width, logo_height]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def max_logo_height
|
|
63
|
+
message_rows = @message.empty? ? 0 : 1
|
|
64
|
+
blank_after_banner = 1
|
|
65
|
+
transcript_row = 1
|
|
66
|
+
reserved_rows = message_rows + blank_after_banner + @minimum_composer_rows + transcript_row
|
|
67
|
+
[@screen_height.call - reserved_rows, 0].max
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def align_plain_row(text, width)
|
|
71
|
+
plain_length = ANSI.strip(text).length
|
|
72
|
+
padding = [width - plain_length, 0].max / 2
|
|
73
|
+
(" " * padding) + text.to_s
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|