kward 0.84.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -11,13 +11,16 @@ 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"
22
25
  require_relative "open_editor"
23
26
  require_relative "replace_editor_buffer"
@@ -29,7 +32,7 @@ require_relative "../mcp/server_config"
29
32
  require_relative "../permissions/policy"
30
33
  require_relative "../telemetry/logger"
31
34
  require_relative "../tool_output_compactor"
32
- require_relative "../workspace"
35
+ require_relative "../workspace/workspace"
33
36
 
34
37
  # Namespace for the Kward CLI agent runtime.
35
38
  module Kward
@@ -66,6 +69,30 @@ module Kward
66
69
  retrieve_tool_output
67
70
  ].freeze
68
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
+
69
96
  # Tool schemas advertised to the model for the current frontend and config.
70
97
  #
71
98
  # @return [Array<Hash>] tool schemas currently advertised to the model
@@ -74,6 +101,7 @@ module Kward
74
101
  # Builds tool objects and the schema list for the current frontend/config.
75
102
  #
76
103
  # @param workspace [Workspace] filesystem/shell boundary used by local tools
104
+ # @param workspace_targets [Tools::WorkspaceTargets, nil] additional host-scoped worktree roles
77
105
  # @param prompt [Object, nil] interactive prompt bridge; tools such as
78
106
  # `ask_user_question` and `open_editor` are advertised only when supported
79
107
  # @param web_search [WebSearch] live web search implementation
@@ -82,12 +110,15 @@ module Kward
82
110
  # @param web_search_enabled [Boolean, nil] override for web search exposure
83
111
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
84
112
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
85
- 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)
86
115
  @workspace = workspace
116
+ @workspace_targets = workspace_targets || Tools::WorkspaceTargets.new(active: workspace)
87
117
  @prompt = prompt
88
118
  @web_search = web_search
89
119
  @web_fetch = web_fetch
90
120
  @code_search = code_search
121
+ @typesafe_client = typesafe_client
91
122
  @skills = skills
92
123
  @web_search_enabled = web_search_enabled
93
124
  @ask_user_question_enabled = ask_user_question_enabled
@@ -97,6 +128,7 @@ module Kward
97
128
  @tool_output_compactor = tool_output_compactor
98
129
  @telemetry_logger = telemetry_logger
99
130
  @context_budget_meter = context_budget_meter
131
+ @plugin_tools = Array(plugin_tools)
100
132
  @tool_approval = tool_approval
101
133
  @approval_for_allowed_tools = approval_for_allowed_tools == true
102
134
  @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
@@ -114,6 +146,16 @@ module Kward
114
146
  @schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
115
147
  end
116
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
+
117
159
  # Builds a registry scoped to one editor prompt turn.
118
160
  #
119
161
  # Editor prompts may inspect the workspace, but their only write operation
@@ -163,7 +205,10 @@ module Kward
163
205
  original_content = if tool
164
206
  before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
165
207
  args = hook_arguments(before_tool, args)
166
- if before_tool.denied?
208
+ target_error = workspace_target_error(name, args)
209
+ if target_error
210
+ target_error
211
+ elsif before_tool.denied?
167
212
  hook_denied_content(before_tool, "tool call denied: #{name}")
168
213
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
169
214
  hook_denied_content(before_tool, "tool call approval denied: #{name}")
@@ -254,6 +299,8 @@ module Kward
254
299
  case tool
255
300
  when Tools::MCPTool
256
301
  "mcp"
302
+ when Tools::PluginTool
303
+ "plugin"
257
304
  when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
258
305
  "web"
259
306
  when Tools::ReadSkill
@@ -491,6 +538,13 @@ module Kward
491
538
  (metadata[:source] || metadata["source"]).to_s == "mcp"
492
539
  end
493
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
+
494
548
  def permission_approval_result(tool_call, name, args, cancellation)
495
549
  return false unless @tool_approval
496
550
 
@@ -514,6 +568,11 @@ module Kward
514
568
  tools = all_tools
515
569
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
516
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
517
576
  raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
518
577
 
519
578
  result[tool.name] = tool
@@ -528,6 +587,8 @@ module Kward
528
587
  tools << @tools["git_commit"] if @tools["git_commit"]
529
588
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
530
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"]
531
592
  tools << @tools["read_skill"] if skills_available?
532
593
  tools << @tools["ask_user_question"] if ask_user_question_available?
533
594
  tools.compact
@@ -535,26 +596,27 @@ module Kward
535
596
 
536
597
  def all_tools
537
598
  tools = core_tools
538
- tools << Tools::GitCommit.new(committer: @git_committer) if @git_committer
539
- 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?),
540
602
  Tools::WebSearch.new(web_search: @web_search),
541
603
  Tools::FetchContent.new(web_fetch: @web_fetch),
542
604
  Tools::FetchRaw.new(web_fetch: @web_fetch),
543
605
  Tools::ReadSkill.new(skills: discovered_skills),
544
606
  Tools::AskUserQuestion.new(prompt: @prompt)
545
- ] + mcp_tool_values
607
+ ]).compact + mcp_tool_values + plugin_tool_values
546
608
  end
547
609
 
548
610
  def core_tools
549
611
  tools = [
550
- Tools::ListDirectory.new(workspace: @workspace),
551
- Tools::ReadFile.new(workspace: @workspace),
552
- Tools::WriteFile.new(workspace: @workspace),
553
- 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),
554
616
  shell_command_tool,
555
617
  Tools::CodeSearch.new(code_search: @code_search),
556
- Tools::SummarizeFileStructure.new(workspace: @workspace),
557
- 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),
558
620
  Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
559
621
  Tools::RetrieveToolOutput.new
560
622
  ]
@@ -568,7 +630,7 @@ module Kward
568
630
  if @shell_prompt_session
569
631
  Tools::RunShellCommand.new(shell_prompt_session: @shell_prompt_session)
570
632
  else
571
- Tools::RunShellCommand.new(workspace: @workspace)
633
+ Tools::RunShellCommand.new(workspace: @workspace, workspace_targets: @workspace_targets)
572
634
  end
573
635
  end
574
636
 
@@ -611,6 +673,27 @@ module Kward
611
673
  @mcp_tool_values ||= build_mcp_tools
612
674
  end
613
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
+
614
697
  def build_mcp_tools
615
698
  Array(@mcp_clients).flat_map do |client|
616
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
@@ -27,7 +27,8 @@ module Kward
27
27
  "git_commit" => "git_commit",
28
28
  "read_skill" => "read_skill",
29
29
  "ask_user_question" => "ask_user_question",
30
- "open_editor" => "open_editor"
30
+ "open_editor" => "open_editor",
31
+ "typesafe_evaluate" => "typesafe_evaluate"
31
32
  }.freeze
32
33
 
33
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
@@ -7,15 +7,15 @@ module Kward
7
7
  # Tool wrapper for guarded full-file writes.
8
8
  class WriteFile < Base
9
9
  # Builds the tool schema and stores the execution dependency.
10
- def initialize(workspace:)
11
- @workspace = workspace
10
+ def initialize(workspace:, workspace_targets: nil)
11
+ configure_workspace_targets(workspace, workspace_targets)
12
12
  super(
13
13
  "write_file",
14
14
  "Write a workspace file. Existing files must be read first.",
15
- properties: {
15
+ properties: targeted_properties(
16
16
  path: { type: "string", description: "Workspace-relative path." },
17
17
  content: { type: "string", description: "Complete file content." }
18
- },
18
+ ),
19
19
  required: ["path", "content"]
20
20
  )
21
21
  end
@@ -25,8 +25,11 @@ module Kward
25
25
  path = argument(args, :path, "")
26
26
  content = argument(args, :content, "")
27
27
 
28
- result = @workspace.write_file(path, content, read_paths: conversation.read_paths)
29
- conversation.refresh_system_message! if agents_file_changed?(@workspace, path, result)
28
+ workspace = workspace_for(args)
29
+ result = workspace.write_file(path, content, read_paths: conversation.read_paths)
30
+ if workspace.equal?(@workspace) && agents_file_changed?(workspace, path, result)
31
+ conversation.refresh_system_message!
32
+ end
30
33
  result
31
34
  end
32
35
  end
@@ -1,5 +1,5 @@
1
1
  require "pathname"
2
- require_relative "path_guard"
2
+ require_relative "../workspace/path_guard"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward
@@ -1,5 +1,5 @@
1
- require_relative "message_access"
2
- require_relative "message_text"
1
+ require_relative "../message_access"
2
+ require_relative "../message_text"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
5
5
  module Kward