kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -1,8 +1,13 @@
1
+ require_relative "../question_contract"
1
2
  require_relative "base"
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
+ # Tool wrapper for structured clarification questions to the user.
5
9
  class AskUserQuestion < Base
10
+ # Builds the tool schema and stores the execution dependency.
6
11
  def initialize(prompt:)
7
12
  @prompt = prompt
8
13
  super(
@@ -42,6 +47,7 @@ module Kward
42
47
  )
43
48
  end
44
49
 
50
+ # Executes the tool and returns model-facing output text.
45
51
  def call(args, _conversation, cancellation: nil)
46
52
  return "Error: ask_user_question requires interactive prompt support." unless @prompt.respond_to?(:ask_user_question)
47
53
 
@@ -67,40 +73,22 @@ module Kward
67
73
  end
68
74
 
69
75
  def validated_questions(args)
70
- questions = argument(args, :questions)
71
- return "Error: ask_user_question requires questions." unless questions.is_a?(Array)
72
- return "Error: ask_user_question requires 1 to 4 questions." unless questions.length.between?(1, 4)
73
-
74
- questions.map.with_index(1) do |question, index|
75
- return "Error: question #{index} must be an object." unless question.respond_to?(:key?)
76
- return "Error: question #{index} uses unsupported multiSelect." if question.key?("multiSelect") || question.key?(:multiSelect)
77
-
78
- text = question_value(question, :question).to_s.strip
79
- header = question_value(question, :header).to_s.strip
80
- options = question_value(question, :options)
81
- return "Error: question #{index} requires question and header." if text.empty? || header.empty?
82
- return "Error: question #{index} requires 2 to 4 options." unless options.is_a?(Array) && options.length.between?(2, 4)
83
-
84
- normalized_options = options.map.with_index(1) do |option, option_index|
85
- return "Error: question #{index} option #{option_index} must be an object." unless option.respond_to?(:key?)
86
- return "Error: question #{index} option #{option_index} uses unsupported preview." if option.key?("preview") || option.key?(:preview)
87
-
88
- label = question_value(option, :label).to_s.strip
89
- description = question_value(option, :description).to_s.strip
90
- return "Error: question #{index} option #{option_index} requires label and description." if label.empty? || description.empty?
91
-
92
- { label: label, description: description }
93
- end
94
-
95
- { question: text, header: header, options: normalized_options }
96
- end
76
+ QuestionContract.normalize_questions(argument(args, :questions))
77
+ rescue ArgumentError => e
78
+ "Error: #{tool_error_message(e.message)}."
97
79
  end
98
80
 
99
- def question_value(object, key)
100
- return object[key] if object.key?(key)
101
- return object[key.to_s] if object.key?(key.to_s)
102
-
103
- nil
81
+ def tool_error_message(message)
82
+ case message
83
+ when "questions must be an array"
84
+ "ask_user_question requires questions"
85
+ when "ui/question requires 1-4 questions"
86
+ "ask_user_question requires 1 to 4 questions"
87
+ else
88
+ message.gsub("2-4", "2 to 4")
89
+ .gsub("multiSelect is unsupported", "uses unsupported multiSelect")
90
+ .gsub("preview is unsupported", "uses unsupported preview")
91
+ end
104
92
  end
105
93
  end
106
94
  end
@@ -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, [])
@@ -0,0 +1,41 @@
1
+ require_relative "base"
2
+ require_relative "search/web_fetch"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
7
+ module Tools
8
+ # Fetches a specific URL and extracts readable page content.
9
+ class FetchContent < Base
10
+ # Builds the tool schema and stores the execution dependency.
11
+ def initialize(web_fetch:)
12
+ @web_fetch = web_fetch
13
+ super(
14
+ "fetch_content",
15
+ "Fetch a specific URL and extract readable bounded content.",
16
+ properties: {
17
+ url: {
18
+ type: "string",
19
+ description: "HTTP or HTTPS URL to fetch."
20
+ },
21
+ max_bytes: {
22
+ type: "integer",
23
+ description: "Maximum returned content bytes; default 16384, max 131072."
24
+ },
25
+ extract: {
26
+ type: "string",
27
+ enum: %w[auto text markdown],
28
+ description: "Extraction mode; default auto."
29
+ }
30
+ },
31
+ required: ["url"]
32
+ )
33
+ end
34
+
35
+ # Executes the tool and returns model-facing output text.
36
+ def call(args, _conversation, cancellation: nil)
37
+ @web_fetch.fetch_content(args)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "base"
2
+ require_relative "search/web_fetch"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
7
+ module Tools
8
+ # Fetches bounded raw content from a specific URL.
9
+ class FetchRaw < Base
10
+ # Builds the tool schema and stores the execution dependency.
11
+ def initialize(web_fetch:)
12
+ @web_fetch = web_fetch
13
+ super(
14
+ "fetch_raw",
15
+ "Fetch bounded raw content from a specific URL.",
16
+ properties: {
17
+ url: {
18
+ type: "string",
19
+ description: "HTTP or HTTPS URL to fetch."
20
+ },
21
+ max_bytes: {
22
+ type: "integer",
23
+ description: "Maximum returned content bytes; default 16384, max 131072."
24
+ },
25
+ accept: {
26
+ type: "string",
27
+ description: "Optional HTTP Accept header."
28
+ }
29
+ },
30
+ required: ["url"]
31
+ )
32
+ end
33
+
34
+ # Executes the tool and returns model-facing output text.
35
+ def call(args, _conversation, cancellation: nil)
36
+ @web_fetch.fetch_raw(args)
37
+ end
38
+ end
39
+ end
40
+ 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 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)
@@ -2,6 +2,8 @@ require_relative "../config_files"
2
2
  require_relative "ask_user_question"
3
3
  require_relative "code_search"
4
4
  require_relative "edit_file"
5
+ require_relative "fetch_content"
6
+ require_relative "fetch_raw"
5
7
  require_relative "list_directory"
6
8
  require_relative "read_file"
7
9
  require_relative "read_skill"
@@ -10,22 +12,52 @@ require_relative "web_search"
10
12
  require_relative "write_file"
11
13
  require_relative "search/code"
12
14
  require_relative "search/web"
15
+ require_relative "search/web_fetch"
13
16
  require_relative "tool_call"
14
17
  require_relative "../workspace"
15
18
 
19
+ # Namespace for the Kward CLI agent runtime.
16
20
  module Kward
17
21
  # Exposes local workspace, search, skill, and interaction tools to the model
18
- # and dispatches approved tool calls into the active conversation.
22
+ # and dispatches tool calls into the active conversation.
23
+ #
24
+ # `ToolRegistry` is the boundary between model-requested function calls and
25
+ # Ruby tool objects. It owns schema exposure and transcript persistence for
26
+ # tool results; individual tools own validation and side effects. Keep frontend
27
+ # policy outside this class by passing dependencies such as `workspace` and
28
+ # `prompt` from CLI or RPC setup.
29
+ #
30
+ # A tool may exist in `@tools` but not be advertised in `schemas`. This allows
31
+ # restored transcripts or compatibility callers to dispatch known tools while
32
+ # config and frontend capability checks decide what the model can request next.
33
+ #
34
+ # Tool schemas are the strict output contract advertised to models and clients.
35
+ # Incoming calls are intentionally more tolerant: extra fields are ignored by
36
+ # individual tools, and legacy-compatible shapes are accepted where already
37
+ # supported. Required fields and invalid required values should still return
38
+ # explicit tool errors.
19
39
  class ToolRegistry
20
40
  # Tool schemas advertised to the model for the current frontend and config.
21
41
  #
22
- # @return [Array<Hash>]
42
+ # @return [Array<Hash>] tool schemas currently advertised to the model
23
43
  attr_reader :schemas
24
44
 
25
- 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)
45
+ # Builds tool objects and the schema list for the current frontend/config.
46
+ #
47
+ # @param workspace [Workspace] filesystem/shell boundary used by local tools
48
+ # @param prompt [Object, nil] interactive prompt bridge; must implement
49
+ # `ask_user_question` before that tool is advertised
50
+ # @param web_search [WebSearch] live web search implementation
51
+ # @param web_fetch [WebFetch] specific URL fetch implementation
52
+ # @param code_search [CodeSearch] public source/package search implementation
53
+ # @param web_search_enabled [Boolean, nil] override for web search exposure
54
+ # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
55
+ # @param ask_user_question_enabled [Boolean, nil] override question exposure
56
+ 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)
26
57
  @workspace = workspace
27
58
  @prompt = prompt
28
59
  @web_search = web_search
60
+ @web_fetch = web_fetch
29
61
  @code_search = code_search
30
62
  @skills = skills
31
63
  @web_search_enabled = web_search_enabled
@@ -37,6 +69,10 @@ module Kward
37
69
  # Executes a model-requested tool call and appends the result to the
38
70
  # conversation transcript.
39
71
  #
72
+ # Unknown tools are recorded as tool results instead of raising. That keeps
73
+ # the conversation valid for the model and lets the assistant recover by
74
+ # choosing an advertised tool on the next turn.
75
+ #
40
76
  # @param tool_call [Hash] model tool call payload
41
77
  # @param conversation [Conversation] active conversation
42
78
  # @return [String] tool output content appended to the conversation
@@ -72,7 +108,7 @@ module Kward
72
108
  tools = @tools.values_at(
73
109
  "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
74
110
  )
75
- tools << @tools["web_search"] if web_search_available?
111
+ tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
76
112
  tools << @tools["read_skill"] if skills_available?
77
113
  tools << @tools["ask_user_question"] if ask_user_question_available?
78
114
  tools
@@ -81,6 +117,8 @@ module Kward
81
117
  def all_tools
82
118
  core_tools + [
83
119
  Tools::WebSearch.new(web_search: @web_search),
120
+ Tools::FetchContent.new(web_fetch: @web_fetch),
121
+ Tools::FetchRaw.new(web_fetch: @web_fetch),
84
122
  Tools::ReadSkill.new,
85
123
  Tools::AskUserQuestion.new(prompt: @prompt)
86
124
  ]
@@ -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
@@ -61,7 +64,7 @@ module Kward
61
64
  provider: provider
62
65
  }
63
66
 
64
- sections = ["# Web search"]
67
+ sections = ["# Web search", "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources."]
65
68
  failures = []
66
69
  any_results = false
67
70
 
@@ -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,8 +713,9 @@ 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
- Response = Struct.new(:code, :body, keyword_init: true)
718
+ Response = Struct.new(:code, :body, :headers, keyword_init: true)
716
719
 
717
720
  def get(url, headers: {})
718
721
  request(url, Net::HTTP::Get, headers: headers)
@@ -739,7 +742,7 @@ module Kward
739
742
  headers.each { |key, value| http_request[key] = value }
740
743
  yield http_request if block_given?
741
744
  response = http.request(http_request)
742
- Response.new(code: response.code, body: response.body)
745
+ Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
743
746
  end
744
747
  end
745
748
  end