kward 0.83.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 +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- 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/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- 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 +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- 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 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- 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/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- 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 +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- 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} +52 -0
- 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/rpc/transcript_normalizer.rb +7 -3
- 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/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- 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/{ansi.rb → terminal/ansi.rb} +110 -10
- 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/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- 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/open_editor.rb +41 -0
- 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 +109 -17
- 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 +3 -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/{project_files.rb → workspace/files.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/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /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/{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/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Configuration prompts behavior.
|
|
2
|
+
module Kward
|
|
3
|
+
module ConfigFiles
|
|
4
|
+
module Prompts
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def agents_prompt(config: read_config)
|
|
8
|
+
return nil unless include_config_principles?(config)
|
|
9
|
+
|
|
10
|
+
path = config_principles_path
|
|
11
|
+
return read_prompt_file(path, "Kward principles file") if File.exist?(path)
|
|
12
|
+
|
|
13
|
+
read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns whether config-directory principles are included in normal system
|
|
17
|
+
# prompt assembly. Replacement prompt files bypass all assembled sections.
|
|
18
|
+
def include_config_principles?(config = read_config)
|
|
19
|
+
settings = config["system_prompt"]
|
|
20
|
+
return true unless settings.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
settings["include_principles"] != false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns the configured replacement system prompt path, or nil when normal
|
|
26
|
+
# prompt assembly should be used. Relative paths are anchored to the config
|
|
27
|
+
# directory so configuration remains portable with KWARD_CONFIG_PATH.
|
|
28
|
+
def system_prompt_file_path(config = read_config)
|
|
29
|
+
settings = config["system_prompt"]
|
|
30
|
+
value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
|
|
31
|
+
return nil if value.empty?
|
|
32
|
+
|
|
33
|
+
File.expand_path(value, config_dir)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the replacement system prompt text when configured. A configured
|
|
37
|
+
# but unavailable file intentionally yields nil: callers must not fall back
|
|
38
|
+
# to Kward's larger assembled prompt in replacement mode.
|
|
39
|
+
def system_prompt_file(config = read_config)
|
|
40
|
+
path = system_prompt_file_path(config)
|
|
41
|
+
return nil unless path
|
|
42
|
+
|
|
43
|
+
read_prompt_file(path, "custom system prompt file")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replacement_system_prompt?(config = read_config)
|
|
47
|
+
!system_prompt_file_path(config).nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns a lightweight fingerprint for config-owned prompt sources. It is
|
|
51
|
+
# used by active conversations to pick up prompt edits without a restart.
|
|
52
|
+
def system_prompt_sources_fingerprint
|
|
53
|
+
config = read_config
|
|
54
|
+
paths = [config_path, system_prompt_file_path(config)]
|
|
55
|
+
if include_config_principles?(config)
|
|
56
|
+
paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def config_principles_path
|
|
63
|
+
File.join(config_dir, "PRINCIPLES.md")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def config_agents_path
|
|
67
|
+
File.join(config_dir, "AGENTS.md")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Builds persona prompt text from default, workspace, model, reasoning,
|
|
71
|
+
# time-of-day, weekday, and suffix config entries.
|
|
72
|
+
#
|
|
73
|
+
# Persona resolution is intentionally data-driven so users can edit config
|
|
74
|
+
# without plugin code. Keep new persona selectors additive and deterministic;
|
|
75
|
+
# prompt construction depends on stable ordering.
|
|
76
|
+
#
|
|
77
|
+
# @param workspace_root [String] active workspace root
|
|
78
|
+
# @param model [String, nil] active model name
|
|
79
|
+
# @param reasoning_effort [String, nil] active reasoning effort
|
|
80
|
+
# @param now [Time] local time used for time-based modifiers
|
|
81
|
+
# @param config [Hash] parsed config object
|
|
82
|
+
# @return [String, nil] persona prompt text when entries match
|
|
83
|
+
def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config)
|
|
84
|
+
text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry|
|
|
85
|
+
entry[:prompt]
|
|
86
|
+
end.join("\n\n")
|
|
87
|
+
return nil if text.empty?
|
|
88
|
+
|
|
89
|
+
text
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the label of the persona selected by default/workspace/model rules.
|
|
93
|
+
def active_persona_label(workspace_root:, model: nil, config: read_config)
|
|
94
|
+
personas = config["personas"]
|
|
95
|
+
return nil unless personas.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
labels = crew_character_labels(personas)
|
|
98
|
+
active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil?
|
|
99
|
+
|
|
100
|
+
workspaces = personas["workspaces"]
|
|
101
|
+
if workspaces.is_a?(Hash)
|
|
102
|
+
root = canonical_workspace_root(workspace_root)
|
|
103
|
+
workspaces.each do |path, key|
|
|
104
|
+
next unless canonical_workspace_root(path) == root
|
|
105
|
+
|
|
106
|
+
active_label = persona_label_for_key(key, labels)
|
|
107
|
+
break
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
models = personas["models"]
|
|
112
|
+
if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
|
|
113
|
+
active_label = persona_label_for_key(models[model.to_s], labels)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
active_label
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true)
|
|
120
|
+
personas = config["personas"]
|
|
121
|
+
return [] unless personas.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
characters = crew_characters(personas)
|
|
124
|
+
entries = []
|
|
125
|
+
active_persona = { layer: "default", value: personas["default"], name: nil }
|
|
126
|
+
|
|
127
|
+
workspaces = personas["workspaces"]
|
|
128
|
+
if workspaces.is_a?(Hash)
|
|
129
|
+
root = canonical_workspace_root(workspace_root)
|
|
130
|
+
workspaces.each do |path, key|
|
|
131
|
+
if canonical_workspace_root(path) == root
|
|
132
|
+
active_persona = { layer: "workspace", value: key, name: path }
|
|
133
|
+
break
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
models = personas["models"]
|
|
139
|
+
if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
|
|
140
|
+
active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
add_persona_entry(
|
|
144
|
+
entries,
|
|
145
|
+
active_persona.fetch(:layer),
|
|
146
|
+
resolved_persona_text(active_persona.fetch(:value), characters: characters),
|
|
147
|
+
name: active_persona[:name]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
modifiers = personas["persona_modifiers"]
|
|
151
|
+
if modifiers.is_a?(Hash)
|
|
152
|
+
if include_reasoning
|
|
153
|
+
reasoning = modifiers["reasoning"]
|
|
154
|
+
add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
time_of_day = modifiers["time_of_day"]
|
|
158
|
+
bucket = time_of_day_bucket(now)
|
|
159
|
+
add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
weekday = modifiers["weekday"]
|
|
162
|
+
day = weekday_name(now)
|
|
163
|
+
add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
add_persona_entry(entries, "suffix", modifiers["suffix"])
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
entries
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def workspace_agents_path(workspace_root)
|
|
172
|
+
File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def workspace_agents_file?(workspace_root)
|
|
176
|
+
File.exist?(workspace_agents_path(workspace_root))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def workspace_agents_prompt(workspace_root)
|
|
180
|
+
read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def prompt_source_signature(path)
|
|
184
|
+
return "#{path}:missing" unless File.exist?(path)
|
|
185
|
+
|
|
186
|
+
stat = File.stat(path)
|
|
187
|
+
"#{path}:#{stat.size}:#{stat.mtime.to_f}"
|
|
188
|
+
rescue StandardError
|
|
189
|
+
"#{path}:unavailable"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def read_prompt_file(path, label)
|
|
193
|
+
return nil unless File.exist?(path)
|
|
194
|
+
|
|
195
|
+
size = File.size(path)
|
|
196
|
+
if size > MAX_PROMPT_FILE_BYTES
|
|
197
|
+
emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
File.read(path)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def canonical_workspace_root(path)
|
|
208
|
+
expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
|
|
209
|
+
File.directory?(expanded) ? File.realpath(expanded) : expanded
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def add_persona_entry(entries, layer, value, name: nil)
|
|
213
|
+
text = presence(value)
|
|
214
|
+
return unless text
|
|
215
|
+
|
|
216
|
+
entries << { layer: layer.to_s, name: name.to_s, prompt: text }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def crew_characters(personas)
|
|
220
|
+
named_character_values(personas) do |_key, definition|
|
|
221
|
+
extract_character_instruction(definition)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def crew_character_labels(personas)
|
|
226
|
+
named_character_values(personas) do |_key, definition|
|
|
227
|
+
extract_character_label(definition)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def resolved_persona_text(value, characters: {})
|
|
232
|
+
return nil if value.nil?
|
|
233
|
+
|
|
234
|
+
key = value.to_s.strip
|
|
235
|
+
return nil if key.empty?
|
|
236
|
+
|
|
237
|
+
text = characters[key.to_s]
|
|
238
|
+
return text unless text.to_s.empty?
|
|
239
|
+
|
|
240
|
+
value
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def persona_label_for_key(value, labels)
|
|
244
|
+
key = value.to_s.strip
|
|
245
|
+
return nil if key.empty?
|
|
246
|
+
|
|
247
|
+
presence(labels[key])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def named_character_values(personas)
|
|
251
|
+
character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping|
|
|
252
|
+
value = yield(key, definition)
|
|
253
|
+
next if value.to_s.empty?
|
|
254
|
+
|
|
255
|
+
mapping[key.to_s] = value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def character_entries(raw)
|
|
260
|
+
case raw
|
|
261
|
+
when Hash
|
|
262
|
+
raw.map { |key, definition| [key, definition] }
|
|
263
|
+
when Array
|
|
264
|
+
raw.filter_map { |entry| character_entry(entry) }
|
|
265
|
+
else
|
|
266
|
+
[]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def character_entry(entry)
|
|
271
|
+
return nil unless entry.is_a?(Hash)
|
|
272
|
+
|
|
273
|
+
if entry.length == 1 && entry.keys.first.is_a?(String)
|
|
274
|
+
[entry.keys.first, entry.values.first]
|
|
275
|
+
else
|
|
276
|
+
key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
|
|
277
|
+
key.to_s.empty? ? nil : [key, entry]
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def extract_character_label(definition)
|
|
282
|
+
return nil unless definition.is_a?(Hash)
|
|
283
|
+
|
|
284
|
+
presence(definition["label"] || definition[:label])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def extract_character_instruction(definition)
|
|
288
|
+
return nil if definition.nil?
|
|
289
|
+
|
|
290
|
+
if definition.is_a?(Hash)
|
|
291
|
+
value = definition["instruction"] || definition[:instruction]
|
|
292
|
+
return presence(value)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
presence(definition)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def time_of_day_bucket(now)
|
|
299
|
+
hour = now.hour
|
|
300
|
+
return "morning" if hour >= 5 && hour < 11
|
|
301
|
+
return "before_lunch" if hour == 11
|
|
302
|
+
return "late_evening" if hour >= 21 || hour < 5
|
|
303
|
+
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def weekday_name(now)
|
|
308
|
+
%w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Configuration settings behavior.
|
|
2
|
+
module Kward
|
|
3
|
+
module ConfigFiles
|
|
4
|
+
module Settings
|
|
5
|
+
extend self
|
|
6
|
+
|
|
7
|
+
def overlay_settings(config = read_config)
|
|
8
|
+
overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
|
|
9
|
+
settings = DEFAULT_OVERLAY_SETTINGS.dup
|
|
10
|
+
alignment = overlay["alignment"].to_s
|
|
11
|
+
width = overlay["width"].to_s
|
|
12
|
+
settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment)
|
|
13
|
+
settings["width"] = width if OVERLAY_WIDTHS.include?(width)
|
|
14
|
+
settings
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the project browser icon theme, or off when unset or invalid.
|
|
18
|
+
def project_browser_icon_theme(config = read_config)
|
|
19
|
+
browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
|
|
20
|
+
theme = browser["icons"].to_s
|
|
21
|
+
PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns whether the composer should show busy-state keyboard help.
|
|
25
|
+
def composer_busy_help?(config = read_config)
|
|
26
|
+
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
27
|
+
composer["busy_help"] != false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the configured tab keybinding family, or auto when unset/invalid.
|
|
31
|
+
def composer_tab_keybindings(config = read_config)
|
|
32
|
+
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
33
|
+
value = composer["tab_keybindings"].to_s.downcase
|
|
34
|
+
%w[auto ctrl alt].include?(value) ? value : "auto"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the built-in TUI editor keymap mode.
|
|
38
|
+
def editor_mode(config = read_config)
|
|
39
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
40
|
+
EditorMode.normalize(editor["mode"])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns configured editor runner definitions, or an empty map.
|
|
44
|
+
#
|
|
45
|
+
# Runner defaults are resolved by the editor runner at execution time so
|
|
46
|
+
# the generated config remains portable across machines.
|
|
47
|
+
def editor_runners(config = read_config)
|
|
48
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
49
|
+
runners = editor["runners"]
|
|
50
|
+
runners.is_a?(Hash) ? runners : {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the optional provider override for editor-agent turns.
|
|
54
|
+
def editor_agent_provider(config = read_config)
|
|
55
|
+
value = ENV["KWARD_EDITOR_PROVIDER"].to_s.strip
|
|
56
|
+
return value unless value.empty?
|
|
57
|
+
|
|
58
|
+
editor_agent_setting(config, "provider")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the optional model override for editor-agent turns.
|
|
62
|
+
def editor_agent_model(config = read_config)
|
|
63
|
+
editor_agent_setting(config, "model")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the optional reasoning-effort override for editor-agent turns.
|
|
67
|
+
def editor_agent_reasoning_effort(config = read_config)
|
|
68
|
+
editor_agent_setting(config, "reasoning_effort")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the shell-agent provider override, preferring the environment.
|
|
72
|
+
def shell_agent_provider(config = read_config)
|
|
73
|
+
value = ENV["KWSH_PROVIDER"].to_s.strip
|
|
74
|
+
return value unless value.empty?
|
|
75
|
+
|
|
76
|
+
shell_agent_setting(config, "provider")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns the shell-agent model override, preferring the environment.
|
|
80
|
+
def shell_agent_model(config = read_config)
|
|
81
|
+
value = ENV["KWSH_MODE"].to_s.strip
|
|
82
|
+
return value unless value.empty?
|
|
83
|
+
|
|
84
|
+
shell_agent_setting(config, "model")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the shell-agent reasoning-effort override, preferring the environment.
|
|
88
|
+
def shell_agent_reasoning_effort(config = read_config)
|
|
89
|
+
value = ENV["KWSH_REASONING"].to_s.strip
|
|
90
|
+
return value unless value.empty?
|
|
91
|
+
|
|
92
|
+
shell_agent_setting(config, "reasoning_effort")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def editor_agent_setting(config, key)
|
|
96
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
97
|
+
agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
|
|
98
|
+
value = agent[key].to_s.strip
|
|
99
|
+
value.empty? ? nil : value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def shell_agent_setting(config, key)
|
|
103
|
+
shell = config["shell"].is_a?(Hash) ? config["shell"] : {}
|
|
104
|
+
agent = shell["agent"].is_a?(Hash) ? shell["agent"] : {}
|
|
105
|
+
value = agent[key].to_s.strip
|
|
106
|
+
value.empty? ? nil : value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns whether the built-in TUI editor should auto-indent new lines.
|
|
110
|
+
def editor_auto_indent?(config = read_config)
|
|
111
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
112
|
+
editor["auto_indent"] != false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns whether the built-in TUI editor should auto-close typed pairs.
|
|
116
|
+
def editor_auto_close_pairs?(config = read_config)
|
|
117
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
118
|
+
editor["auto_close_pairs"] != false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns whether the built-in TUI editor should soft-wrap long lines.
|
|
122
|
+
def editor_soft_wrap?(config = read_config)
|
|
123
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
124
|
+
editor["soft_wrap"] != false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns whether editable built-in TUI editor buffers should use a bar cursor.
|
|
128
|
+
def editor_bar_cursor?(config = read_config)
|
|
129
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
130
|
+
editor["bar_cursor"] != false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns the built-in TUI editor line-number display mode.
|
|
134
|
+
def editor_line_numbers(config = read_config)
|
|
135
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
136
|
+
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns the integrated diff viewer display mode.
|
|
140
|
+
def diff_view(config = read_config)
|
|
141
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
142
|
+
DiffViewMode.normalize(editor["diff_view"])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns whether file tools must stay inside the active workspace root.
|
|
146
|
+
def workspace_guardrails_enabled?(config = read_config)
|
|
147
|
+
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
148
|
+
tools["workspace_guardrails"] != false
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Builds the opt-in model-tool permission policy from persisted configuration.
|
|
152
|
+
def permission_policy(config = read_config)
|
|
153
|
+
Permissions::Policy.from_config(config)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Builds the user-controlled command sandbox policy for a workspace.
|
|
157
|
+
def sandbox_policy(workspace_root, config = read_config)
|
|
158
|
+
sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
|
|
159
|
+
Sandbox::Policy.new(
|
|
160
|
+
mode: sandbox.fetch("mode", "off"),
|
|
161
|
+
network: sandbox.fetch("network", "deny"),
|
|
162
|
+
workspace_root: workspace_root,
|
|
163
|
+
writable_roots: sandbox.fetch("writable_roots", []),
|
|
164
|
+
protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
169
|
+
def project_skills_trusted?(config = read_config)
|
|
170
|
+
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
171
|
+
skills["trust_project"] == true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Returns whether new frontends should resume the last active session automatically.
|
|
175
|
+
def session_auto_resume_enabled?(config = read_config)
|
|
176
|
+
sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
|
|
177
|
+
sessions["auto_resume"] == true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Returns whether workspace AGENTS.md contents should be injected directly
|
|
181
|
+
# instead of a compact read-when-relevant instruction.
|
|
182
|
+
def enforce_workspace_agents_file?(config = read_config)
|
|
183
|
+
config["enforce_workspace_agents_file"] == true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Returns the nested web-search config object, or an empty config when absent.
|
|
187
|
+
def web_search_config(config = read_config)
|
|
188
|
+
value = config["web_search"]
|
|
189
|
+
value.is_a?(Hash) ? value : {}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Returns the private configuration namespace for one identified plugin.
|
|
193
|
+
def plugin_config(plugin_id, config = read_config)
|
|
194
|
+
plugins = config.fetch("plugins", {})
|
|
195
|
+
raise ArgumentError, "Kward plugin config must be an object" unless plugins.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
values = plugins.fetch(plugin_id.to_s, {})
|
|
198
|
+
raise ArgumentError, "Kward plugin config for #{plugin_id} must be an object" unless values.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
DeepCopy.dup(values)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns the private configuration namespace for one transport.
|
|
204
|
+
def transport_config(transport_id, config = read_config)
|
|
205
|
+
transports = config["transports"]
|
|
206
|
+
return {} unless transports.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
values = transports[transport_id.to_s]
|
|
209
|
+
values.is_a?(Hash) ? DeepCopy.dup(values) : {}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Returns configured MCP stdio servers, or an empty config when absent.
|
|
213
|
+
def mcp_servers(config = read_config)
|
|
214
|
+
value = config["mcpServers"] || config.dig("mcp", "servers")
|
|
215
|
+
value.is_a?(Hash) ? value : {}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validates and persists terminal overlay settings.
|
|
219
|
+
def update_overlay_settings(values)
|
|
220
|
+
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
config = read_config
|
|
223
|
+
overlay = overlay_settings(config)
|
|
224
|
+
values.each do |key, value|
|
|
225
|
+
key = key.to_s
|
|
226
|
+
value = value.to_s
|
|
227
|
+
case key
|
|
228
|
+
when "alignment"
|
|
229
|
+
raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value)
|
|
230
|
+
when "width"
|
|
231
|
+
raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value)
|
|
232
|
+
else
|
|
233
|
+
raise "Unknown overlay setting: #{key}"
|
|
234
|
+
end
|
|
235
|
+
overlay[key] = value
|
|
236
|
+
end
|
|
237
|
+
config["overlay"] = overlay
|
|
238
|
+
write_config(config)
|
|
239
|
+
overlay_settings(config)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Reads global principle instructions from the config directory.
|
|
243
|
+
#
|
|
244
|
+
# `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
|
|
245
|
+
# alias for existing installations.
|
|
246
|
+
#
|
|
247
|
+
# @return [String, nil] prompt text, or nil when absent/too large
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|