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/config_files.rb
CHANGED
|
@@ -4,16 +4,21 @@ require "json"
|
|
|
4
4
|
require_relative "frontmatter"
|
|
5
5
|
require_relative "deep_copy"
|
|
6
6
|
require_relative "private_file"
|
|
7
|
-
require_relative "path_guard"
|
|
7
|
+
require_relative "workspace/path_guard"
|
|
8
8
|
require_relative "permissions/policy"
|
|
9
9
|
require_relative "sandbox/policy"
|
|
10
|
-
require_relative "kwsh"
|
|
11
|
-
require_relative "kwshrc"
|
|
12
|
-
require_relative "editor_mode"
|
|
13
|
-
require_relative "diff_view_mode"
|
|
10
|
+
require_relative "shell/kwsh"
|
|
11
|
+
require_relative "shell/kwshrc"
|
|
12
|
+
require_relative "prompt_interface/editor/editor_mode"
|
|
13
|
+
require_relative "prompt_interface/editor/diff_view_mode"
|
|
14
14
|
require_relative "prompts/templates"
|
|
15
15
|
require_relative "skills/registry"
|
|
16
16
|
|
|
17
|
+
require_relative "config/core"
|
|
18
|
+
require_relative "config/settings"
|
|
19
|
+
require_relative "config/prompts"
|
|
20
|
+
require_relative "config/extensions"
|
|
21
|
+
|
|
17
22
|
# Namespace for the Kward CLI agent runtime.
|
|
18
23
|
module Kward
|
|
19
24
|
# Resolves Kward configuration, cache, memory, prompt, skill, and plugin
|
|
@@ -29,1008 +34,9 @@ module Kward
|
|
|
29
34
|
# reconstructing `~/.kward` paths independently.
|
|
30
35
|
# @api public
|
|
31
36
|
module ConfigFiles
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def initialize(path:, format:, detail:)
|
|
37
|
-
@path = path
|
|
38
|
-
@format = format
|
|
39
|
-
@detail = detail
|
|
40
|
-
super("Invalid Kward config #{format}: #{path}: #{detail}")
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
MAX_SKILL_FILE_BYTES = 100_000
|
|
45
|
-
MAX_PROMPT_FILE_BYTES = 32 * 1024
|
|
46
|
-
DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
|
|
47
|
-
PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
|
|
48
|
-
DEFAULT_PERSONAS = {
|
|
49
|
-
"characters" => [
|
|
50
|
-
{
|
|
51
|
-
"key" => "kward",
|
|
52
|
-
"label" => "Kward",
|
|
53
|
-
"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."
|
|
54
|
-
}
|
|
55
|
-
],
|
|
56
|
-
"default" => "kward"
|
|
57
|
-
}.freeze
|
|
58
|
-
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
59
|
-
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
60
|
-
|
|
61
|
-
Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
|
|
62
|
-
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
63
|
-
def expand(arguments)
|
|
64
|
-
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
@skip_config = false
|
|
69
|
-
@warning_sink = nil
|
|
70
|
-
|
|
71
|
-
module_function
|
|
72
|
-
|
|
73
|
-
def skip_config=(value)
|
|
74
|
-
@skip_config = value
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def warning_sink=(sink)
|
|
78
|
-
@warning_sink = sink
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def warning_sink
|
|
82
|
-
@warning_sink
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def skip_config?
|
|
86
|
-
@skip_config == true
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Directory that contains Kward's user config and adjacent prompt/skill
|
|
90
|
-
# data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
|
|
91
|
-
#
|
|
92
|
-
# @return [String] expanded config directory path
|
|
93
|
-
def config_dir
|
|
94
|
-
config_path = ENV["KWARD_CONFIG_PATH"]
|
|
95
|
-
return File.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?
|
|
96
|
-
|
|
97
|
-
File.expand_path("~/.kward")
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# @return [String] expanded JSON config file path
|
|
101
|
-
def config_path
|
|
102
|
-
File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# @return [String] directory containing reusable local caches
|
|
106
|
-
def cache_dir
|
|
107
|
-
File.join(config_dir, "cache")
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# @return [String] embedded-shell rc config path
|
|
111
|
-
def kwshrc_path
|
|
112
|
-
File.join(config_dir, "kwshrc")
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# @return [Array<String>] embedded-shell rc config paths in load order
|
|
116
|
-
def kwshrc_paths
|
|
117
|
-
[kwshrc_path, File.expand_path("~/.kwshrc")].uniq
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def workspace_hooks_path(root = Dir.pwd)
|
|
121
|
-
File.join(File.expand_path(root), ".kward", "hooks.json")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def trusted_workspace_hooks_path
|
|
125
|
-
File.join(config_dir, "trusted_workspace_hooks.json")
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Returns a fresh default config suitable for first-run persistence.
|
|
129
|
-
#
|
|
130
|
-
# @return [Hash] deep-mutable config defaults
|
|
131
|
-
def default_config
|
|
132
|
-
{
|
|
133
|
-
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
134
|
-
"memory" => {
|
|
135
|
-
"enabled" => false,
|
|
136
|
-
"auto_summary" => false
|
|
137
|
-
},
|
|
138
|
-
"composer" => {
|
|
139
|
-
"busy_help" => true,
|
|
140
|
-
"tab_keybindings" => "auto"
|
|
141
|
-
},
|
|
142
|
-
"editor" => {
|
|
143
|
-
"mode" => "modern",
|
|
144
|
-
"agent" => {},
|
|
145
|
-
"runners" => {},
|
|
146
|
-
"auto_indent" => true,
|
|
147
|
-
"auto_close_pairs" => true,
|
|
148
|
-
"soft_wrap" => true,
|
|
149
|
-
"bar_cursor" => true,
|
|
150
|
-
"line_numbers" => "absolute",
|
|
151
|
-
"diff_view" => "auto"
|
|
152
|
-
},
|
|
153
|
-
"overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
|
|
154
|
-
"project_browser" => {
|
|
155
|
-
"icons" => "off"
|
|
156
|
-
},
|
|
157
|
-
"web_search" => {
|
|
158
|
-
"enabled" => true,
|
|
159
|
-
"provider" => "auto",
|
|
160
|
-
"allow_model_providers" => false
|
|
161
|
-
},
|
|
162
|
-
"updates" => {
|
|
163
|
-
"check" => true
|
|
164
|
-
},
|
|
165
|
-
"sessions" => {
|
|
166
|
-
"auto_resume" => false
|
|
167
|
-
},
|
|
168
|
-
"skills" => {
|
|
169
|
-
"trust_project" => false
|
|
170
|
-
},
|
|
171
|
-
"enforce_workspace_agents_file" => false,
|
|
172
|
-
"system_prompt" => {
|
|
173
|
-
"include_principles" => true
|
|
174
|
-
},
|
|
175
|
-
"mcpServers" => {},
|
|
176
|
-
"transports" => {},
|
|
177
|
-
"tools" => {
|
|
178
|
-
"workspace_guardrails" => true
|
|
179
|
-
},
|
|
180
|
-
"permissions" => {
|
|
181
|
-
"enabled" => false,
|
|
182
|
-
"mode" => "ask"
|
|
183
|
-
},
|
|
184
|
-
"sandbox" => {
|
|
185
|
-
"mode" => "off",
|
|
186
|
-
"network" => "deny",
|
|
187
|
-
"writable_roots" => [],
|
|
188
|
-
"protect_git_metadata" => true
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# Performs ensure default config for configuration file and path handling.
|
|
194
|
-
def ensure_default_config!(path = config_path)
|
|
195
|
-
path = File.expand_path(path)
|
|
196
|
-
return false if skip_config? && path == config_path
|
|
197
|
-
return false if File.exist?(path)
|
|
198
|
-
|
|
199
|
-
write_config(default_config, path)
|
|
200
|
-
true
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def code_search_cache_dir
|
|
204
|
-
File.join(cache_dir, "code_search")
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def openrouter_models_cache_path
|
|
208
|
-
File.join(cache_dir, "openrouter_models.json")
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def model_catalog_cache_path(provider_id)
|
|
212
|
-
provider = provider_id.to_s.gsub(/[^a-z0-9_-]/i, "_")
|
|
213
|
-
raise ArgumentError, "Provider id must be a non-empty string" if provider.empty?
|
|
214
|
-
|
|
215
|
-
File.join(cache_dir, "models", "#{provider}.json")
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def update_check_cache_path
|
|
219
|
-
File.join(cache_dir, "update_check.json")
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def project_browser_state_path
|
|
223
|
-
File.join(cache_dir, "project_browser_state.json")
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
|
|
227
|
-
key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
|
|
228
|
-
return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
|
|
229
|
-
|
|
230
|
-
File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
# @return [String] directory containing structured memory files
|
|
234
|
-
def memory_dir
|
|
235
|
-
File.join(config_dir, "memory")
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def memory_core_path
|
|
239
|
-
File.join(memory_dir, "core.json")
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def memory_soft_path
|
|
243
|
-
File.join(memory_dir, "soft.jsonl")
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def memory_events_path
|
|
247
|
-
File.join(memory_dir, "events.jsonl")
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
# Reads the JSON config file.
|
|
251
|
-
#
|
|
252
|
-
# Missing files are treated as an empty config. Invalid JSON raises a
|
|
253
|
-
# user-facing error that includes the file path. This method does not merge
|
|
254
|
-
# defaults; callers should apply feature-specific defaults at the point where
|
|
255
|
-
# behavior is decided.
|
|
256
|
-
#
|
|
257
|
-
# @param path [String] config file path
|
|
258
|
-
# @return [Hash] parsed config object
|
|
259
|
-
def read_config(path = config_path)
|
|
260
|
-
path = File.expand_path(path)
|
|
261
|
-
return {} if skip_config? && path == config_path
|
|
262
|
-
return {} unless File.exist?(path)
|
|
263
|
-
|
|
264
|
-
config = JSON.parse(File.read(path))
|
|
265
|
-
return config if config.is_a?(Hash)
|
|
266
|
-
|
|
267
|
-
raise ConfigError.new(path: path, format: "JSON", detail: "top-level value must be an object")
|
|
268
|
-
rescue JSON::ParserError => e
|
|
269
|
-
raise ConfigError.new(path: path, format: "JSON", detail: e.message)
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Writes config JSON using private file permissions.
|
|
273
|
-
#
|
|
274
|
-
# @param config [Hash] config object to persist
|
|
275
|
-
# @param path [String] config file path
|
|
276
|
-
def write_config(config, path = config_path)
|
|
277
|
-
path = File.expand_path(path)
|
|
278
|
-
raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
|
|
279
|
-
|
|
280
|
-
PrivateFile.write_json(path, config)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def lifecycle_hooks_config(workspace_root = Dir.pwd)
|
|
284
|
-
config = read_config
|
|
285
|
-
workspace_config = read_trusted_workspace_hooks_config(workspace_root)
|
|
286
|
-
return config if workspace_config.empty?
|
|
287
|
-
|
|
288
|
-
merge_hooks_config(config, workspace_config)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
|
|
292
|
-
path = workspace_hooks_path(workspace_root)
|
|
293
|
-
return {} unless workspace_hooks_trusted?(workspace_root)
|
|
294
|
-
|
|
295
|
-
read_config(path)
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def workspace_hooks_trusted?(workspace_root = Dir.pwd)
|
|
299
|
-
path = workspace_hooks_path(workspace_root)
|
|
300
|
-
return false unless File.file?(path)
|
|
301
|
-
|
|
302
|
-
trusted = read_trusted_workspace_hooks
|
|
303
|
-
trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
def trust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
307
|
-
path = workspace_hooks_path(workspace_root)
|
|
308
|
-
raise "No workspace hook config found: #{path}" unless File.file?(path)
|
|
309
|
-
|
|
310
|
-
trusted = read_trusted_workspace_hooks
|
|
311
|
-
trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
|
|
312
|
-
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def untrust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
316
|
-
path = File.expand_path(workspace_hooks_path(workspace_root))
|
|
317
|
-
trusted = read_trusted_workspace_hooks
|
|
318
|
-
trusted.delete(path)
|
|
319
|
-
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
def workspace_hooks_digest(workspace_root = Dir.pwd)
|
|
323
|
-
Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def read_trusted_workspace_hooks
|
|
327
|
-
config = read_config(trusted_workspace_hooks_path)
|
|
328
|
-
config.is_a?(Hash) ? config : {}
|
|
329
|
-
rescue ConfigError
|
|
330
|
-
{}
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def merge_hooks_config(config, workspace_config)
|
|
334
|
-
merged = JSON.parse(JSON.generate(config || {}))
|
|
335
|
-
merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
|
|
336
|
-
merged
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def merge_hook_maps(left, right)
|
|
340
|
-
left = left.is_a?(Hash) ? left : {}
|
|
341
|
-
right = right.is_a?(Hash) ? right : {}
|
|
342
|
-
merged = JSON.parse(JSON.generate(left))
|
|
343
|
-
right.each do |event, entries|
|
|
344
|
-
merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
|
|
345
|
-
end
|
|
346
|
-
merged
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
def read_kwsh_config
|
|
350
|
-
config = {
|
|
351
|
-
shell: Kwsh::DEFAULT_SHELL,
|
|
352
|
-
timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS,
|
|
353
|
-
max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES,
|
|
354
|
-
history_limit: Kwsh::DEFAULT_HISTORY_LIMIT,
|
|
355
|
-
env: {},
|
|
356
|
-
aliases: {}
|
|
357
|
-
}
|
|
358
|
-
rc_config = read_kwshrc_config
|
|
359
|
-
config[:env].merge!(rc_config[:env])
|
|
360
|
-
config[:aliases].merge!(rc_config[:aliases])
|
|
361
|
-
config
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
def read_kwshrc_config(paths = kwshrc_paths, env: ENV.to_h)
|
|
365
|
-
Kwshrc.read(paths, env: env)
|
|
366
|
-
rescue Kwshrc::ParseError => e
|
|
367
|
-
raise "Invalid kwshrc config: #{e.message}"
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
# Merges top-level config values and writes the updated config privately.
|
|
371
|
-
def update_config(values, path = config_path)
|
|
372
|
-
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
373
|
-
|
|
374
|
-
config = read_config(path)
|
|
375
|
-
values.each { |key, value| config[key.to_s] = value }
|
|
376
|
-
write_config(config, path)
|
|
377
|
-
config
|
|
378
|
-
end
|
|
379
|
-
|
|
380
|
-
# Merges values into a one-level nested config section and writes privately.
|
|
381
|
-
def update_nested_config(section, values, path = config_path)
|
|
382
|
-
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
383
|
-
|
|
384
|
-
config = read_config(path)
|
|
385
|
-
current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
|
|
386
|
-
config[section.to_s] = current.merge(values.transform_keys(&:to_s))
|
|
387
|
-
write_config(config, path)
|
|
388
|
-
config
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
# Removes a top-level config key when it exists.
|
|
392
|
-
def delete_config_key(key, path = config_path)
|
|
393
|
-
config = read_config(path)
|
|
394
|
-
existed = config.key?(key.to_s)
|
|
395
|
-
config.delete(key.to_s)
|
|
396
|
-
write_config(config, path) if existed
|
|
397
|
-
existed
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
# Returns the first present non-empty string value among several config keys.
|
|
401
|
-
def config_value(config, *keys)
|
|
402
|
-
keys.each do |key|
|
|
403
|
-
text = presence(config[key])
|
|
404
|
-
return text if text
|
|
405
|
-
end
|
|
406
|
-
nil
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
# Returns validated overlay settings with defaults for missing or invalid
|
|
410
|
-
# values.
|
|
411
|
-
#
|
|
412
|
-
# @param config [Hash] parsed config object
|
|
413
|
-
# @return [Hash] overlay settings with `alignment` and `width`
|
|
414
|
-
def overlay_settings(config = read_config)
|
|
415
|
-
overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
|
|
416
|
-
settings = DEFAULT_OVERLAY_SETTINGS.dup
|
|
417
|
-
alignment = overlay["alignment"].to_s
|
|
418
|
-
width = overlay["width"].to_s
|
|
419
|
-
settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment)
|
|
420
|
-
settings["width"] = width if OVERLAY_WIDTHS.include?(width)
|
|
421
|
-
settings
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
# Returns the project browser icon theme, or off when unset or invalid.
|
|
425
|
-
def project_browser_icon_theme(config = read_config)
|
|
426
|
-
browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
|
|
427
|
-
theme = browser["icons"].to_s
|
|
428
|
-
PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
# Returns whether the composer should show busy-state keyboard help.
|
|
432
|
-
def composer_busy_help?(config = read_config)
|
|
433
|
-
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
434
|
-
composer["busy_help"] != false
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
# Returns the configured tab keybinding family, or auto when unset/invalid.
|
|
438
|
-
def composer_tab_keybindings(config = read_config)
|
|
439
|
-
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
440
|
-
value = composer["tab_keybindings"].to_s.downcase
|
|
441
|
-
%w[auto ctrl alt].include?(value) ? value : "auto"
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# Returns the built-in TUI editor keymap mode.
|
|
445
|
-
def editor_mode(config = read_config)
|
|
446
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
447
|
-
EditorMode.normalize(editor["mode"])
|
|
448
|
-
end
|
|
449
|
-
|
|
450
|
-
# Returns configured editor runner definitions, or an empty map.
|
|
451
|
-
#
|
|
452
|
-
# Runner defaults are resolved by the editor runner at execution time so
|
|
453
|
-
# the generated config remains portable across machines.
|
|
454
|
-
def editor_runners(config = read_config)
|
|
455
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
456
|
-
runners = editor["runners"]
|
|
457
|
-
runners.is_a?(Hash) ? runners : {}
|
|
458
|
-
end
|
|
459
|
-
|
|
460
|
-
# Returns the optional provider override for editor-agent turns.
|
|
461
|
-
def editor_agent_provider(config = read_config)
|
|
462
|
-
value = ENV["KWARD_EDITOR_PROVIDER"].to_s.strip
|
|
463
|
-
return value unless value.empty?
|
|
464
|
-
|
|
465
|
-
editor_agent_setting(config, "provider")
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Returns the optional model override for editor-agent turns.
|
|
469
|
-
def editor_agent_model(config = read_config)
|
|
470
|
-
editor_agent_setting(config, "model")
|
|
471
|
-
end
|
|
472
|
-
|
|
473
|
-
# Returns the optional reasoning-effort override for editor-agent turns.
|
|
474
|
-
def editor_agent_reasoning_effort(config = read_config)
|
|
475
|
-
editor_agent_setting(config, "reasoning_effort")
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# Returns the shell-agent provider override, preferring the environment.
|
|
479
|
-
def shell_agent_provider(config = read_config)
|
|
480
|
-
value = ENV["KWSH_PROVIDER"].to_s.strip
|
|
481
|
-
return value unless value.empty?
|
|
482
|
-
|
|
483
|
-
shell_agent_setting(config, "provider")
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
# Returns the shell-agent model override, preferring the environment.
|
|
487
|
-
def shell_agent_model(config = read_config)
|
|
488
|
-
value = ENV["KWSH_MODE"].to_s.strip
|
|
489
|
-
return value unless value.empty?
|
|
490
|
-
|
|
491
|
-
shell_agent_setting(config, "model")
|
|
492
|
-
end
|
|
493
|
-
|
|
494
|
-
# Returns the shell-agent reasoning-effort override, preferring the environment.
|
|
495
|
-
def shell_agent_reasoning_effort(config = read_config)
|
|
496
|
-
value = ENV["KWSH_REASONING"].to_s.strip
|
|
497
|
-
return value unless value.empty?
|
|
498
|
-
|
|
499
|
-
shell_agent_setting(config, "reasoning_effort")
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
def editor_agent_setting(config, key)
|
|
503
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
504
|
-
agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
|
|
505
|
-
value = agent[key].to_s.strip
|
|
506
|
-
value.empty? ? nil : value
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
def shell_agent_setting(config, key)
|
|
510
|
-
shell = config["shell"].is_a?(Hash) ? config["shell"] : {}
|
|
511
|
-
agent = shell["agent"].is_a?(Hash) ? shell["agent"] : {}
|
|
512
|
-
value = agent[key].to_s.strip
|
|
513
|
-
value.empty? ? nil : value
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
# Returns whether the built-in TUI editor should auto-indent new lines.
|
|
517
|
-
def editor_auto_indent?(config = read_config)
|
|
518
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
519
|
-
editor["auto_indent"] != false
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
# Returns whether the built-in TUI editor should auto-close typed pairs.
|
|
523
|
-
def editor_auto_close_pairs?(config = read_config)
|
|
524
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
525
|
-
editor["auto_close_pairs"] != false
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# Returns whether the built-in TUI editor should soft-wrap long lines.
|
|
529
|
-
def editor_soft_wrap?(config = read_config)
|
|
530
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
531
|
-
editor["soft_wrap"] != false
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# Returns whether editable built-in TUI editor buffers should use a bar cursor.
|
|
535
|
-
def editor_bar_cursor?(config = read_config)
|
|
536
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
537
|
-
editor["bar_cursor"] != false
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# Returns the built-in TUI editor line-number display mode.
|
|
541
|
-
def editor_line_numbers(config = read_config)
|
|
542
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
543
|
-
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# Returns the integrated diff viewer display mode.
|
|
547
|
-
def diff_view(config = read_config)
|
|
548
|
-
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
549
|
-
DiffViewMode.normalize(editor["diff_view"])
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
# Returns whether file tools must stay inside the active workspace root.
|
|
553
|
-
def workspace_guardrails_enabled?(config = read_config)
|
|
554
|
-
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
555
|
-
tools["workspace_guardrails"] != false
|
|
556
|
-
end
|
|
557
|
-
|
|
558
|
-
# Builds the opt-in model-tool permission policy from persisted configuration.
|
|
559
|
-
def permission_policy(config = read_config)
|
|
560
|
-
Permissions::Policy.from_config(config)
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
# Builds the user-controlled command sandbox policy for a workspace.
|
|
564
|
-
def sandbox_policy(workspace_root, config = read_config)
|
|
565
|
-
sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
|
|
566
|
-
Sandbox::Policy.new(
|
|
567
|
-
mode: sandbox.fetch("mode", "off"),
|
|
568
|
-
network: sandbox.fetch("network", "deny"),
|
|
569
|
-
workspace_root: workspace_root,
|
|
570
|
-
writable_roots: sandbox.fetch("writable_roots", []),
|
|
571
|
-
protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
|
|
572
|
-
)
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
576
|
-
def project_skills_trusted?(config = read_config)
|
|
577
|
-
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
578
|
-
skills["trust_project"] == true
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
# Returns whether new frontends should resume the last active session automatically.
|
|
582
|
-
def session_auto_resume_enabled?(config = read_config)
|
|
583
|
-
sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
|
|
584
|
-
sessions["auto_resume"] == true
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
# Returns whether workspace AGENTS.md contents should be injected directly
|
|
588
|
-
# instead of a compact read-when-relevant instruction.
|
|
589
|
-
def enforce_workspace_agents_file?(config = read_config)
|
|
590
|
-
config["enforce_workspace_agents_file"] == true
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
# Returns the nested web-search config object, or an empty config when absent.
|
|
594
|
-
def web_search_config(config = read_config)
|
|
595
|
-
value = config["web_search"]
|
|
596
|
-
value.is_a?(Hash) ? value : {}
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
# Returns the private configuration namespace for one transport.
|
|
600
|
-
def transport_config(transport_id, config = read_config)
|
|
601
|
-
transports = config["transports"]
|
|
602
|
-
return {} unless transports.is_a?(Hash)
|
|
603
|
-
|
|
604
|
-
values = transports[transport_id.to_s]
|
|
605
|
-
values.is_a?(Hash) ? DeepCopy.dup(values) : {}
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
# Returns configured MCP stdio servers, or an empty config when absent.
|
|
609
|
-
def mcp_servers(config = read_config)
|
|
610
|
-
value = config["mcpServers"] || config.dig("mcp", "servers")
|
|
611
|
-
value.is_a?(Hash) ? value : {}
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
# Validates and persists terminal overlay settings.
|
|
615
|
-
def update_overlay_settings(values)
|
|
616
|
-
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
617
|
-
|
|
618
|
-
config = read_config
|
|
619
|
-
overlay = overlay_settings(config)
|
|
620
|
-
values.each do |key, value|
|
|
621
|
-
key = key.to_s
|
|
622
|
-
value = value.to_s
|
|
623
|
-
case key
|
|
624
|
-
when "alignment"
|
|
625
|
-
raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value)
|
|
626
|
-
when "width"
|
|
627
|
-
raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value)
|
|
628
|
-
else
|
|
629
|
-
raise "Unknown overlay setting: #{key}"
|
|
630
|
-
end
|
|
631
|
-
overlay[key] = value
|
|
632
|
-
end
|
|
633
|
-
config["overlay"] = overlay
|
|
634
|
-
write_config(config)
|
|
635
|
-
overlay_settings(config)
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Reads global principle instructions from the config directory.
|
|
639
|
-
#
|
|
640
|
-
# `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
|
|
641
|
-
# alias for existing installations.
|
|
642
|
-
#
|
|
643
|
-
# @return [String, nil] prompt text, or nil when absent/too large
|
|
644
|
-
def agents_prompt(config: read_config)
|
|
645
|
-
return nil unless include_config_principles?(config)
|
|
646
|
-
|
|
647
|
-
path = config_principles_path
|
|
648
|
-
return read_prompt_file(path, "Kward principles file") if File.exist?(path)
|
|
649
|
-
|
|
650
|
-
read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
# Returns whether config-directory principles are included in normal system
|
|
654
|
-
# prompt assembly. Replacement prompt files bypass all assembled sections.
|
|
655
|
-
def include_config_principles?(config = read_config)
|
|
656
|
-
settings = config["system_prompt"]
|
|
657
|
-
return true unless settings.is_a?(Hash)
|
|
658
|
-
|
|
659
|
-
settings["include_principles"] != false
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
# Returns the configured replacement system prompt path, or nil when normal
|
|
663
|
-
# prompt assembly should be used. Relative paths are anchored to the config
|
|
664
|
-
# directory so configuration remains portable with KWARD_CONFIG_PATH.
|
|
665
|
-
def system_prompt_file_path(config = read_config)
|
|
666
|
-
settings = config["system_prompt"]
|
|
667
|
-
value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
|
|
668
|
-
return nil if value.empty?
|
|
669
|
-
|
|
670
|
-
File.expand_path(value, config_dir)
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
# Returns the replacement system prompt text when configured. A configured
|
|
674
|
-
# but unavailable file intentionally yields nil: callers must not fall back
|
|
675
|
-
# to Kward's larger assembled prompt in replacement mode.
|
|
676
|
-
def system_prompt_file(config = read_config)
|
|
677
|
-
path = system_prompt_file_path(config)
|
|
678
|
-
return nil unless path
|
|
679
|
-
|
|
680
|
-
read_prompt_file(path, "custom system prompt file")
|
|
681
|
-
end
|
|
682
|
-
|
|
683
|
-
def replacement_system_prompt?(config = read_config)
|
|
684
|
-
!system_prompt_file_path(config).nil?
|
|
685
|
-
end
|
|
686
|
-
|
|
687
|
-
# Returns a lightweight fingerprint for config-owned prompt sources. It is
|
|
688
|
-
# used by active conversations to pick up prompt edits without a restart.
|
|
689
|
-
def system_prompt_sources_fingerprint
|
|
690
|
-
config = read_config
|
|
691
|
-
paths = [config_path, system_prompt_file_path(config)]
|
|
692
|
-
if include_config_principles?(config)
|
|
693
|
-
paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
|
|
694
|
-
end
|
|
695
|
-
|
|
696
|
-
paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
def config_principles_path
|
|
700
|
-
File.join(config_dir, "PRINCIPLES.md")
|
|
701
|
-
end
|
|
702
|
-
|
|
703
|
-
def config_agents_path
|
|
704
|
-
File.join(config_dir, "AGENTS.md")
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
# Builds persona prompt text from default, workspace, model, reasoning,
|
|
708
|
-
# time-of-day, weekday, and suffix config entries.
|
|
709
|
-
#
|
|
710
|
-
# Persona resolution is intentionally data-driven so users can edit config
|
|
711
|
-
# without plugin code. Keep new persona selectors additive and deterministic;
|
|
712
|
-
# prompt construction depends on stable ordering.
|
|
713
|
-
#
|
|
714
|
-
# @param workspace_root [String] active workspace root
|
|
715
|
-
# @param model [String, nil] active model name
|
|
716
|
-
# @param reasoning_effort [String, nil] active reasoning effort
|
|
717
|
-
# @param now [Time] local time used for time-based modifiers
|
|
718
|
-
# @param config [Hash] parsed config object
|
|
719
|
-
# @return [String, nil] persona prompt text when entries match
|
|
720
|
-
def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config)
|
|
721
|
-
text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry|
|
|
722
|
-
entry[:prompt]
|
|
723
|
-
end.join("\n\n")
|
|
724
|
-
return nil if text.empty?
|
|
725
|
-
|
|
726
|
-
text
|
|
727
|
-
end
|
|
728
|
-
|
|
729
|
-
# Returns the label of the persona selected by default/workspace/model rules.
|
|
730
|
-
def active_persona_label(workspace_root:, model: nil, config: read_config)
|
|
731
|
-
personas = config["personas"]
|
|
732
|
-
return nil unless personas.is_a?(Hash)
|
|
733
|
-
|
|
734
|
-
labels = crew_character_labels(personas)
|
|
735
|
-
active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil?
|
|
736
|
-
|
|
737
|
-
workspaces = personas["workspaces"]
|
|
738
|
-
if workspaces.is_a?(Hash)
|
|
739
|
-
root = canonical_workspace_root(workspace_root)
|
|
740
|
-
workspaces.each do |path, key|
|
|
741
|
-
next unless canonical_workspace_root(path) == root
|
|
742
|
-
|
|
743
|
-
active_label = persona_label_for_key(key, labels)
|
|
744
|
-
break
|
|
745
|
-
end
|
|
746
|
-
end
|
|
747
|
-
|
|
748
|
-
models = personas["models"]
|
|
749
|
-
if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
|
|
750
|
-
active_label = persona_label_for_key(models[model.to_s], labels)
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
active_label
|
|
754
|
-
end
|
|
755
|
-
|
|
756
|
-
def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true)
|
|
757
|
-
personas = config["personas"]
|
|
758
|
-
return [] unless personas.is_a?(Hash)
|
|
759
|
-
|
|
760
|
-
characters = crew_characters(personas)
|
|
761
|
-
entries = []
|
|
762
|
-
active_persona = { layer: "default", value: personas["default"], name: nil }
|
|
763
|
-
|
|
764
|
-
workspaces = personas["workspaces"]
|
|
765
|
-
if workspaces.is_a?(Hash)
|
|
766
|
-
root = canonical_workspace_root(workspace_root)
|
|
767
|
-
workspaces.each do |path, key|
|
|
768
|
-
if canonical_workspace_root(path) == root
|
|
769
|
-
active_persona = { layer: "workspace", value: key, name: path }
|
|
770
|
-
break
|
|
771
|
-
end
|
|
772
|
-
end
|
|
773
|
-
end
|
|
774
|
-
|
|
775
|
-
models = personas["models"]
|
|
776
|
-
if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
|
|
777
|
-
active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
|
|
778
|
-
end
|
|
779
|
-
|
|
780
|
-
add_persona_entry(
|
|
781
|
-
entries,
|
|
782
|
-
active_persona.fetch(:layer),
|
|
783
|
-
resolved_persona_text(active_persona.fetch(:value), characters: characters),
|
|
784
|
-
name: active_persona[:name]
|
|
785
|
-
)
|
|
786
|
-
|
|
787
|
-
modifiers = personas["persona_modifiers"]
|
|
788
|
-
if modifiers.is_a?(Hash)
|
|
789
|
-
if include_reasoning
|
|
790
|
-
reasoning = modifiers["reasoning"]
|
|
791
|
-
add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty?
|
|
792
|
-
end
|
|
793
|
-
|
|
794
|
-
time_of_day = modifiers["time_of_day"]
|
|
795
|
-
bucket = time_of_day_bucket(now)
|
|
796
|
-
add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash)
|
|
797
|
-
|
|
798
|
-
weekday = modifiers["weekday"]
|
|
799
|
-
day = weekday_name(now)
|
|
800
|
-
add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash)
|
|
801
|
-
|
|
802
|
-
add_persona_entry(entries, "suffix", modifiers["suffix"])
|
|
803
|
-
end
|
|
804
|
-
|
|
805
|
-
entries
|
|
806
|
-
end
|
|
807
|
-
|
|
808
|
-
def workspace_agents_path(workspace_root)
|
|
809
|
-
File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
|
|
810
|
-
end
|
|
811
|
-
|
|
812
|
-
def workspace_agents_file?(workspace_root)
|
|
813
|
-
File.exist?(workspace_agents_path(workspace_root))
|
|
814
|
-
end
|
|
815
|
-
|
|
816
|
-
def workspace_agents_prompt(workspace_root)
|
|
817
|
-
read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
|
|
818
|
-
end
|
|
819
|
-
|
|
820
|
-
def prompt_source_signature(path)
|
|
821
|
-
return "#{path}:missing" unless File.exist?(path)
|
|
822
|
-
|
|
823
|
-
stat = File.stat(path)
|
|
824
|
-
"#{path}:#{stat.size}:#{stat.mtime.to_f}"
|
|
825
|
-
rescue StandardError
|
|
826
|
-
"#{path}:unavailable"
|
|
827
|
-
end
|
|
828
|
-
|
|
829
|
-
def read_prompt_file(path, label)
|
|
830
|
-
return nil unless File.exist?(path)
|
|
831
|
-
|
|
832
|
-
size = File.size(path)
|
|
833
|
-
if size > MAX_PROMPT_FILE_BYTES
|
|
834
|
-
emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
|
|
835
|
-
return nil
|
|
836
|
-
end
|
|
837
|
-
|
|
838
|
-
File.read(path)
|
|
839
|
-
rescue StandardError => e
|
|
840
|
-
emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
|
|
841
|
-
nil
|
|
842
|
-
end
|
|
843
|
-
|
|
844
|
-
def canonical_workspace_root(path)
|
|
845
|
-
expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
|
|
846
|
-
File.directory?(expanded) ? File.realpath(expanded) : expanded
|
|
847
|
-
end
|
|
848
|
-
|
|
849
|
-
def add_persona_entry(entries, layer, value, name: nil)
|
|
850
|
-
text = presence(value)
|
|
851
|
-
return unless text
|
|
852
|
-
|
|
853
|
-
entries << { layer: layer.to_s, name: name.to_s, prompt: text }
|
|
854
|
-
end
|
|
855
|
-
|
|
856
|
-
def crew_characters(personas)
|
|
857
|
-
named_character_values(personas) do |_key, definition|
|
|
858
|
-
extract_character_instruction(definition)
|
|
859
|
-
end
|
|
860
|
-
end
|
|
861
|
-
|
|
862
|
-
def crew_character_labels(personas)
|
|
863
|
-
named_character_values(personas) do |_key, definition|
|
|
864
|
-
extract_character_label(definition)
|
|
865
|
-
end
|
|
866
|
-
end
|
|
867
|
-
|
|
868
|
-
def resolved_persona_text(value, characters: {})
|
|
869
|
-
return nil if value.nil?
|
|
870
|
-
|
|
871
|
-
key = value.to_s.strip
|
|
872
|
-
return nil if key.empty?
|
|
873
|
-
|
|
874
|
-
text = characters[key.to_s]
|
|
875
|
-
return text unless text.to_s.empty?
|
|
876
|
-
|
|
877
|
-
value
|
|
878
|
-
end
|
|
879
|
-
|
|
880
|
-
def persona_label_for_key(value, labels)
|
|
881
|
-
key = value.to_s.strip
|
|
882
|
-
return nil if key.empty?
|
|
883
|
-
|
|
884
|
-
presence(labels[key])
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
def named_character_values(personas)
|
|
888
|
-
character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping|
|
|
889
|
-
value = yield(key, definition)
|
|
890
|
-
next if value.to_s.empty?
|
|
891
|
-
|
|
892
|
-
mapping[key.to_s] = value
|
|
893
|
-
end
|
|
894
|
-
end
|
|
895
|
-
|
|
896
|
-
def character_entries(raw)
|
|
897
|
-
case raw
|
|
898
|
-
when Hash
|
|
899
|
-
raw.map { |key, definition| [key, definition] }
|
|
900
|
-
when Array
|
|
901
|
-
raw.filter_map { |entry| character_entry(entry) }
|
|
902
|
-
else
|
|
903
|
-
[]
|
|
904
|
-
end
|
|
905
|
-
end
|
|
906
|
-
|
|
907
|
-
def character_entry(entry)
|
|
908
|
-
return nil unless entry.is_a?(Hash)
|
|
909
|
-
|
|
910
|
-
if entry.length == 1 && entry.keys.first.is_a?(String)
|
|
911
|
-
[entry.keys.first, entry.values.first]
|
|
912
|
-
else
|
|
913
|
-
key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
|
|
914
|
-
key.to_s.empty? ? nil : [key, entry]
|
|
915
|
-
end
|
|
916
|
-
end
|
|
917
|
-
|
|
918
|
-
def extract_character_label(definition)
|
|
919
|
-
return nil unless definition.is_a?(Hash)
|
|
920
|
-
|
|
921
|
-
presence(definition["label"] || definition[:label])
|
|
922
|
-
end
|
|
923
|
-
|
|
924
|
-
def extract_character_instruction(definition)
|
|
925
|
-
return nil if definition.nil?
|
|
926
|
-
|
|
927
|
-
if definition.is_a?(Hash)
|
|
928
|
-
value = definition["instruction"] || definition[:instruction]
|
|
929
|
-
return presence(value)
|
|
930
|
-
end
|
|
931
|
-
|
|
932
|
-
presence(definition)
|
|
933
|
-
end
|
|
934
|
-
|
|
935
|
-
def time_of_day_bucket(now)
|
|
936
|
-
hour = now.hour
|
|
937
|
-
return "morning" if hour >= 5 && hour < 11
|
|
938
|
-
return "before_lunch" if hour == 11
|
|
939
|
-
return "late_evening" if hour >= 21 || hour < 5
|
|
940
|
-
|
|
941
|
-
nil
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
def weekday_name(now)
|
|
945
|
-
%w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
|
|
946
|
-
end
|
|
947
|
-
|
|
948
|
-
# Lists configured skills discovered under the config directory.
|
|
949
|
-
#
|
|
950
|
-
# @return [Array<Skill>] skill metadata available to the model
|
|
951
|
-
def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
952
|
-
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
|
|
953
|
-
end
|
|
954
|
-
|
|
955
|
-
def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
|
|
956
|
-
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
|
|
957
|
-
end
|
|
958
|
-
|
|
959
|
-
# @return [String] trusted user plugin directory
|
|
960
|
-
def plugin_dir
|
|
961
|
-
File.expand_path("~/.kward/plugins")
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
# Finds trusted plugin files and package entrypoints.
|
|
965
|
-
#
|
|
966
|
-
# Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
|
|
967
|
-
# workspace or custom `KWARD_CONFIG_PATH` directory.
|
|
968
|
-
#
|
|
969
|
-
# @return [Array<String>] sorted plugin file paths
|
|
970
|
-
def plugin_paths(warning_sink: nil)
|
|
971
|
-
plugins_root = plugin_dir
|
|
972
|
-
return [] unless Dir.exist?(plugins_root)
|
|
973
|
-
|
|
974
|
-
paths = Dir.glob(File.join(plugins_root, "*.rb"))
|
|
975
|
-
paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
|
|
976
|
-
paths.sort
|
|
977
|
-
rescue StandardError => e
|
|
978
|
-
emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
|
|
979
|
-
[]
|
|
980
|
-
end
|
|
981
|
-
|
|
982
|
-
# Lists prompt templates exposed as slash commands.
|
|
983
|
-
#
|
|
984
|
-
# @param reserved_commands [Array<String>] command names unavailable to templates
|
|
985
|
-
# @return [Array<PromptTemplate>] prompt template metadata and bodies
|
|
986
|
-
def prompt_templates(reserved_commands: [])
|
|
987
|
-
prompt_template_registry.prompt_templates(reserved_commands: reserved_commands)
|
|
988
|
-
end
|
|
989
|
-
|
|
990
|
-
# Reads a skill file by skill name and optional relative path.
|
|
991
|
-
#
|
|
992
|
-
# @param name [String] configured skill name
|
|
993
|
-
# @param relative_path [String, nil] path inside the skill directory
|
|
994
|
-
# @return [String] file contents or an error string
|
|
995
|
-
def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
|
|
996
|
-
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
997
|
-
end
|
|
998
|
-
|
|
999
|
-
def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
1000
|
-
Skills::Registry.new(
|
|
1001
|
-
config_dir: config_dir,
|
|
1002
|
-
workspace_root: workspace_root,
|
|
1003
|
-
project_skills_trusted: project_skills_trusted?,
|
|
1004
|
-
skill_class: Skill,
|
|
1005
|
-
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
1006
|
-
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
1007
|
-
inside_directory: method(:inside_directory?),
|
|
1008
|
-
warning_sink: warning_sink || @warning_sink,
|
|
1009
|
-
project_skill_paths: project_skill_paths
|
|
1010
|
-
)
|
|
1011
|
-
end
|
|
1012
|
-
|
|
1013
|
-
def prompt_template_registry
|
|
1014
|
-
Prompts::Templates.new(
|
|
1015
|
-
config_dir: config_dir,
|
|
1016
|
-
template_class: PromptTemplate,
|
|
1017
|
-
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
|
|
1018
|
-
warning_sink: @warning_sink
|
|
1019
|
-
)
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
def emit_warning(message, warning_sink: nil)
|
|
1023
|
-
sink = warning_sink || @warning_sink
|
|
1024
|
-
sink ? sink.call(message) : warn(message)
|
|
1025
|
-
end
|
|
1026
|
-
|
|
1027
|
-
def inside_directory?(path, base)
|
|
1028
|
-
PathGuard.inside?(path, base)
|
|
1029
|
-
end
|
|
1030
|
-
|
|
1031
|
-
def presence(value)
|
|
1032
|
-
text = value.to_s
|
|
1033
|
-
text.empty? ? nil : text
|
|
1034
|
-
end
|
|
37
|
+
extend Core
|
|
38
|
+
extend Settings
|
|
39
|
+
extend Prompts
|
|
40
|
+
extend Extensions
|
|
1035
41
|
end
|
|
1036
42
|
end
|