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,19 +7,19 @@ module Kward
7
7
  # Tool wrapper for listing workspace directory entries.
8
8
  class ListDirectory < 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
  "list_directory",
14
14
  "List workspace directory entries.",
15
- properties: { path: { type: "string", description: "Workspace-relative directory." } },
15
+ properties: targeted_properties(path: { type: "string", description: "Workspace-relative directory." }),
16
16
  required: ["path"]
17
17
  )
18
18
  end
19
19
 
20
20
  # Executes the tool and returns model-facing output text.
21
21
  def call(args, _conversation, cancellation: nil)
22
- @workspace.list_directory(argument(args, :path, "."))
22
+ workspace_for(args).list_directory(argument(args, :path, "."))
23
23
  end
24
24
  end
25
25
  end
@@ -0,0 +1,41 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Tool wrapper for opening a workspace file in the integrated editor.
8
+ class OpenEditor < Base
9
+ # Builds the tool schema and stores the execution dependencies.
10
+ def initialize(workspace:, prompt:)
11
+ @workspace = workspace
12
+ @prompt = prompt
13
+ super(
14
+ "open_editor",
15
+ "Open a workspace file in Kward's integrated editor for the user. Use this when the user explicitly asks to open or edit a file in the built-in editor. This does not itself modify or save the file; the user controls those actions in the editor.",
16
+ properties: {
17
+ path: { type: "string", description: "Workspace-relative path of the file to open." }
18
+ },
19
+ required: ["path"]
20
+ )
21
+ end
22
+
23
+ # Opens the requested file and returns after the editor closes.
24
+ def call(args, _conversation, cancellation: nil)
25
+ cancellation&.raise_if_cancelled!
26
+ return "Error: open_editor requires interactive editor support." unless @prompt.respond_to?(:edit_file)
27
+
28
+ path = argument(args, :path, "").to_s
29
+ return "Error: open_editor requires a file path." if path.empty?
30
+
31
+ resolved_path = @workspace.resolved_path(path)
32
+ return "Error: not a file: #{path}" unless File.file?(resolved_path)
33
+
34
+ opened = @prompt.edit_file(resolved_path, base_dir: @workspace.root, allow_new: false)
35
+ opened ? "Opened #{path} in the integrated editor." : "Error: could not open #{path} in the integrated editor."
36
+ rescue SecurityError, StandardError => e
37
+ "Error: could not open #{path}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Adapts a trusted plugin tool registration to the normal tool interface.
8
+ class PluginTool < Base
9
+ attr_reader :plugin_path
10
+
11
+ def initialize(registration:, context_factory:)
12
+ @description = registration.description
13
+ @parameters = registration.schema
14
+ @plugin_path = registration.path
15
+ @handler = registration.handler
16
+ @context_factory = context_factory
17
+ super(registration.name, registration.description)
18
+ end
19
+
20
+ # Returns the strict JSON schema declared by the plugin.
21
+ def schema
22
+ {
23
+ type: "function",
24
+ function: {
25
+ name: name,
26
+ description: @description,
27
+ parameters: @parameters
28
+ }
29
+ }
30
+ end
31
+
32
+ # Executes trusted plugin code with parsed model arguments and scoped context.
33
+ def call(args, conversation, cancellation: nil)
34
+ context = @context_factory.call(conversation, cancellation)
35
+ result = @handler.call(args, context)
36
+ cancellation&.raise_if_cancelled!
37
+ result.to_s
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  require_relative "base"
2
- require_relative "../shell_prompt_session"
2
+ require_relative "../shell/prompt_session"
3
3
 
4
4
  # Namespace for the Kward agent runtime.
5
5
  module Kward
@@ -7,18 +7,18 @@ module Kward
7
7
  # Tool wrapper for bounded workspace file reads.
8
8
  class ReadFile < 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
  "read_file",
14
14
  "Read a workspace text file. Output is capped; use offset/limit or mode to control context budget.",
15
- properties: {
15
+ properties: targeted_properties(
16
16
  path: { type: "string", description: "Workspace-relative path." },
17
17
  limit: { type: "integer", description: "Maximum lines to return." },
18
18
  max_bytes: { type: "integer", description: "Optional byte budget for this read, capped by Kward's workspace read limit." },
19
19
  mode: { type: "string", enum: %w[preview outline range full], description: "Context mode. preview returns a short slice, outline returns source declarations, range reads offset/limit, full reads until Kward's cap." },
20
20
  offset: { type: "integer", description: "1-indexed start line." }
21
- },
21
+ ),
22
22
  required: ["path"]
23
23
  )
24
24
  end
@@ -30,8 +30,9 @@ module Kward
30
30
  limit = argument(args, :limit)
31
31
  mode = argument(args, :mode)
32
32
  max_bytes = argument(args, :max_bytes)
33
- content = @workspace.read_file(path, offset: offset, limit: limit, mode: mode, max_bytes: max_bytes)
34
- conversation.mark_read(@workspace.resolved_path(path)) unless content.start_with?("Error:")
33
+ workspace = workspace_for(args)
34
+ content = workspace.read_file(path, offset: offset, limit: limit, mode: mode, max_bytes: max_bytes)
35
+ conversation.mark_read(workspace.resolved_path(path)) unless content.start_with?("Error:")
35
36
  content
36
37
  end
37
38
  end
@@ -11,14 +11,18 @@ require_relative "fetch_raw"
11
11
  require_relative "git_commit"
12
12
  require_relative "list_directory"
13
13
  require_relative "mcp_tool"
14
+ require_relative "plugin_tool"
14
15
  require_relative "read_file"
15
16
  require_relative "read_skill"
16
17
  require_relative "run_shell_command"
17
18
  require_relative "prepare_shell_command"
18
19
  require_relative "summarize_file_structure"
20
+ require_relative "typesafe_evaluate"
19
21
  require_relative "retrieve_tool_output"
20
22
  require_relative "web_search"
23
+ require_relative "workspace_targets"
21
24
  require_relative "write_file"
25
+ require_relative "open_editor"
22
26
  require_relative "replace_editor_buffer"
23
27
  require_relative "search/code"
24
28
  require_relative "search/web"
@@ -28,7 +32,7 @@ require_relative "../mcp/server_config"
28
32
  require_relative "../permissions/policy"
29
33
  require_relative "../telemetry/logger"
30
34
  require_relative "../tool_output_compactor"
31
- require_relative "../workspace"
35
+ require_relative "../workspace/workspace"
32
36
 
33
37
  # Namespace for the Kward CLI agent runtime.
34
38
  module Kward
@@ -65,6 +69,30 @@ module Kward
65
69
  retrieve_tool_output
66
70
  ].freeze
67
71
 
72
+ TARGETED_WORKSPACE_TOOLS = %w[
73
+ list_directory
74
+ read_file
75
+ write_file
76
+ edit_file
77
+ run_shell_command
78
+ summarize_file_structure
79
+ context_for_task
80
+ git_commit
81
+ ].freeze
82
+
83
+ BUILTIN_TOOL_NAMES = (CORE_TOOL_NAMES + %w[
84
+ git_commit
85
+ web_search
86
+ fetch_content
87
+ fetch_raw
88
+ read_skill
89
+ ask_user_question
90
+ replace_editor_buffer
91
+ open_editor
92
+ prepare_shell_command
93
+ typesafe_evaluate
94
+ ]).freeze
95
+
68
96
  # Tool schemas advertised to the model for the current frontend and config.
69
97
  #
70
98
  # @return [Array<Hash>] tool schemas currently advertised to the model
@@ -73,20 +101,24 @@ module Kward
73
101
  # Builds tool objects and the schema list for the current frontend/config.
74
102
  #
75
103
  # @param workspace [Workspace] filesystem/shell boundary used by local tools
76
- # @param prompt [Object, nil] interactive prompt bridge; must implement
77
- # `ask_user_question` before that tool is advertised
104
+ # @param workspace_targets [Tools::WorkspaceTargets, nil] additional host-scoped worktree roles
105
+ # @param prompt [Object, nil] interactive prompt bridge; tools such as
106
+ # `ask_user_question` and `open_editor` are advertised only when supported
78
107
  # @param web_search [WebSearch] live web search implementation
79
108
  # @param web_fetch [WebFetch] specific URL fetch implementation
80
109
  # @param code_search [CodeSearch] public source/package search implementation
81
110
  # @param web_search_enabled [Boolean, nil] override for web search exposure
82
111
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
83
112
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
84
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, editor_prompt_session: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil, git_committer: nil)
113
+ # @param plugin_tools [Array<PluginRegistry::Tool>] trusted plugin tool registrations
114
+ def initialize(workspace: Workspace.new, workspace_targets: nil, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, editor_prompt_session: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, plugin_tools: [], typesafe_client: TypeSafeClient.new, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil, git_committer: nil)
85
115
  @workspace = workspace
116
+ @workspace_targets = workspace_targets || Tools::WorkspaceTargets.new(active: workspace)
86
117
  @prompt = prompt
87
118
  @web_search = web_search
88
119
  @web_fetch = web_fetch
89
120
  @code_search = code_search
121
+ @typesafe_client = typesafe_client
90
122
  @skills = skills
91
123
  @web_search_enabled = web_search_enabled
92
124
  @ask_user_question_enabled = ask_user_question_enabled
@@ -96,6 +128,7 @@ module Kward
96
128
  @tool_output_compactor = tool_output_compactor
97
129
  @telemetry_logger = telemetry_logger
98
130
  @context_budget_meter = context_budget_meter
131
+ @plugin_tools = Array(plugin_tools)
99
132
  @tool_approval = tool_approval
100
133
  @approval_for_allowed_tools = approval_for_allowed_tools == true
101
134
  @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
@@ -113,6 +146,16 @@ module Kward
113
146
  @schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
114
147
  end
115
148
 
149
+ # Replaces plugin registrations while preserving this registry's frontend,
150
+ # workspace, permission, hook, and execution-profile restrictions.
151
+ def replace_plugin_tools!(plugin_tools)
152
+ @plugin_tools = Array(plugin_tools)
153
+ @plugin_tool_values = nil
154
+ @tools = build_tools.freeze
155
+ @schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
156
+ self
157
+ end
158
+
116
159
  # Builds a registry scoped to one editor prompt turn.
117
160
  #
118
161
  # Editor prompts may inspect the workspace, but their only write operation
@@ -162,7 +205,10 @@ module Kward
162
205
  original_content = if tool
163
206
  before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
164
207
  args = hook_arguments(before_tool, args)
165
- if before_tool.denied?
208
+ target_error = workspace_target_error(name, args)
209
+ if target_error
210
+ target_error
211
+ elsif before_tool.denied?
166
212
  hook_denied_content(before_tool, "tool call denied: #{name}")
167
213
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
168
214
  hook_denied_content(before_tool, "tool call approval denied: #{name}")
@@ -253,11 +299,13 @@ module Kward
253
299
  case tool
254
300
  when Tools::MCPTool
255
301
  "mcp"
302
+ when Tools::PluginTool
303
+ "plugin"
256
304
  when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
257
305
  "web"
258
306
  when Tools::ReadSkill
259
307
  "skill"
260
- when Tools::AskUserQuestion
308
+ when Tools::AskUserQuestion, Tools::OpenEditor
261
309
  "ui"
262
310
  else
263
311
  "builtin"
@@ -490,6 +538,13 @@ module Kward
490
538
  (metadata[:source] || metadata["source"]).to_s == "mcp"
491
539
  end
492
540
 
541
+ def workspace_target_error(name, args)
542
+ return nil unless TARGETED_WORKSPACE_TOOLS.include?(name.to_s)
543
+ return nil if @workspace_targets.valid?(args)
544
+
545
+ "Error: Unknown workspace target: #{@workspace_targets.target_name(args)}"
546
+ end
547
+
493
548
  def permission_approval_result(tool_call, name, args, cancellation)
494
549
  return false unless @tool_approval
495
550
 
@@ -513,6 +568,11 @@ module Kward
513
568
  tools = all_tools
514
569
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
515
570
  tools.each_with_object({}) do |tool, result|
571
+ if tool.is_a?(Tools::PluginTool) && (BUILTIN_TOOL_NAMES.include?(tool.name) || result.key?(tool.name))
572
+ source = tool.plugin_path.to_s.empty? ? "" : " (#{tool.plugin_path})"
573
+ ConfigFiles.emit_warning("Warning: skipping Kward plugin tool #{tool.name}#{source}: duplicate tool name")
574
+ next
575
+ end
516
576
  raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
517
577
 
518
578
  result[tool.name] = tool
@@ -522,10 +582,13 @@ module Kward
522
582
  def build_schema_tools
523
583
  tools = @tools.values_at(*CORE_TOOL_NAMES)
524
584
  tools << @tools["replace_editor_buffer"] if @tools["replace_editor_buffer"]
585
+ tools << @tools["open_editor"] if @tools["open_editor"]
525
586
  tools << @tools["prepare_shell_command"] if @tools["prepare_shell_command"]
526
587
  tools << @tools["git_commit"] if @tools["git_commit"]
527
588
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
528
589
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
590
+ tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::PluginTool) })
591
+ tools << @tools["typesafe_evaluate"] if @tools["typesafe_evaluate"]
529
592
  tools << @tools["read_skill"] if skills_available?
530
593
  tools << @tools["ask_user_question"] if ask_user_question_available?
531
594
  tools.compact
@@ -533,30 +596,32 @@ module Kward
533
596
 
534
597
  def all_tools
535
598
  tools = core_tools
536
- tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
537
- tools + [
599
+ tools << Tools::GitCommit.new(committer: @git_committer, workspace_targets: @workspace_targets) if @git_committer
600
+ (tools + [
601
+ (Tools::TypeSafeEvaluate.new(client: @typesafe_client) if @typesafe_client&.available?),
538
602
  Tools::WebSearch.new(web_search: @web_search),
539
603
  Tools::FetchContent.new(web_fetch: @web_fetch),
540
604
  Tools::FetchRaw.new(web_fetch: @web_fetch),
541
605
  Tools::ReadSkill.new(skills: discovered_skills),
542
606
  Tools::AskUserQuestion.new(prompt: @prompt)
543
- ] + mcp_tool_values
607
+ ]).compact + mcp_tool_values + plugin_tool_values
544
608
  end
545
609
 
546
610
  def core_tools
547
611
  tools = [
548
- Tools::ListDirectory.new(workspace: @workspace),
549
- Tools::ReadFile.new(workspace: @workspace),
550
- Tools::WriteFile.new(workspace: @workspace),
551
- Tools::EditFile.new(workspace: @workspace),
612
+ Tools::ListDirectory.new(workspace: @workspace, workspace_targets: @workspace_targets),
613
+ Tools::ReadFile.new(workspace: @workspace, workspace_targets: @workspace_targets),
614
+ Tools::WriteFile.new(workspace: @workspace, workspace_targets: @workspace_targets),
615
+ Tools::EditFile.new(workspace: @workspace, workspace_targets: @workspace_targets),
552
616
  shell_command_tool,
553
617
  Tools::CodeSearch.new(code_search: @code_search),
554
- Tools::SummarizeFileStructure.new(workspace: @workspace),
555
- Tools::ContextForTask.new(workspace: @workspace),
618
+ Tools::SummarizeFileStructure.new(workspace: @workspace, workspace_targets: @workspace_targets),
619
+ Tools::ContextForTask.new(workspace: @workspace, workspace_targets: @workspace_targets),
556
620
  Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
557
621
  Tools::RetrieveToolOutput.new
558
622
  ]
559
623
  tools << Tools::ReplaceEditorBuffer.new(editor_prompt_session: @editor_prompt_session) if @editor_prompt_session
624
+ tools << Tools::OpenEditor.new(workspace: @workspace, prompt: @prompt) if editor_available?
560
625
  tools << Tools::PrepareShellCommand.new(shell_prompt_session: @shell_prompt_session) if @shell_prompt_session
561
626
  tools
562
627
  end
@@ -565,10 +630,14 @@ module Kward
565
630
  if @shell_prompt_session
566
631
  Tools::RunShellCommand.new(shell_prompt_session: @shell_prompt_session)
567
632
  else
568
- Tools::RunShellCommand.new(workspace: @workspace)
633
+ Tools::RunShellCommand.new(workspace: @workspace, workspace_targets: @workspace_targets)
569
634
  end
570
635
  end
571
636
 
637
+ def editor_available?
638
+ @prompt&.respond_to?(:edit_file)
639
+ end
640
+
572
641
  def editor_prompt_tool_names
573
642
  readonly_tools = CORE_TOOL_NAMES - %w[write_file edit_file run_shell_command]
574
643
  readonly_tools + ["replace_editor_buffer"]
@@ -576,7 +645,9 @@ module Kward
576
645
 
577
646
  def shell_prompt_tool_names
578
647
  readonly_tools = CORE_TOOL_NAMES - %w[write_file edit_file run_shell_command]
579
- readonly_tools + %w[run_shell_command prepare_shell_command]
648
+ tools = readonly_tools + %w[run_shell_command prepare_shell_command]
649
+ tools << "open_editor" if editor_available?
650
+ tools
580
651
  end
581
652
 
582
653
  def web_search_available?
@@ -602,6 +673,27 @@ module Kward
602
673
  @mcp_tool_values ||= build_mcp_tools
603
674
  end
604
675
 
676
+ def plugin_tool_values
677
+ @plugin_tool_values ||= @plugin_tools.map do |registration|
678
+ Tools::PluginTool.new(
679
+ registration: registration,
680
+ context_factory: method(:plugin_tool_context)
681
+ )
682
+ end
683
+ end
684
+
685
+ def plugin_tool_context(conversation, cancellation)
686
+ if @hook_context.respond_to?(:for_tool)
687
+ @hook_context.for_tool(conversation: conversation, cancellation: cancellation)
688
+ else
689
+ PluginRegistry::Context.new(
690
+ conversation: conversation,
691
+ workspace_root: @workspace.respond_to?(:root) ? @workspace.root.to_s : Dir.pwd,
692
+ cancellation: cancellation
693
+ )
694
+ end
695
+ end
696
+
605
697
  def build_mcp_tools
606
698
  Array(@mcp_clients).flat_map do |client|
607
699
  client.list_tools.map do |tool|
@@ -1,5 +1,5 @@
1
1
  require_relative "base"
2
- require_relative "../workspace"
2
+ require_relative "../workspace/workspace"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -8,11 +8,11 @@ module Kward
8
8
  # Tool wrapper for bounded shell commands in the workspace or active shell.
9
9
  class RunShellCommand < Base
10
10
  # Builds the tool schema and stores the execution dependency.
11
- def initialize(workspace: nil, shell_prompt_session: nil)
11
+ def initialize(workspace: nil, workspace_targets: nil, shell_prompt_session: nil)
12
12
  raise ArgumentError, "RunShellCommand requires a workspace or shell session" unless workspace || shell_prompt_session
13
13
  raise ArgumentError, "RunShellCommand accepts only one execution target" if workspace && shell_prompt_session
14
14
 
15
- @workspace = workspace
15
+ configure_workspace_targets(workspace, workspace_targets) if workspace
16
16
  @shell_prompt_session = shell_prompt_session
17
17
  description = shell_prompt_session ?
18
18
  "Run a bounded noninteractive command in the active embedded shell session." :
@@ -20,13 +20,15 @@ module Kward
20
20
  timeout_description = shell_prompt_session ?
21
21
  "Timeout seconds; defaults to the embedded shell setting." :
22
22
  "Timeout seconds; default 30."
23
+ properties = {
24
+ command: { type: "string", description: "Command to run." },
25
+ timeout_seconds: { type: "integer", description: timeout_description }
26
+ }
27
+ properties = targeted_properties(properties) if workspace
23
28
  super(
24
29
  "run_shell_command",
25
30
  description,
26
- properties: {
27
- command: { type: "string", description: "Command to run." },
28
- timeout_seconds: { type: "integer", description: timeout_description }
29
- },
31
+ properties: properties,
30
32
  required: ["command"]
31
33
  )
32
34
  end
@@ -43,7 +45,7 @@ module Kward
43
45
  if @shell_prompt_session
44
46
  @shell_prompt_session.run(command, timeout_seconds: timeout_seconds, cancellation: cancellation)
45
47
  else
46
- @workspace.run_shell_command(command, timeout_seconds: timeout_seconds, cancellation: cancellation)
48
+ workspace_for(args).run_shell_command(command, timeout_seconds: timeout_seconds, cancellation: cancellation)
47
49
  end
48
50
  end
49
51
  end
@@ -5,7 +5,7 @@ require "net/http"
5
5
  require "open3"
6
6
  require "pathname"
7
7
  require "uri"
8
- require_relative "../../path_guard"
8
+ require_relative "../../workspace/path_guard"
9
9
  require_relative "../../config_files"
10
10
  require_relative "web"
11
11
 
@@ -7,14 +7,14 @@ module Kward
7
7
  # Returns a compact symbol outline for a workspace source file.
8
8
  class SummarizeFileStructure < 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
  "summarize_file_structure",
14
14
  "Return a compact outline of classes, modules, methods, and functions in a workspace source file.",
15
- properties: {
15
+ properties: targeted_properties(
16
16
  path: { type: "string", description: "Workspace-relative source file path." }
17
- },
17
+ ),
18
18
  required: ["path"]
19
19
  )
20
20
  end
@@ -22,7 +22,7 @@ module Kward
22
22
  # Executes the structure summary tool.
23
23
  def call(args, _conversation, cancellation: nil)
24
24
  cancellation&.raise_if_cancelled!
25
- @workspace.summarize_file_structure(argument(args, :path, ""))
25
+ workspace_for(args).summarize_file_structure(argument(args, :path, ""))
26
26
  end
27
27
  end
28
28
  end
@@ -26,7 +26,9 @@ module Kward
26
26
  "fetch_raw" => "fetch_raw",
27
27
  "git_commit" => "git_commit",
28
28
  "read_skill" => "read_skill",
29
- "ask_user_question" => "ask_user_question"
29
+ "ask_user_question" => "ask_user_question",
30
+ "open_editor" => "open_editor",
31
+ "typesafe_evaluate" => "typesafe_evaluate"
30
32
  }.freeze
31
33
 
32
34
  module_function
@@ -0,0 +1,81 @@
1
+ require "json"
2
+ require_relative "base"
3
+ require_relative "../model/typesafe_client"
4
+
5
+ module Kward
6
+ module Tools
7
+ # Evaluates bounded state with TypeSafe's typed decision model.
8
+ class TypeSafeEvaluate < Base
9
+ def initialize(client: TypeSafeClient.new)
10
+ @client = client
11
+ super(
12
+ "typesafe_evaluate",
13
+ "Evaluate text with TypeSafe and return typed decisions, probabilities, and confidence. Use for classification, scoring, routing, or verification; this does not generate text.",
14
+ properties: {
15
+ state: {
16
+ type: "string",
17
+ description: "The text or structured state to evaluate; do not include secrets unless explicitly requested."
18
+ },
19
+ model: {
20
+ type: "string",
21
+ description: "TypeSafe model ID or alias; defaults to jev-latest."
22
+ },
23
+ questions: {
24
+ type: "object",
25
+ description: "Named typed questions. Each value must be a noul, choice, or score question.",
26
+ additionalProperties: {
27
+ type: "object",
28
+ properties: {
29
+ type: { type: "string", enum: %w[noul choice score] },
30
+ instructions: { type: "string" },
31
+ criteria: { type: ["object", "array"] }
32
+ },
33
+ required: %w[type instructions],
34
+ additionalProperties: false
35
+ }
36
+ }
37
+ },
38
+ required: %w[state questions]
39
+ )
40
+ end
41
+
42
+ def call(args, _conversation, cancellation: nil)
43
+ state = argument(args, "state").to_s
44
+ questions = argument(args, "questions")
45
+ error = validate(state, questions)
46
+ return "Error: #{error}" if error
47
+
48
+ response = @client.evaluate(
49
+ state: state,
50
+ questions: questions,
51
+ model: argument(args, "model") || TypeSafeClient::DEFAULT_MODEL,
52
+ cancellation: cancellation
53
+ )
54
+ JSON.pretty_generate(response)
55
+ rescue TypeSafeClient::RequestError => e
56
+ "Error: #{e.message}"
57
+ rescue JSON::GeneratorError, TypeError => e
58
+ "Error: invalid TypeSafe response: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def validate(state, questions)
64
+ return "state must be a non-empty string" if state.empty?
65
+ return "state exceeds #{TypeSafeClient::MAX_STATE_BYTES} bytes" if state.bytesize > TypeSafeClient::MAX_STATE_BYTES
66
+ return "questions must be an object" unless questions.is_a?(Hash)
67
+ return "questions must contain 1-#{TypeSafeClient::MAX_QUESTIONS} entries" if questions.empty? || questions.length > TypeSafeClient::MAX_QUESTIONS
68
+
69
+ questions.each do |name, question|
70
+ return "question names must be non-empty strings" unless name.is_a?(String) && !name.empty?
71
+ return "question #{name.inspect} must be an object" unless question.is_a?(Hash)
72
+ type = argument(question, "type").to_s
73
+ return "question #{name.inspect} has unsupported type" unless %w[noul choice score].include?(type)
74
+ return "question #{name.inspect} needs instructions" if argument(question, "instructions").to_s.empty?
75
+ return "question #{name.inspect} needs criteria" if %w[choice score].include?(type) && !argument(question, "criteria")
76
+ end
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,58 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Model-callable tool wrappers and their argument schemas.
4
+ module Tools
5
+ # Resolves a small host-scoped set of workspace roles for local tools.
6
+ # Target names are supplied by trusted runtime orchestration, never by the
7
+ # model, so selecting a target cannot escape into an arbitrary filesystem
8
+ # path.
9
+ class WorkspaceTargets
10
+ ACTIVE = "active"
11
+
12
+ def initialize(active:, targets: {})
13
+ @workspaces = { ACTIVE => active }
14
+ targets.each do |name, workspace|
15
+ name = name.to_s
16
+ raise ArgumentError, "Workspace target name is required" if name.empty?
17
+ raise ArgumentError, "Workspace target #{ACTIVE.inspect} is reserved" if name == ACTIVE
18
+
19
+ @workspaces[name] = workspace
20
+ end
21
+ @workspaces.freeze
22
+ end
23
+
24
+ def workspace_for(args)
25
+ @workspaces.fetch(target_name(args)) do |name|
26
+ raise ArgumentError, "Unknown workspace target: #{name}"
27
+ end
28
+ end
29
+
30
+ def target_name(args)
31
+ value = if args.respond_to?(:key?)
32
+ args[:target] || args["target"]
33
+ end
34
+ value.to_s.empty? ? ACTIVE : value.to_s
35
+ end
36
+
37
+ def valid?(args)
38
+ @workspaces.key?(target_name(args))
39
+ end
40
+
41
+ def properties(properties)
42
+ return properties if @workspaces.length == 1
43
+
44
+ properties.merge(
45
+ target: {
46
+ type: "string",
47
+ enum: @workspaces.keys,
48
+ description: "Workspace role. Defaults to active; origin is the host repository worktree."
49
+ }
50
+ )
51
+ end
52
+
53
+ def without_target(args)
54
+ args.reject { |key, _value| key.to_s == "target" }
55
+ end
56
+ end
57
+ end
58
+ end