kward 0.67.1 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +114 -24
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -1,8 +1,13 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Model-callable tool wrappers and their argument schemas.
2
4
  module Tools
5
+ # Base class for model-callable tools and their JSON schemas.
3
6
  class Base
7
+ # @return [String] function name exposed to the model
4
8
  attr_reader :name
5
9
 
10
+ # Creates a tool schema definition shared by all concrete tool wrappers.
6
11
  def initialize(name, description, properties: {}, required: [])
7
12
  @name = name
8
13
  @description = description
@@ -10,6 +15,7 @@ module Kward
10
15
  @required = required
11
16
  end
12
17
 
18
+ # Returns the strict JSON schema advertised to model providers.
13
19
  def schema
14
20
  {
15
21
  type: "function",
@@ -28,6 +34,7 @@ module Kward
28
34
 
29
35
  private
30
36
 
37
+ # Reads a tool argument while accepting symbol or string keys from restored calls.
31
38
  def argument(args, key, default = nil)
32
39
  return args[key] if args.key?(key)
33
40
  return args[key.to_s] if args.key?(key.to_s)
@@ -35,6 +42,7 @@ module Kward
35
42
  default
36
43
  end
37
44
 
45
+ # Detects successful AGENTS.md writes so callers can refresh prompt context.
38
46
  def agents_file_changed?(workspace, path, result)
39
47
  result.to_s.start_with?("Wrote ", "Edited ") && File.basename(path.to_s) == "AGENTS.md" && workspace.resolved_path(path) == File.join(workspace.root.to_s, "AGENTS.md")
40
48
  rescue StandardError
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Package lookup and GitHub repository cache/search implementation.
5
8
  class CodeSearch < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(code_search:)
7
11
  @code_search = code_search
8
12
  super(
@@ -56,6 +60,7 @@ module Kward
56
60
  )
57
61
  end
58
62
 
63
+ # Executes the tool and returns model-facing output text.
59
64
  def call(args, _conversation, cancellation: nil)
60
65
  cancellation&.raise_if_cancelled!
61
66
  @code_search.call(args)
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Tool wrapper for exact block replacement edits.
5
8
  class EditFile < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(workspace:)
7
11
  @workspace = workspace
8
12
  super(
@@ -28,6 +32,7 @@ module Kward
28
32
  )
29
33
  end
30
34
 
35
+ # Executes the tool and returns model-facing output text.
31
36
  def call(args, conversation, cancellation: nil)
32
37
  path = argument(args, :path, "")
33
38
  edits = argument(args, :edits, [])
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Tool wrapper for listing workspace directory entries.
5
8
  class ListDirectory < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(workspace:)
7
11
  @workspace = workspace
8
12
  super(
@@ -13,6 +17,7 @@ module Kward
13
17
  )
14
18
  end
15
19
 
20
+ # Executes the tool and returns model-facing output text.
16
21
  def call(args, _conversation, cancellation: nil)
17
22
  @workspace.list_directory(argument(args, :path, "."))
18
23
  end
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Tool wrapper for bounded workspace file reads.
5
8
  class ReadFile < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(workspace:)
7
11
  @workspace = workspace
8
12
  super(
@@ -17,6 +21,7 @@ module Kward
17
21
  )
18
22
  end
19
23
 
24
+ # Executes the tool and returns model-facing output text.
20
25
  def call(args, conversation, cancellation: nil)
21
26
  path = argument(args, :path, "")
22
27
  offset = argument(args, :offset)
@@ -1,9 +1,13 @@
1
1
  require_relative "base"
2
2
  require_relative "../config_files"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
5
7
  module Tools
8
+ # Tool wrapper for reading configured skill instructions.
6
9
  class ReadSkill < Base
10
+ # Builds the tool schema and stores the execution dependency.
7
11
  def initialize
8
12
  super(
9
13
  "read_skill",
@@ -16,6 +20,7 @@ module Kward
16
20
  )
17
21
  end
18
22
 
23
+ # Executes the tool and returns model-facing output text.
19
24
  def call(args, _conversation, cancellation: nil)
20
25
  name = argument(args, :name, "")
21
26
  path = argument(args, :path)
@@ -13,15 +13,42 @@ require_relative "search/web"
13
13
  require_relative "tool_call"
14
14
  require_relative "../workspace"
15
15
 
16
+ # Namespace for the Kward CLI agent runtime.
16
17
  module Kward
17
18
  # Exposes local workspace, search, skill, and interaction tools to the model
18
- # and dispatches approved tool calls into the active conversation.
19
+ # and dispatches tool calls into the active conversation.
20
+ #
21
+ # `ToolRegistry` is the boundary between model-requested function calls and
22
+ # Ruby tool objects. It owns schema exposure and transcript persistence for
23
+ # tool results; individual tools own validation and side effects. Keep frontend
24
+ # policy outside this class by passing dependencies such as `workspace` and
25
+ # `prompt` from CLI or RPC setup.
26
+ #
27
+ # A tool may exist in `@tools` but not be advertised in `schemas`. This allows
28
+ # restored transcripts or compatibility callers to dispatch known tools while
29
+ # config and frontend capability checks decide what the model can request next.
30
+ #
31
+ # Tool schemas are the strict output contract advertised to models and clients.
32
+ # Incoming calls are intentionally more tolerant: extra fields are ignored by
33
+ # individual tools, and legacy-compatible shapes are accepted where already
34
+ # supported. Required fields and invalid required values should still return
35
+ # explicit tool errors.
19
36
  class ToolRegistry
20
37
  # Tool schemas advertised to the model for the current frontend and config.
21
38
  #
22
- # @return [Array<Hash>]
39
+ # @return [Array<Hash>] tool schemas currently advertised to the model
23
40
  attr_reader :schemas
24
41
 
42
+ # Builds tool objects and the schema list for the current frontend/config.
43
+ #
44
+ # @param workspace [Workspace] filesystem/shell boundary used by local tools
45
+ # @param prompt [Object, nil] interactive prompt bridge; must implement
46
+ # `ask_user_question` before that tool is advertised
47
+ # @param web_search [WebSearch] live web search implementation
48
+ # @param code_search [CodeSearch] public source/package search implementation
49
+ # @param web_search_enabled [Boolean, nil] override for web search exposure
50
+ # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
51
+ # @param ask_user_question_enabled [Boolean, nil] override question exposure
25
52
  def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
26
53
  @workspace = workspace
27
54
  @prompt = prompt
@@ -37,6 +64,10 @@ module Kward
37
64
  # Executes a model-requested tool call and appends the result to the
38
65
  # conversation transcript.
39
66
  #
67
+ # Unknown tools are recorded as tool results instead of raising. That keeps
68
+ # the conversation valid for the model and lets the assistant recover by
69
+ # choosing an advertised tool on the next turn.
70
+ #
40
71
  # @param tool_call [Hash] model tool call payload
41
72
  # @param conversation [Conversation] active conversation
42
73
  # @return [String] tool output content appended to the conversation
@@ -1,9 +1,13 @@
1
1
  require_relative "base"
2
2
  require_relative "../workspace"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
5
7
  module Tools
8
+ # Tool wrapper for bounded shell commands in the workspace.
6
9
  class RunShellCommand < Base
10
+ # Builds the tool schema and stores the execution dependency.
7
11
  def initialize(workspace:)
8
12
  @workspace = workspace
9
13
  super(
@@ -17,6 +21,7 @@ module Kward
17
21
  )
18
22
  end
19
23
 
24
+ # Executes the tool and returns model-facing output text.
20
25
  def call(args, _conversation, cancellation: nil)
21
26
  command = argument(args, :command, "")
22
27
  timeout_seconds = argument(args, :timeout_seconds, Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS)
@@ -7,7 +7,9 @@ require "pathname"
7
7
  require "uri"
8
8
  require_relative "../../config_files"
9
9
 
10
+ # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
12
+ # Package lookup and GitHub repository cache/search implementation.
11
13
  class CodeSearch
12
14
  DEFAULT_MAX_RESULTS = 10
13
15
  MAX_MAX_RESULTS = 50
@@ -21,6 +23,7 @@ module Kward
21
23
  ECOSYSTEMS = %w[rubygems npm pypi crates go].freeze
22
24
  ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
23
25
 
26
+ # Creates an object for code search and repository cache operations.
24
27
  def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
25
28
  @cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
26
29
  @http_client = http_client
@@ -46,6 +49,7 @@ module Kward
46
49
  "Error: code_search failed: #{redact(e.message)}"
47
50
  end
48
51
 
52
+ # HTTP adapter used by code search registry/package lookups.
49
53
  class NetHttpClient
50
54
  def get_json(url, headers: {})
51
55
  JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
@@ -65,6 +69,7 @@ module Kward
65
69
  end
66
70
  end
67
71
 
72
+ # Git command adapter used by repository cache operations.
68
73
  class GitRunner
69
74
  def run(*args, chdir: nil)
70
75
  command = ["git", *args]
@@ -410,6 +415,7 @@ module Kward
410
415
  text[%r{https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?}]
411
416
  end
412
417
 
418
+ # Returns GitHub API headers, including an optional token when configured.
413
419
  def github_headers
414
420
  token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
415
421
  token.to_s.empty? ? {} : { "Authorization" => "Bearer #{token}" }
@@ -422,6 +428,7 @@ module Kward
422
428
  nil
423
429
  end
424
430
 
431
+ # Returns a cache file path relative to the cloned repository root.
425
432
  def relative_path(root, path)
426
433
  Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
427
434
  end
@@ -5,7 +5,9 @@ require "nokogiri"
5
5
  require "uri"
6
6
  require_relative "../../config_files"
7
7
 
8
+ # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
10
+ # Live web-search implementation with provider fallbacks.
9
11
  class WebSearch
10
12
  DEFAULT_MAX_RESULTS = 5
11
13
  MAX_MAX_RESULTS = 20
@@ -27,11 +29,12 @@ module Kward
27
29
  "https://search.inetol.net",
28
30
  "https://searx.tiekoetter.com"
29
31
  ].freeze
30
- PROVIDERS = %w[auto exa perplexity gemini legacy duckduckgo].freeze
32
+ PROVIDERS = %w[auto exa perplexity gemini duckduckgo].freeze
31
33
 
32
34
  Result = Struct.new(:title, :url, :excerpt, :provider, keyword_init: true)
33
35
  SearchResponse = Struct.new(:answer, :results, :provider, :note, keyword_init: true)
34
36
 
37
+ # Creates an object for web search provider operations.
35
38
  def initialize(http_client: NetHttpClient.new, searxng_instances: PUBLIC_SEARXNG_INSTANCES, max_output_bytes: MAX_OUTPUT_BYTES, config: nil)
36
39
  @http_client = http_client
37
40
  @searxng_instances = searxng_instances
@@ -92,8 +95,8 @@ module Kward
92
95
  perplexity_search(query, options)
93
96
  when "gemini"
94
97
  gemini_search(query, options)
95
- when "legacy"
96
- legacy_search(query, options)
98
+ when "duckduckgo"
99
+ duckduckgo_provider_search(query, options)
97
100
  end
98
101
  return [response, errors.empty? ? nil : errors.join("; ")] if successful_response?(response)
99
102
  errors << "#{provider}: no results"
@@ -105,6 +108,7 @@ module Kward
105
108
  [nil, errors.join("; ")]
106
109
  end
107
110
 
111
+ # Returns the configured provider fallback order for a query.
108
112
  def provider_order(provider)
109
113
  case provider
110
114
  when "auto"
@@ -113,10 +117,10 @@ module Kward
113
117
  order << "perplexity" if api_key("perplexity")
114
118
  order << "gemini" if api_key("gemini")
115
119
  end
116
- order << "legacy"
120
+ order << "duckduckgo"
117
121
  order
118
122
  when "duckduckgo"
119
- ["legacy"]
123
+ ["duckduckgo"]
120
124
  else
121
125
  [provider]
122
126
  end
@@ -352,15 +356,15 @@ module Kward
352
356
  SearchResponse.new(answer: answer, results: results, provider: "gemini")
353
357
  end
354
358
 
355
- def legacy_search(query, options)
356
- legacy_query = query_with_domain_filter(query, options[:domain_filter])
357
- results, error = legacy_search_query(legacy_query, options[:max_results], options[:recency_filter])
359
+ def duckduckgo_provider_search(query, options)
360
+ duckduckgo_query = query_with_domain_filter(query, options[:domain_filter])
361
+ results, error = duckduckgo_provider_query(duckduckgo_query, options[:max_results], options[:recency_filter])
358
362
  raise error if results.empty? && error
359
363
 
360
- SearchResponse.new(answer: "", results: results, provider: results.first&.provider || "legacy", note: error)
364
+ SearchResponse.new(answer: "", results: results, provider: results.first&.provider || "duckduckgo", note: error)
361
365
  end
362
366
 
363
- def legacy_search_query(query, max_results, recency_filter)
367
+ def duckduckgo_provider_query(query, max_results, recency_filter)
364
368
  begin
365
369
  duckduckgo_results = duckduckgo_search(query, max_results, recency_filter)
366
370
  return [duckduckgo_results, nil] unless duckduckgo_results.empty?
@@ -576,6 +580,7 @@ module Kward
576
580
  text
577
581
  end
578
582
 
583
+ # Returns browser-like headers used for HTML search fallbacks.
579
584
  def browser_headers(accept)
580
585
  {
581
586
  "Accept" => accept,
@@ -604,19 +609,16 @@ module Kward
604
609
  end
605
610
 
606
611
  def web_config
607
- value = config["web_search"] || config["webSearch"] || config["web_research"] || config["webResearch"] || {}
608
- value.is_a?(Hash) ? value : {}
612
+ ConfigFiles.web_search_config(config)
609
613
  end
610
614
 
611
615
  def config_value(key)
612
616
  snake = key.to_s
613
617
  camel = snake.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
614
618
  prefixed = "web_search_#{snake}"
615
- legacy_prefixed = "web_research_#{snake}"
616
619
  return web_config[snake] if web_config.key?(snake)
617
620
  return web_config[camel] if web_config.key?(camel)
618
621
  return config[prefixed] if config.key?(prefixed)
619
- return config[legacy_prefixed] if config.key?(legacy_prefixed)
620
622
  return config[snake] if config.key?(snake)
621
623
  return config[camel] if config.key?(camel)
622
624
 
@@ -711,6 +713,7 @@ module Kward
711
713
  { "day" => "d", "week" => "w", "month" => "m", "year" => "y" }[filter]
712
714
  end
713
715
 
716
+ # HTTP adapter used by web-search providers and fallbacks.
714
717
  class NetHttpClient
715
718
  Response = Struct.new(:code, :body, keyword_init: true)
716
719
 
@@ -1,6 +1,14 @@
1
1
  require "json"
2
+ require_relative "../message_access"
2
3
 
4
+ # Namespace for the Kward CLI agent runtime.
3
5
  module Kward
6
+ # Reads and normalizes model tool-call hashes.
7
+ #
8
+ # Tool calls arrive from several providers and may be restored from session
9
+ # files. This module keeps provider/string/symbol compatibility in one place
10
+ # and exposes small helpers used by the agent loop, tool registry, transcript
11
+ # formatters, and RPC event normalizers.
4
12
  module ToolCall
5
13
  TOOL_NAME_MAP = {
6
14
  "read_file" => "read",
@@ -8,6 +16,7 @@ module Kward
8
16
  "write_file" => "write",
9
17
  "run_shell_command" => "bash",
10
18
  "list_directory" => "list_directory",
19
+ "code_search" => "code_search",
11
20
  "web_search" => "web_search",
12
21
  "read_skill" => "read_skill",
13
22
  "ask_user_question" => "ask_user_question"
@@ -15,19 +24,27 @@ module Kward
15
24
 
16
25
  module_function
17
26
 
27
+ # @return [String, nil] provider tool-call id
18
28
  def id(tool_call)
19
29
  value(tool_call, :id)
20
30
  end
21
31
 
32
+ # @return [String, nil] requested tool/function name
22
33
  def name(tool_call)
23
34
  value(function(tool_call), :name)
24
35
  end
25
36
 
37
+ # Returns the short name used in compact UI labels.
38
+ #
39
+ # @return [String] display label such as `read`, `edit`, or `bash`
26
40
  def display_name(tool_call)
27
41
  raw_name = name(tool_call)
28
42
  normalized_name(raw_name) || raw_name || "unknown_tool"
29
43
  end
30
44
 
45
+ # Parses the requested tool arguments.
46
+ #
47
+ # @return [Hash] decoded argument object, or an empty hash for invalid JSON
31
48
  def arguments(tool_call)
32
49
  parse_arguments(raw_arguments(tool_call))
33
50
  end
@@ -44,6 +61,10 @@ module Kward
44
61
  TOOL_NAME_MAP[name.to_s]
45
62
  end
46
63
 
64
+ # Converts provider argument payloads into hashes.
65
+ #
66
+ # Providers normally send JSON strings, while tests and compatibility callers
67
+ # may pass hashes directly.
47
68
  def parse_arguments(arguments)
48
69
  return {} if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
49
70
  return arguments if arguments.is_a?(Hash)
@@ -53,6 +74,9 @@ module Kward
53
74
  {}
54
75
  end
55
76
 
77
+ # Recursively converts snake_case hash keys to camelCase symbols.
78
+ #
79
+ # @return [Hash] camelized copy of `args`
56
80
  def camelize_args(args)
57
81
  return {} unless args.is_a?(Hash)
58
82
 
@@ -62,11 +86,7 @@ module Kward
62
86
  end
63
87
 
64
88
  def value(object, key)
65
- return nil unless object.respond_to?(:key?)
66
- return object[key] if object.key?(key)
67
- return object[key.to_s] if object.key?(key.to_s)
68
-
69
- nil
89
+ MessageAccess.value(object, key)
70
90
  end
71
91
 
72
92
  def camelize_value(item)
@@ -1,8 +1,13 @@
1
1
  require_relative "base"
2
+ require_relative "search/web"
2
3
 
4
+ # Namespace for the Kward CLI agent runtime.
3
5
  module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
4
7
  module Tools
8
+ # Live web-search implementation with provider fallbacks.
5
9
  class WebSearch < Base
10
+ # Builds the tool schema and stores the execution dependency.
6
11
  def initialize(web_search:)
7
12
  @web_search = web_search
8
13
  super(
@@ -22,7 +27,7 @@ module Kward
22
27
  },
23
28
  provider: {
24
29
  type: "string",
25
- enum: %w[auto exa perplexity gemini legacy duckduckgo],
30
+ enum: Kward::WebSearch::PROVIDERS,
26
31
  description: "Provider override; default auto."
27
32
  },
28
33
  recency_filter: {
@@ -40,6 +45,7 @@ module Kward
40
45
  )
41
46
  end
42
47
 
48
+ # Executes the tool and returns model-facing output text.
43
49
  def call(args, _conversation, cancellation: nil)
44
50
  @web_search.search(args)
45
51
  end
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Tool wrapper for guarded full-file writes.
5
8
  class WriteFile < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(workspace:)
7
11
  @workspace = workspace
8
12
  super(
@@ -16,6 +20,7 @@ module Kward
16
20
  )
17
21
  end
18
22
 
23
+ # Executes the tool and returns model-facing output text.
19
24
  def call(args, conversation, cancellation: nil)
20
25
  path = argument(args, :path, "")
21
26
  content = argument(args, :content, "")
@@ -1,7 +1,9 @@
1
1
  require "cgi"
2
2
  require_relative "markdown_transcript"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Serializes conversations for transcript export formats.
5
7
  class TranscriptExport
6
8
  SUPPORTED_FORMATS = ["markdown", "html"].freeze
7
9
 
data/lib/kward/version.rb CHANGED
@@ -1,4 +1,5 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
2
3
  # Current gem version.
3
- VERSION = "0.67.1"
4
+ VERSION = "0.68.0"
4
5
  end
@@ -3,7 +3,19 @@ require "pathname"
3
3
  require "timeout"
4
4
  require_relative "session_diff"
5
5
 
6
+ # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
8
+ # Filesystem and shell-command boundary for workspace tools.
9
+ #
10
+ # `Workspace` is deliberately low-level: it validates paths, enforces output
11
+ # limits, applies exact edits, writes files, and runs shell commands from one
12
+ # root directory. It should not know about model prompts, sessions, telemetry,
13
+ # or UI confirmation. Tool wrappers and frontends provide those policies.
14
+ #
15
+ # Guardrails are enabled by default and require all file paths to resolve under
16
+ # `root`. RPC may report when guardrails are disabled, but callers should avoid
17
+ # bypassing this class for local filesystem mutation so read-before-write and
18
+ # path safety remain consistent.
7
19
  class Workspace
8
20
  MAX_FILE_BYTES = 256 * 1024
9
21
  MAX_READ_OUTPUT_BYTES = 50 * 1024
@@ -12,6 +24,7 @@ module Kward
12
24
  MAX_EDIT_DIFF_BYTES = 8 * 1024
13
25
  DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
14
26
 
27
+ # Creates an object for workspace filesystem and shell operations.
15
28
  def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
16
29
  @root = Pathname.new(root).realpath
17
30
  @guardrails = guardrails
@@ -21,8 +34,10 @@ module Kward
21
34
  @max_command_output_bytes = max_command_output_bytes
22
35
  end
23
36
 
37
+ # @return [Pathname] canonical workspace root used as the base for file and shell tools
24
38
  attr_reader :root
25
39
 
40
+ # Lists immediate directory children after resolving `path` through workspace guardrails.
26
41
  def list_directory(path)
27
42
  resolved = workspace_path(path)
28
43
  return "Error: not a directory: #{path}" unless File.directory?(resolved)
@@ -34,6 +49,11 @@ module Kward
34
49
  "Error: #{e.message}"
35
50
  end
36
51
 
52
+ # Reads a bounded text slice from a workspace file.
53
+ #
54
+ # The returned string is user/model-facing and includes continuation notices
55
+ # when output is truncated. Errors are returned as `"Error: ..."` strings so
56
+ # tool calls can be persisted in the conversation without raising.
37
57
  def read_file(path, offset: nil, limit: nil)
38
58
  resolved = workspace_path(path)
39
59
  return "Error: not a file: #{path}" unless File.file?(resolved)
@@ -41,11 +61,20 @@ module Kward
41
61
  size = File.size(resolved)
42
62
  return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
43
63
 
44
- read_file_slice(File.read(resolved), offset: offset, limit: limit)
64
+ content = File.read(resolved)
65
+ return "Error: not a text file: #{path}" if binary_content?(content)
66
+
67
+ read_file_slice(content, offset: offset, limit: limit)
45
68
  rescue SecurityError, Errno::ENOENT => e
46
69
  "Error: #{e.message}"
47
70
  end
48
71
 
72
+ # Writes complete file content after enforcing read-before-write for
73
+ # existing files.
74
+ #
75
+ # `read_paths` must contain resolved paths previously observed by
76
+ # `ReadFile`; this keeps tool-driven edits explicit and prevents overwriting
77
+ # unseen user files.
49
78
  def write_file(path, content, read_paths:)
50
79
  resolved = workspace_write_path(path)
51
80
 
@@ -53,10 +82,6 @@ module Kward
53
82
  return "Error: existing file must be read before writing: #{path}"
54
83
  end
55
84
 
56
- if block_given? && !yield(relative_path(resolved), content.bytesize)
57
- return "Declined: write_file was not approved for #{path}"
58
- end
59
-
60
85
  old_content = File.exist?(resolved) ? File.read(resolved) : nil
61
86
  File.write(resolved, content)
62
87
  output = "Wrote #{content.bytesize} bytes to #{path}"
@@ -66,6 +91,11 @@ module Kward
66
91
  "Error: #{e.message}"
67
92
  end
68
93
 
94
+ # Applies exact non-overlapping replacements to a previously read file.
95
+ #
96
+ # Each `old_text` must match exactly once. This favors predictable model edits
97
+ # over fuzzy patching and returns readable error strings when more context is
98
+ # needed.
69
99
  def edit_file(path, edits, read_paths:)
70
100
  resolved = workspace_path(path)
71
101
  return "Error: not a file: #{path}" unless File.file?(resolved)
@@ -84,6 +114,11 @@ module Kward
84
114
  "Error: #{e.message}"
85
115
  end
86
116
 
117
+ # Runs a shell command from the workspace root with timeout, cancellation,
118
+ # and bounded combined output.
119
+ #
120
+ # This method intentionally does not ask for confirmation; CLI/RPC policy
121
+ # must decide whether a command is allowed before reaching this boundary.
87
122
  def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
88
123
  command = command.to_s.strip
89
124
  return "Error: command is required" if command.empty?
@@ -114,6 +149,7 @@ module Kward
114
149
  "Error: #{e.message}"
115
150
  end
116
151
 
152
+ # Resolves a path with the same guardrails used by file tools.
117
153
  def resolved_path(path)
118
154
  workspace_path(path)
119
155
  end
@@ -189,6 +225,10 @@ module Kward
189
225
  output
190
226
  end
191
227
 
228
+ def binary_content?(content)
229
+ content.include?("\x00")
230
+ end
231
+
192
232
  def read_start_index(offset)
193
233
  return 0 if offset.nil?
194
234