kward 0.68.0 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +34 -0
- data/Gemfile.lock +8 -2
- data/README.md +32 -25
- data/Rakefile +14 -1
- data/doc/authentication.md +74 -56
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +18 -0
- data/doc/extensibility.md +89 -128
- data/doc/getting-started.md +52 -54
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -97
- data/doc/releasing.md +3 -1
- data/doc/rpc.md +1 -1
- data/doc/usage.md +125 -144
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +10 -3
- data/lib/kward/cli/compaction.rb +3 -3
- data/lib/kward/cli/interactive_turn.rb +3 -1
- data/lib/kward/cli/memory_commands.rb +16 -16
- data/lib/kward/cli/plugins.rb +3 -3
- data/lib/kward/cli/prompt_interface.rb +15 -13
- data/lib/kward/cli/rendering.rb +35 -46
- data/lib/kward/cli/runtime_helpers.rb +13 -2
- data/lib/kward/cli/sessions.rb +21 -21
- data/lib/kward/cli/settings.rb +49 -43
- data/lib/kward/cli/slash_commands.rb +6 -4
- data/lib/kward/cli/stats.rb +2 -2
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +5 -1
- data/lib/kward/cli.rb +14 -2
- data/lib/kward/cli_transcript_formatter.rb +36 -5
- data/lib/kward/compactor.rb +2 -2
- data/lib/kward/config_files.rb +45 -10
- data/lib/kward/conversation.rb +41 -9
- data/lib/kward/memory/manager.rb +131 -14
- data/lib/kward/message_access.rb +6 -0
- data/lib/kward/model/context_usage.rb +11 -10
- data/lib/kward/model/model_info.rb +18 -1
- data/lib/kward/model/payloads.rb +89 -10
- data/lib/kward/model/stream_parser.rb +258 -25
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
- data/lib/kward/prompts.rb +61 -7
- data/lib/kward/rpc/server.rb +7 -2
- data/lib/kward/rpc/session_manager.rb +18 -2
- data/lib/kward/rpc/session_metrics.rb +2 -2
- data/lib/kward/rpc/transcript_normalizer.rb +47 -0
- data/lib/kward/session_store.rb +40 -1
- data/lib/kward/starter_pack_installer.rb +2 -2
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/registry.rb +9 -2
- data/lib/kward/tools/search/web.rb +3 -3
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +14 -1
|
@@ -191,6 +191,7 @@ module Kward
|
|
|
191
191
|
when "Edit" then "edit_file"
|
|
192
192
|
when "Bash" then "run_shell_command"
|
|
193
193
|
when "WebSearch" then "web_search"
|
|
194
|
+
when "WebFetch" then "fetch_content"
|
|
194
195
|
when "AskUserQuestion" then "ask_user_question"
|
|
195
196
|
else name.to_s
|
|
196
197
|
end
|
|
@@ -218,7 +219,18 @@ module Kward
|
|
|
218
219
|
end
|
|
219
220
|
|
|
220
221
|
def codex_sse_state
|
|
221
|
-
{
|
|
222
|
+
{
|
|
223
|
+
content: +"",
|
|
224
|
+
raw_content: +"",
|
|
225
|
+
emitted_message_keys: [],
|
|
226
|
+
reasoning_summary: +"",
|
|
227
|
+
tool_calls: [],
|
|
228
|
+
response_item_keys: [],
|
|
229
|
+
items_by_id: {},
|
|
230
|
+
active_item_id: nil,
|
|
231
|
+
current_text_content_part: nil,
|
|
232
|
+
usage: nil
|
|
233
|
+
}
|
|
222
234
|
end
|
|
223
235
|
|
|
224
236
|
def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
@@ -227,30 +239,33 @@ module Kward
|
|
|
227
239
|
|
|
228
240
|
event = JSON.parse(data)
|
|
229
241
|
case event["type"]
|
|
230
|
-
when "response.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
242
|
+
when "response.output_item.added"
|
|
243
|
+
codex_output_item_added(state, event["item"])
|
|
244
|
+
when "response.content_part.added"
|
|
245
|
+
codex_content_part_added(state, event["part"])
|
|
246
|
+
when "response.output_text.delta", "response.refusal.delta"
|
|
247
|
+
codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
|
|
248
|
+
when "response.reasoning_summary_part.added"
|
|
249
|
+
codex_reasoning_summary_part_added(state, event["part"])
|
|
234
250
|
when "response.reasoning_summary_text.delta"
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
on_reasoning_delta
|
|
251
|
+
codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
252
|
+
when "response.reasoning_summary_part.done"
|
|
253
|
+
codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
|
|
254
|
+
when "response.reasoning_text.delta"
|
|
255
|
+
codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
256
|
+
when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
|
|
257
|
+
codex_tool_arguments_delta(state, event["delta"])
|
|
258
|
+
when "response.function_call_arguments.done"
|
|
259
|
+
codex_tool_arguments_done(state, event["arguments"])
|
|
238
260
|
when "response.output_item.done"
|
|
239
|
-
|
|
240
|
-
state[:final_output] << item if item.is_a?(Hash)
|
|
241
|
-
tool_call = codex_tool_call(item)
|
|
242
|
-
state[:tool_calls] << tool_call if tool_call
|
|
261
|
+
codex_output_item_done(state, event["item"], on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
243
262
|
when "response.completed"
|
|
244
263
|
response = event["response"]
|
|
245
264
|
state[:usage] ||= usage_normalizer&.call(response["usage"]) if response.is_a?(Hash)
|
|
246
265
|
state[:usage] ||= usage_normalizer&.call(event["usage"])
|
|
247
|
-
if
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
state[:content] << text
|
|
251
|
-
on_assistant_delta&.call(text) unless text.empty?
|
|
252
|
-
if state[:reasoning_summary].empty?
|
|
253
|
-
state[:reasoning_summary] << reasoning_summary_from_codex_items(state[:final_output])
|
|
266
|
+
if response.is_a?(Hash) && response["output"].is_a?(Array) && state[:response_item_keys].empty?
|
|
267
|
+
response["output"].each do |item|
|
|
268
|
+
codex_output_item_done(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
254
269
|
end
|
|
255
270
|
end
|
|
256
271
|
when "response.failed", "response.incomplete"
|
|
@@ -275,21 +290,239 @@ module Kward
|
|
|
275
290
|
end
|
|
276
291
|
end
|
|
277
292
|
|
|
278
|
-
def
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
293
|
+
def codex_output_item_added(state, item)
|
|
294
|
+
return unless item.is_a?(Hash)
|
|
295
|
+
|
|
296
|
+
item = deep_dup_hash(item)
|
|
297
|
+
item["content"] = [] if item["type"] == "message" && !item["content"].is_a?(Array)
|
|
298
|
+
item["summary"] = [] if item["type"] == "reasoning" && !item["summary"].is_a?(Array)
|
|
299
|
+
remember_codex_item(state, item)
|
|
300
|
+
state[:active_item_id] = codex_item_key(item)
|
|
301
|
+
state[:current_text_content_part] = nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def codex_content_part_added(state, part)
|
|
305
|
+
item = active_codex_item(state)
|
|
306
|
+
return unless item&.fetch("type", nil) == "message" && part.is_a?(Hash)
|
|
307
|
+
return unless ["output_text", "text", "refusal"].include?(part["type"])
|
|
308
|
+
|
|
309
|
+
item["content"] ||= []
|
|
310
|
+
item["content"] << deep_dup_hash(part)
|
|
311
|
+
state[:current_text_content_part] = item["content"].last
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def codex_output_text_delta(state, delta, on_assistant_delta: nil)
|
|
315
|
+
text = delta.to_s
|
|
316
|
+
return if text.empty?
|
|
317
|
+
|
|
318
|
+
item = active_codex_item(state)
|
|
319
|
+
if item&.fetch("type", nil) == "message"
|
|
320
|
+
item["content"] ||= [{ "type" => "output_text", "text" => +"" }]
|
|
321
|
+
part = state[:current_text_content_part] || item["content"].last
|
|
322
|
+
part = item["content"].last unless part.is_a?(Hash)
|
|
323
|
+
part["type"] ||= "output_text"
|
|
324
|
+
text_key = part["type"] == "refusal" ? "refusal" : "text"
|
|
325
|
+
part[text_key] = part[text_key].to_s + text
|
|
326
|
+
end
|
|
327
|
+
state[:raw_content] << text
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def codex_reasoning_summary_part_added(state, part)
|
|
331
|
+
item = active_codex_item(state)
|
|
332
|
+
return unless item&.fetch("type", nil) == "reasoning" && part.is_a?(Hash)
|
|
333
|
+
|
|
334
|
+
item["summary"] ||= []
|
|
335
|
+
item["summary"] << deep_dup_hash(part)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
|
|
339
|
+
text = delta.to_s
|
|
340
|
+
return if text.empty?
|
|
341
|
+
|
|
342
|
+
item = active_codex_item(state)
|
|
343
|
+
if item&.fetch("type", nil) == "reasoning"
|
|
344
|
+
item["summary"] ||= []
|
|
345
|
+
item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
|
|
346
|
+
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
347
|
+
end
|
|
348
|
+
state[:reasoning_summary] << text
|
|
349
|
+
on_reasoning_delta&.call(text)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def codex_reasoning_part_done(state, on_reasoning_delta: nil)
|
|
353
|
+
item = active_codex_item(state)
|
|
354
|
+
return unless item&.fetch("type", nil) == "reasoning"
|
|
355
|
+
return if item["summary"].to_a.empty?
|
|
356
|
+
|
|
357
|
+
text = "\n\n"
|
|
358
|
+
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
359
|
+
state[:reasoning_summary] << text
|
|
360
|
+
on_reasoning_delta&.call(text)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def codex_tool_arguments_delta(state, delta)
|
|
364
|
+
item = active_codex_item(state)
|
|
365
|
+
return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
|
|
366
|
+
|
|
367
|
+
key = item["type"] == "custom_tool_call" ? "input" : "arguments"
|
|
368
|
+
item[key] = item[key].to_s + delta.to_s
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def codex_tool_arguments_done(state, arguments)
|
|
372
|
+
item = active_codex_item(state)
|
|
373
|
+
return unless item&.fetch("type", nil) == "function_call"
|
|
374
|
+
|
|
375
|
+
item["arguments"] = arguments.to_s
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def codex_output_item_done(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
|
|
379
|
+
return unless item.is_a?(Hash)
|
|
380
|
+
|
|
381
|
+
item = merge_codex_item(active_or_known_codex_item(state, item), item)
|
|
382
|
+
remember_codex_item(state, item)
|
|
383
|
+
collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
384
|
+
state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
|
|
385
|
+
state[:current_text_content_part] = nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
|
|
389
|
+
case item["type"]
|
|
390
|
+
when "message"
|
|
391
|
+
text = text_from_codex_items([item])
|
|
392
|
+
return if text.empty? || !codex_streamable_message_item?(item)
|
|
393
|
+
|
|
394
|
+
state[:content] << text
|
|
395
|
+
key = codex_item_key(item)
|
|
396
|
+
unless state[:emitted_message_keys].include?(key)
|
|
397
|
+
on_assistant_delta&.call(text)
|
|
398
|
+
state[:emitted_message_keys] << key
|
|
283
399
|
end
|
|
400
|
+
when "reasoning"
|
|
401
|
+
text = reasoning_summary_from_codex_items([item])
|
|
402
|
+
if state[:reasoning_summary].empty? && !text.empty?
|
|
403
|
+
state[:reasoning_summary] << text
|
|
404
|
+
on_reasoning_delta&.call(text)
|
|
405
|
+
end
|
|
406
|
+
when "function_call", "custom_tool_call"
|
|
407
|
+
tool_call = codex_tool_call(item)
|
|
408
|
+
state[:tool_calls] << tool_call if tool_call && !state[:tool_calls].any? { |call| call["id"] == tool_call["id"] }
|
|
284
409
|
end
|
|
410
|
+
end
|
|
285
411
|
|
|
286
|
-
|
|
412
|
+
def active_or_known_codex_item(state, item)
|
|
413
|
+
key = codex_item_key(item)
|
|
414
|
+
known = state[:items_by_id][key]
|
|
415
|
+
return known if known
|
|
416
|
+
|
|
417
|
+
active = active_codex_item(state)
|
|
418
|
+
return active if active && (!codex_item_has_stable_key?(item) || codex_item_key(active) == key)
|
|
419
|
+
|
|
420
|
+
nil
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def codex_item_has_stable_key?(item)
|
|
424
|
+
item.key?("id") || item.key?("call_id")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def active_codex_item(state)
|
|
428
|
+
key = state[:active_item_id]
|
|
429
|
+
key ? state[:items_by_id][key] : nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def remember_codex_item(state, item)
|
|
433
|
+
key = codex_item_key(item)
|
|
434
|
+
if state[:items_by_id].key?(key)
|
|
435
|
+
stored = state[:items_by_id][key]
|
|
436
|
+
stored.replace(merge_codex_item(stored, item))
|
|
437
|
+
else
|
|
438
|
+
state[:items_by_id][key] = item
|
|
439
|
+
state[:response_item_keys] << key
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def codex_item_key(item)
|
|
444
|
+
item["id"] || item["call_id"] || "item_#{item.object_id}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def merge_codex_item(existing, update)
|
|
448
|
+
return deep_dup_hash(update) unless existing.is_a?(Hash)
|
|
449
|
+
|
|
450
|
+
merged = deep_dup_hash(existing)
|
|
451
|
+
update.each do |key, value|
|
|
452
|
+
next if value.nil?
|
|
453
|
+
|
|
454
|
+
merged[key] = if key == "content" && merged[key].is_a?(Array) && value.is_a?(Array)
|
|
455
|
+
value.empty? ? merged[key] : value
|
|
456
|
+
elsif key == "summary" && merged[key].is_a?(Array) && value.is_a?(Array)
|
|
457
|
+
value.empty? ? merged[key] : value
|
|
458
|
+
elsif key == "arguments" && value.to_s.empty? && !merged[key].to_s.empty?
|
|
459
|
+
merged[key]
|
|
460
|
+
else
|
|
461
|
+
deep_dup(value)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
merged
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def deep_dup_hash(hash)
|
|
468
|
+
deep_dup(hash)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def deep_dup(value)
|
|
472
|
+
case value
|
|
473
|
+
when Hash
|
|
474
|
+
value.each_with_object({}) { |(key, entry), result| result[key] = deep_dup(entry) }
|
|
475
|
+
when Array
|
|
476
|
+
value.map { |entry| deep_dup(entry) }
|
|
477
|
+
when String
|
|
478
|
+
value.dup
|
|
479
|
+
else
|
|
480
|
+
value
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def codex_sse_message(state)
|
|
485
|
+
message = { "role" => "assistant", "content" => codex_visible_content(state) }
|
|
287
486
|
message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
|
|
288
487
|
message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
|
|
488
|
+
response_items = codex_response_items(state)
|
|
489
|
+
message["response_items"] = response_items unless response_items.empty?
|
|
289
490
|
message["usage"] = state[:usage] if state[:usage]
|
|
290
491
|
message
|
|
291
492
|
end
|
|
292
493
|
|
|
494
|
+
def codex_visible_content(state)
|
|
495
|
+
return state[:content] unless state[:content].empty?
|
|
496
|
+
|
|
497
|
+
response_items = codex_response_items(state)
|
|
498
|
+
visible_text = text_from_codex_items(visible_codex_message_items(response_items, tool_calls: state[:tool_calls]))
|
|
499
|
+
return visible_text unless visible_text.empty?
|
|
500
|
+
return "" unless state[:tool_calls].empty?
|
|
501
|
+
|
|
502
|
+
state[:raw_content]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def visible_codex_message_items(items, tool_calls: [])
|
|
506
|
+
messages = Array(items).select { |item| item.is_a?(Hash) && item["type"] == "message" }
|
|
507
|
+
final_messages = messages.select { |item| codex_message_phase(item) == "final_answer" }
|
|
508
|
+
return final_messages unless final_messages.empty?
|
|
509
|
+
return [] unless tool_calls.empty?
|
|
510
|
+
|
|
511
|
+
messages.reject { |item| codex_message_phase(item) == "commentary" }
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def codex_streamable_message_item?(item)
|
|
515
|
+
codex_message_phase(item) == "final_answer"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def codex_message_phase(item)
|
|
519
|
+
item["phase"].to_s
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def codex_response_items(state)
|
|
523
|
+
state[:response_item_keys].filter_map { |key| state[:items_by_id][key] }
|
|
524
|
+
end
|
|
525
|
+
|
|
293
526
|
def codex_tool_call(item)
|
|
294
527
|
return nil unless item.is_a?(Hash) && ["function_call", "custom_tool_call"].include?(item["type"])
|
|
295
528
|
|
|
@@ -11,7 +11,7 @@ module Kward
|
|
|
11
11
|
prepare_transcript_output_locked unless @restoring_transcript
|
|
12
12
|
if label && @stream_state.block != label
|
|
13
13
|
ensure_transcript_block_separator_locked
|
|
14
|
-
write_transcript_text_locked("#{colored("#{transcript_label(label)}>",
|
|
14
|
+
write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
|
|
15
15
|
@stream_state.start_block(label)
|
|
16
16
|
end
|
|
17
17
|
write_transcript_text_locked(delta) unless delta.empty?
|
|
@@ -119,21 +119,30 @@ module Kward
|
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def transcript_label(label)
|
|
122
|
-
|
|
122
|
+
case label
|
|
123
|
+
when "Assistant"
|
|
124
|
+
@assistant_label
|
|
125
|
+
when "Tool failed"
|
|
126
|
+
"Tool"
|
|
127
|
+
else
|
|
128
|
+
label
|
|
129
|
+
end
|
|
123
130
|
end
|
|
124
131
|
|
|
125
|
-
def
|
|
132
|
+
def label_styles(label)
|
|
126
133
|
case label
|
|
127
|
-
when "Reasoning"
|
|
128
|
-
:
|
|
134
|
+
when "Reasoning", "Compaction summary"
|
|
135
|
+
[:gray, :bold]
|
|
129
136
|
when "Assistant", "Kward"
|
|
130
|
-
:green
|
|
131
|
-
when "Tool"
|
|
132
|
-
:
|
|
133
|
-
when "Tool
|
|
134
|
-
:
|
|
137
|
+
[:green, :bold]
|
|
138
|
+
when "Tool", "Tool output"
|
|
139
|
+
[:cyan, :bold]
|
|
140
|
+
when "Tool failed"
|
|
141
|
+
[:red, :bold]
|
|
142
|
+
when "Retry"
|
|
143
|
+
[:yellow, :bold]
|
|
135
144
|
else
|
|
136
|
-
:
|
|
145
|
+
[:gray, :bold]
|
|
137
146
|
end
|
|
138
147
|
end
|
|
139
148
|
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -14,18 +14,27 @@ module Kward
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
21
|
+
sections = [prompt_section("Built-in system prompt", base_prompt)]
|
|
22
|
+
sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
|
|
23
|
+
sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
|
|
24
|
+
if include_workspace_personality
|
|
25
|
+
sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
|
|
26
|
+
sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
|
|
27
|
+
end
|
|
28
|
+
sections << prompt_section("Configured skills", skills_prompt, source: ConfigFiles.skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
|
|
29
|
+
sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
|
|
30
|
+
sections.compact
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def base_prompt
|
|
27
34
|
<<~PROMPT.strip
|
|
28
35
|
You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
|
|
36
|
+
|
|
37
|
+
For web research, use web_search to discover sources, then fetch_content for important human-readable pages before relying on them. Use fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources when practical, and cite or mention the URLs you relied on.
|
|
29
38
|
PROMPT
|
|
30
39
|
end
|
|
31
40
|
|
|
@@ -33,14 +42,59 @@ module Kward
|
|
|
33
42
|
ConfigFiles.agents_prompt
|
|
34
43
|
end
|
|
35
44
|
|
|
45
|
+
def config_agents_prompt_label
|
|
46
|
+
return "Config principles" if File.exist?(ConfigFiles.config_principles_path)
|
|
47
|
+
return "Config AGENTS.md alias" if File.exist?(ConfigFiles.config_agents_path)
|
|
48
|
+
|
|
49
|
+
"Config principles"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def config_agents_prompt_source
|
|
53
|
+
return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
|
|
54
|
+
return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
36
59
|
def persona_prompt(workspace_root = Dir.pwd, model: nil, reasoning_effort: nil, now: Time.now)
|
|
37
60
|
ConfigFiles.persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now)
|
|
38
61
|
end
|
|
39
62
|
|
|
63
|
+
def workspace_agents_context(workspace_root = Dir.pwd)
|
|
64
|
+
if ConfigFiles.enforce_workspace_agents_file?
|
|
65
|
+
ConfigFiles.workspace_agents_prompt(workspace_root)
|
|
66
|
+
else
|
|
67
|
+
workspace_agents_hint(workspace_root)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
40
71
|
def workspace_agents_prompt(workspace_root = Dir.pwd)
|
|
41
72
|
ConfigFiles.workspace_agents_prompt(workspace_root)
|
|
42
73
|
end
|
|
43
74
|
|
|
75
|
+
def workspace_agents_hint(workspace_root = Dir.pwd)
|
|
76
|
+
return nil unless ConfigFiles.workspace_agents_file?(workspace_root)
|
|
77
|
+
|
|
78
|
+
path = ConfigFiles.workspace_agents_path(workspace_root)
|
|
79
|
+
<<~PROMPT.strip
|
|
80
|
+
Workspace guidance is available in AGENTS.md at the workspace root: #{path}
|
|
81
|
+
For tasks involving this repository, read it before analyzing or modifying project files, and follow it when it does not conflict with higher-priority instructions or the user's request.
|
|
82
|
+
PROMPT
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def workspace_agents_context_label(workspace_root = Dir.pwd)
|
|
86
|
+
return "Workspace AGENTS.md" unless ConfigFiles.workspace_agents_file?(workspace_root)
|
|
87
|
+
return "Workspace AGENTS.md" if ConfigFiles.enforce_workspace_agents_file?
|
|
88
|
+
|
|
89
|
+
"Workspace AGENTS.md hint"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def prompt_section(label, content, source: nil)
|
|
93
|
+
return nil if content.to_s.empty?
|
|
94
|
+
|
|
95
|
+
{ label: label, content: content, source: source }
|
|
96
|
+
end
|
|
97
|
+
|
|
44
98
|
def skills_prompt
|
|
45
99
|
skills = ConfigFiles.skills
|
|
46
100
|
return nil if skills.empty?
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -560,8 +560,13 @@ module Kward
|
|
|
560
560
|
def startup_resources(params)
|
|
561
561
|
@session_manager.runtime_state(session_id: params.fetch("sessionId"))
|
|
562
562
|
sections = []
|
|
563
|
-
|
|
564
|
-
|
|
563
|
+
context_items = []
|
|
564
|
+
if File.exist?(ConfigFiles.config_principles_path)
|
|
565
|
+
context_items << "PRINCIPLES.md"
|
|
566
|
+
elsif File.exist?(ConfigFiles.config_agents_path)
|
|
567
|
+
context_items << "AGENTS.md"
|
|
568
|
+
end
|
|
569
|
+
sections << { name: "Context", items: context_items } unless context_items.empty?
|
|
565
570
|
skills = ConfigFiles.skills.map(&:name)
|
|
566
571
|
prompts = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).map { |template| "/#{template.command}" }
|
|
567
572
|
plugins = @session_manager.plugin_commands.map { |command| "/#{command.name}" }
|
|
@@ -576,7 +576,13 @@ module Kward
|
|
|
576
576
|
sessions.each do |rpc_session|
|
|
577
577
|
rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
|
|
578
578
|
rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
|
|
579
|
-
|
|
579
|
+
if registry.footer_renderer
|
|
580
|
+
start_footer_worker(rpc_session)
|
|
581
|
+
emit_footer_update(rpc_session)
|
|
582
|
+
else
|
|
583
|
+
stop_footer_worker(rpc_session)
|
|
584
|
+
clear_footer_update(rpc_session)
|
|
585
|
+
end
|
|
580
586
|
end
|
|
581
587
|
end
|
|
582
588
|
|
|
@@ -1140,7 +1146,7 @@ module Kward
|
|
|
1140
1146
|
|
|
1141
1147
|
def emit_footer_update(rpc_session)
|
|
1142
1148
|
renderer = plugin_registry.footer_renderer
|
|
1143
|
-
return unless renderer
|
|
1149
|
+
return clear_footer_update(rpc_session) unless renderer
|
|
1144
1150
|
|
|
1145
1151
|
text = begin
|
|
1146
1152
|
context = PluginRegistry::Context.new(
|
|
@@ -1160,6 +1166,13 @@ module Kward
|
|
|
1160
1166
|
@server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
|
|
1161
1167
|
end
|
|
1162
1168
|
|
|
1169
|
+
def clear_footer_update(rpc_session)
|
|
1170
|
+
return if rpc_session.last_footer_text.to_s.empty?
|
|
1171
|
+
|
|
1172
|
+
rpc_session.last_footer_text = ""
|
|
1173
|
+
@server.notify("ui/footer", { sessionId: rpc_session.id, text: "" })
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1163
1176
|
def emit_turn_event(turn, type, payload)
|
|
1164
1177
|
event = {
|
|
1165
1178
|
sequence: turn.next_sequence,
|
|
@@ -1210,6 +1223,9 @@ module Kward
|
|
|
1210
1223
|
name: info.name,
|
|
1211
1224
|
firstMessage: info.first_message.to_s,
|
|
1212
1225
|
messageCount: info.message_count.to_i,
|
|
1226
|
+
provider: info.provider,
|
|
1227
|
+
model: info.model,
|
|
1228
|
+
reasoningEffort: info.reasoning_effort,
|
|
1213
1229
|
parentId: info.parent_id,
|
|
1214
1230
|
parentPath: info.parent_path,
|
|
1215
1231
|
depth: info.depth.to_i,
|
|
@@ -34,12 +34,12 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
def context_usage(rpc_session, model, client:)
|
|
36
36
|
context_parts = if client.respond_to?(:current_context_parts)
|
|
37
|
-
client.current_context_parts(rpc_session.conversation.
|
|
37
|
+
client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas)
|
|
38
38
|
else
|
|
39
39
|
{
|
|
40
40
|
provider: model[:provider],
|
|
41
41
|
model: model[:id],
|
|
42
|
-
messages: rpc_session.conversation.
|
|
42
|
+
messages: rpc_session.conversation.context_messages,
|
|
43
43
|
tools: rpc_session.tool_registry.schemas
|
|
44
44
|
}
|
|
45
45
|
end
|
|
@@ -61,6 +61,7 @@ module Kward
|
|
|
61
61
|
|
|
62
62
|
def normalize_assistant_message(message)
|
|
63
63
|
content = reasoning_first_content(normalize_content(ToolCall.value(message, :content), preserve_thinking: true))
|
|
64
|
+
content = response_item_content(message) if text_content_empty?(content)
|
|
64
65
|
reasoning = normalize_reasoning_summary(message)
|
|
65
66
|
content.unshift(reasoning) if reasoning && !thinking_content?(content)
|
|
66
67
|
tool_calls(message).each do |tool_call|
|
|
@@ -154,6 +155,52 @@ module Kward
|
|
|
154
155
|
summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
|
|
155
156
|
end
|
|
156
157
|
|
|
158
|
+
def response_item_content(message)
|
|
159
|
+
response_items(message).filter_map do |item|
|
|
160
|
+
next unless item.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
case ToolCall.value(item, :type).to_s
|
|
163
|
+
when "reasoning"
|
|
164
|
+
thinking = reasoning_item_text(item)
|
|
165
|
+
{ type: "thinking", thinking: thinking } unless thinking.empty?
|
|
166
|
+
when "message"
|
|
167
|
+
next if ToolCall.value(item, :phase).to_s == "commentary"
|
|
168
|
+
|
|
169
|
+
text = response_message_item_text(item)
|
|
170
|
+
{ type: "text", text: text } unless text.empty?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def response_items(message)
|
|
176
|
+
items = ToolCall.value(message, :response_items) || ToolCall.value(message, :responseItems)
|
|
177
|
+
items.is_a?(Array) ? items : []
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def reasoning_item_text(item)
|
|
181
|
+
summary = ToolCall.value(item, :summary)
|
|
182
|
+
content = ToolCall.value(item, :content)
|
|
183
|
+
response_text_parts(summary).empty? ? response_text_parts(content).join("\n\n") : response_text_parts(summary).join("\n\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def response_message_item_text(item)
|
|
187
|
+
response_text_parts(ToolCall.value(item, :content)).join
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def response_text_parts(parts)
|
|
191
|
+
Array(parts).filter_map do |part|
|
|
192
|
+
next unless part.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
ToolCall.value(part, :text) || ToolCall.value(part, :refusal)
|
|
195
|
+
end.map(&:to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def text_content_empty?(content)
|
|
199
|
+
Array(content).all? do |part|
|
|
200
|
+
!part.is_a?(Hash) || !["text", "thinking"].include?(ToolCall.value(part, :type).to_s) || ToolCall.value(part, :text).to_s.empty? && ToolCall.value(part, :thinking).to_s.empty?
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
157
204
|
def thinking_content?(content)
|
|
158
205
|
content.any? { |part| thinking_content_part?(part) }
|
|
159
206
|
end
|