kward 0.79.0 → 0.80.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -0,0 +1,179 @@
1
+ require "thread"
2
+ require "time"
3
+ require_relative "host"
4
+
5
+ module Kward
6
+ module Transport
7
+ # Starts and supervises registered transport adapters.
8
+ class Manager
9
+ Entry = Struct.new(:type, :host, :adapter, :state, :error, :started_at, :stopped_at, keyword_init: true)
10
+
11
+ def initialize(registry:, gateway: nil, plugin_chat_gateway: nil, config_root: nil, config_provider: nil, policy: nil, logger: nil)
12
+ @registry = registry
13
+ @gateway = gateway
14
+ @plugin_chat_gateway = plugin_chat_gateway
15
+ @config_root = config_root
16
+ @config_provider = config_provider
17
+ @policy = policy
18
+ @logger = logger || Logger.new($stderr)
19
+ @entries = {}
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def list
24
+ @registry.transports.map { |type| descriptor(type) }
25
+ end
26
+
27
+ def status(name_or_id = nil)
28
+ return status_for(resolve_type(name_or_id)) if name_or_id
29
+
30
+ list.map { |type| status_for(type) }
31
+ end
32
+
33
+ def start(name_or_id, config: nil, workspace_root: nil)
34
+ type = resolve_type(name_or_id)
35
+ @mutex.synchronize do
36
+ raise "Transport #{type.id} is already running" if running_entry?(type.id)
37
+ end
38
+
39
+ config ||= @config_provider&.call(type.id) || {}
40
+ config = config.merge("workspace" => workspace_root.to_s) if workspace_root
41
+ gateway = @gateway.respond_to?(:call) ? @gateway.call(type.id) : @gateway
42
+ plugin_chat_gateway = @plugin_chat_gateway.respond_to?(:call) ? @plugin_chat_gateway.call(type.id) : @plugin_chat_gateway
43
+ host = Host.new(
44
+ transport_id: type.id,
45
+ gateway: gateway,
46
+ plugin_chat_gateway: plugin_chat_gateway,
47
+ config: config,
48
+ storage: @config_root && Store.new(type.id, root: @config_root),
49
+ policy: @policy,
50
+ logger: @logger,
51
+ execution_profile: type.execution_profile
52
+ )
53
+ adapter = type.handler.call(host, host.config)
54
+ unless adapter.respond_to?(:start) && adapter.respond_to?(:stop)
55
+ raise "Transport #{type.id} must implement start and stop"
56
+ end
57
+
58
+ entry = Entry.new(type: type, host: host, adapter: adapter, state: "starting")
59
+ @mutex.synchronize { @entries[type.id] = entry }
60
+ begin
61
+ adapter.start
62
+ @mutex.synchronize do
63
+ entry.state = "running"
64
+ entry.started_at = Time.now.utc
65
+ entry.error = nil
66
+ end
67
+ adapter
68
+ rescue StandardError => e
69
+ @mutex.synchronize do
70
+ entry.state = "failed"
71
+ entry.error = e.message
72
+ entry.stopped_at = Time.now.utc
73
+ end
74
+ log_error(type, e)
75
+ raise
76
+ end
77
+ end
78
+
79
+ def stop(name_or_id)
80
+ type = resolve_type(name_or_id)
81
+ entry = @mutex.synchronize { @entries[type.id] }
82
+ return false unless entry
83
+
84
+ begin
85
+ entry.adapter.stop
86
+ ensure
87
+ entry.host.shutdown
88
+ @mutex.synchronize do
89
+ entry.state = "stopped"
90
+ entry.stopped_at = Time.now.utc
91
+ end
92
+ end
93
+ true
94
+ end
95
+
96
+ def restart(name_or_id, config: nil)
97
+ stop(name_or_id)
98
+ start(name_or_id, config: config)
99
+ end
100
+
101
+ def reload(registry)
102
+ shutdown
103
+ @mutex.synchronize { @registry = registry }
104
+ list
105
+ end
106
+
107
+ def shutdown
108
+ entries = @mutex.synchronize { @entries.values.dup }
109
+ entries.each { |entry| stop(entry.type.id) if entry.state == "running" || entry.state == "starting" }
110
+ nil
111
+ end
112
+
113
+ private
114
+
115
+ def resolve_type(name_or_id)
116
+ type = @registry.transport_for(name_or_id) || @registry.transport_for_id(name_or_id)
117
+ raise ArgumentError, "Unknown transport: #{name_or_id}" unless type
118
+
119
+ type
120
+ end
121
+
122
+ def running_entry?(id)
123
+ entry = @entries[id]
124
+ entry && %w[starting running].include?(entry.state)
125
+ end
126
+
127
+ def descriptor(type)
128
+ {
129
+ id: type.id,
130
+ name: type.name,
131
+ capabilities: type.capabilities.to_h,
132
+ execution_profile: profile_payload(type.execution_profile),
133
+ path: type.path
134
+ }
135
+ end
136
+
137
+ def status_for(type)
138
+ entry = @mutex.synchronize { @entries[type.id] }
139
+ descriptor(type).merge(
140
+ state: entry&.state || "stopped",
141
+ error: entry&.error,
142
+ health: health_for(entry),
143
+ started_at: entry&.started_at,
144
+ stopped_at: entry&.stopped_at
145
+ )
146
+ end
147
+
148
+ def profile_payload(profile)
149
+ return nil unless profile
150
+
151
+ {
152
+ id: profile.id,
153
+ tool_mode: profile.tool_mode,
154
+ allowed_tools: profile.allowed_tools,
155
+ disabled_tools: profile.disabled_tools,
156
+ plugin_commands: profile.plugin_commands,
157
+ approval_mode: profile.approval_mode,
158
+ memory: profile.memory,
159
+ attachments: profile.attachments,
160
+ workspace_mode: profile.workspace_mode
161
+ }
162
+ end
163
+
164
+ def health_for(entry)
165
+ return nil unless entry&.adapter&.respond_to?(:health)
166
+
167
+ entry.adapter.health
168
+ rescue StandardError => e
169
+ { state: "error", message: e.message }
170
+ end
171
+
172
+ def log_error(type, error)
173
+ @logger.error("Transport #{type.id} failed to start: #{error.class}: #{error.message}")
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,186 @@
1
+ require "base64"
2
+ require "thread"
3
+ require_relative "../plugin_chat_runtime"
4
+
5
+ module Kward
6
+ module Transport
7
+ # Adapts the shared plugin-chat runtime to the transport host contract.
8
+ class PluginChatGateway
9
+ POLL_INTERVAL = 0.05
10
+ TERMINAL_STATUSES = %w[completed canceled failed].freeze
11
+ IMAGE_MIME_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
12
+
13
+ def initialize(runtime:, transport_id:, poll_interval: POLL_INTERVAL)
14
+ @runtime = runtime
15
+ @transport_id = transport_id.to_s
16
+ @poll_interval = poll_interval
17
+ @subscriptions = []
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def resolve_transport_chat(transport_id:, type_id:, conversation:, actor:, scope_key:, descriptor: {}, workspace_root: nil)
22
+ validate_transport_id!(transport_id)
23
+ validate_conversation!(conversation)
24
+ validate_actor!(actor)
25
+ chat = @runtime.open(
26
+ type_id: type_id,
27
+ surface: :transport,
28
+ scope_key: scope_key,
29
+ descriptor: transport_descriptor(conversation, actor, descriptor),
30
+ workspace_root: workspace_root || Dir.pwd
31
+ )
32
+ Host::PluginChatHandle.new(
33
+ id: chat.id,
34
+ type_id: chat.type.id,
35
+ scope_key: chat.scope_key,
36
+ name: chat.type.title
37
+ )
38
+ end
39
+
40
+ def start_transport_plugin_chat_turn(chat_id:, actor:, input:, attachments: [], streaming_behavior: nil, execution_profile: nil)
41
+ if execution_profile && !execution_profile.attachments && !Array(attachments).empty?
42
+ raise ArgumentError, "transport execution profile does not allow attachments"
43
+ end
44
+ unless streaming_behavior.nil? || %i[aggregate none].include?(streaming_behavior.to_sym)
45
+ raise ArgumentError, "plugin chat turns do not support #{streaming_behavior} streaming"
46
+ end
47
+
48
+ turn = @runtime.start_turn(
49
+ chat_id: chat_id,
50
+ input: @runtime.input_with_attachments(input, normalize_attachments(attachments)),
51
+ display_input: input.to_s,
52
+ context: { actor: actor }
53
+ )
54
+ Host::PluginChatTurnHandle.new(id: turn.id, chat_id: turn.chat_id)
55
+ end
56
+
57
+ def transport_plugin_chat_transcript(chat_id:)
58
+ chat = fetch_chat(chat_id)
59
+ { messages: chat.driver.messages }
60
+ end
61
+
62
+ def transport_plugin_chat_turn_events(turn_id:, after: nil)
63
+ @runtime.turn_events(turn_id: turn_id, after_sequence: after.to_i).map do |event|
64
+ Transport.turn_event(
65
+ type: event.fetch(:type),
66
+ session_id: event.fetch(:chatId),
67
+ turn_id: event.fetch(:turnId),
68
+ sequence: event.fetch(:sequence),
69
+ payload: event.fetch(:payload, {})
70
+ )
71
+ end
72
+ end
73
+
74
+ def transport_plugin_chat_turn_status(turn_id:)
75
+ turn_payload(@runtime.turn_status(turn_id: turn_id))
76
+ end
77
+
78
+ def cancel_transport_plugin_chat_turn(turn_id:)
79
+ @runtime.cancel_turn(turn_id: turn_id)
80
+ nil
81
+ end
82
+
83
+ def subscribe_transport_plugin_chat_turn(turn_id:, after: nil)
84
+ cursor = after.to_i
85
+ thread = Thread.new do
86
+ loop do
87
+ events = transport_plugin_chat_turn_events(turn_id: turn_id, after: cursor)
88
+ events.each do |event|
89
+ cursor = event.sequence
90
+ yield event
91
+ end
92
+ status = transport_plugin_chat_turn_status(turn_id: turn_id)
93
+ break if TERMINAL_STATUSES.include?(status[:status].to_s)
94
+
95
+ sleep @poll_interval
96
+ end
97
+ rescue StandardError
98
+ nil
99
+ end
100
+ @mutex.synchronize { @subscriptions << thread }
101
+ thread
102
+ end
103
+
104
+ def shutdown
105
+ subscriptions = @mutex.synchronize do
106
+ current = @subscriptions
107
+ @subscriptions = []
108
+ current
109
+ end
110
+ subscriptions.each(&:kill)
111
+ subscriptions.each(&:join)
112
+ nil
113
+ end
114
+
115
+ private
116
+
117
+ def validate_transport_id!(transport_id)
118
+ return if transport_id.to_s == @transport_id
119
+
120
+ raise ArgumentError, "transport id does not match plugin chat gateway"
121
+ end
122
+
123
+ def validate_conversation!(conversation)
124
+ unless conversation.is_a?(ConversationKey)
125
+ raise ArgumentError, "conversation must be a Transport::ConversationKey"
126
+ end
127
+ return if conversation.transport_id == @transport_id
128
+
129
+ raise ArgumentError, "conversation transport does not match plugin chat gateway"
130
+ end
131
+
132
+ def validate_actor!(actor)
133
+ return if actor.is_a?(Actor)
134
+
135
+ raise ArgumentError, "actor must be a Transport::Actor"
136
+ end
137
+
138
+ def fetch_chat(chat_id)
139
+ @runtime.chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
140
+ end
141
+
142
+ def transport_descriptor(conversation, actor, descriptor)
143
+ {
144
+ "kind" => "transport",
145
+ "transport_id" => @transport_id,
146
+ "conversation" => conversation.to_h,
147
+ "actor" => actor.to_h
148
+ }.merge(descriptor.transform_keys(&:to_s))
149
+ end
150
+
151
+ def normalize_attachments(attachments)
152
+ Array(attachments).map do |attachment|
153
+ unless attachment.is_a?(Attachment)
154
+ raise ArgumentError, "transport attachment must be a Transport::Attachment"
155
+ end
156
+ if attachment.url
157
+ raise ArgumentError, "plugin chat attachment URLs are not supported"
158
+ end
159
+ unless IMAGE_MIME_TYPES.include?(attachment.mime_type.to_s.downcase)
160
+ raise ArgumentError, "unsupported plugin chat attachment MIME type: #{attachment.mime_type}"
161
+ end
162
+
163
+ {
164
+ mimeType: attachment.mime_type,
165
+ data: Base64.strict_encode64(attachment.data),
166
+ alt: attachment.name
167
+ }.compact
168
+ end
169
+ end
170
+
171
+ def turn_payload(turn)
172
+ turn.mutex.synchronize do
173
+ {
174
+ id: turn.id,
175
+ chatId: turn.chat_id,
176
+ status: turn.status,
177
+ createdAt: turn.created_at,
178
+ startedAt: turn.started_at,
179
+ finishedAt: turn.finished_at,
180
+ error: turn.error
181
+ }.compact
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,41 @@
1
+ require "stringio"
2
+ require_relative "../config_files"
3
+ require_relative "../rpc/server"
4
+ require_relative "gateway"
5
+ require_relative "manager"
6
+ require_relative "plugin_chat_gateway"
7
+
8
+ module Kward
9
+ module Transport
10
+ # Builds the Kward runtime needed by foreground transport processes.
11
+ class Runtime
12
+ attr_reader :manager
13
+
14
+ def initialize(client:)
15
+ @server = RPC::Server.new(
16
+ input: StringIO.new,
17
+ output: StringIO.new,
18
+ error_output: $stderr,
19
+ client: client
20
+ )
21
+ @manager = Manager.new(
22
+ registry: @server.session_manager.plugin_registry,
23
+ gateway: lambda { |transport_id|
24
+ Gateway.new(session_manager: @server.session_manager, transport_id: transport_id)
25
+ },
26
+ plugin_chat_gateway: lambda { |transport_id|
27
+ PluginChatGateway.new(runtime: @server.plugin_chat_manager.runtime, transport_id: transport_id)
28
+ },
29
+ config_root: ConfigFiles.config_dir,
30
+ config_provider: ->(transport_id) { ConfigFiles.transport_config(transport_id) }
31
+ )
32
+ end
33
+
34
+ def shutdown
35
+ @manager.shutdown
36
+ @server.shutdown
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,101 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "thread"
4
+ require "time"
5
+ require_relative "../config_files"
6
+ require_relative "../deep_copy"
7
+ require_relative "../private_file"
8
+
9
+ module Kward
10
+ module Transport
11
+ # Small private JSON store scoped to one transport plugin.
12
+ class Store
13
+ KEY_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9:._-]*\z/.freeze
14
+ DEFAULT_MAX_PROCESSED_KEYS = 10_000
15
+
16
+ attr_reader :transport_id
17
+
18
+ def initialize(transport_id, root: ConfigFiles.config_dir)
19
+ @transport_id = validate_key(transport_id, "transport id")
20
+ @path = File.join(File.expand_path(root), "transports", @transport_id, "state.json")
21
+ @mutex = Mutex.new
22
+ @values, @processed = load_state
23
+ end
24
+
25
+ def get(key)
26
+ key = validate_key(key, "storage key")
27
+ @mutex.synchronize { copy(@values[key]) }
28
+ end
29
+
30
+ def put(key, value)
31
+ key = validate_key(key, "storage key")
32
+ @mutex.synchronize do
33
+ @values[key] = copy(value)
34
+ persist
35
+ end
36
+ value
37
+ end
38
+
39
+ def delete(key)
40
+ key = validate_key(key, "storage key")
41
+ @mutex.synchronize do
42
+ present = @values.key?(key)
43
+ value = @values.delete(key)
44
+ persist if present
45
+ copy(value)
46
+ end
47
+ end
48
+
49
+ # Claims an external event key. Returns false when the key was already
50
+ # claimed, making duplicate webhook and polling deliveries harmless.
51
+ def claim(key, max_keys: DEFAULT_MAX_PROCESSED_KEYS)
52
+ key = validate_key(key, "idempotency key")
53
+ max_keys = Integer(max_keys)
54
+ raise ArgumentError, "max_keys must be positive" unless max_keys.positive?
55
+
56
+ @mutex.synchronize do
57
+ return false if @processed.key?(key)
58
+
59
+ @processed[key] = Time.now.utc.iso8601
60
+ @processed.shift while @processed.length > max_keys
61
+ persist
62
+ true
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def load_state
69
+ return [{}, {}] unless File.file?(@path)
70
+
71
+ state = JSON.parse(File.read(@path))
72
+ values = state.fetch("values", {})
73
+ processed = state.fetch("processed", {})
74
+ unless values.is_a?(Hash) && processed.is_a?(Hash)
75
+ raise JSON::ParserError, "transport state must contain JSON objects"
76
+ end
77
+
78
+ [values, processed]
79
+ rescue JSON::ParserError => e
80
+ raise "Invalid transport state #{@path}: #{e.message}"
81
+ end
82
+
83
+ def persist
84
+ PrivateFile.write_json(@path, "values" => @values, "processed" => @processed)
85
+ end
86
+
87
+ def validate_key(value, name)
88
+ value = value.to_s
89
+ raise ArgumentError, "#{name} is required" unless value.match?(KEY_PATTERN)
90
+
91
+ value
92
+ end
93
+
94
+ def copy(value)
95
+ return nil if value.nil?
96
+
97
+ DeepCopy.dup(value)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,204 @@
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
+
201
+ require_relative "transport/store"
202
+ require_relative "transport/host"
203
+ require_relative "transport/manager"
204
+ require_relative "transport/gateway"