kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -0,0 +1,68 @@
1
+ require_relative "../message_access"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # JSON-RPC backend namespace used by UI clients.
6
+ module RPC
7
+ # Computes conversation metrics and context usage for RPC runtime payloads.
8
+ class SessionMetrics
9
+ def initialize(context_usage:)
10
+ @context_usage = context_usage
11
+ end
12
+
13
+ def message_count(conversation)
14
+ conversation.messages.count { |message| MessageAccess.role(message) != "system" }
15
+ end
16
+
17
+ def message_stats(conversation)
18
+ conversation.messages.each_with_object(default_message_stats) do |message, counts|
19
+ role = MessageAccess.role(message)
20
+ next if role == "system"
21
+
22
+ counts[:totalMessages] += 1
23
+ case role
24
+ when "user"
25
+ counts[:userMessages] += 1
26
+ when "assistant"
27
+ counts[:assistantMessages] += 1
28
+ counts[:toolCalls] += MessageAccess.tool_calls(message).length
29
+ when "tool", "toolResult"
30
+ counts[:toolResults] += 1
31
+ end
32
+ end
33
+ end
34
+
35
+ def context_usage(rpc_session, model, client:)
36
+ context_parts = if client.respond_to?(:current_context_parts)
37
+ client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
38
+ else
39
+ {
40
+ provider: model[:provider],
41
+ model: model[:id],
42
+ messages: rpc_session.conversation.messages,
43
+ tools: rpc_session.tool_registry.schemas
44
+ }
45
+ end
46
+
47
+ @context_usage.call(
48
+ provider: model[:provider],
49
+ model: model[:id],
50
+ context_window: model[:contextWindow],
51
+ context_parts: context_parts
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def default_message_stats
58
+ {
59
+ userMessages: 0,
60
+ assistantMessages: 0,
61
+ toolCalls: 0,
62
+ toolResults: 0,
63
+ totalMessages: 0
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,48 @@
1
+ require_relative "../message_access"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # JSON-RPC backend namespace used by UI clients.
6
+ module RPC
7
+ # Helper for resolving and selecting persisted session tree entries.
8
+ class SessionTree
9
+ def initialize(rpc_session)
10
+ @rpc_session = rpc_session
11
+ end
12
+
13
+ def entries
14
+ @rpc_session.store.session_entries(@rpc_session.session.path)
15
+ end
16
+
17
+ def resolve_entry_id(entry_id, entries: self.entries)
18
+ id = entry_id.to_s
19
+ return id if entries.any? { |record| record["id"].to_s == id }
20
+
21
+ match = id.match(/\Amessage:(\d+)\z/)
22
+ return entries[match[1].to_i]&.dig("id") if match
23
+
24
+ id
25
+ end
26
+
27
+ def active_path_ids(entries, leaf_id)
28
+ by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
29
+ ids = []
30
+ current = by_id[leaf_id.to_s]
31
+ while current
32
+ ids << current["id"].to_s
33
+ current = by_id[current["parentId"].to_s]
34
+ end
35
+ ids
36
+ end
37
+
38
+ def user_entry?(entry)
39
+ message = entry["message"]
40
+ message.is_a?(Hash) && MessageAccess.role(message) == "user"
41
+ end
42
+
43
+ def selectable_entry?(entry)
44
+ !entry["id"].to_s.empty? && ["message", "compaction", "branch_summary"].include?(entry["type"])
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,208 @@
1
+ require_relative "../message_access"
2
+ require_relative "../message_text"
3
+ require_relative "../session_tree_tool_display"
4
+ require_relative "../tools/tool_call"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # JSON-RPC backend namespace used by UI clients.
9
+ module RPC
10
+ # Builds frontend-neutral RPC row payloads from a persisted session tree.
11
+ #
12
+ # `SessionManager` owns session lifecycle and decides when a tree is needed;
13
+ # this class owns only the mechanics of flattening tree nodes into the row
14
+ # fields sent over JSON-RPC. Keeping row presentation here prevents the RPC
15
+ # session coordinator from accumulating rendering details while preserving the
16
+ # exact Tauren-compatible payload shape.
17
+ class SessionTreeRows
18
+ # @param roots [Array<Hash>] tree roots returned by `SessionStore#session_tree`
19
+ # @param current_leaf [String, nil] active persisted tree leaf id
20
+ # @param selectable [#call] predicate receiving an entry hash
21
+ def initialize(roots:, current_leaf:, selectable:)
22
+ @roots = roots
23
+ @current_leaf = current_leaf
24
+ @selectable = selectable
25
+ end
26
+
27
+ # Returns flattened RPC row hashes in active-path-first display order.
28
+ #
29
+ # @return [Array<Hash>] rows for the `session/tree` RPC method
30
+ def rows
31
+ active_path = tree_active_path(@roots, @current_leaf)
32
+ tool_calls_by_id = tree_tool_calls(@roots)
33
+ visible_roots = @roots.flat_map { |root| visible_tree_nodes(root) }
34
+ multiple_roots = visible_roots.length > 1
35
+ result = []
36
+
37
+ walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
38
+ entry = node[:source]["entry"] || {}
39
+ entry_id = entry["id"].to_s
40
+ formatted = tree_entry_display(entry, tool_calls_by_id)
41
+ display_indent = multiple_roots ? [indent - 1, 0].max : indent
42
+ result << {
43
+ entryId: entry_id,
44
+ parentId: entry["parentId"],
45
+ role: formatted[:role],
46
+ text: formatted[:text],
47
+ current: !@current_leaf.to_s.empty? && entry_id == @current_leaf.to_s,
48
+ depth: display_indent,
49
+ isLast: is_last,
50
+ ancestorContinues: gutters.map { |gutter| gutter[:show] },
51
+ activePath: active_path.include?(entry_id),
52
+ selectable: @selectable.call(entry),
53
+ label: node[:source]["label"] || entry["resolvedLabel"],
54
+ labelTimestamp: node[:source]["labelTimestamp"],
55
+ prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
56
+ }.compact
57
+
58
+ children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
59
+ multiple_children = children.length > 1
60
+ child_indent = if multiple_children
61
+ indent + 1
62
+ elsif just_branched && indent.positive?
63
+ indent + 1
64
+ else
65
+ indent
66
+ end
67
+ connector_position = [display_indent - 1, 0].max
68
+ child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
69
+ children.each_with_index do |child, index|
70
+ walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
71
+ end
72
+ end
73
+
74
+ visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
75
+ walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
76
+ end
77
+ result
78
+ end
79
+
80
+ private
81
+
82
+ def tree_active_path(roots, leaf_id)
83
+ by_id = tree_entries_by_id(roots)
84
+ ids = []
85
+ current = by_id[leaf_id.to_s]
86
+ while current
87
+ ids << current["id"].to_s
88
+ current = by_id[current["parentId"].to_s]
89
+ end
90
+ ids
91
+ end
92
+
93
+ def tree_entries_by_id(roots)
94
+ roots.each_with_object({}) do |root, map|
95
+ stack = [root]
96
+ until stack.empty?
97
+ node = stack.pop
98
+ entry = node["entry"] || {}
99
+ map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
100
+ stack.concat(Array(node["children"]))
101
+ end
102
+ end
103
+ end
104
+
105
+ def visible_tree_nodes(node)
106
+ children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child) }
107
+ return children if hidden_tree_entry?(node["entry"] || {})
108
+
109
+ [{ source: node, children: children }]
110
+ end
111
+
112
+ def hidden_tree_entry?(entry)
113
+ return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
114
+ return false unless entry["type"] == "message"
115
+
116
+ message = entry["message"]
117
+ return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
118
+
119
+ content = MessageAccess.content(message)
120
+ content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
121
+ (content_tool_calls && !tree_text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
122
+ end
123
+
124
+ def tree_text_content?(content)
125
+ Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
126
+ end
127
+
128
+ def tree_contains_active_path?(node, active_path)
129
+ entry_id = (node[:source]["entry"] || {})["id"].to_s
130
+ active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
131
+ end
132
+
133
+ def tree_tool_calls(roots)
134
+ roots.each_with_object({}) do |root, tool_calls_by_id|
135
+ stack = [root]
136
+ until stack.empty?
137
+ node = stack.pop
138
+ entry = node["entry"] || {}
139
+ message = entry["message"]
140
+ if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
141
+ MessageAccess.tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
142
+ end
143
+ stack.concat(Array(node["children"]))
144
+ end
145
+ end
146
+ end
147
+
148
+ def tree_entry_display(entry, tool_calls_by_id = {})
149
+ case entry["type"]
150
+ when "message"
151
+ message = entry["message"] || {}
152
+ role = MessageAccess.role(message).to_s
153
+ return { role: "tool", text: format_tool_result(message, tool_calls_by_id) } if ["tool", "toolResult"].include?(role)
154
+ return { role: role.empty? ? "message" : role, text: display_message_text(message) }
155
+ when "compaction"
156
+ return { role: "compaction", text: display_message_text(entry["message"] || {}) }
157
+ when "branch_summary"
158
+ return { role: "summary", text: truncate_tree_text(entry["summary"]) }
159
+ end
160
+
161
+ { role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
162
+ end
163
+
164
+ def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
165
+ return "" if display_indent.to_i <= 0
166
+
167
+ connector_position = show_connector ? display_indent - 1 : -1
168
+ (0...(display_indent * 3)).map do |index|
169
+ level = index / 3
170
+ position = index % 3
171
+ gutter = gutters.find { |candidate| candidate[:position] == level }
172
+
173
+ if gutter
174
+ position.zero? && gutter[:show] ? "│" : " "
175
+ elsif show_connector && level == connector_position
176
+ if position.zero?
177
+ is_last ? "└" : "├"
178
+ elsif position == 1
179
+ foldable ? "⊟" : "─"
180
+ else
181
+ " "
182
+ end
183
+ else
184
+ " "
185
+ end
186
+ end.join
187
+ end
188
+
189
+ def format_tool_result(message, tool_calls_by_id)
190
+ tool_call = tool_calls_by_id[MessageAccess.tool_call_id(message).to_s]
191
+ return SessionTreeToolDisplay.label(tool_call) if tool_call
192
+
193
+ name = MessageAccess.tool_name(message).to_s
194
+ name = "tool" if name.empty?
195
+ "[#{name}]"
196
+ end
197
+
198
+ def display_message_text(message)
199
+ truncate_tree_text(MessageText.full_text(message))
200
+ end
201
+
202
+ def truncate_tree_text(text)
203
+ normalized = text.to_s.gsub(/\s+/, " ").strip
204
+ normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
205
+ end
206
+ end
207
+ end
208
+ end
@@ -1,8 +1,11 @@
1
1
  require "time"
2
2
  require_relative "tool_metadata"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
5
7
  module RPC
8
+ # Converts tool calls and results into RPC event payloads.
6
9
  class ToolEventNormalizer
7
10
  def initialize(tool_call, content: nil)
8
11
  @tool_call = tool_call
@@ -1,8 +1,11 @@
1
1
  require_relative "../tools/tool_call"
2
2
  require_relative "../workspace"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
5
7
  module RPC
8
+ # Builds compact metadata for RPC tool-call display.
6
9
  module ToolMetadata
7
10
  module_function
8
11
 
@@ -1,8 +1,11 @@
1
1
  require_relative "../tools/tool_call"
2
2
  require_relative "tool_metadata"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
5
7
  module RPC
8
+ # Normalizes Kward transcript messages into Tauren-compatible RPC payloads.
6
9
  class TranscriptNormalizer
7
10
  IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
8
11
  THINKING_CONTENT_TYPES = ["thinking", "reasoning"].freeze
@@ -1,7 +1,10 @@
1
1
  require "json"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # JSON-RPC backend namespace used by UI clients.
4
6
  module RPC
7
+ # Line-delimited JSON-RPC transport over input and output streams.
5
8
  class Transport
6
9
  def initialize(input:, output:)
7
10
  @input = input
@@ -1,6 +1,8 @@
1
1
  require "json"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Counts unified-diff additions and deletions for summaries.
4
6
  class SessionDiff
5
7
  attr_reader :additions, :deletions
6
8