kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -0,0 +1,204 @@
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
+ return "No matching candidate files found for focused context." if ranked.empty?
49
+
50
+ render_context(task: task, budget: budget, terms: terms, ranked: ranked, cancellation: cancellation)
51
+ rescue SecurityError, Errno::ENOENT => e
52
+ "Error: #{e.message}"
53
+ end
54
+
55
+ private
56
+
57
+ def normalized_budget(value)
58
+ return DEFAULT_BUDGET if value.nil?
59
+
60
+ budget = value.to_i
61
+ return "Error: budget must be positive" unless budget.positive?
62
+
63
+ [budget, MAX_BUDGET].min
64
+ end
65
+
66
+ def candidate_files(paths, cancellation:)
67
+ roots = paths.empty? ? [@workspace.root.to_s] : paths.map { |path| @workspace.resolved_path(path) }
68
+ files = roots.flat_map do |path|
69
+ cancellation&.raise_if_cancelled!
70
+ File.directory?(path) ? files_under(path, cancellation: cancellation) : [path]
71
+ end
72
+ files.uniq.select { |path| readable_context_file?(path) }.first(MAX_FILES * 8)
73
+ end
74
+
75
+ def files_under(root, cancellation:)
76
+ files = []
77
+ stack = [root]
78
+ until stack.empty? || files.length >= MAX_FILES * 8
79
+ cancellation&.raise_if_cancelled!
80
+ current = stack.pop
81
+ next if skipped_directory?(current)
82
+
83
+ entries = Dir.children(current).sort.map { |entry| File.join(current, entry) }
84
+ entries.each do |entry|
85
+ if File.directory?(entry)
86
+ stack << entry
87
+ else
88
+ files << entry if readable_context_file?(entry)
89
+ end
90
+ end
91
+ end
92
+ files
93
+ end
94
+
95
+ def readable_context_file?(path)
96
+ return false unless File.file?(path)
97
+ return false if File.size(path) > Workspace::MAX_FILE_BYTES
98
+ return false unless DEFAULT_EXTENSIONS.include?(File.extname(path)) || File.basename(path) == "Gemfile"
99
+
100
+ sample = File.open(path, "rb") { |file| file.read(4096).to_s }
101
+ !sample.include?("\x00")
102
+ rescue Errno::ENOENT, Errno::EACCES
103
+ false
104
+ end
105
+
106
+ def skipped_directory?(path)
107
+ SKIP_DIRECTORIES.include?(File.basename(path))
108
+ end
109
+
110
+ def search_terms(task)
111
+ task.scan(/[A-Za-z_][A-Za-z0-9_]{2,}/).map(&:downcase).reject { |term| stopword?(term) }.uniq.first(20)
112
+ end
113
+
114
+ def stopword?(term)
115
+ %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)
116
+ end
117
+
118
+ def rank_files(files, terms)
119
+ scored = files.map do |path|
120
+ content = File.read(path)
121
+ relative = relative_path(path)
122
+ score = score_file(relative, content, terms)
123
+ { path: path, relative: relative, content: content, score: score }
124
+ rescue Errno::ENOENT, Errno::EACCES
125
+ nil
126
+ end.compact
127
+ filtered = terms.empty? ? scored : scored.select { |file| file[:score].positive? }
128
+ filtered.sort_by { |file| [-file[:score], file[:relative]] }.first(MAX_FILES)
129
+ end
130
+
131
+ def score_file(relative, content, terms)
132
+ haystack = "#{relative}\n#{content}".downcase
133
+ terms.sum { |term| haystack.scan(term).length } + (terms.any? { |term| relative.downcase.include?(term) } ? 5 : 0)
134
+ end
135
+
136
+ def render_context(task:, budget:, terms:, ranked:, cancellation:)
137
+ lines = ["# Focused context", "- Task: #{task}", "- Budget: #{budget} bytes", "- Search terms: #{terms.empty? ? '(none)' : terms.join(', ')}", ""]
138
+ used = lines.join("\n").bytesize
139
+
140
+ ranked.each do |file|
141
+ cancellation&.raise_if_cancelled!
142
+ section = file_section(file, terms)
143
+ break if used + section.bytesize > budget && lines.length > 5
144
+
145
+ if used + section.bytesize > budget
146
+ remaining = budget - used
147
+ break if remaining < 200
148
+
149
+ section = section.byteslice(0, remaining).to_s.scrub << "\n[Context budget reached.]"
150
+ end
151
+ lines << section
152
+ used += section.bytesize
153
+ end
154
+
155
+ lines.join("\n")
156
+ end
157
+
158
+ def file_section(file, terms)
159
+ outline = @workspace.summarize_file_structure(file[:relative])
160
+ matches = matching_excerpt(file[:content], terms)
161
+ parts = ["## #{file[:relative]}", "- Score: #{file[:score]}"]
162
+ parts << outline unless outline.start_with?("No recognizable source structure")
163
+ parts << matches unless matches.empty?
164
+ parts.join("\n") << "\n"
165
+ end
166
+
167
+ def matching_excerpt(content, terms)
168
+ return "" if terms.empty?
169
+
170
+ lines = content.split("\n", -1)
171
+ indexes = matching_indexes(lines, terms)
172
+ return "" if indexes.empty?
173
+
174
+ selected = indexes.flat_map { |index| ([index - 2, 0].max..[index + 2, lines.length - 1].min).to_a }.uniq.sort
175
+ render_excerpt(lines, selected)
176
+ end
177
+
178
+ def matching_indexes(lines, terms)
179
+ indexes = []
180
+ lines.each_with_index do |line, index|
181
+ lower = line.downcase
182
+ indexes << index if terms.any? { |term| lower.include?(term) }
183
+ break if indexes.length >= MAX_MATCHES_PER_FILE
184
+ end
185
+ indexes
186
+ end
187
+
188
+ def render_excerpt(lines, selected)
189
+ output = ["### Matching excerpts"]
190
+ previous = nil
191
+ selected.each do |index|
192
+ output << "..." if previous && index > previous + 1
193
+ output << "%4d: %s" % [index + 1, lines[index]]
194
+ previous = index
195
+ end
196
+ output.join("\n")
197
+ end
198
+
199
+ def relative_path(path)
200
+ Pathname.new(path).relative_path_from(@workspace.root).to_s
201
+ end
202
+ end
203
+ end
204
+ 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
@@ -2,6 +2,8 @@ require_relative "../config_files"
2
2
  require_relative "../conversation"
3
3
  require_relative "ask_user_question"
4
4
  require_relative "code_search"
5
+ require_relative "context_budget_stats"
6
+ require_relative "context_for_task"
5
7
  require_relative "edit_file"
6
8
  require_relative "fetch_content"
7
9
  require_relative "fetch_raw"
@@ -45,7 +47,7 @@ module Kward
45
47
  # Tool schemas advertised to the model for the current frontend and config.
46
48
  #
47
49
  # @return [Array<Hash>] tool schemas currently advertised to the model
48
- attr_reader :schemas
50
+ attr_reader :schemas, :writer_id
49
51
 
50
52
  # Builds tool objects and the schema list for the current frontend/config.
51
53
  #
@@ -58,7 +60,7 @@ module Kward
58
60
  # @param web_search_enabled [Boolean, nil] override for web search exposure
59
61
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
60
62
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
61
- 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, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new)
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)
62
64
  @workspace = workspace
63
65
  @prompt = prompt
64
66
  @web_search = web_search
@@ -67,8 +69,12 @@ module Kward
67
69
  @skills = skills
68
70
  @web_search_enabled = web_search_enabled
69
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
70
75
  @tool_output_compactor = tool_output_compactor
71
76
  @telemetry_logger = telemetry_logger
77
+ @context_budget_meter = context_budget_meter
72
78
  @tools = build_tools.freeze
73
79
  @schemas = build_schema_tools.map(&:schema).freeze
74
80
  end
@@ -89,34 +95,60 @@ module Kward
89
95
  args = ToolCall.arguments(tool_call)
90
96
  tool = @tools[name]
91
97
 
92
- content = if tool
93
- tool.call(args, conversation, cancellation: cancellation)
94
- else
95
- "Unknown tool: #{name}"
96
- end
97
- content = Conversation.normalize_tool_content(content)
98
- duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: content)
99
- if conversation.tool_output_artifacts.key?(duplicate_id)
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)
100
111
  content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
101
112
  end
102
113
 
103
114
  artifact_id = nil
104
115
  model_content = @tool_output_compactor.compact(name, content) do
105
- artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: content)
116
+ artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
106
117
  end
107
- log_tool_output_compaction(name, artifact_id: artifact_id, before: content, after: model_content) if model_content != content
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
108
120
  conversation.append_tool(
109
121
  tool_call_id: tool_call["id"] || tool_call[:id],
110
122
  name: name,
111
123
  content: model_content
112
124
  )
113
- conversation.append_tool_execution(tool_call: tool_call, content: content)
125
+ conversation.append_tool_execution(tool_call: tool_call, content: original_content)
114
126
 
115
127
  model_content
116
128
  end
117
129
 
118
130
  private
119
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
+
120
152
  def log_tool_output_compaction(name, artifact_id:, before:, after:)
121
153
  @telemetry_logger.log(
122
154
  "compaction",
@@ -129,18 +161,30 @@ module Kward
129
161
  )
130
162
  end
131
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
+
132
174
  def build_tools
133
- 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] }
134
178
  end
135
179
 
136
180
  def build_schema_tools
137
181
  tools = @tools.values_at(
138
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "retrieve_tool_output"
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"
139
183
  )
140
184
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
141
185
  tools << @tools["read_skill"] if skills_available?
142
186
  tools << @tools["ask_user_question"] if ask_user_question_available?
143
- tools
187
+ tools.compact
144
188
  end
145
189
 
146
190
  def all_tools
@@ -162,6 +206,8 @@ module Kward
162
206
  Tools::RunShellCommand.new(workspace: @workspace),
163
207
  Tools::CodeSearch.new(code_search: @code_search),
164
208
  Tools::SummarizeFileStructure.new(workspace: @workspace),
209
+ Tools::ContextForTask.new(workspace: @workspace),
210
+ Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
165
211
  Tools::RetrieveToolOutput.new
166
212
  ]
167
213
  end
@@ -18,6 +18,8 @@ module Kward
18
18
  "list_directory" => "list_directory",
19
19
  "code_search" => "code_search",
20
20
  "summarize_file_structure" => "summarize_file_structure",
21
+ "context_for_task" => "context_for_task",
22
+ "context_budget_stats" => "context_budget_stats",
21
23
  "retrieve_tool_output" => "retrieve_tool_output",
22
24
  "web_search" => "web_search",
23
25
  "fetch_content" => "fetch_content",
@@ -65,6 +67,14 @@ module Kward
65
67
  TOOL_NAME_MAP[name.to_s]
66
68
  end
67
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
+
68
78
  # Converts provider argument payloads into hashes.
69
79
  #
70
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.71.0"
4
+ VERSION = "0.73.0"
5
5
  end
@@ -0,0 +1,93 @@
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
+ def stash(message)
45
+ before = stash_refs
46
+ result = run("stash", "push", "--include-untracked", "-m", message)
47
+ return Result.new(success: false, stdout: result.stdout, stderr: result.stderr) unless result.success?
48
+ return Result.new(success: true, stdout: result.stdout, stderr: result.stderr) if result.stdout.include?("No local changes")
49
+
50
+ ref = (stash_refs - before).first || stash_refs.first
51
+ Result.new(success: true, stdout: result.stdout, stderr: result.stderr, commit: ref)
52
+ end
53
+
54
+ def apply_stash(ref)
55
+ run("stash", "apply", ref.to_s)
56
+ end
57
+
58
+ def drop_stash(ref)
59
+ run("stash", "drop", ref.to_s)
60
+ end
61
+
62
+ private
63
+
64
+ Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
65
+ def success?
66
+ success
67
+ end
68
+
69
+ def output
70
+ [stdout, stderr].compact.reject(&:empty?).join("\n")
71
+ end
72
+ end
73
+
74
+ def success?(*args)
75
+ run(*args).success?
76
+ end
77
+
78
+ def stash_refs
79
+ result = run("stash", "list", "--format=%gd")
80
+ return [] unless result.success?
81
+
82
+ result.stdout.lines.map(&:strip).reject(&:empty?)
83
+ end
84
+
85
+ def run(*args)
86
+ stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
87
+ Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
88
+ rescue Errno::ENOENT
89
+ Result.new(success: false, stdout: "", stderr: "git executable not found")
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,99 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require_relative "../config_files"
4
+
5
+ module Kward
6
+ module Workers
7
+ # Persistent queue entry for a session-backed worker.
8
+ class Job
9
+ STATUSES = %w[queued running suspended ready_for_review failed blocked cancelled archived].freeze
10
+
11
+ def initialize(id: SecureRandom.hex(4), title:, session_path:, workspace_root: Dir.pwd, status: "queued", position: nil, commit_sha: nil, stash_ref: nil, error: nil, enqueued_at: Time.now.utc, started_at: nil, finished_at: nil, updated_at: nil)
12
+ @id = id.to_s
13
+ @title = title.to_s
14
+ @session_path = session_path.to_s
15
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
16
+ @status = status.to_s
17
+ @position = position
18
+ @commit_sha = commit_sha
19
+ @stash_ref = stash_ref
20
+ @error = error
21
+ @enqueued_at = enqueued_at
22
+ @started_at = started_at
23
+ @finished_at = finished_at
24
+ @updated_at = updated_at || enqueued_at
25
+ end
26
+
27
+ attr_reader :id, :title, :session_path, :workspace_root, :position, :commit_sha, :stash_ref, :error, :enqueued_at, :started_at, :finished_at, :updated_at
28
+
29
+ def status
30
+ @status
31
+ end
32
+
33
+ def update_status(status, commit_sha: nil, stash_ref: nil, error: nil, position: nil)
34
+ @status = status.to_s
35
+ @commit_sha = commit_sha unless commit_sha.nil?
36
+ @stash_ref = stash_ref unless stash_ref.nil?
37
+ @error = error unless error.nil?
38
+ @position = position unless position.nil?
39
+ now = Time.now.utc
40
+ @updated_at = now
41
+ @started_at ||= now if @status == "running"
42
+ @finished_at = now if %w[ready_for_review failed blocked cancelled archived].include?(@status)
43
+ self
44
+ end
45
+
46
+ def to_h
47
+ {
48
+ "id" => id,
49
+ "title" => title,
50
+ "session_path" => session_path,
51
+ "workspace_root" => workspace_root,
52
+ "status" => status,
53
+ "position" => position,
54
+ "commit_sha" => commit_sha,
55
+ "stash_ref" => stash_ref,
56
+ "error" => error,
57
+ "enqueued_at" => timestamp(enqueued_at),
58
+ "started_at" => timestamp(started_at),
59
+ "finished_at" => timestamp(finished_at),
60
+ "updated_at" => timestamp(updated_at)
61
+ }
62
+ end
63
+
64
+ def self.from_h(record)
65
+ new(
66
+ id: record.fetch("id"),
67
+ title: record.fetch("title"),
68
+ session_path: record.fetch("session_path"),
69
+ workspace_root: record["workspace_root"] || Dir.pwd,
70
+ status: record["status"] || "queued",
71
+ position: record["position"],
72
+ commit_sha: record["commit_sha"],
73
+ stash_ref: record["stash_ref"],
74
+ error: record["error"],
75
+ enqueued_at: parse_time(record["enqueued_at"]) || Time.now.utc,
76
+ started_at: parse_time(record["started_at"]),
77
+ finished_at: parse_time(record["finished_at"]),
78
+ updated_at: parse_time(record["updated_at"])
79
+ )
80
+ end
81
+
82
+ def self.parse_time(value)
83
+ return nil if value.to_s.empty?
84
+
85
+ Time.parse(value.to_s).utc
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ private_class_method :parse_time
91
+
92
+ private
93
+
94
+ def timestamp(value)
95
+ value&.utc&.iso8601(3)
96
+ end
97
+ end
98
+ end
99
+ 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