kward 0.84.0 → 0.85.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/CHANGELOG.md +34 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +17 -2
- data/doc/composer.md +1 -1
- data/doc/configuration.md +27 -4
- data/doc/extensibility.md +2 -1
- data/doc/git.md +3 -1
- data/doc/pan.md +6 -0
- data/doc/permissions.md +4 -4
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +7 -1
- data/doc/session-management.md +5 -4
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/usage.md +4 -1
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/lib/kward/agent.rb +24 -11
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/prompt_interface.rb +25 -5
- data/lib/kward/cli/rendering.rb +3 -0
- data/lib/kward/cli/runtime_helpers.rb +121 -27
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +45 -4
- data/lib/kward/cli/tabs.rb +167 -27
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +26 -24
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -1008
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +2 -2
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/server.rb +10 -7
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
- data/lib/kward/prompt_interface/composer_state.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +1 -1
- data/lib/kward/prompt_interface/editor/runner.rb +1 -1
- data/lib/kward/prompt_interface/editor/state.rb +1 -1
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +9 -2
- data/lib/kward/prompt_interface.rb +60 -11
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +96 -13
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/fulldoc/html/css/kward.css +125 -0
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/layout/html/layout.erb +16 -4
- metadata +67 -48
- data/lib/kward/tab_driver.rb +0 -90
- data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
- /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
- /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
- /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
- /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
- /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
- /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
data/lib/kward/cli.rb
CHANGED
|
@@ -3,26 +3,26 @@ require "json"
|
|
|
3
3
|
require "thread"
|
|
4
4
|
require "tty-prompt"
|
|
5
5
|
require_relative "agent"
|
|
6
|
-
require_relative "ansi"
|
|
6
|
+
require_relative "terminal/ansi"
|
|
7
7
|
require_relative "version"
|
|
8
8
|
require_relative "model/client"
|
|
9
9
|
require_relative "compactor"
|
|
10
10
|
require_relative "config_files"
|
|
11
|
-
require_relative "clipboard"
|
|
11
|
+
require_relative "terminal/clipboard"
|
|
12
12
|
require_relative "cancellation"
|
|
13
|
-
require_relative "editor_prompt"
|
|
14
|
-
require_relative "editor_prompt_session"
|
|
15
|
-
require_relative "
|
|
16
|
-
require_relative "
|
|
17
|
-
require_relative "
|
|
13
|
+
require_relative "cli/editor_prompt"
|
|
14
|
+
require_relative "cli/editor_prompt_session"
|
|
15
|
+
require_relative "shell/prompt"
|
|
16
|
+
require_relative "shell/prompt_session"
|
|
17
|
+
require_relative "cli/transcript_formatter"
|
|
18
18
|
require_relative "model/context_usage"
|
|
19
19
|
require_relative "events"
|
|
20
|
-
require_relative "export_path"
|
|
21
|
-
require_relative "kwsh"
|
|
22
|
-
require_relative "
|
|
23
|
-
require_relative "
|
|
24
|
-
require_relative "
|
|
25
|
-
require_relative "
|
|
20
|
+
require_relative "transcripts/export_path"
|
|
21
|
+
require_relative "shell/kwsh"
|
|
22
|
+
require_relative "shell/persistent_session"
|
|
23
|
+
require_relative "pty/interactive_runner"
|
|
24
|
+
require_relative "pty/adaptive_output_sink"
|
|
25
|
+
require_relative "pty/transcript_normalizer"
|
|
26
26
|
require_relative "auth/anthropic_oauth"
|
|
27
27
|
require_relative "auth/api_key_store"
|
|
28
28
|
require_relative "auth/github_oauth"
|
|
@@ -30,7 +30,7 @@ require_relative "auth/openrouter_api_key"
|
|
|
30
30
|
require_relative "image_attachments"
|
|
31
31
|
require_relative "memory/manager"
|
|
32
32
|
require_relative "memory/turn_context"
|
|
33
|
-
require_relative "transcript_export"
|
|
33
|
+
require_relative "transcripts/transcript_export"
|
|
34
34
|
require_relative "message_access"
|
|
35
35
|
require_relative "model/model_info"
|
|
36
36
|
require_relative "model/azure_openai_config"
|
|
@@ -38,18 +38,18 @@ require_relative "model/provider_catalog"
|
|
|
38
38
|
require_relative "model/catalog"
|
|
39
39
|
require_relative "auth/openai_oauth"
|
|
40
40
|
require_relative "pan/server"
|
|
41
|
-
require_relative "
|
|
41
|
+
require_relative "plugins/registry"
|
|
42
42
|
require_relative "prompts/commands"
|
|
43
43
|
require_relative "model/retry_message"
|
|
44
44
|
require_relative "rpc/server"
|
|
45
45
|
require_relative "transport/runtime"
|
|
46
|
-
require_relative "
|
|
47
|
-
require_relative "
|
|
48
|
-
require_relative "
|
|
49
|
-
require_relative "
|
|
50
|
-
require_relative "
|
|
51
|
-
require_relative "
|
|
52
|
-
require_relative "
|
|
46
|
+
require_relative "sessions/diff"
|
|
47
|
+
require_relative "sessions/store"
|
|
48
|
+
require_relative "sessions/naming"
|
|
49
|
+
require_relative "tabs/store"
|
|
50
|
+
require_relative "tabs/driver"
|
|
51
|
+
require_relative "sessions/trash"
|
|
52
|
+
require_relative "sessions/tree_renderer"
|
|
53
53
|
require_relative "skills/capture"
|
|
54
54
|
require_relative "starter_pack_installer"
|
|
55
55
|
require_relative "steering"
|
|
@@ -57,8 +57,8 @@ require_relative "update_check"
|
|
|
57
57
|
require_relative "tools/tool_call"
|
|
58
58
|
require_relative "tools/registry"
|
|
59
59
|
require_relative "telemetry/stats"
|
|
60
|
-
require_relative "
|
|
61
|
-
require_relative "git_worktree_manager"
|
|
60
|
+
require_relative "workspace/factory"
|
|
61
|
+
require_relative "workspace/git_worktree_manager"
|
|
62
62
|
require_relative "cli/commands"
|
|
63
63
|
require_relative "cli/auth_commands"
|
|
64
64
|
require_relative "cli/doctor"
|
|
@@ -167,6 +167,7 @@ module Kward
|
|
|
167
167
|
warn runtime_error_message(e)
|
|
168
168
|
exit 1
|
|
169
169
|
ensure
|
|
170
|
+
shutdown_plugins if respond_to?(:shutdown_plugins, true)
|
|
170
171
|
ConfigFiles.skip_config = false
|
|
171
172
|
end
|
|
172
173
|
|
|
@@ -424,6 +425,7 @@ module Kward
|
|
|
424
425
|
tool_registry: ToolRegistry.new(
|
|
425
426
|
workspace: configured_workspace,
|
|
426
427
|
prompt: @prompt,
|
|
428
|
+
plugin_tools: plugin_registry.tools,
|
|
427
429
|
hook_manager: hook_manager,
|
|
428
430
|
hook_context: hook_context
|
|
429
431
|
),
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "model/chat_invocation"
|
|
3
|
+
require_relative "cancellation"
|
|
3
4
|
require_relative "compaction/file_operation_tracker"
|
|
4
5
|
require_relative "compaction/token_estimator"
|
|
5
6
|
require_relative "config_files"
|
|
@@ -671,29 +672,33 @@ module Kward
|
|
|
671
672
|
@prompt_builder = prompt_builder
|
|
672
673
|
end
|
|
673
674
|
|
|
674
|
-
def summarize(preparation, custom_instructions: nil)
|
|
675
|
+
def summarize(preparation, custom_instructions: nil, cancellation: nil)
|
|
675
676
|
summary = chat(
|
|
676
677
|
@prompt_builder.build(preparation, custom_instructions: custom_instructions),
|
|
677
|
-
max_tokens: @prompt_builder.normal_summary_max_tokens(preparation.settings, model_max_tokens: model_max_tokens)
|
|
678
|
+
max_tokens: @prompt_builder.normal_summary_max_tokens(preparation.settings, model_max_tokens: model_max_tokens),
|
|
679
|
+
cancellation: cancellation
|
|
678
680
|
)
|
|
679
681
|
if preparation.split_turn && !preparation.turn_prefix_messages.empty?
|
|
680
682
|
turn_summary = chat(
|
|
681
683
|
@prompt_builder.build_split(preparation),
|
|
682
|
-
max_tokens: @prompt_builder.split_turn_max_tokens(preparation.settings, model_max_tokens: model_max_tokens)
|
|
684
|
+
max_tokens: @prompt_builder.split_turn_max_tokens(preparation.settings, model_max_tokens: model_max_tokens),
|
|
685
|
+
cancellation: cancellation
|
|
683
686
|
)
|
|
684
687
|
summary = "#{summary}\n\n---\n\n**Turn Context (split turn):**\n\n#{turn_summary}"
|
|
685
688
|
end
|
|
686
689
|
summary.to_s.strip
|
|
687
690
|
rescue Cancelled
|
|
688
691
|
raise
|
|
692
|
+
rescue Kward::Cancellation::CancelledError
|
|
693
|
+
raise
|
|
689
694
|
rescue StandardError => e
|
|
690
695
|
raise SummarizationFailed, e.message
|
|
691
696
|
end
|
|
692
697
|
|
|
693
698
|
private
|
|
694
699
|
|
|
695
|
-
def chat(messages, max_tokens: nil)
|
|
696
|
-
message = ChatInvocation.call(@client, messages, { tools: [], max_tokens: max_tokens })
|
|
700
|
+
def chat(messages, max_tokens: nil, cancellation: nil)
|
|
701
|
+
message = ChatInvocation.call(@client, messages, { tools: [], max_tokens: max_tokens, cancellation: cancellation })
|
|
697
702
|
content = message_content(message)
|
|
698
703
|
text = message_content_text(content).strip
|
|
699
704
|
raise SummarizationFailed, "Compaction produced an empty summary; context was not changed." if text.empty?
|
|
@@ -752,13 +757,19 @@ module Kward
|
|
|
752
757
|
false
|
|
753
758
|
end
|
|
754
759
|
|
|
755
|
-
def compact(custom_instructions: nil, compaction_summary: true)
|
|
760
|
+
def compact(custom_instructions: nil, compaction_summary: true, cancellation: nil)
|
|
761
|
+
cancellation&.raise_if_cancelled!
|
|
756
762
|
old_count = @conversation.messages.length
|
|
757
763
|
preparation = prepare
|
|
758
|
-
summary =
|
|
764
|
+
summary = if cancellation
|
|
765
|
+
@summarizer.summarize(preparation, custom_instructions: custom_instructions, cancellation: cancellation)
|
|
766
|
+
else
|
|
767
|
+
@summarizer.summarize(preparation, custom_instructions: custom_instructions)
|
|
768
|
+
end
|
|
759
769
|
summary = append_files_section(summary, preparation.file_ops)
|
|
760
770
|
raise Compaction::SummarizationFailed, "Compaction produced an empty summary; context was not changed." if summary.strip.empty?
|
|
761
771
|
|
|
772
|
+
cancellation&.raise_if_cancelled!
|
|
762
773
|
@conversation.compact!(
|
|
763
774
|
summary,
|
|
764
775
|
compaction_summary: compaction_summary,
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Core configuration paths, persistence, and shared configuration values.
|
|
2
|
+
module Kward
|
|
3
|
+
module ConfigFiles
|
|
4
|
+
class ConfigError < StandardError
|
|
5
|
+
attr_reader :path, :format, :detail
|
|
6
|
+
|
|
7
|
+
def initialize(path:, format:, detail:)
|
|
8
|
+
@path = path
|
|
9
|
+
@format = format
|
|
10
|
+
@detail = detail
|
|
11
|
+
super("Invalid Kward config #{format}: #{path}: #{detail}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
MAX_SKILL_FILE_BYTES = 100_000
|
|
16
|
+
MAX_PROMPT_FILE_BYTES = 32 * 1024
|
|
17
|
+
DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
|
|
18
|
+
PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
|
|
19
|
+
DEFAULT_PERSONAS = {
|
|
20
|
+
"characters" => [
|
|
21
|
+
{
|
|
22
|
+
"key" => "kward",
|
|
23
|
+
"label" => "Kward",
|
|
24
|
+
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"default" => "kward"
|
|
28
|
+
}.freeze
|
|
29
|
+
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
30
|
+
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
31
|
+
|
|
32
|
+
Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
|
|
33
|
+
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
34
|
+
def expand(arguments)
|
|
35
|
+
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@skip_config = false
|
|
40
|
+
@warning_sink = nil
|
|
41
|
+
|
|
42
|
+
module Core
|
|
43
|
+
extend self
|
|
44
|
+
|
|
45
|
+
def skip_config=(value)
|
|
46
|
+
@skip_config = value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def warning_sink=(sink)
|
|
50
|
+
@warning_sink = sink
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def warning_sink
|
|
54
|
+
@warning_sink
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def skip_config?
|
|
58
|
+
@skip_config == true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Directory that contains Kward's user config and adjacent prompt/skill
|
|
62
|
+
# data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
|
|
63
|
+
#
|
|
64
|
+
# @return [String] expanded config directory path
|
|
65
|
+
def config_dir
|
|
66
|
+
config_path = ENV["KWARD_CONFIG_PATH"]
|
|
67
|
+
return File.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?
|
|
68
|
+
|
|
69
|
+
File.expand_path("~/.kward")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String] expanded JSON config file path
|
|
73
|
+
def config_path
|
|
74
|
+
File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String] directory containing reusable local caches
|
|
78
|
+
def cache_dir
|
|
79
|
+
File.join(config_dir, "cache")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] embedded-shell rc config path
|
|
83
|
+
def kwshrc_path
|
|
84
|
+
File.join(config_dir, "kwshrc")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Array<String>] embedded-shell rc config paths in load order
|
|
88
|
+
def kwshrc_paths
|
|
89
|
+
[kwshrc_path, File.expand_path("~/.kwshrc")].uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def workspace_hooks_path(root = Dir.pwd)
|
|
93
|
+
File.join(File.expand_path(root), ".kward", "hooks.json")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def trusted_workspace_hooks_path
|
|
97
|
+
File.join(config_dir, "trusted_workspace_hooks.json")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns a fresh default config suitable for first-run persistence.
|
|
101
|
+
#
|
|
102
|
+
# @return [Hash] deep-mutable config defaults
|
|
103
|
+
def default_config
|
|
104
|
+
{
|
|
105
|
+
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
106
|
+
"memory" => {
|
|
107
|
+
"enabled" => false,
|
|
108
|
+
"auto_summary" => false
|
|
109
|
+
},
|
|
110
|
+
"composer" => {
|
|
111
|
+
"busy_help" => true,
|
|
112
|
+
"tab_keybindings" => "auto"
|
|
113
|
+
},
|
|
114
|
+
"editor" => {
|
|
115
|
+
"mode" => "modern",
|
|
116
|
+
"agent" => {},
|
|
117
|
+
"runners" => {},
|
|
118
|
+
"auto_indent" => true,
|
|
119
|
+
"auto_close_pairs" => true,
|
|
120
|
+
"soft_wrap" => true,
|
|
121
|
+
"bar_cursor" => true,
|
|
122
|
+
"line_numbers" => "absolute",
|
|
123
|
+
"diff_view" => "auto"
|
|
124
|
+
},
|
|
125
|
+
"overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
|
|
126
|
+
"project_browser" => {
|
|
127
|
+
"icons" => "off"
|
|
128
|
+
},
|
|
129
|
+
"web_search" => {
|
|
130
|
+
"enabled" => true,
|
|
131
|
+
"provider" => "auto",
|
|
132
|
+
"allow_model_providers" => false
|
|
133
|
+
},
|
|
134
|
+
"updates" => {
|
|
135
|
+
"check" => true
|
|
136
|
+
},
|
|
137
|
+
"sessions" => {
|
|
138
|
+
"auto_resume" => false
|
|
139
|
+
},
|
|
140
|
+
"skills" => {
|
|
141
|
+
"trust_project" => false
|
|
142
|
+
},
|
|
143
|
+
"enforce_workspace_agents_file" => false,
|
|
144
|
+
"system_prompt" => {
|
|
145
|
+
"include_principles" => true
|
|
146
|
+
},
|
|
147
|
+
"mcpServers" => {},
|
|
148
|
+
"plugins" => {},
|
|
149
|
+
"transports" => {},
|
|
150
|
+
"tools" => {
|
|
151
|
+
"workspace_guardrails" => true
|
|
152
|
+
},
|
|
153
|
+
"permissions" => {
|
|
154
|
+
"enabled" => false,
|
|
155
|
+
"mode" => "ask"
|
|
156
|
+
},
|
|
157
|
+
"sandbox" => {
|
|
158
|
+
"mode" => "off",
|
|
159
|
+
"network" => "deny",
|
|
160
|
+
"writable_roots" => [],
|
|
161
|
+
"protect_git_metadata" => true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Performs ensure default config for configuration file and path handling.
|
|
167
|
+
def ensure_default_config!(path = config_path)
|
|
168
|
+
path = File.expand_path(path)
|
|
169
|
+
return false if skip_config? && path == config_path
|
|
170
|
+
return false if File.exist?(path)
|
|
171
|
+
|
|
172
|
+
write_config(default_config, path)
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def code_search_cache_dir
|
|
177
|
+
File.join(cache_dir, "code_search")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def openrouter_models_cache_path
|
|
181
|
+
File.join(cache_dir, "openrouter_models.json")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def model_catalog_cache_path(provider_id)
|
|
185
|
+
provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
|
|
186
|
+
raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
|
|
187
|
+
|
|
188
|
+
File.join(cache_dir, "models", "#{provider}.json")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def update_check_cache_path
|
|
192
|
+
File.join(cache_dir, "update_check.json")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def project_browser_state_path
|
|
196
|
+
File.join(cache_dir, "project_browser_state.json")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
|
|
200
|
+
key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
|
|
201
|
+
return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
|
|
202
|
+
|
|
203
|
+
File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [String] directory containing structured memory files
|
|
207
|
+
def memory_dir
|
|
208
|
+
File.join(config_dir, "memory")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def memory_core_path
|
|
212
|
+
File.join(memory_dir, "core.json")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def memory_soft_path
|
|
216
|
+
File.join(memory_dir, "soft.jsonl")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def memory_events_path
|
|
220
|
+
File.join(memory_dir, "events.jsonl")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Reads the JSON config file.
|
|
224
|
+
#
|
|
225
|
+
# Missing files are treated as an empty config. Invalid JSON raises a
|
|
226
|
+
# user-facing error that includes the file path. This method does not merge
|
|
227
|
+
# defaults; callers should apply feature-specific defaults at the point where
|
|
228
|
+
# behavior is decided.
|
|
229
|
+
#
|
|
230
|
+
# @param path [String] config file path
|
|
231
|
+
# @return [Hash] parsed config object
|
|
232
|
+
def read_config(path = config_path)
|
|
233
|
+
path = File.expand_path(path)
|
|
234
|
+
return {} if skip_config? && path == config_path
|
|
235
|
+
return {} unless File.exist?(path)
|
|
236
|
+
|
|
237
|
+
config = JSON.parse(File.read(path))
|
|
238
|
+
return config if config.is_a?(Hash)
|
|
239
|
+
|
|
240
|
+
raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
|
|
241
|
+
rescue JSON::ParserError => e
|
|
242
|
+
raise ConfigError.new(path: path, format: "JSON", detail: e.message)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Writes config JSON using private file permissions.
|
|
246
|
+
#
|
|
247
|
+
# @param config [Hash] config object to persist
|
|
248
|
+
# @param path [String] config file path
|
|
249
|
+
def write_config(config, path = config_path)
|
|
250
|
+
path = File.expand_path(path)
|
|
251
|
+
raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
|
|
252
|
+
|
|
253
|
+
PrivateFile.write_json(path, config)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def lifecycle_hooks_config(workspace_root = Dir.pwd)
|
|
257
|
+
config = read_config
|
|
258
|
+
workspace_config = read_trusted_workspace_hooks_config(workspace_root)
|
|
259
|
+
return config if workspace_config.empty?
|
|
260
|
+
|
|
261
|
+
merge_hooks_config(config, workspace_config)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
|
|
265
|
+
path = workspace_hooks_path(workspace_root)
|
|
266
|
+
return {} unless workspace_hooks_trusted?(workspace_root)
|
|
267
|
+
|
|
268
|
+
read_config(path)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def workspace_hooks_trusted?(workspace_root = Dir.pwd)
|
|
272
|
+
path = workspace_hooks_path(workspace_root)
|
|
273
|
+
return false unless File.file?(path)
|
|
274
|
+
|
|
275
|
+
trusted = read_trusted_workspace_hooks
|
|
276
|
+
trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def trust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
280
|
+
path = workspace_hooks_path(workspace_root)
|
|
281
|
+
raise "No workspace hook config found: #{path}" unless File.file?(path)
|
|
282
|
+
|
|
283
|
+
trusted = read_trusted_workspace_hooks
|
|
284
|
+
trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
|
|
285
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def untrust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
289
|
+
path = File.expand_path(workspace_hooks_path(workspace_root))
|
|
290
|
+
trusted = read_trusted_workspace_hooks
|
|
291
|
+
trusted.delete(path)
|
|
292
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def workspace_hooks_digest(workspace_root = Dir.pwd)
|
|
296
|
+
Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def read_trusted_workspace_hooks
|
|
300
|
+
config = read_config(trusted_workspace_hooks_path)
|
|
301
|
+
config.is_a?(Hash) ? config : {}
|
|
302
|
+
rescue ConfigError
|
|
303
|
+
{}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def merge_hooks_config(config, workspace_config)
|
|
307
|
+
merged = JSON.parse(JSON.generate(config || {}))
|
|
308
|
+
merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
|
|
309
|
+
merged
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def merge_hook_maps(left, right)
|
|
313
|
+
left = left.is_a?(Hash) ? left : {}
|
|
314
|
+
right = right.is_a?(Hash) ? right : {}
|
|
315
|
+
merged = JSON.parse(JSON.generate(left))
|
|
316
|
+
right.each do |event, entries|
|
|
317
|
+
merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
|
|
318
|
+
end
|
|
319
|
+
merged
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def read_kwsh_config
|
|
323
|
+
config = {
|
|
324
|
+
shell: Kwsh::DEFAULT_SHELL,
|
|
325
|
+
timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS,
|
|
326
|
+
max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES,
|
|
327
|
+
history_limit: Kwsh::DEFAULT_HISTORY_LIMIT,
|
|
328
|
+
env: {},
|
|
329
|
+
aliases: {}
|
|
330
|
+
}
|
|
331
|
+
rc_config = read_kwshrc_config
|
|
332
|
+
config[:env].merge!(rc_config[:env])
|
|
333
|
+
config[:aliases].merge!(rc_config[:aliases])
|
|
334
|
+
config
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def read_kwshrc_config(paths = kwshrc_paths, env: ENV.to_h)
|
|
338
|
+
Kwshrc.read(paths, env: env)
|
|
339
|
+
rescue Kwshrc::ParseError => e
|
|
340
|
+
raise "Invalid kwshrc config: #{e.message}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Merges top-level config values and writes the updated config privately.
|
|
344
|
+
def update_config(values, path = config_path)
|
|
345
|
+
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
346
|
+
|
|
347
|
+
config = read_config(path)
|
|
348
|
+
values.each { |key, value| config[key.to_s] = value }
|
|
349
|
+
write_config(config, path)
|
|
350
|
+
config
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Merges values into a one-level nested config section and writes privately.
|
|
354
|
+
def update_nested_config(section, values, path = config_path)
|
|
355
|
+
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
356
|
+
|
|
357
|
+
config = read_config(path)
|
|
358
|
+
current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
|
|
359
|
+
config[section.to_s] = current.merge(values.transform_keys(&:to_s))
|
|
360
|
+
write_config(config, path)
|
|
361
|
+
config
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Removes a top-level config key when it exists.
|
|
365
|
+
def delete_config_key(key, path = config_path)
|
|
366
|
+
config = read_config(path)
|
|
367
|
+
existed = config.key?(key.to_s)
|
|
368
|
+
config.delete(key.to_s)
|
|
369
|
+
write_config(config, path) if existed
|
|
370
|
+
existed
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Returns the first present non-empty string value among several config keys.
|
|
374
|
+
def config_value(config, *keys)
|
|
375
|
+
keys.each do |key|
|
|
376
|
+
text = presence(config[key])
|
|
377
|
+
return text if text
|
|
378
|
+
end
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Returns validated overlay settings with defaults for missing or invalid
|
|
383
|
+
# values.
|
|
384
|
+
#
|
|
385
|
+
# @param config [Hash] parsed config object
|
|
386
|
+
# @return [Hash] overlay settings with `alignment` and `width`
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Configuration extensions behavior.
|
|
2
|
+
module Kward
|
|
3
|
+
module ConfigFiles
|
|
4
|
+
module Extensions
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
# Lists configured skills discovered under the config directory.
|
|
8
|
+
#
|
|
9
|
+
# @return [Array<Skill>] skill metadata available to the model
|
|
10
|
+
def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
11
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
|
|
15
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] trusted user plugin directory
|
|
19
|
+
def plugin_dir
|
|
20
|
+
File.expand_path("~/.kward/plugins")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Finds trusted plugin files and package entrypoints.
|
|
24
|
+
#
|
|
25
|
+
# Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
|
|
26
|
+
# workspace or custom `KWARD_CONFIG_PATH` directory.
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<String>] sorted plugin file paths
|
|
29
|
+
def plugin_paths(warning_sink: nil)
|
|
30
|
+
plugins_root = plugin_dir
|
|
31
|
+
return [] unless Dir.exist?(plugins_root)
|
|
32
|
+
|
|
33
|
+
paths = Dir.glob(File.join(plugins_root, "*.rb"))
|
|
34
|
+
paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
|
|
35
|
+
paths.sort
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lists prompt templates exposed as slash commands.
|
|
42
|
+
#
|
|
43
|
+
# @param reserved_commands [Array<String>] command names unavailable to templates
|
|
44
|
+
# @return [Array<PromptTemplate>] prompt template metadata and bodies
|
|
45
|
+
def prompt_templates(reserved_commands: [])
|
|
46
|
+
prompt_template_registry.prompt_templates(reserved_commands: reserved_commands)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reads a skill file by skill name and optional relative path.
|
|
50
|
+
#
|
|
51
|
+
# @param name [String] configured skill name
|
|
52
|
+
# @param relative_path [String, nil] path inside the skill directory
|
|
53
|
+
# @return [String] file contents or an error string
|
|
54
|
+
def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
|
|
55
|
+
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
59
|
+
Skills::Registry.new(
|
|
60
|
+
config_dir: config_dir,
|
|
61
|
+
workspace_root: workspace_root,
|
|
62
|
+
project_skills_trusted: project_skills_trusted?,
|
|
63
|
+
skill_class: Skill,
|
|
64
|
+
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
65
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
66
|
+
inside_directory: method(:inside_directory?),
|
|
67
|
+
warning_sink: warning_sink || @warning_sink,
|
|
68
|
+
project_skill_paths: project_skill_paths
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prompt_template_registry
|
|
73
|
+
::Kward::Prompts::Templates.new(
|
|
74
|
+
config_dir: config_dir,
|
|
75
|
+
template_class: PromptTemplate,
|
|
76
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
|
|
77
|
+
warning_sink: @warning_sink
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def emit_warning(message, warning_sink: nil)
|
|
82
|
+
sink = warning_sink || @warning_sink
|
|
83
|
+
sink ? sink.call(message) : warn(message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inside_directory?(path, base)
|
|
87
|
+
PathGuard.inside?(path, base)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def presence(value)
|
|
91
|
+
text = value.to_s
|
|
92
|
+
text.empty? ? nil : text
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|