kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -7,15 +7,15 @@ module Kward
7
7
  # Tool wrapper for guarded full-file writes.
8
8
  class WriteFile < Base
9
9
  # Builds the tool schema and stores the execution dependency.
10
- def initialize(workspace:)
11
- @workspace = workspace
10
+ def initialize(workspace:, workspace_targets: nil)
11
+ configure_workspace_targets(workspace, workspace_targets)
12
12
  super(
13
13
  "write_file",
14
14
  "Write a workspace file. Existing files must be read first.",
15
- properties: {
15
+ properties: targeted_properties(
16
16
  path: { type: "string", description: "Workspace-relative path." },
17
17
  content: { type: "string", description: "Complete file content." }
18
- },
18
+ ),
19
19
  required: ["path", "content"]
20
20
  )
21
21
  end
@@ -25,8 +25,11 @@ module Kward
25
25
  path = argument(args, :path, "")
26
26
  content = argument(args, :content, "")
27
27
 
28
- result = @workspace.write_file(path, content, read_paths: conversation.read_paths)
29
- conversation.refresh_system_message! if agents_file_changed?(@workspace, path, result)
28
+ workspace = workspace_for(args)
29
+ result = workspace.write_file(path, content, read_paths: conversation.read_paths)
30
+ if workspace.equal?(@workspace) && agents_file_changed?(workspace, path, result)
31
+ conversation.refresh_system_message!
32
+ end
30
33
  result
31
34
  end
32
35
  end
@@ -1,5 +1,5 @@
1
1
  require "pathname"
2
- require_relative "path_guard"
2
+ require_relative "../workspace/path_guard"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -1,5 +1,5 @@
1
- require_relative "message_access"
2
- require_relative "message_text"
1
+ require_relative "../message_access"
2
+ require_relative "../message_text"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -0,0 +1,200 @@
1
+ # Frontend-neutral external transport contracts.
2
+ require "logger"
3
+ require_relative "../deep_copy"
4
+
5
+ # Namespace for frontend-neutral external transport contracts.
6
+ module Kward
7
+ module Transport
8
+ STREAMING_MODES = %i[native edit aggregate none].freeze
9
+
10
+ Actor = Data.define(:id, :display_name, :metadata)
11
+ Attachment = Data.define(:name, :mime_type, :data, :url, :metadata)
12
+ ConversationKey = Data.define(:transport_id, :external_id)
13
+ InboundMessage = Data.define(
14
+ :conversation,
15
+ :message_id,
16
+ :actor,
17
+ :text,
18
+ :attachments,
19
+ :reply_context,
20
+ :idempotency_key,
21
+ :metadata
22
+ )
23
+ TurnEvent = Data.define(:type, :session_id, :turn_id, :sequence, :payload)
24
+ InteractionRequest = Data.define(
25
+ :id,
26
+ :session_id,
27
+ :turn_id,
28
+ :kind,
29
+ :prompt,
30
+ :choices,
31
+ :expires_at,
32
+ :metadata
33
+ )
34
+ Capabilities = Data.define(:inbound, :outbound, :streaming, :limits)
35
+ ExecutionProfile = Data.define(
36
+ :id,
37
+ :tool_mode,
38
+ :allowed_tools,
39
+ :disabled_tools,
40
+ :plugin_commands,
41
+ :approval_mode,
42
+ :memory,
43
+ :attachments,
44
+ :workspace_mode,
45
+ :prompt_context
46
+ )
47
+
48
+ TOOL_MODES = %i[all none allowlist].freeze
49
+ APPROVAL_MODES = %i[default ask deny].freeze
50
+ MEMORY_MODES = %i[default none].freeze
51
+ WORKSPACE_MODES = %i[session fixed].freeze
52
+
53
+ module_function
54
+
55
+ def actor(id:, display_name: nil, metadata: {})
56
+ Actor.new(
57
+ id: required_string(id, "actor id"),
58
+ display_name: optional_string(display_name),
59
+ metadata: frozen_copy(metadata)
60
+ )
61
+ end
62
+
63
+ def attachment(name: nil, mime_type:, data: nil, url: nil, metadata: {})
64
+ raise ArgumentError, "attachment requires data or url" if data.nil? && url.nil?
65
+ raise ArgumentError, "attachment cannot contain both data and url" unless data.nil? || url.nil?
66
+
67
+ Attachment.new(
68
+ name: optional_string(name),
69
+ mime_type: required_string(mime_type, "attachment MIME type"),
70
+ data: data&.dup&.freeze,
71
+ url: url && required_string(url, "attachment URL"),
72
+ metadata: frozen_copy(metadata)
73
+ )
74
+ end
75
+
76
+ def conversation_key(transport_id:, external_id:)
77
+ ConversationKey.new(
78
+ transport_id: required_string(transport_id, "transport id"),
79
+ external_id: required_string(external_id, "external conversation id")
80
+ )
81
+ end
82
+
83
+ def inbound_message(conversation:, message_id:, actor:, text: "", attachments: [], reply_context: {}, idempotency_key:, metadata: {})
84
+ unless conversation.is_a?(ConversationKey)
85
+ raise ArgumentError, "conversation must be a Transport::ConversationKey"
86
+ end
87
+ unless actor.is_a?(Actor)
88
+ raise ArgumentError, "actor must be a Transport::Actor"
89
+ end
90
+
91
+ InboundMessage.new(
92
+ conversation: conversation,
93
+ message_id: required_string(message_id, "message id"),
94
+ actor: actor,
95
+ text: text.to_s.freeze,
96
+ attachments: frozen_copy(Array(attachments)),
97
+ reply_context: frozen_copy(reply_context),
98
+ idempotency_key: required_string(idempotency_key, "idempotency key"),
99
+ metadata: frozen_copy(metadata)
100
+ )
101
+ end
102
+
103
+ def turn_event(type:, session_id:, turn_id:, sequence:, payload: {})
104
+ TurnEvent.new(
105
+ type: required_string(type, "turn event type"),
106
+ session_id: required_string(session_id, "session id"),
107
+ turn_id: required_string(turn_id, "turn id"),
108
+ sequence: Integer(sequence),
109
+ payload: frozen_copy(payload)
110
+ )
111
+ end
112
+
113
+ def interaction_request(id:, session_id:, turn_id:, kind:, prompt:, choices: [], expires_at: nil, metadata: {})
114
+ InteractionRequest.new(
115
+ id: required_string(id, "interaction id"),
116
+ session_id: required_string(session_id, "session id"),
117
+ turn_id: required_string(turn_id, "turn id"),
118
+ kind: required_string(kind, "interaction kind"),
119
+ prompt: required_string(prompt, "interaction prompt"),
120
+ choices: frozen_copy(Array(choices)),
121
+ expires_at: expires_at,
122
+ metadata: frozen_copy(metadata)
123
+ )
124
+ end
125
+
126
+ def execution_profile(
127
+ id:,
128
+ tool_mode: :all,
129
+ allowed_tools: nil,
130
+ disabled_tools: nil,
131
+ plugin_commands: true,
132
+ approval_mode: :default,
133
+ memory: :default,
134
+ attachments: true,
135
+ workspace_mode: :session,
136
+ prompt_context: nil
137
+ )
138
+ id = required_string(id, "execution profile id")
139
+ tool_mode = tool_mode.to_sym
140
+ approval_mode = approval_mode.to_sym
141
+ memory = memory.to_sym
142
+ workspace_mode = workspace_mode.to_sym
143
+ raise ArgumentError, "unsupported tool mode: #{tool_mode}" unless TOOL_MODES.include?(tool_mode)
144
+ raise ArgumentError, "unsupported approval mode: #{approval_mode}" unless APPROVAL_MODES.include?(approval_mode)
145
+ raise ArgumentError, "unsupported memory mode: #{memory}" unless MEMORY_MODES.include?(memory)
146
+ raise ArgumentError, "unsupported workspace mode: #{workspace_mode}" unless WORKSPACE_MODES.include?(workspace_mode)
147
+ raise ArgumentError, "allowlist tool mode requires allowed_tools" if tool_mode == :allowlist && Array(allowed_tools).empty?
148
+ raise ArgumentError, "none tool mode cannot define allowed_tools" if tool_mode == :none && allowed_tools
149
+ raise ArgumentError, "allowed_tools and disabled_tools cannot both be set" if allowed_tools && disabled_tools
150
+
151
+ ExecutionProfile.new(
152
+ id: id,
153
+ tool_mode: tool_mode,
154
+ allowed_tools: frozen_symbols(allowed_tools),
155
+ disabled_tools: frozen_symbols(disabled_tools),
156
+ plugin_commands: !!plugin_commands,
157
+ approval_mode: approval_mode,
158
+ memory: memory,
159
+ attachments: !!attachments,
160
+ workspace_mode: workspace_mode,
161
+ prompt_context: prompt_context.nil? ? nil : prompt_context.to_s.freeze
162
+ )
163
+ end
164
+
165
+ def capabilities(inbound: [], outbound: [], streaming: :none, limits: {})
166
+ streaming = streaming.to_sym
167
+ raise ArgumentError, "unsupported streaming mode: #{streaming}" unless STREAMING_MODES.include?(streaming)
168
+
169
+ Capabilities.new(
170
+ inbound: frozen_symbols(inbound),
171
+ outbound: frozen_symbols(outbound),
172
+ streaming: streaming,
173
+ limits: frozen_copy(limits)
174
+ )
175
+ end
176
+
177
+ def required_string(value, name)
178
+ value = value.to_s
179
+ raise ArgumentError, "#{name} is required" if value.empty?
180
+
181
+ value.freeze
182
+ end
183
+ private_class_method :required_string
184
+
185
+ def optional_string(value)
186
+ value.nil? ? nil : value.to_s.freeze
187
+ end
188
+ private_class_method :optional_string
189
+
190
+ def frozen_symbols(values)
191
+ Array(values).map { |value| value.to_sym }.uniq.freeze
192
+ end
193
+ private_class_method :frozen_symbols
194
+
195
+ def frozen_copy(value)
196
+ DeepCopy.freeze(DeepCopy.dup(value))
197
+ end
198
+ private_class_method :frozen_copy
199
+ end
200
+ end
@@ -13,6 +13,7 @@ module Kward
13
13
  # without changing transport plugins.
14
14
  class Gateway
15
15
  POLL_INTERVAL = 0.05
16
+ INTERACTION_ROUTE_LIMIT = 1_000
16
17
  TERMINAL_STATUSES = %w[completed canceled failed].freeze
17
18
 
18
19
  def initialize(session_manager:, transport_id:, storage: nil, poll_interval: POLL_INTERVAL)
@@ -22,6 +23,7 @@ module Kward
22
23
  @poll_interval = poll_interval
23
24
  @subscriptions = []
24
25
  @interaction_subscribers = []
26
+ @interaction_routes = {}
25
27
  @mutex = Mutex.new
26
28
  @session_manager.subscribe_events { |method, payload| handle_runtime_event(method, payload) } if @session_manager.respond_to?(:subscribe_events)
27
29
  end
@@ -73,19 +75,16 @@ module Kward
73
75
  end
74
76
 
75
77
  def answer_transport_interaction(session_id:, request_id:, answer:)
76
- if answer == true || answer == false
77
- @session_manager.answer_tool_approval(
78
- session_id: session_id,
79
- approval_request_id: request_id,
80
- approved: answer
81
- )
78
+ route = @mutex.synchronize { @interaction_routes.delete(request_id.to_s) }
79
+ case route
80
+ when :plugin_ui
81
+ @session_manager.answer_plugin_ui(session_id: session_id, request_id: request_id, value: answer)
82
+ when :tool_approval
83
+ @session_manager.answer_tool_approval(session_id: session_id, approval_request_id: request_id, approved: answer == true)
84
+ when :question
85
+ answer_transport_question(session_id, request_id, answer)
82
86
  else
83
- answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
84
- @session_manager.answer_question(
85
- session_id: session_id,
86
- question_request_id: request_id,
87
- answers: answers
88
- )
87
+ answer == true || answer == false ? answer_transport_approval(session_id, request_id, answer) : answer_transport_question(session_id, request_id, answer)
89
88
  end
90
89
  end
91
90
 
@@ -122,6 +121,7 @@ module Kward
122
121
  current = @subscriptions
123
122
  @subscriptions = []
124
123
  @interaction_subscribers = []
124
+ @interaction_routes = {}
125
125
  current
126
126
  end
127
127
  subscriptions.each(&:kill)
@@ -168,31 +168,17 @@ module Kward
168
168
  end
169
169
 
170
170
  def handle_runtime_event(method, payload)
171
- request = case method
172
- when "ui/question"
173
- questions = Array(payload[:questions] || payload["questions"])
174
- Transport.interaction_request(
175
- id: payload[:questionRequestId] || payload["questionRequestId"],
176
- session_id: payload[:sessionId] || payload["sessionId"],
177
- turn_id: payload[:turnId] || payload["turnId"] || "unknown",
178
- kind: :question,
179
- prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
180
- choices: questions
181
- )
182
- when "tool/approvalRequested"
183
- Transport.interaction_request(
184
- id: payload[:approvalRequestId] || payload["approvalRequestId"],
185
- session_id: payload[:sessionId] || payload["sessionId"],
186
- turn_id: payload[:turnId] || payload["turnId"] || "unknown",
187
- kind: :tool_approval,
188
- prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
189
- choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
190
- metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
191
- )
192
- end
171
+ request, route = interaction_for_runtime_event(method, payload)
193
172
  return unless request
194
173
 
195
- subscribers = @mutex.synchronize { @interaction_subscribers.dup }
174
+ subscribers = @mutex.synchronize do
175
+ current = @interaction_subscribers.dup
176
+ if route && !current.empty?
177
+ @interaction_routes[request.id.to_s] = route
178
+ @interaction_routes.shift while @interaction_routes.length > INTERACTION_ROUTE_LIMIT
179
+ end
180
+ current
181
+ end
196
182
  subscribers.each do |subscriber|
197
183
  begin
198
184
  subscriber.call(request)
@@ -202,6 +188,64 @@ module Kward
202
188
  end
203
189
  end
204
190
 
191
+ def interaction_for_runtime_event(method, payload)
192
+ session_id = payload[:sessionId] || payload["sessionId"]
193
+ turn_id = payload[:turnId] || payload["turnId"] || "unknown"
194
+ case method
195
+ when "ui/question"
196
+ questions = Array(payload[:questions] || payload["questions"])
197
+ [Transport.interaction_request(
198
+ id: payload[:questionRequestId] || payload["questionRequestId"],
199
+ session_id: session_id,
200
+ turn_id: turn_id,
201
+ kind: :question,
202
+ prompt: questions.map { |question| question[:question] || question["question"] }.join("\\n"),
203
+ choices: questions
204
+ ), :question]
205
+ when "ui/request"
206
+ plugin_ui_interaction(payload, session_id, turn_id)
207
+ when "tool/approvalRequested"
208
+ [Transport.interaction_request(
209
+ id: payload[:approvalRequestId] || payload["approvalRequestId"],
210
+ session_id: session_id,
211
+ turn_id: turn_id,
212
+ kind: :tool_approval,
213
+ prompt: "Allow #{payload[:toolName] || payload["toolName"]}?",
214
+ choices: [{ id: "approve", label: "Approve" }, { id: "deny", label: "Deny" }],
215
+ metadata: { tool_call_id: payload[:toolCallId] || payload["toolCallId"], args: payload[:args] || payload["args"] }
216
+ ), :tool_approval]
217
+ end
218
+ end
219
+
220
+ def plugin_ui_interaction(payload, session_id, turn_id)
221
+ kind = (payload[:kind] || payload["kind"]).to_s
222
+ details = payload[:payload] || payload["payload"] || {}
223
+ prompt = details[:message] || details["message"] || details[:title] || details["title"]
224
+ choices = details[:options] || details["options"] || []
225
+ if kind == "confirm"
226
+ choices = [{ label: "Yes", value: true }, { label: "No", value: false }]
227
+ end
228
+ default = details.key?(:default) ? details[:default] : details["default"]
229
+ [Transport.interaction_request(
230
+ id: payload[:requestId] || payload["requestId"],
231
+ session_id: session_id,
232
+ turn_id: turn_id,
233
+ kind: kind,
234
+ prompt: prompt,
235
+ choices: choices,
236
+ metadata: { title: details[:title] || details["title"], placeholder: details[:placeholder] || details["placeholder"], default: default }.compact
237
+ ), :plugin_ui]
238
+ end
239
+
240
+ def answer_transport_approval(session_id, request_id, answer)
241
+ @session_manager.answer_tool_approval(session_id: session_id, approval_request_id: request_id, approved: answer)
242
+ end
243
+
244
+ def answer_transport_question(session_id, request_id, answer)
245
+ answers = answer.is_a?(Array) ? answer : [{ question: request_id, answer: answer.to_s }]
246
+ @session_manager.answer_question(session_id: session_id, question_request_id: request_id, answers: answers)
247
+ end
248
+
205
249
  def profile_options(options, profile)
206
250
  return options unless profile
207
251
 
@@ -1,6 +1,6 @@
1
1
  require "base64"
2
2
  require "thread"
3
- require_relative "../plugin_chat_runtime"
3
+ require_relative "../plugins/chat_runtime"
4
4
 
5
5
  module Kward
6
6
  module Transport
@@ -45,9 +45,10 @@ module Kward
45
45
  raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
46
46
  end
47
47
 
48
+ chat = fetch_chat(chat_id)
48
49
  turn = @runtime.start_turn(
49
50
  chat_id: chat_id,
50
- input: @runtime.input_with_attachments(input, normalize_attachments(attachments)),
51
+ input: @runtime.input_with_attachments(input, normalize_attachments(attachments), capabilities: chat.type.capabilities),
51
52
  display_input: input.to_s,
52
53
  context: { actor: actor }
53
54
  )
@@ -1,203 +1,4 @@
1
- require "logger"
2
- require_relative "deep_copy"
3
-
4
- # Namespace for frontend-neutral external transport contracts.
5
- module Kward
6
- module Transport
7
- STREAMING_MODES = %i[native edit aggregate none].freeze
8
-
9
- Actor = Data.define(:id, :display_name, :metadata)
10
- Attachment = Data.define(:name, :mime_type, :data, :url, :metadata)
11
- ConversationKey = Data.define(:transport_id, :external_id)
12
- InboundMessage = Data.define(
13
- :conversation,
14
- :message_id,
15
- :actor,
16
- :text,
17
- :attachments,
18
- :reply_context,
19
- :idempotency_key,
20
- :metadata
21
- )
22
- TurnEvent = Data.define(:type, :session_id, :turn_id, :sequence, :payload)
23
- InteractionRequest = Data.define(
24
- :id,
25
- :session_id,
26
- :turn_id,
27
- :kind,
28
- :prompt,
29
- :choices,
30
- :expires_at,
31
- :metadata
32
- )
33
- Capabilities = Data.define(:inbound, :outbound, :streaming, :limits)
34
- ExecutionProfile = Data.define(
35
- :id,
36
- :tool_mode,
37
- :allowed_tools,
38
- :disabled_tools,
39
- :plugin_commands,
40
- :approval_mode,
41
- :memory,
42
- :attachments,
43
- :workspace_mode,
44
- :prompt_context
45
- )
46
-
47
- TOOL_MODES = %i[all none allowlist].freeze
48
- APPROVAL_MODES = %i[default ask deny].freeze
49
- MEMORY_MODES = %i[default none].freeze
50
- WORKSPACE_MODES = %i[session fixed].freeze
51
-
52
- module_function
53
-
54
- def actor(id:, display_name: nil, metadata: {})
55
- Actor.new(
56
- id: required_string(id, "actor id"),
57
- display_name: optional_string(display_name),
58
- metadata: frozen_copy(metadata)
59
- )
60
- end
61
-
62
- def attachment(name: nil, mime_type:, data: nil, url: nil, metadata: {})
63
- raise ArgumentError, "attachment requires data or url" if data.nil? && url.nil?
64
- raise ArgumentError, "attachment cannot contain both data and url" unless data.nil? || url.nil?
65
-
66
- Attachment.new(
67
- name: optional_string(name),
68
- mime_type: required_string(mime_type, "attachment MIME type"),
69
- data: data&.dup&.freeze,
70
- url: url && required_string(url, "attachment URL"),
71
- metadata: frozen_copy(metadata)
72
- )
73
- end
74
-
75
- def conversation_key(transport_id:, external_id:)
76
- ConversationKey.new(
77
- transport_id: required_string(transport_id, "transport id"),
78
- external_id: required_string(external_id, "external conversation id")
79
- )
80
- end
81
-
82
- def inbound_message(conversation:, message_id:, actor:, text: "", attachments: [], reply_context: {}, idempotency_key:, metadata: {})
83
- unless conversation.is_a?(ConversationKey)
84
- raise ArgumentError, "conversation must be a Transport::ConversationKey"
85
- end
86
- unless actor.is_a?(Actor)
87
- raise ArgumentError, "actor must be a Transport::Actor"
88
- end
89
-
90
- InboundMessage.new(
91
- conversation: conversation,
92
- message_id: required_string(message_id, "message id"),
93
- actor: actor,
94
- text: text.to_s.freeze,
95
- attachments: frozen_copy(Array(attachments)),
96
- reply_context: frozen_copy(reply_context),
97
- idempotency_key: required_string(idempotency_key, "idempotency key"),
98
- metadata: frozen_copy(metadata)
99
- )
100
- end
101
-
102
- def turn_event(type:, session_id:, turn_id:, sequence:, payload: {})
103
- TurnEvent.new(
104
- type: required_string(type, "turn event type"),
105
- session_id: required_string(session_id, "session id"),
106
- turn_id: required_string(turn_id, "turn id"),
107
- sequence: Integer(sequence),
108
- payload: frozen_copy(payload)
109
- )
110
- end
111
-
112
- def interaction_request(id:, session_id:, turn_id:, kind:, prompt:, choices: [], expires_at: nil, metadata: {})
113
- InteractionRequest.new(
114
- id: required_string(id, "interaction id"),
115
- session_id: required_string(session_id, "session id"),
116
- turn_id: required_string(turn_id, "turn id"),
117
- kind: required_string(kind, "interaction kind"),
118
- prompt: required_string(prompt, "interaction prompt"),
119
- choices: frozen_copy(Array(choices)),
120
- expires_at: expires_at,
121
- metadata: frozen_copy(metadata)
122
- )
123
- end
124
-
125
- def execution_profile(
126
- id:,
127
- tool_mode: :all,
128
- allowed_tools: nil,
129
- disabled_tools: nil,
130
- plugin_commands: true,
131
- approval_mode: :default,
132
- memory: :default,
133
- attachments: true,
134
- workspace_mode: :session,
135
- prompt_context: nil
136
- )
137
- id = required_string(id, "execution profile id")
138
- tool_mode = tool_mode.to_sym
139
- approval_mode = approval_mode.to_sym
140
- memory = memory.to_sym
141
- workspace_mode = workspace_mode.to_sym
142
- raise ArgumentError, "unsupported tool mode: #{tool_mode}" unless TOOL_MODES.include?(tool_mode)
143
- raise ArgumentError, "unsupported approval mode: #{approval_mode}" unless APPROVAL_MODES.include?(approval_mode)
144
- raise ArgumentError, "unsupported memory mode: #{memory}" unless MEMORY_MODES.include?(memory)
145
- raise ArgumentError, "unsupported workspace mode: #{workspace_mode}" unless WORKSPACE_MODES.include?(workspace_mode)
146
- raise ArgumentError, "allowlist tool mode requires allowed_tools" if tool_mode == :allowlist && Array(allowed_tools).empty?
147
- raise ArgumentError, "none tool mode cannot define allowed_tools" if tool_mode == :none && allowed_tools
148
- raise ArgumentError, "allowed_tools and disabled_tools cannot both be set" if allowed_tools && disabled_tools
149
-
150
- ExecutionProfile.new(
151
- id: id,
152
- tool_mode: tool_mode,
153
- allowed_tools: frozen_symbols(allowed_tools),
154
- disabled_tools: frozen_symbols(disabled_tools),
155
- plugin_commands: !!plugin_commands,
156
- approval_mode: approval_mode,
157
- memory: memory,
158
- attachments: !!attachments,
159
- workspace_mode: workspace_mode,
160
- prompt_context: prompt_context.nil? ? nil : prompt_context.to_s.freeze
161
- )
162
- end
163
-
164
- def capabilities(inbound: [], outbound: [], streaming: :none, limits: {})
165
- streaming = streaming.to_sym
166
- raise ArgumentError, "unsupported streaming mode: #{streaming}" unless STREAMING_MODES.include?(streaming)
167
-
168
- Capabilities.new(
169
- inbound: frozen_symbols(inbound),
170
- outbound: frozen_symbols(outbound),
171
- streaming: streaming,
172
- limits: frozen_copy(limits)
173
- )
174
- end
175
-
176
- def required_string(value, name)
177
- value = value.to_s
178
- raise ArgumentError, "#{name} is required" if value.empty?
179
-
180
- value.freeze
181
- end
182
- private_class_method :required_string
183
-
184
- def optional_string(value)
185
- value.nil? ? nil : value.to_s.freeze
186
- end
187
- private_class_method :optional_string
188
-
189
- def frozen_symbols(values)
190
- Array(values).map { |value| value.to_sym }.uniq.freeze
191
- end
192
- private_class_method :frozen_symbols
193
-
194
- def frozen_copy(value)
195
- DeepCopy.freeze(DeepCopy.dup(value))
196
- end
197
- private_class_method :frozen_copy
198
- end
199
- end
200
-
1
+ require_relative "transport/contracts"
201
2
  require_relative "transport/store"
202
3
  require_relative "transport/host"
203
4
  require_relative "transport/manager"
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.83.0"
4
+ VERSION = "0.85.0"
5
5
  end
@@ -1,5 +1,5 @@
1
- require_relative "config_files"
2
- require_relative "sandbox"
1
+ require_relative "../config_files"
2
+ require_relative "../sandbox"
3
3
  require_relative "workspace"
4
4
 
5
5
  # Namespace for the Kward CLI agent runtime.
@@ -15,13 +15,13 @@ module Kward
15
15
  end
16
16
 
17
17
  def git_paths(root, include_ignored: false)
18
- output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
18
+ output, status = Open3.capture2e("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
19
  return [] unless status.success?
20
20
 
21
21
  paths = output.lines.map(&:chomp).reject(&:empty?)
22
22
  return paths unless include_ignored
23
23
 
24
- ignored_output, ignored_status = Open3.capture2("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
24
+ ignored_output, ignored_status = Open3.capture2e("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
25
25
  return paths unless ignored_status.success?
26
26
 
27
27
  paths + ignored_output.lines.map(&:chomp).reject(&:empty?)