kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -0,0 +1,25 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # CLI slash-command helpers for manual context compaction.
6
+ module CompactionCommands
7
+ private
8
+
9
+ def compact_context(agent, argument)
10
+ result = Compactor.new(
11
+ conversation: agent.conversation,
12
+ client: @client,
13
+ tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
14
+ ).compact(custom_instructions: argument)
15
+ @prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
16
+ render_transcript_block("Assistant", result.summary)
17
+ rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
18
+ @prompt.say("\n#{e.message}\n")
19
+ rescue StandardError => e
20
+ @prompt.say("\nCompaction error: #{e.message}\n")
21
+ end
22
+
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,121 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Environment and configuration diagnostics for the `doctor` command.
6
+ module Doctor
7
+ private
8
+
9
+ # Writes the doctor output for the terminal CLI flow.
10
+ def print_doctor
11
+ lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
12
+ doctor_checks.each do |check|
13
+ lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
14
+ end
15
+ @prompt.say lines.join("\n")
16
+ end
17
+
18
+ def doctor_checks
19
+ config = safely_read_config
20
+ [
21
+ doctor_config_check,
22
+ doctor_config_json_check(config),
23
+ doctor_directory_check("Config directory", ConfigFiles.config_dir),
24
+ doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
25
+ doctor_workspace_check,
26
+ doctor_model_check,
27
+ doctor_auth_check(config),
28
+ doctor_pan_check(config),
29
+ { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
30
+ ]
31
+ end
32
+
33
+ def safely_read_config
34
+ ConfigFiles.read_config
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ def doctor_config_check
40
+ path = ConfigFiles.config_path
41
+ if File.exist?(path)
42
+ readable = File.readable?(path)
43
+ return { status: readable ? :ok : :error, label: "Config", message: readable ? path : "not readable: #{path}" }
44
+ end
45
+
46
+ { status: :warning, label: "Config", message: "not found: #{path}" }
47
+ end
48
+
49
+ def doctor_config_json_check(config)
50
+ return { status: :error, label: "Config JSON", message: "invalid or unreadable" } unless config.is_a?(Hash)
51
+
52
+ { status: :ok, label: "Config JSON", message: "valid" }
53
+ end
54
+
55
+ def doctor_directory_check(label, path, create: false)
56
+ FileUtils.mkdir_p(path, mode: 0o700) if create
57
+ if Dir.exist?(path) && File.writable?(path)
58
+ { status: :ok, label: label, message: "writable: #{path}" }
59
+ elsif Dir.exist?(path)
60
+ { status: :error, label: label, message: "not writable: #{path}" }
61
+ else
62
+ { status: :error, label: label, message: "missing: #{path}" }
63
+ end
64
+ rescue StandardError => e
65
+ { status: :error, label: label, message: e.message }
66
+ end
67
+
68
+ def doctor_workspace_check
69
+ root = current_workspace_root
70
+ return { status: :ok, label: "Workspace", message: root } if Dir.exist?(root) && File.directory?(root)
71
+
72
+ { status: :error, label: "Workspace", message: "not a directory: #{root}" }
73
+ end
74
+
75
+ def doctor_model_check
76
+ provider = @client.current_provider if @client.respond_to?(:current_provider)
77
+ model = @client.current_model if @client.respond_to?(:current_model)
78
+ parts = [provider, model].compact.map(&:to_s).reject(&:empty?)
79
+ return { status: :ok, label: "Model", message: parts.join(" / ") } if parts.any?
80
+
81
+ { status: :warning, label: "Model", message: "not configured" }
82
+ rescue StandardError => e
83
+ { status: :warning, label: "Model", message: e.message }
84
+ end
85
+
86
+ def doctor_auth_check(config)
87
+ openai_auth = OpenAIOAuth.default_auth_path
88
+ github_auth = GithubOAuth.default_auth_path
89
+ has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
90
+ paths = []
91
+ paths << "OpenAI OAuth" if File.exist?(openai_auth)
92
+ paths << "GitHub OAuth" if File.exist?(github_auth)
93
+ paths << "OpenRouter API key" if has_openrouter
94
+ return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
95
+
96
+ { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
97
+ end
98
+
99
+ def doctor_pan_check(config)
100
+ pan = config.to_h["pan_mode"] || {}
101
+ if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
102
+ { status: :ok, label: "Pan mode", message: "credentials configured" }
103
+ else
104
+ { status: :warning, label: "Pan mode", message: "username/password not configured" }
105
+ end
106
+ end
107
+
108
+ def doctor_mark(status)
109
+ case status
110
+ when :ok
111
+ colored("✓", :green, :bold)
112
+ when :warning
113
+ colored("!", :yellow, :bold)
114
+ else
115
+ colored("✗", :red, :bold)
116
+ end
117
+ end
118
+
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,225 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Interactive turn loop helpers for streaming, cancellation, and queued user input.
6
+ module InteractiveTurn
7
+ private
8
+
9
+ def run_interactive_turn(agent, input, display_input: nil)
10
+ prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
+ print_user_transcript(input, display_input: display_input) if prompt_interface?
12
+ return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
13
+
14
+ queued_inputs = []
15
+ cancellation = Cancellation.new
16
+ cancelled = false
17
+ steering = steering_supported? ? Steering.new : nil
18
+ event_queue = Queue.new
19
+ stream_state = {
20
+ streamed: false,
21
+ last_flush: monotonic_now,
22
+ stream_block_open: false,
23
+ markdown_streams: {},
24
+ defer_assistant_streaming: defer_assistant_streaming?(agent)
25
+ }
26
+ markdown_chunks = []
27
+ answer = nil
28
+ error = nil
29
+ @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
30
+
31
+ worker = Thread.new do
32
+ options = agent_display_options(display_input)
33
+ options[:cancellation] = cancellation
34
+ options[:steering] = steering if steering
35
+ answer = agent.ask(input, **options) do |event|
36
+ event_queue << event
37
+ end
38
+ rescue StandardError => e
39
+ error = e
40
+ end
41
+ worker.report_on_exception = false
42
+
43
+ while worker.alive?
44
+ begin
45
+ poll_result = collect_busy_input(queued_inputs, steering)
46
+ sleep 0.01
47
+ rescue Interrupt
48
+ poll_result = PromptInterface::CANCEL_INPUT
49
+ end
50
+ if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
51
+ cancelled = true
52
+ cancellation.cancel!
53
+ worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
54
+ end
55
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
56
+ end
57
+ begin
58
+ worker.join
59
+ rescue Cancellation::CancelledError => e
60
+ error ||= e
61
+ end
62
+ drain_busy_input(queued_inputs, nil) unless cancelled
63
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
64
+ raise error if error && !error.is_a?(Cancellation::CancelledError)
65
+
66
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
67
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
68
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
69
+ queued_inputs
70
+ ensure
71
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
72
+ end
73
+
74
+ def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
75
+ drained = 0
76
+ loop do
77
+ break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
78
+
79
+ event = event_queue.pop(true)
80
+ drained += 1
81
+ notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
82
+ handle_interactive_event(event, markdown_chunks, stream_state)
83
+ rescue ThreadError
84
+ break
85
+ end
86
+
87
+ flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
88
+ end
89
+
90
+ def handle_interactive_event(event, markdown_chunks, stream_state)
91
+ case event
92
+ when Events::ReasoningDelta
93
+ stream_state[:streamed] = true
94
+ append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
95
+ when Events::AssistantDelta
96
+ stream_state[:streamed] = true
97
+ append_markdown_delta(markdown_chunks, "Assistant", event.delta)
98
+ when Events::SteeringApplied
99
+ @prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
100
+ when Events::Retry
101
+ stream_state[:streamed] = true
102
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
103
+ print_retry(event)
104
+ when Events::ToolCall
105
+ stream_state[:streamed] = true
106
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
107
+ print_tool_call(event.tool_call)
108
+ when Events::ToolResult
109
+ stream_state[:streamed] = true
110
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
111
+ update_session_diff(event.content, tool_call: event.tool_call)
112
+ print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
113
+ end
114
+ end
115
+
116
+ def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
117
+ if force
118
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
119
+ return
120
+ end
121
+ return if markdown_chunks.empty?
122
+ return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
123
+
124
+ chunks_to_flush = markdown_chunks
125
+ if stream_state[:defer_assistant_streaming]
126
+ chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
127
+ return if chunks_to_flush.empty?
128
+
129
+ markdown_chunks.replace(delayed_chunks)
130
+ end
131
+
132
+ stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
133
+ stream_state[:last_flush] = monotonic_now
134
+ end
135
+
136
+ def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
137
+ wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
138
+ finish_stream_block if stream_state[:stream_block_open] && !wrote
139
+ stream_state[:stream_block_open] = false
140
+ stream_state[:last_flush] = monotonic_now
141
+ end
142
+
143
+ def split_deferred_assistant_entries(markdown_chunks)
144
+ markdown_chunks.partition { |label, _content| label != "Assistant" }
145
+ end
146
+
147
+ def monotonic_now
148
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ end
150
+
151
+ def collect_queued_input(queued_inputs)
152
+ collect_busy_input(queued_inputs, nil)
153
+ end
154
+
155
+ def collect_busy_input(queued_inputs, steering)
156
+ return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
157
+
158
+ poll_result = @prompt.poll_input
159
+ case poll_result
160
+ when String
161
+ if steering && !poll_result.strip.empty?
162
+ begin
163
+ steering.submit(poll_result)
164
+ @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
165
+ rescue StandardError
166
+ queued_inputs << poll_result
167
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
168
+ end
169
+ else
170
+ queued_inputs << poll_result unless poll_result.strip.empty?
171
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
172
+ end
173
+ when PromptInterface::EXIT_INPUT
174
+ queued_inputs << "/exit"
175
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
176
+ end
177
+ poll_result
178
+ end
179
+
180
+ def drain_queued_input(queued_inputs)
181
+ drain_busy_input(queued_inputs, nil)
182
+ end
183
+
184
+ def drain_busy_input(queued_inputs, steering)
185
+ deadline = Time.now + 0.15
186
+ loop do
187
+ poll_result = collect_busy_input(queued_inputs, steering)
188
+ break if Time.now > deadline && poll_result.nil?
189
+
190
+ sleep 0.01
191
+ end
192
+ end
193
+
194
+ def steering_supported?
195
+ @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
196
+ end
197
+
198
+ def defer_assistant_streaming?(agent)
199
+ return false unless agent.respond_to?(:conversation)
200
+
201
+ conversation = agent.conversation
202
+ model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
203
+ ModelInfo.reasoning_supported?(current_model_provider, model)
204
+ end
205
+
206
+ def run_blocking_interactive_turn(agent, input, display_input: nil)
207
+ streamed = false
208
+ markdown_chunks = []
209
+ answer = agent.ask(input, **agent_display_options(display_input)) do |event|
210
+ streamed = true if render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
211
+ end
212
+ flush_markdown_deltas(markdown_chunks) if streamed
213
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
214
+ persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
215
+ auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
216
+ []
217
+ end
218
+
219
+ def agent_display_options(display_input)
220
+ display_input.nil? ? {} : { display_input: display_input }
221
+ end
222
+
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,133 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Interactive memory management commands mixed into the CLI frontend.
6
+ module MemoryCommands
7
+ private
8
+
9
+ def memory_summarize_command?(argument)
10
+ subcommand, = argument.to_s.strip.split(/\s+/, 2)
11
+ ["summarize", "learn"].include?(subcommand)
12
+ end
13
+
14
+ def handle_memory_command(argument, agent)
15
+ subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
16
+ manager = Memory::Manager.new
17
+ case subcommand
18
+ when "enable"
19
+ manager.enable
20
+ agent.conversation.refresh_system_message!
21
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
22
+ when "disable"
23
+ manager.disable
24
+ agent.conversation.memory_context = nil
25
+ agent.conversation.refresh_system_message!
26
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
27
+ when "auto-summary"
28
+ case rest.to_s.strip
29
+ when "enable", "on"
30
+ manager.auto_summary_enable
31
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
32
+ when "disable", "off"
33
+ manager.auto_summary_disable
34
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
35
+ else
36
+ @prompt.say("\nUsage: /memory auto-summary enable|disable\n")
37
+ end
38
+ when "core"
39
+ record = manager.add_core(unquote_argument(rest))
40
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
41
+ when "add"
42
+ record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
43
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
44
+ when "list"
45
+ @prompt.say("\n#{format_memory_list(manager.hierarchy(workspace_root: agent.conversation.workspace_root))}\n")
46
+ when "forget"
47
+ forgotten = manager.forget_memory(rest.to_s.strip)
48
+ @prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
49
+ when "promote"
50
+ record = manager.promote_memory(rest.to_s.strip)
51
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted memory #{record["id"]}.\n")
52
+ when "relax"
53
+ record = manager.relax_core(rest.to_s.strip, workspace_root: agent.conversation.workspace_root)
54
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Relaxed memory #{record["id"]}.\n")
55
+ when "inspect"
56
+ @prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
57
+ when "why"
58
+ explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
59
+ @prompt.say("\n#{format_memory_why(explanation)}\n")
60
+ when "summarize", "learn"
61
+ records = summarize_memory(agent.conversation, manager: manager)
62
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
63
+ else
64
+ @prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize\n")
65
+ end
66
+ rescue StandardError => e
67
+ @prompt.say("\nMemory command failed: #{e.message}\n")
68
+ end
69
+
70
+ def summarize_memory(conversation, manager: Memory::Manager.new)
71
+ records = manager.summarize_conversation(conversation, client: @client)
72
+ @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
73
+ records
74
+ end
75
+
76
+ def unquote_argument(text)
77
+ value = text.to_s.strip
78
+ value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
79
+ value
80
+ end
81
+
82
+ def format_memory_list(memories)
83
+ sections = [
84
+ ["Global Core Memories:", Array(memories["global_core"])],
85
+ ["Workspace Core Memories:", Array(memories["workspace_core"])],
86
+ ["Workspace Soft Memories:", Array(memories["workspace_soft"])]
87
+ ]
88
+
89
+ sections.flat_map do |heading, records|
90
+ lines = [heading]
91
+ records.each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
92
+ lines << "- none" if records.empty?
93
+ lines
94
+ end.join("\n")
95
+ end
96
+
97
+ def format_memory_why(explanation)
98
+ reasons = Array(explanation["reasons"])
99
+ return explanation["message"] || "No memories were retrieved." if reasons.empty?
100
+
101
+ (["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
102
+ end
103
+
104
+ def prepare_memory_context(conversation, input)
105
+ manager = Memory::Manager.new
106
+ retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
107
+ conversation.last_memory_retrieval = retrieval
108
+ conversation.memory_context = manager.memory_block(retrieval)
109
+ conversation.refresh_system_message!
110
+ rescue StandardError => e
111
+ warn "Memory retrieval failed: #{e.message}"
112
+ nil
113
+ end
114
+
115
+ def persist_memory_state(conversation)
116
+ @active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
117
+ rescue StandardError
118
+ nil
119
+ end
120
+
121
+ def auto_summarize_memory(conversation)
122
+ manager = Memory::Manager.new
123
+ return unless manager.enabled? && manager.auto_summary_enabled?
124
+
125
+ summarize_memory(conversation, manager: manager)
126
+ rescue StandardError => e
127
+ warn "Memory auto-summary failed: #{e.message}"
128
+ nil
129
+ end
130
+
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,112 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Plugin command loading and execution helpers mixed into the CLI frontend.
6
+ module Plugins
7
+ private
8
+
9
+ def prompt_templates
10
+ @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
11
+ end
12
+
13
+ def plugin_registry
14
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
15
+ end
16
+
17
+ def plugin_commands
18
+ plugin_registry.commands
19
+ end
20
+
21
+ def plugin_command_for(command)
22
+ plugin_registry.command_for(command)
23
+ end
24
+
25
+ def reload_plugins(conversation)
26
+ @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
27
+ conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
28
+ conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
29
+ @prompt.say("\nPlugins reloaded.\n")
30
+ end
31
+
32
+ def reserved_slash_command_names
33
+ BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
34
+ end
35
+
36
+ def slash_command_entries
37
+ prompt_entries = prompt_templates.map do |template|
38
+ {
39
+ name: template.command,
40
+ description: template.description,
41
+ argument_hint: template.argument_hint
42
+ }
43
+ end
44
+ plugin_entries = plugin_commands.map(&:entry)
45
+ BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
46
+ end
47
+
48
+ def prompt_template_for(command)
49
+ prompt_templates.find { |template| template.command == command }
50
+ end
51
+
52
+ def expand_prompt_template(input)
53
+ PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
54
+ end
55
+
56
+ def run_plugin_command(name, argument, agent)
57
+ command = plugin_command_for(name)
58
+ return [false, nil] unless command
59
+
60
+ agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
61
+ context = plugin_context(agent.conversation, argument)
62
+ command.handler.call(argument, context)
63
+ [true, nil]
64
+ rescue StandardError => e
65
+ @prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
66
+ [true, nil]
67
+ end
68
+
69
+ def plugin_context(conversation, args)
70
+ PluginRegistry::Context.new(
71
+ conversation: conversation,
72
+ args: args,
73
+ session: @active_session,
74
+ workspace_root: conversation.workspace_root,
75
+ say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
76
+ )
77
+ end
78
+
79
+ def selected_slash_command_input(input)
80
+ return nil if prompt_interface?
81
+ return nil unless @prompt.respond_to?(:select)
82
+ return nil unless input.match?(%r{\A/[^\s/]*\z})
83
+ return nil if prompt_template_for(input.delete_prefix("/"))
84
+
85
+ prefix = input.delete_prefix("/").downcase
86
+ return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
87
+
88
+ matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
89
+ return nil if matches.empty?
90
+
91
+ labels = matches.map { |entry| slash_command_label(entry) }
92
+ choice = @prompt.select("Slash command>", labels)
93
+ entry = matches[labels.index(choice)]
94
+ entry ? "/#{entry[:name]}" : nil
95
+ end
96
+
97
+ def slash_command_label(entry)
98
+ hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
99
+ description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
100
+ "/#{entry[:name]}#{hint}#{description}"
101
+ end
102
+
103
+ def notify_plugin_transcript_event(event, conversation)
104
+ return unless conversation
105
+ return if plugin_registry.transcript_event_handlers.empty?
106
+
107
+ plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
108
+ end
109
+
110
+ end
111
+ end
112
+ end