kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -0,0 +1,202 @@
1
+ require "pathname"
2
+ require_relative "../workspace"
3
+ require_relative "base"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model-callable tool wrappers and their argument schemas.
8
+ module Tools
9
+ # Builds a compact, task-shaped bundle from likely workspace files.
10
+ class ContextForTask < Base
11
+ DEFAULT_BUDGET = 4_000
12
+ MAX_BUDGET = 20_000
13
+ MAX_FILES = 8
14
+ MAX_MATCHES_PER_FILE = 8
15
+ DEFAULT_EXTENSIONS = %w[.rb .js .jsx .ts .tsx .py .go .rs .java .cs .cpp .c .h .hpp .md .yml .yaml .json .toml].freeze
16
+ SKIP_DIRECTORIES = %w[.git .yardoc _yardoc node_modules vendor tmp log coverage dist build .bundle].freeze
17
+
18
+ # Builds the tool schema and stores the execution dependency.
19
+ def initialize(workspace:)
20
+ @workspace = workspace
21
+ super(
22
+ "context_for_task",
23
+ "Build focused workspace context for a task from outlines and matching excerpts within a byte budget.",
24
+ properties: {
25
+ budget: { type: "integer", description: "Approximate byte budget for the returned context. Default 4000, maximum 20000." },
26
+ paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative files or directories to focus." },
27
+ task: { type: "string", description: "Task or question to gather context for." }
28
+ },
29
+ required: ["task"]
30
+ )
31
+ end
32
+
33
+ # Executes focused context retrieval.
34
+ def call(args, _conversation, cancellation: nil)
35
+ cancellation&.raise_if_cancelled!
36
+ task = argument(args, :task, "").to_s.strip
37
+ return "Error: task is required" if task.empty?
38
+
39
+ budget = normalized_budget(argument(args, :budget))
40
+ return budget if budget.is_a?(String)
41
+
42
+ focus_paths = Array(argument(args, :paths, [])).map(&:to_s).reject(&:empty?)
43
+ files = candidate_files(focus_paths, cancellation: cancellation)
44
+ return "No readable candidate files found for focused context." if files.empty?
45
+
46
+ terms = search_terms(task)
47
+ ranked = rank_files(files, terms)
48
+ render_context(task: task, budget: budget, terms: terms, ranked: ranked, cancellation: cancellation)
49
+ rescue SecurityError, Errno::ENOENT => e
50
+ "Error: #{e.message}"
51
+ end
52
+
53
+ private
54
+
55
+ def normalized_budget(value)
56
+ return DEFAULT_BUDGET if value.nil?
57
+
58
+ budget = value.to_i
59
+ return "Error: budget must be positive" unless budget.positive?
60
+
61
+ [budget, MAX_BUDGET].min
62
+ end
63
+
64
+ def candidate_files(paths, cancellation:)
65
+ roots = paths.empty? ? [@workspace.root.to_s] : paths.map { |path| @workspace.resolved_path(path) }
66
+ files = roots.flat_map do |path|
67
+ cancellation&.raise_if_cancelled!
68
+ File.directory?(path) ? files_under(path, cancellation: cancellation) : [path]
69
+ end
70
+ files.uniq.select { |path| readable_context_file?(path) }.first(MAX_FILES * 8)
71
+ end
72
+
73
+ def files_under(root, cancellation:)
74
+ files = []
75
+ stack = [root]
76
+ until stack.empty? || files.length >= MAX_FILES * 8
77
+ cancellation&.raise_if_cancelled!
78
+ current = stack.pop
79
+ next if skipped_directory?(current)
80
+
81
+ entries = Dir.children(current).sort.map { |entry| File.join(current, entry) }
82
+ entries.each do |entry|
83
+ if File.directory?(entry)
84
+ stack << entry
85
+ else
86
+ files << entry if readable_context_file?(entry)
87
+ end
88
+ end
89
+ end
90
+ files
91
+ end
92
+
93
+ def readable_context_file?(path)
94
+ return false unless File.file?(path)
95
+ return false if File.size(path) > Workspace::MAX_FILE_BYTES
96
+ return false unless DEFAULT_EXTENSIONS.include?(File.extname(path)) || File.basename(path) == "Gemfile"
97
+
98
+ sample = File.open(path, "rb") { |file| file.read(4096).to_s }
99
+ !sample.include?("\x00")
100
+ rescue Errno::ENOENT, Errno::EACCES
101
+ false
102
+ end
103
+
104
+ def skipped_directory?(path)
105
+ SKIP_DIRECTORIES.include?(File.basename(path))
106
+ end
107
+
108
+ def search_terms(task)
109
+ task.scan(/[A-Za-z_][A-Za-z0-9_]{2,}/).map(&:downcase).reject { |term| stopword?(term) }.uniq.first(20)
110
+ end
111
+
112
+ def stopword?(term)
113
+ %w[the and for with from this that into when where what why how fix add update change implement review debug explain failing failure error issue].include?(term)
114
+ end
115
+
116
+ def rank_files(files, terms)
117
+ scored = files.map do |path|
118
+ content = File.read(path)
119
+ relative = relative_path(path)
120
+ score = score_file(relative, content, terms)
121
+ { path: path, relative: relative, content: content, score: score }
122
+ rescue Errno::ENOENT, Errno::EACCES
123
+ nil
124
+ end.compact
125
+ filtered = terms.empty? ? scored : scored.select { |file| file[:score].positive? }
126
+ filtered.sort_by { |file| [-file[:score], file[:relative]] }.first(MAX_FILES)
127
+ end
128
+
129
+ def score_file(relative, content, terms)
130
+ haystack = "#{relative}\n#{content}".downcase
131
+ terms.sum { |term| haystack.scan(term).length } + (terms.any? { |term| relative.downcase.include?(term) } ? 5 : 0)
132
+ end
133
+
134
+ def render_context(task:, budget:, terms:, ranked:, cancellation:)
135
+ lines = ["# Focused context", "- Task: #{task}", "- Budget: #{budget} bytes", "- Search terms: #{terms.empty? ? '(none)' : terms.join(', ')}", ""]
136
+ used = lines.join("\n").bytesize
137
+
138
+ ranked.each do |file|
139
+ cancellation&.raise_if_cancelled!
140
+ section = file_section(file, terms)
141
+ break if used + section.bytesize > budget && lines.length > 5
142
+
143
+ if used + section.bytesize > budget
144
+ remaining = budget - used
145
+ break if remaining < 200
146
+
147
+ section = section.byteslice(0, remaining).to_s.scrub << "\n[Context budget reached.]"
148
+ end
149
+ lines << section
150
+ used += section.bytesize
151
+ end
152
+
153
+ lines.join("\n")
154
+ end
155
+
156
+ def file_section(file, terms)
157
+ outline = @workspace.summarize_file_structure(file[:relative])
158
+ matches = matching_excerpt(file[:content], terms)
159
+ parts = ["## #{file[:relative]}", "- Score: #{file[:score]}"]
160
+ parts << outline unless outline.start_with?("No recognizable source structure")
161
+ parts << matches unless matches.empty?
162
+ parts.join("\n") << "\n"
163
+ end
164
+
165
+ def matching_excerpt(content, terms)
166
+ return "" if terms.empty?
167
+
168
+ lines = content.split("\n", -1)
169
+ indexes = matching_indexes(lines, terms)
170
+ return "" if indexes.empty?
171
+
172
+ selected = indexes.flat_map { |index| ([index - 2, 0].max..[index + 2, lines.length - 1].min).to_a }.uniq.sort
173
+ render_excerpt(lines, selected)
174
+ end
175
+
176
+ def matching_indexes(lines, terms)
177
+ indexes = []
178
+ lines.each_with_index do |line, index|
179
+ lower = line.downcase
180
+ indexes << index if terms.any? { |term| lower.include?(term) }
181
+ break if indexes.length >= MAX_MATCHES_PER_FILE
182
+ end
183
+ indexes
184
+ end
185
+
186
+ def render_excerpt(lines, selected)
187
+ output = ["### Matching excerpts"]
188
+ previous = nil
189
+ selected.each do |index|
190
+ output << "..." if previous && index > previous + 1
191
+ output << "%4d: %s" % [index + 1, lines[index]]
192
+ previous = index
193
+ end
194
+ output.join("\n")
195
+ end
196
+
197
+ def relative_path(path)
198
+ Pathname.new(path).relative_path_from(@workspace.root).to_s
199
+ end
200
+ end
201
+ end
202
+ end
@@ -11,11 +11,13 @@ module Kward
11
11
  @workspace = workspace
12
12
  super(
13
13
  "read_file",
14
- "Read a workspace text file. Output is capped; use offset/limit to continue.",
14
+ "Read a workspace text file. Output is capped; use offset/limit or mode to control context budget.",
15
15
  properties: {
16
16
  path: { type: "string", description: "Workspace-relative path." },
17
- offset: { type: "integer", description: "1-indexed start line." },
18
- limit: { type: "integer", description: "Maximum lines to return." }
17
+ limit: { type: "integer", description: "Maximum lines to return." },
18
+ max_bytes: { type: "integer", description: "Optional byte budget for this read, capped by Kward's workspace read limit." },
19
+ mode: { type: "string", enum: %w[preview outline range full], description: "Context mode. preview returns a short slice, outline returns source declarations, range reads offset/limit, full reads until Kward's cap." },
20
+ offset: { type: "integer", description: "1-indexed start line." }
19
21
  },
20
22
  required: ["path"]
21
23
  )
@@ -26,7 +28,9 @@ module Kward
26
28
  path = argument(args, :path, "")
27
29
  offset = argument(args, :offset)
28
30
  limit = argument(args, :limit)
29
- content = @workspace.read_file(path, offset: offset, limit: limit)
31
+ mode = argument(args, :mode)
32
+ max_bytes = argument(args, :max_bytes)
33
+ content = @workspace.read_file(path, offset: offset, limit: limit, mode: mode, max_bytes: max_bytes)
30
34
  conversation.mark_read(@workspace.resolved_path(path)) unless content.start_with?("Error:")
31
35
  content
32
36
  end
@@ -1,6 +1,9 @@
1
1
  require_relative "../config_files"
2
+ require_relative "../conversation"
2
3
  require_relative "ask_user_question"
3
4
  require_relative "code_search"
5
+ require_relative "context_budget_stats"
6
+ require_relative "context_for_task"
4
7
  require_relative "edit_file"
5
8
  require_relative "fetch_content"
6
9
  require_relative "fetch_raw"
@@ -8,12 +11,16 @@ require_relative "list_directory"
8
11
  require_relative "read_file"
9
12
  require_relative "read_skill"
10
13
  require_relative "run_shell_command"
14
+ require_relative "summarize_file_structure"
15
+ require_relative "retrieve_tool_output"
11
16
  require_relative "web_search"
12
17
  require_relative "write_file"
13
18
  require_relative "search/code"
14
19
  require_relative "search/web"
15
20
  require_relative "search/web_fetch"
16
21
  require_relative "tool_call"
22
+ require_relative "../telemetry/logger"
23
+ require_relative "../tool_output_compactor"
17
24
  require_relative "../workspace"
18
25
 
19
26
  # Namespace for the Kward CLI agent runtime.
@@ -40,7 +47,7 @@ module Kward
40
47
  # Tool schemas advertised to the model for the current frontend and config.
41
48
  #
42
49
  # @return [Array<Hash>] tool schemas currently advertised to the model
43
- attr_reader :schemas
50
+ attr_reader :schemas, :writer_id
44
51
 
45
52
  # Builds tool objects and the schema list for the current frontend/config.
46
53
  #
@@ -53,7 +60,7 @@ module Kward
53
60
  # @param web_search_enabled [Boolean, nil] override for web search exposure
54
61
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
55
62
  # @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)
63
+ 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, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil)
57
64
  @workspace = workspace
58
65
  @prompt = prompt
59
66
  @web_search = web_search
@@ -62,6 +69,12 @@ module Kward
62
69
  @skills = skills
63
70
  @web_search_enabled = web_search_enabled
64
71
  @ask_user_question_enabled = ask_user_question_enabled
72
+ @allowed_tool_names = allowed_tool_names&.map(&:to_s)
73
+ @write_lock = write_lock
74
+ @writer_id = writer_id
75
+ @tool_output_compactor = tool_output_compactor
76
+ @telemetry_logger = telemetry_logger
77
+ @context_budget_meter = context_budget_meter
65
78
  @tools = build_tools.freeze
66
79
  @schemas = build_schema_tools.map(&:schema).freeze
67
80
  end
@@ -82,36 +95,96 @@ module Kward
82
95
  args = ToolCall.arguments(tool_call)
83
96
  tool = @tools[name]
84
97
 
85
- content = if tool
86
- tool.call(args, conversation, cancellation: cancellation)
87
- else
88
- "Unknown tool: #{name}"
89
- end
90
-
98
+ original_content = if tool
99
+ if mutation_tool?(name) && !write_lock_owned?
100
+ "Workspace write denied: another worker owns the write lock."
101
+ else
102
+ tool.call(args, conversation, cancellation: cancellation)
103
+ end
104
+ else
105
+ "Unknown tool: #{name}"
106
+ end
107
+ original_content = Conversation.normalize_tool_content(original_content)
108
+ duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: original_content)
109
+ content = original_content
110
+ if reusable_duplicate_output?(name) && conversation.tool_output_artifacts.key?(duplicate_id)
111
+ content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
112
+ end
113
+
114
+ artifact_id = nil
115
+ model_content = @tool_output_compactor.compact(name, content) do
116
+ artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
117
+ end
118
+ record_context_budget(conversation, name, before: original_content, after: model_content)
119
+ log_tool_output_compaction(name, artifact_id: artifact_id, before: original_content, after: model_content) if model_content != original_content
91
120
  conversation.append_tool(
92
121
  tool_call_id: tool_call["id"] || tool_call[:id],
93
122
  name: name,
94
- content: content
123
+ content: model_content
95
124
  )
96
- conversation.append_tool_execution(tool_call: tool_call, content: content)
125
+ conversation.append_tool_execution(tool_call: tool_call, content: original_content)
97
126
 
98
- content
127
+ model_content
99
128
  end
100
129
 
101
130
  private
102
131
 
132
+ def record_context_budget(conversation, name, before:, after:)
133
+ meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
134
+ return unless meter
135
+ return if name.to_s == "context_budget_stats"
136
+
137
+ saved = meter.record(tool_name: name, original_bytes: before.bytesize, returned_bytes: after.bytesize)
138
+ @telemetry_logger.log(
139
+ "compaction",
140
+ "context_budget",
141
+ "tool_name" => name,
142
+ "bytes_before" => before.bytesize,
143
+ "bytes_after" => after.bytesize,
144
+ "bytes_saved" => saved
145
+ )
146
+ end
147
+
148
+ def reusable_duplicate_output?(name)
149
+ name.to_s != "read_skill"
150
+ end
151
+
152
+ def log_tool_output_compaction(name, artifact_id:, before:, after:)
153
+ @telemetry_logger.log(
154
+ "compaction",
155
+ "tool_output",
156
+ "tool_name" => name,
157
+ "artifact_id" => artifact_id,
158
+ "bytes_before" => before.bytesize,
159
+ "bytes_after" => after.bytesize,
160
+ "bytes_saved" => before.bytesize - after.bytesize
161
+ )
162
+ end
163
+
164
+ def mutation_tool?(name)
165
+ ToolCall.write_lock_required?(name)
166
+ end
167
+
168
+ def write_lock_owned?
169
+ return true unless @write_lock
170
+
171
+ @write_lock.owned_by?(@writer_id)
172
+ end
173
+
103
174
  def build_tools
104
- all_tools.to_h { |tool| [tool.name, tool] }
175
+ tools = all_tools
176
+ tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
177
+ tools.to_h { |tool| [tool.name, tool] }
105
178
  end
106
179
 
107
180
  def build_schema_tools
108
181
  tools = @tools.values_at(
109
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
182
+ "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
110
183
  )
111
184
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
112
185
  tools << @tools["read_skill"] if skills_available?
113
186
  tools << @tools["ask_user_question"] if ask_user_question_available?
114
- tools
187
+ tools.compact
115
188
  end
116
189
 
117
190
  def all_tools
@@ -131,7 +204,11 @@ module Kward
131
204
  Tools::WriteFile.new(workspace: @workspace),
132
205
  Tools::EditFile.new(workspace: @workspace),
133
206
  Tools::RunShellCommand.new(workspace: @workspace),
134
- Tools::CodeSearch.new(code_search: @code_search)
207
+ Tools::CodeSearch.new(code_search: @code_search),
208
+ Tools::SummarizeFileStructure.new(workspace: @workspace),
209
+ Tools::ContextForTask.new(workspace: @workspace),
210
+ Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
211
+ Tools::RetrieveToolOutput.new
135
212
  ]
136
213
  end
137
214
 
@@ -0,0 +1,71 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Retrieves original tool outputs that were compacted out of model context.
8
+ class RetrieveToolOutput < Base
9
+ DEFAULT_LIMIT = 120
10
+
11
+ # Builds the retrieval tool schema.
12
+ def initialize
13
+ super(
14
+ "retrieve_tool_output",
15
+ "Retrieve original output from a compacted previous tool result.",
16
+ properties: {
17
+ id: { type: "string", description: "Tool output id, for example toolout_abc123." },
18
+ query: { type: "string", description: "Optional case-insensitive text search within the original output." },
19
+ offset: { type: "integer", description: "1-indexed line offset for returned output." },
20
+ limit: { type: "integer", description: "Maximum lines to return; default 120." }
21
+ },
22
+ required: ["id"]
23
+ )
24
+ end
25
+
26
+ # Executes retrieval from the active conversation artifact store.
27
+ def call(args, conversation, cancellation: nil)
28
+ cancellation&.raise_if_cancelled!
29
+ id = argument(args, :id, "").to_s
30
+ return "Error: id is required" if id.empty?
31
+
32
+ artifact = conversation.tool_output_artifacts[id]
33
+ return "Error: unknown tool output id: #{id}" unless artifact
34
+
35
+ content = artifact[:content].to_s
36
+ query = argument(args, :query, "").to_s
37
+ lines = query.empty? ? content.split("\n", -1) : matching_lines(content, query)
38
+ return "No matching lines for #{query.inspect} in #{id}." if lines.empty?
39
+
40
+ slice_lines(id, lines, offset: argument(args, :offset), limit: argument(args, :limit), query: query)
41
+ end
42
+
43
+ private
44
+
45
+ def matching_lines(content, query)
46
+ needle = query.downcase
47
+ content.split("\n", -1).each_with_index.filter_map do |line, index|
48
+ next unless line.downcase.include?(needle)
49
+
50
+ "#{index + 1}: #{line}"
51
+ end
52
+ end
53
+
54
+ def slice_lines(id, lines, offset:, limit:, query:)
55
+ start_index = [offset.to_i - 1, 0].max
56
+ return "Error: offset #{offset} is beyond output (#{lines.length} lines total)" if start_index >= lines.length
57
+
58
+ line_limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
59
+ selected = lines[start_index, line_limit] || []
60
+ header = "[Retrieved tool output #{id}"
61
+ header << " matching #{query.inspect}" unless query.empty?
62
+ header << ": lines #{start_index + 1}-#{start_index + selected.length} of #{lines.length}]"
63
+ output = "#{header}\n#{selected.join("\n")}".rstrip
64
+ if start_index + selected.length < lines.length
65
+ output << "\n\n[#{lines.length - start_index - selected.length} more lines. Use offset=#{start_index + selected.length + 1} to continue.]"
66
+ end
67
+ output
68
+ end
69
+ end
70
+ end
71
+ end
@@ -619,8 +619,8 @@ module Kward
619
619
  return web_config[snake] if web_config.key?(snake)
620
620
  return web_config[camel] if web_config.key?(camel)
621
621
  return config[prefixed] if config.key?(prefixed)
622
- return config[snake] if config.key?(snake)
623
- return config[camel] if config.key?(camel)
622
+ return config[snake] if key.to_s != "provider" && config.key?(snake)
623
+ return config[camel] if key.to_s != "provider" && config.key?(camel)
624
624
 
625
625
  nil
626
626
  end
@@ -0,0 +1,29 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Returns a compact symbol outline for a workspace source file.
8
+ class SummarizeFileStructure < Base
9
+ # Builds the tool schema and stores the execution dependency.
10
+ def initialize(workspace:)
11
+ @workspace = workspace
12
+ super(
13
+ "summarize_file_structure",
14
+ "Return a compact outline of classes, modules, methods, and functions in a workspace source file.",
15
+ properties: {
16
+ path: { type: "string", description: "Workspace-relative source file path." }
17
+ },
18
+ required: ["path"]
19
+ )
20
+ end
21
+
22
+ # Executes the structure summary tool.
23
+ def call(args, _conversation, cancellation: nil)
24
+ cancellation&.raise_if_cancelled!
25
+ @workspace.summarize_file_structure(argument(args, :path, ""))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -17,6 +17,10 @@ module Kward
17
17
  "run_shell_command" => "bash",
18
18
  "list_directory" => "list_directory",
19
19
  "code_search" => "code_search",
20
+ "summarize_file_structure" => "summarize_file_structure",
21
+ "context_for_task" => "context_for_task",
22
+ "context_budget_stats" => "context_budget_stats",
23
+ "retrieve_tool_output" => "retrieve_tool_output",
20
24
  "web_search" => "web_search",
21
25
  "fetch_content" => "fetch_content",
22
26
  "fetch_raw" => "fetch_raw",
@@ -63,6 +67,14 @@ module Kward
63
67
  TOOL_NAME_MAP[name.to_s]
64
68
  end
65
69
 
70
+ def write_lock_required?(name)
71
+ %w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
72
+ end
73
+
74
+ def file_change_tool?(name)
75
+ %w[edit_file write_file edit write].include?(name.to_s)
76
+ end
77
+
66
78
  # Converts provider argument payloads into hashes.
67
79
  #
68
80
  # Providers normally send JSON strings, while tests and compatibility callers
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.70.0"
4
+ VERSION = "0.72.0"
5
5
  end
@@ -0,0 +1,68 @@
1
+ require "open3"
2
+
3
+ module Kward
4
+ module Workers
5
+ # Small git boundary used by write-lane workers to keep implementation work isolated.
6
+ class GitGuard
7
+ def initialize(root: Dir.pwd)
8
+ @root = root.to_s
9
+ end
10
+
11
+ def repository?
12
+ success?("rev-parse", "--is-inside-work-tree")
13
+ end
14
+
15
+ def clean?
16
+ return true unless repository?
17
+
18
+ status.empty?
19
+ end
20
+
21
+ def dirty?
22
+ !clean?
23
+ end
24
+
25
+ def status
26
+ run("status", "--porcelain").stdout
27
+ end
28
+
29
+ def head
30
+ result = run("rev-parse", "--verify", "HEAD")
31
+ result.success? ? result.stdout.strip : nil
32
+ end
33
+
34
+ def commit_all(message)
35
+ add = run("add", "-A")
36
+ return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
37
+
38
+ commit = run("commit", "-m", message)
39
+ return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
40
+
41
+ Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
42
+ end
43
+
44
+ private
45
+
46
+ Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
47
+ def success?
48
+ success
49
+ end
50
+
51
+ def output
52
+ [stdout, stderr].compact.reject(&:empty?).join("\n")
53
+ end
54
+ end
55
+
56
+ def success?(*args)
57
+ run(*args).success?
58
+ end
59
+
60
+ def run(*args)
61
+ stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
62
+ Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
63
+ rescue Errno::ENOENT
64
+ Result.new(success: false, stdout: "", stderr: "git executable not found")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,49 @@
1
+ module Kward
2
+ module Workers
3
+ # Drains a running worker's accumulated event history into a frontend renderer.
4
+ class LiveView
5
+ FINISHED_STATUSES = %w[ready failed cancelled archived].freeze
6
+
7
+ def initialize(worker:, agent:, renderer:, poll_interval: 0.05)
8
+ @worker = worker
9
+ @agent = agent
10
+ @renderer = renderer
11
+ @poll_interval = poll_interval
12
+ @seen_events = worker.event_history.length
13
+ @stop = false
14
+ @thread = nil
15
+ end
16
+
17
+ attr_reader :worker, :agent
18
+
19
+ def start
20
+ @thread = Thread.new { run }
21
+ @thread.report_on_exception = false
22
+ self
23
+ end
24
+
25
+ def stop
26
+ @stop = true
27
+ @thread&.join(0.2)
28
+ end
29
+
30
+ private
31
+
32
+ def run
33
+ until @stop
34
+ events = @worker.event_history[@seen_events..] || []
35
+ events.each { |event| @renderer.call(event, @agent) }
36
+ @seen_events += events.length
37
+ @renderer.call(:flush, @agent) if finished?
38
+ break if finished?
39
+
40
+ sleep @poll_interval
41
+ end
42
+ end
43
+
44
+ def finished?
45
+ FINISHED_STATUSES.include?(@worker.status)
46
+ end
47
+ end
48
+ end
49
+ end