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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 "../
|
|
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
|
)
|
data/lib/kward/transport.rb
CHANGED
|
@@ -1,203 +1,4 @@
|
|
|
1
|
-
|
|
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
|
@@ -15,13 +15,13 @@ module Kward
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def git_paths(root, include_ignored: false)
|
|
18
|
-
output, status = Open3.
|
|
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.
|
|
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?)
|