kward 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
data/doc/usage.md CHANGED
@@ -12,7 +12,7 @@ cd ~/code/project
12
12
  kward
13
13
  ```
14
14
 
15
- When running from source, replace `kward` with `ruby lib/main.rb`.
15
+ When running from source, replace `kward` with `ruby lib/main.rb`. See [Getting started](getting-started.md) for install and sign-in.
16
16
 
17
17
  ## Common workflows
18
18
 
@@ -42,6 +42,14 @@ For larger reviews, use interactive mode so Kward can inspect related files:
42
42
  Review the current git diff. If something looks risky, inspect the relevant files before recommending changes.
43
43
  ```
44
44
 
45
+ To review and commit changes interactively, use `/git`:
46
+
47
+ ```text
48
+ /git
49
+ ```
50
+
51
+ It opens a staging overlay where you can stage or unstage files, preview diffs, and write a commit message without leaving Kward.
52
+
45
53
  ### Make a small code change
46
54
 
47
55
  ```text
@@ -64,6 +72,14 @@ Or run a shell command yourself from the composer by prefixing it with `!`:
64
72
  !git status --short
65
73
  ```
66
74
 
75
+ For several commands, enter the embedded Kward shell:
76
+
77
+ ```text
78
+ /shell
79
+ ```
80
+
81
+ `/shell` opens `ekwsh`, a Kward-native command mode. Kward keeps the tab bar, composer editing, and transcript rendering while each command runs through your configured shell. Built-ins such as `cd`, `pwd`, `export`, `unset`, `alias`, `clear`, and `exit` maintain shell-mode state between commands. Plain Tab completes built-in command names, configured aliases, executables from `$PATH`, and file paths from the shell's current directory; `cd` completion suggests directories only. `ekwsh` preserves safe ANSI SGR color/style output while stripping terminal-control sequences that could corrupt the TUI, and sets conservative color-friendly environment defaults such as `CLICOLOR=1`, `COLORTERM=truecolor`, and `TERM=xterm-256color` when needed. It does not force color by default; set `FORCE_COLOR`, `CLICOLOR_FORCE`, or command-specific flags such as `--color=always` if you want more aggressive color output. You can set global shell env vars and aliases in `~/.kward/ekwsh.yml`; see [Configuration](configuration.md). It is intended for command output, not full-screen interactive programs such as editors or pagers.
82
+
67
83
  ## Shell commands
68
84
 
69
85
  Useful shell commands:
@@ -72,10 +88,12 @@ Useful shell commands:
72
88
  kward # start interactive chat
73
89
  kward "Explain this project" # ask one question and exit
74
90
  kward help # show commands and examples
91
+ kward version # show the installed version
75
92
  kward doctor # check local setup
76
93
  kward login # sign in or save credentials
77
94
  kward auth status # show credential status without secrets
78
95
  kward sysprompt # inspect assembled instructions
96
+ kward stats tokens # export local token telemetry as CSV
79
97
  kward rpc # start the experimental RPC backend
80
98
  ```
81
99
 
@@ -88,15 +106,26 @@ kward --working-directory ~/code/project "Summarize this repository"
88
106
 
89
107
  ## Interactive slash commands
90
108
 
91
- Use slash commands for local actions that should not go to the model:
109
+ Slash commands run local actions in the current session. Most do not send a prompt to the model; exceptions like `/git` and `/workers` orchestrate local flows that may then trigger model work.
92
110
 
93
111
  | Command | Use it when you want to... |
94
112
  | --- | --- |
95
113
  | `/login` | sign in or save provider credentials. |
96
114
  | `/model` | choose the active model. |
97
115
  | `/reasoning` | choose reasoning effort. |
116
+ | `/git` | review uncommitted changes, stage files, and commit. |
117
+ | `/files` | browse project files in a nested tree and open them in the editor. |
118
+ | `/shell` | run workspace commands in the embedded Kward shell. |
119
+ | `/settings` | configure prompt overlays. |
98
120
  | `/status` | see session, model, and context status. |
99
- | `/new` | start a fresh session. |
121
+ | `/new` | start a fresh session in the current tab. |
122
+ | `/tab 2` | switch to tab 2. |
123
+ | `/tab move 1` | move the active tab to position 1. |
124
+ | `/tab move left` | move the active tab one position left. |
125
+ | `/tab move right` | move the active tab one position right. |
126
+ | `/tab close` | close the active tab. |
127
+ | `/tab new` | open a new tab. |
128
+ | `/tab name <label>` | rename the active tab label. |
100
129
  | `/sessions` | open the saved sessions picker or continue a previous session by path. |
101
130
  | `/resume` | alias for `/sessions`. |
102
131
  | `/name <name>` | name or clear the current session. |
@@ -107,13 +136,23 @@ Use slash commands for local actions that should not go to the model:
107
136
  | `/copy last` | copy the latest assistant answer. |
108
137
  | `/copy transcript` | copy the transcript as Markdown. |
109
138
  | `/export notes.md` | write the transcript to a Markdown file. |
110
- | `/compact [focus]` | summarize older context so a long chat can continue. |
139
+ | `/compact [instructions]` | summarize older context so a long chat can continue. |
111
140
  | `/memory ...` | manage opt-in memory. |
112
141
  | `/redraw` | fix terminal drawing after resize or glitches. |
142
+ | `/reload` | reload installed plugins. |
143
+ | `/workers` | open the experimental worker pipeline (`new`, `do <task>`, or `list`). |
113
144
  | `/exit` | leave Kward. |
114
145
 
115
146
  Prompt templates and plugins can add more slash commands.
116
147
 
148
+ ## Prompt history
149
+
150
+ In interactive mode, Kward keeps prompt history per workspace under `~/.kward/history/`. Press Up/Down to recall previous prompts across restarts.
151
+
152
+ Press `Ctrl-R` to search history. Type a fuzzy query in the composer, use Up/Down to choose a result from the overlay, then press Enter to place it back in the composer for editing or resubmission. Press Esc or Ctrl-C to cancel the search and restore the draft.
153
+
154
+ `$path` editor-open commands are also saved after a file opens successfully, using the resolved workspace-relative path.
155
+
117
156
  ## Sessions
118
157
 
119
158
  Interactive chats are saved as workspace-scoped sessions under:
@@ -149,11 +188,18 @@ One-shot prompts are best for short tasks that do not need session history:
149
188
 
150
189
  ```bash
151
190
  kward "What does this repository do?"
191
+ cat error.log | kward
192
+ ```
193
+
194
+ When stdin and a prompt are both present, Kward runs in filter mode: stdin is the input, the prompt is the instruction, and stdout contains only the transformed result. You can also force this with `--filter` or `--mode filter`.
195
+
196
+ ```bash
152
197
  git diff | kward "Review this diff"
153
- cat error.log | kward "Explain the likely cause"
198
+ echo "Hello" | kward --filter "Translate to German"
199
+ kward --mode filter "Indent this JSON" < unindented.json
154
200
  ```
155
201
 
156
- Use `--` when your prompt starts with something that could be parsed as a command or option:
202
+ Use `--mode chat`, `--mode oneshot`, or `--mode filter` to override automatic mode detection. Use `--` when your prompt starts with something that could be parsed as a command or option:
157
203
 
158
204
  ```bash
159
205
  kward -- explain --working-directory
@@ -181,7 +227,7 @@ Important guardrails:
181
227
 
182
228
  ## Images
183
229
 
184
- If the active model supports images, Kward can attach image paths, Markdown image links, `file://` URLs, or image data URLs pasted into the composer.
230
+ If the active model supports images, Kward can attach image paths, Markdown image links, `file://` URLs, or image data URLs pasted into the composer. Supported formats are GIF, JPEG, PNG, and WebP, up to 20 MB per image.
185
231
 
186
232
  Use this for tasks such as:
187
233
 
data/doc/web-search.md CHANGED
@@ -36,16 +36,18 @@ Kward should search first, then fetch important pages before relying on them.
36
36
 
37
37
  ## Network behavior
38
38
 
39
- Web tools are advertised to the model by default. Queries and fetched URLs are sent over the network to the selected provider or target host.
39
+ Web tools are advertised to the model by default. Queries and fetched URLs are sent over the network to the selected provider or target host. See [Configuration](configuration.md) for the full `web_search` config reference including API key storage and the `provider` config setting.
40
40
 
41
41
  In automatic mode, provider fallback is:
42
42
 
43
43
  1. Exa API when `EXA_API_KEY` is configured, otherwise keyless Exa MCP.
44
- 2. Perplexity API when configured and model-provider fallback is allowed.
45
- 3. Gemini API with Google Search grounding when configured and model-provider fallback is allowed.
44
+ 2. Perplexity API when `PERPLEXITY_API_KEY` is configured and model-provider fallback is allowed.
45
+ 3. Gemini API with Google Search grounding when `GEMINI_API_KEY` is configured and model-provider fallback is allowed.
46
46
  4. DuckDuckGo HTML search, then bundled public SearXNG instances.
47
47
 
48
- You do not need an API key for basic web search, but keys can improve limits or provider choice.
48
+ You do not need an API key for basic web search, but keys can improve limits or provider choice. The default provider can also be set in config as `web_search.provider`; the tool argument overrides it for a single call.
49
+
50
+ Search output is capped at 8 KB total, with excerpts up to 300 characters and answer text up to 2,000 characters. HTTP requests use a 10-second timeout.
49
51
 
50
52
  ## Disable web tools
51
53
 
@@ -83,6 +85,10 @@ Arguments:
83
85
  - `max_bytes`: default 16384, capped at 131072.
84
86
  - `extract`: optional `auto`, `text`, or `markdown`.
85
87
 
88
+ In `auto` mode (default), Kward detects HTML content and extracts readable text by stripping scripts, styles, navigation, and forms, preserving headings, paragraphs, lists, code blocks, and blockquotes. Non-HTML content is returned as cleaned text. Use `markdown` to format extracted headings and code blocks as Markdown, or `text` for plain text without formatting.
89
+
90
+ Fetches follow up to 5 redirects and use a 10-second HTTP timeout.
91
+
86
92
  ### `fetch_raw`
87
93
 
88
94
  Reads a specific HTTP or HTTPS resource without readability extraction. Use it for JSON, YAML, XML, RSS, OpenAPI specs, and plain text.
@@ -92,3 +98,5 @@ Arguments:
92
98
  - `url`
93
99
  - `max_bytes`: default 16384, capped at 131072.
94
100
  - `accept`: optional HTTP `Accept` header.
101
+
102
+ Fetches follow up to 5 redirects and use a 10-second HTTP timeout.
@@ -12,14 +12,14 @@ Kward normally chooses these tools itself. You do not need to know their exact n
12
12
 
13
13
  ## Guardrails
14
14
 
15
- Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files.
15
+ Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files. Guardrails can be disabled with the `tools.workspace_guardrails` setting — see [Configuration](configuration.md). When disabled, file tools can access paths outside the workspace, but shell commands are unaffected since they already run as your OS user.
16
16
 
17
17
  Important behavior:
18
18
 
19
19
  - Existing files must be read in the current conversation before `write_file` or `edit_file` can change them.
20
- - Reads are bounded to avoid pulling very large files into context by accident.
20
+ - Reads are bounded to avoid pulling very large files into context by accident. Files larger than 256 KB cannot be read or edited. Read output is capped at 50 KB or 2,000 lines, whichever comes first.
21
21
  - Edits use exact text replacement, so accidental partial or fuzzy changes fail instead of guessing.
22
- - Shell commands run as your operating-system user from the workspace. They are powerful and should be treated like commands you run yourself.
22
+ - Shell commands run as your operating-system user from the workspace. They are powerful and should be treated like commands you run yourself. Command output is capped at 128 KB.
23
23
 
24
24
  ## Reading the workspace
25
25
 
@@ -33,25 +33,55 @@ Arguments:
33
33
 
34
34
  ### `read_file`
35
35
 
36
- Reads a workspace text file. Output is capped, and Kward can continue with line offsets when it needs more detail.
36
+ Reads a workspace text file. Output is capped, and Kward can continue with line offsets when it needs more detail. The tool also supports explicit context modes so Kward can start cheap and widen only when needed.
37
37
 
38
38
  Arguments:
39
39
 
40
40
  - `path`: workspace-relative file path.
41
41
  - `offset`: optional 1-indexed start line.
42
42
  - `limit`: optional maximum number of lines.
43
+ - `mode`: optional context mode:
44
+ - `preview`: read a short preview slice, defaulting to 120 lines when `limit` is omitted.
45
+ - `outline`: return only the source declaration outline.
46
+ - `range`: read the requested `offset`/`limit` slice.
47
+ - `full`: read from `offset` until Kward's read caps stop the response.
48
+ - `max_bytes`: optional per-call byte budget, capped by Kward's workspace read limit.
43
49
 
44
50
  A successful read marks the resolved file path as read for the conversation, allowing later edits to that file.
45
51
 
52
+ When called without `offset`, `limit`, or `mode` on a file that exceeds 2,000 lines or 50 KB, Kward returns a structure outline (classes, modules, methods) plus the first 120 lines instead of truncating blindly. This helps identify relevant entry points before requesting specific line ranges.
53
+
54
+ Binary files (detected by null bytes) return an error instead of content.
55
+
56
+ ### `context_budget_stats`
57
+
58
+ Returns approximate context-budget savings for the current active conversation since it was opened in this process. The report compares each raw tool result size with the model-facing result after compaction or duplicate replacement, includes per-tool totals, and estimates saved tokens using roughly four bytes per token.
59
+
60
+ Arguments: none.
61
+
62
+ These numbers are intentionally approximate runtime stats. They are useful for seeing whether Kward's budgeting is helping the current conversation, not for billing, and they are not reconstructed when a saved session is resumed.
63
+
64
+ ### `context_for_task`
65
+
66
+ Builds a compact, task-shaped context bundle from likely workspace files. It ranks files by task terms, includes source outlines, and adds short matching excerpts while respecting an approximate byte budget. This is intentionally lightweight: it does not maintain a persistent index or semantic graph.
67
+
68
+ Arguments:
69
+
70
+ - `task`: required task or question to gather context for.
71
+ - `paths`: optional files or directories to focus. Defaults to the workspace root.
72
+ - `budget`: optional approximate byte budget, default 4,000 and capped at 20,000.
73
+
74
+ Use this when Kward needs orientation for a bug, review, implementation, or explanation before deciding which exact file ranges to read.
75
+
46
76
  ### `summarize_file_structure`
47
77
 
48
- Returns a compact outline of classes, modules, methods, and functions in a source file. Kward uses it when a file may be too large to read fully at first.
78
+ Returns a compact outline of recognizable declarations in a source file, including line ranges and declaration kind where Kward can infer them. Kward uses it when a file may be too large to read fully at first.
49
79
 
50
80
  Arguments:
51
81
 
52
82
  - `path`: workspace-relative source file path.
53
83
 
54
- This tool saves tokens by letting Kward identify relevant entry points before requesting exact line ranges with `read_file`.
84
+ This tool saves tokens by letting Kward identify relevant entry points before requesting exact line ranges with `read_file`. The outline is capped at 80 entries and recognizes common Ruby, JavaScript/TypeScript, Go, Rust, Java, and C#-style declarations with lightweight pattern matching rather than a full parser. Binary files (detected by null bytes) return an error instead of content.
55
85
 
56
86
  ## Changing files
57
87
 
@@ -66,6 +96,8 @@ Arguments:
66
96
 
67
97
  Use full writes when replacing generated content or creating a new file. For small edits to existing files, Kward should usually prefer `edit_file`.
68
98
 
99
+ The result includes a unified diff of the changes, capped at 8 KB with a summary of additions and deletions when truncated.
100
+
69
101
  ### `edit_file`
70
102
 
71
103
  Applies one or more exact replacements to a file that has already been read.
@@ -77,7 +109,9 @@ Arguments:
77
109
  - `old_text`: unique exact text to replace.
78
110
  - `new_text`: replacement text.
79
111
 
80
- Each `old_text` must match exactly once, and replacements must not overlap. This keeps edits deterministic and avoids broad fuzzy rewriting.
112
+ Each `old_text` must match exactly once, and replacements must not overlap. This keeps edits deterministic and avoids broad fuzzy rewriting. Empty `old_text`, multiple matches, and no-op edits (where the result is identical) are all rejected with an error.
113
+
114
+ The result includes a unified diff of the changes, capped at 8 KB with a summary of additions and deletions when truncated.
81
115
 
82
116
  ## Running commands
83
117
 
@@ -90,16 +124,21 @@ Arguments:
90
124
  - `command`: command to run.
91
125
  - `timeout_seconds`: optional timeout, default 30 seconds.
92
126
 
93
- Kward uses shell commands for tests, linters, build checks, and simple repository inspection. Command output is bounded and may be compacted before it is sent back into model context, while the original output remains available in the session record.
127
+ Kward uses shell commands for tests, linters, build checks, and simple repository inspection. Command output is bounded at 128 KB and may be compacted before it is sent back into model context, while the original output remains available in the session record.
128
+
129
+ The output format is `Exit status: N` followed by `STDOUT:` and `STDERR:` sections. On timeout, Kward sends SIGTERM then SIGKILL after 0.2 seconds and returns a timeout error. Commands support cooperative cancellation when the session is cancelled.
94
130
 
95
131
  ## Token behavior
96
132
 
97
133
  Workspace tools are intentionally incremental:
98
134
 
99
135
  1. list directories to find likely files,
100
- 2. summarize large source files before reading everything,
101
- 3. read focused line ranges,
102
- 4. make exact edits,
103
- 5. run focused verification commands.
136
+ 2. use `read_file` with `mode: "outline"` or `summarize_file_structure` before reading everything,
137
+ 3. read focused line ranges with `mode: "range"`, `offset`, `limit`, and optional `max_bytes`,
138
+ 4. widen to `mode: "full"` only when focused context is insufficient,
139
+ 5. make exact edits,
140
+ 6. run focused verification commands.
104
141
 
105
142
  This keeps the model's context window focused on relevant evidence instead of flooding it with entire repositories or long command output.
143
+
144
+ For details on how tool outputs are compacted, deduplicated, and retrieved, see [Agent tools](agent-tools.md).
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Space Invaders — a full classic arcade game built as a Kward interactive
4
+ # plugin command.
5
+ #
6
+ # Install: copy this file to ~/.kward/plugins/ and run `/invaders` in the
7
+ # Kward TUI.
8
+ #
9
+ # Controls:
10
+ # ←/→ Move
11
+ # Space Fire
12
+ # Q / Esc Quit
13
+ # Ctrl+C Force quit (handled by Kward)
14
+ #
15
+ # The game renders colored sprites and particle-burst explosions inside the
16
+ # composer canvas region using the interactive mode API.
17
+
18
+ Kward.plugin do |plugin|
19
+ plugin.interactive_command "invaders", rows: 18, fps: 30, description: "Space Invaders arcade game" do |ui, ctx|
20
+ game = SpaceInvadersGame.new(width: ui.width, height: ui.height)
21
+ ui.on_tick { |ui| game.tick(ui) }
22
+ end
23
+ end
24
+
25
+ # rubocop:disable Metrics/ClassLength, Metrics/MethodLength
26
+ class SpaceInvadersGame
27
+ PLAYER_CHAR = "▲"
28
+ PLAYER_COLOR = :cyan
29
+ BULLET_CHAR = "│"
30
+ BULLET_COLOR = :yellow
31
+ BOMB_CHAR = "V"
32
+ BOMB_COLOR = :magenta
33
+ SCORE_LABEL = "Score"
34
+ LIVES_LABEL = "Lives"
35
+
36
+ INVADER_ROWS = [
37
+ { char: "M", color: :red, points: 30 },
38
+ { char: "M", color: :red, points: 30 },
39
+ { char: "W", color: :yellow, points: 20 },
40
+ { char: "W", color: :yellow, points: 20 },
41
+ { char: "A", color: :green, points: 10 }
42
+ ].freeze
43
+
44
+ PARTICLE_CHARS = %w[* + . o].freeze
45
+ PARTICLE_COLORS = %i[red yellow].freeze
46
+ PARTICLE_LIFE = 6
47
+
48
+ MOVE_EVERY = 12
49
+ BULLET_SPEED = 2
50
+ BOMB_SPEED = 1
51
+ MAX_BULLETS = 3
52
+ FIRE_COOLDOWN = 8
53
+ BOMB_CHANCE = 0.015
54
+
55
+ def initialize(width:, height:)
56
+ @width = width
57
+ @height = height
58
+ @phase = :playing
59
+ @tick_count = 0
60
+ @score = 0
61
+ @lives = 3
62
+ @wave = 1
63
+ @fire_cooldown = 0
64
+ @invader_dir = 1
65
+ @invaders = []
66
+ @bullets = []
67
+ @bombs = []
68
+ @explosions = []
69
+ @message_timer = 0
70
+ spawn_invaders
71
+ place_player
72
+ end
73
+
74
+ def tick(ui)
75
+ drain_keys(ui)
76
+ case @phase
77
+ when :playing
78
+ update_playing
79
+ render_playing(ui)
80
+ when :game_over, :won
81
+ update_message_phase
82
+ render_message(ui)
83
+ end
84
+ ui.render
85
+ end
86
+
87
+ private
88
+
89
+ def drain_keys(ui)
90
+ while (key = ui.poll_key)
91
+ case key
92
+ when :escape, "q", "Q"
93
+ ui.exit
94
+ when :left
95
+ @player_col -= 1 if @phase == :playing
96
+ when :right
97
+ @player_col += 1 if @phase == :playing
98
+ when :space
99
+ fire_bullet if @phase == :playing
100
+ when :return, :enter
101
+ restart if @phase != :playing
102
+ end
103
+ end
104
+ end
105
+
106
+ def place_player
107
+ @player_col = @width / 2
108
+ @player_row = @height - 2
109
+ end
110
+
111
+ # ---- Invader spawning ----
112
+
113
+ def spawn_invaders
114
+ @invaders = []
115
+ rows = INVADER_ROWS.length
116
+ cols = [(@width - 4) / 4, 6].min
117
+ cols = [cols, 3].max
118
+ start_row = 2
119
+ start_col = (@width - cols * 4) / 2
120
+ start_col = [start_col, 1].max
121
+
122
+ rows.times do |row|
123
+ config = INVADER_ROWS[row]
124
+ cols.times do |col|
125
+ @invaders << {
126
+ row: start_row + row,
127
+ col: start_col + col * 4,
128
+ char: config[:char],
129
+ color: config[:color],
130
+ points: config[:points],
131
+ alive: true
132
+ }
133
+ end
134
+ end
135
+ @invader_dir = 1
136
+ end
137
+
138
+ # ---- Playing phase updates ----
139
+
140
+ def update_playing
141
+ @tick_count += 1
142
+ @fire_cooldown = [@fire_cooldown - 1, 0].max if @fire_cooldown.positive?
143
+
144
+ move_invaders if (@tick_count % move_interval).zero?
145
+ move_bullets
146
+ move_bombs
147
+ maybe_drop_bomb
148
+ update_explosions
149
+ check_collisions
150
+ check_win_lose
151
+ end
152
+
153
+ def move_interval
154
+ alive = @invaders.count { |inv| inv[:alive] }
155
+ total = @invaders.length
156
+ return MOVE_EVERY if total.zero?
157
+
158
+ speedup = ((total - alive) * MOVE_EVERY) / (total * 2)
159
+ [MOVE_EVERY - speedup, 2].max
160
+ end
161
+
162
+ def move_invaders
163
+ living = @invaders.select { |inv| inv[:alive] }
164
+ return if living.empty?
165
+
166
+ min_col = living.map { |inv| inv[:col] }.min
167
+ max_col = living.map { |inv| inv[:col] }.max
168
+
169
+ if @invader_dir.positive? && max_col >= @width - 2
170
+ @invader_dir = -1
171
+ @invaders.each { |inv| inv[:row] += 1 if inv[:alive] }
172
+ elsif @invader_dir.negative? && min_col <= 1
173
+ @invader_dir = 1
174
+ @invaders.each { |inv| inv[:row] += 1 if inv[:alive] }
175
+ else
176
+ @invaders.each { |inv| inv[:col] += @invader_dir if inv[:alive] }
177
+ end
178
+ end
179
+
180
+ def fire_bullet
181
+ return if @bullets.length >= MAX_BULLETS
182
+ return if @fire_cooldown.positive?
183
+
184
+ @bullets << { row: @player_row - 1, col: @player_col }
185
+ @fire_cooldown = FIRE_COOLDOWN
186
+ end
187
+
188
+ def move_bullets
189
+ @bullets.each { |b| b[:row] -= BULLET_SPEED }
190
+ @bullets.reject! { |b| b[:row] < 0 }
191
+ end
192
+
193
+ def move_bombs
194
+ @bombs.each { |b| b[:row] += BOMB_SPEED }
195
+ @bombs.reject! { |b| b[:row] >= @height }
196
+ end
197
+
198
+ def maybe_drop_bomb
199
+ return if rand > BOMB_CHANCE
200
+
201
+ living = @invaders.select { |inv| inv[:alive] }
202
+ return if living.empty?
203
+
204
+ # Drop from a random bottom-most invader per column
205
+ columns = living.group_by { |inv| inv[:col] }
206
+ col, invaders = columns.min_by { rand }
207
+ return unless invaders
208
+
209
+ bottom = invaders.max_by { |inv| inv[:row] }
210
+ @bombs << { row: bottom[:row] + 1, col: bottom[:col] } if bottom
211
+ end
212
+
213
+ # ---- Explosions ----
214
+
215
+ def spawn_explosion(row, col, color = :red)
216
+ PARTICLE_LIFE.times do |i|
217
+ angle = (i * 360 / PARTICLE_LIFE) * Math::PI / 180
218
+ dx = (Math.cos(angle) * (i + 1)).round
219
+ dy = (Math.sin(angle) * (i + 1)).round
220
+ @explosions << {
221
+ row: row + dy,
222
+ col: col + dx,
223
+ char: PARTICLE_CHARS[i % PARTICLE_CHARS.length],
224
+ color: PARTICLE_COLORS[i % PARTICLE_COLORS.length],
225
+ life: PARTICLE_LIFE
226
+ }
227
+ end
228
+ end
229
+
230
+ def update_explosions
231
+ @explosions.each { |e| e[:life] -= 1 }
232
+ @explosions.reject! { |e| e[:life] <= 0 }
233
+ end
234
+
235
+ # ---- Collisions ----
236
+
237
+ def check_collisions
238
+ check_bullet_invader_hits
239
+ check_bomb_player_hits
240
+ end
241
+
242
+ def check_bullet_invader_hits
243
+ @bullets.reject! do |bullet|
244
+ hit = @invaders.find { |inv| inv[:alive] && inv[:row] == bullet[:row] && inv[:col] == bullet[:col] }
245
+ next false unless hit
246
+
247
+ hit[:alive] = false
248
+ @score += hit[:points]
249
+ spawn_explosion(hit[:row], hit[:col], hit[:color])
250
+ true
251
+ end
252
+ end
253
+
254
+ def check_bomb_player_hits
255
+ @bombs.reject! do |bomb|
256
+ hit = bomb[:row] == @player_row && bomb[:col] == @player_col
257
+ next false unless hit
258
+
259
+ @lives -= 1
260
+ spawn_explosion(@player_row, @player_col, :cyan)
261
+ @phase = :game_over if @lives <= 0
262
+ true
263
+ end
264
+ end
265
+
266
+ def check_win_lose
267
+ return unless @phase == :playing
268
+
269
+ @phase = :won if @invaders.none? { |inv| inv[:alive] }
270
+
271
+ return unless @invaders.any? { |inv| inv[:alive] && inv[:row] >= @player_row }
272
+
273
+ @phase = :game_over
274
+ spawn_explosion(@player_row, @player_col, :cyan)
275
+ end
276
+
277
+ # ---- Message phase ----
278
+
279
+ def update_message_phase
280
+ @message_timer += 1
281
+ end
282
+
283
+ def restart
284
+ @score = 0
285
+ @lives = 3
286
+ @wave = 1
287
+ @tick_count = 0
288
+ @bullets.clear
289
+ @bombs.clear
290
+ @explosions.clear
291
+ @fire_cooldown = 0
292
+ @invader_dir = 1
293
+ spawn_invaders
294
+ place_player
295
+ @phase = :playing
296
+ @message_timer = 0
297
+ end
298
+
299
+ # ---- Rendering ----
300
+
301
+ def render_playing(ui)
302
+ ui.clear_frame
303
+
304
+ render_hud(ui)
305
+ render_invaders(ui)
306
+ render_bullets(ui)
307
+ render_bombs(ui)
308
+ render_explosions(ui)
309
+ ui.put(@player_row, @player_col, PLAYER_CHAR, PLAYER_COLOR)
310
+ end
311
+
312
+ def render_hud(ui)
313
+ score_text = "#{SCORE_LABEL}: #{@score}"
314
+ lives_text = "#{LIVES_LABEL}: #{@lives}"
315
+ wave_text = "Wave: #{@wave}"
316
+
317
+ score_text.chars.each_with_index do |char, i|
318
+ ui.put(0, i, char, :green)
319
+ end
320
+ lives_col = @width - lives_text.length
321
+ lives_text.chars.each_with_index do |char, i|
322
+ ui.put(0, lives_col + i, char, :cyan)
323
+ end
324
+ wave_col = (@width - wave_text.length) / 2
325
+ wave_text.chars.each_with_index do |char, i|
326
+ ui.put(0, wave_col + i, char, :gray)
327
+ end
328
+ end
329
+
330
+ def render_invaders(ui)
331
+ @invaders.each do |inv|
332
+ next unless inv[:alive]
333
+
334
+ ui.put(inv[:row], inv[:col], inv[:char], inv[:color])
335
+ end
336
+ end
337
+
338
+ def render_bullets(ui)
339
+ @bullets.each { |b| ui.put(b[:row], b[:col], BULLET_CHAR, BULLET_COLOR) }
340
+ end
341
+
342
+ def render_bombs(ui)
343
+ @bombs.each { |b| ui.put(b[:row], b[:col], BOMB_CHAR, BOMB_COLOR) }
344
+ end
345
+
346
+ def render_explosions(ui)
347
+ @explosions.each do |e|
348
+ ui.put(e[:row], e[:col], e[:char], e[:color])
349
+ end
350
+ end
351
+
352
+ def render_message(ui)
353
+ ui.clear_frame
354
+ render_invaders(ui)
355
+ render_explosions(ui)
356
+
357
+ if @phase == :won
358
+ message = "YOU WIN!"
359
+ color = :green
360
+ else
361
+ message = "GAME OVER"
362
+ color = :red
363
+ end
364
+
365
+ center_text(ui, @height / 2, message, color)
366
+ center_text(ui, @height / 2 + 2, "Score: #{@score}", :yellow)
367
+ center_text(ui, @height / 2 + 4, "Enter: restart Q: quit", :gray)
368
+ end
369
+
370
+ def center_text(ui, row, text, color)
371
+ col = (@width - text.length) / 2
372
+ text.chars.each_with_index do |char, i|
373
+ ui.put(row, col + i, char, color)
374
+ end
375
+ end
376
+ end
377
+ # rubocop:enable Metrics/ClassLength, Metrics/MethodLength
data/lib/kward/agent.rb CHANGED
@@ -31,7 +31,7 @@ module Kward
31
31
  @telemetry_logger = telemetry_logger
32
32
  end
33
33
 
34
- attr_reader :conversation
34
+ attr_reader :conversation, :tool_registry
35
35
 
36
36
  # Adds a user message, compacts context when needed, and runs the turn.
37
37
  #