letsdo 0.1.0 → 0.2.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.
@@ -1,45 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module Letsdo
6
- # Routes pi output across two streams:
7
- # stdout — only the agent's answer text (text_delta), no service lines;
8
- # stderr — service lines: tool calls (name, arguments, result) as
9
- # execution progress.
10
- #
11
- # Every action line gets a shared HH:MM:SS time prefix: the difference
12
- # between prefixes shows how long ago an action happened and how long
13
- # a tool ran. Format of service tool lines:
14
- # HH:MM:SS ⚙ name: arguments — tool call on one line (for bash the
15
- # command text, for read/write/edit the
16
- # path, etc.);
17
- # indented " " lines — execution result (stdout/stderr),
18
- # no time prefix (data, not actions);
19
- # HH:MM:SS ✓ name: done (Xs) — tool completion (success), printed
20
- # after the result block;
21
- # HH:MM:SS ✖ name: error (Xs) — tool completion (error);
22
- # ✖ Error: ... — error result marked explicitly;
23
- # … [output truncated: N lines, M] — big output → summary note.
24
- # Remembers the last response character so that on finish a final newline
25
- # is guaranteed — output must not break in the middle.
6
+ # Routes pi output: agent text on stdout, tool lines on stderr (or a log).
26
7
  class OutputStreamer
27
- # Max lines of a tool result in service output.
28
- MAX_RESULT_LINES = 100
29
- # Max characters of a tool result in service output.
30
- MAX_RESULT_CHARS = 4_000
31
- # Max characters of a short call-arguments representation.
32
8
  MAX_ARGS_CHARS = 300
33
9
 
34
- # @param stdout [IO] stream for the agent's answer text
35
- # @param stderr [IO] stream for service lines
36
- # @param log [Object, nil] an alternate log target (TUI mode): when
37
- # given, ALL output — answer text and service tool lines — is
38
- # written there as one combined stream (a
39
- # Letsdo::Tui::LogBuffer); without it the plain stdout/stderr
40
- # split is byte-identical to before
41
- # @param clock [Proc] callable → Time, the source of time for prefixes
42
- # (injected in tests for deterministic HH:MM:SS)
43
10
  def initialize(stdout: $stdout, stderr: $stderr, log: nil, clock: nil)
44
11
  @stdout = stdout
45
12
  @stderr = stderr
@@ -49,9 +16,6 @@ module Letsdo
49
16
  @action_started_at = nil
50
17
  end
51
18
 
52
- # Prints a chunk of the agent's answer text immediately.
53
- #
54
- # @param delta [String] the next text fragment
55
19
  def text_delta(delta)
56
20
  return if delta.nil? || delta.empty?
57
21
 
@@ -60,51 +24,18 @@ module Letsdo
60
24
  @last_char = delta[-1]
61
25
  end
62
26
 
63
- # A service line about a tool call: HH:MM:SS time prefix, tool name and
64
- # short arguments (e.g. the executed bash command text). One line without
65
- # empty placeholders, to stderr so it does not mix with the agent text
66
- # on stdout. The call moment is remembered — the action duration in the
67
- # completion line is measured from it.
68
- #
69
- # @param name [String] tool name
70
- # @param args [Hash, String, nil] call arguments (pi event)
71
27
  def tool_start(name, args: nil)
72
28
  @action_started_at = @clock.call
73
29
  line = +"#{timestamp} ⚙ #{name}"
74
- summary = summarize_args(name, args)
30
+ summary = ArgSummary.call(name, args)
75
31
  line << ": #{summary}" if summary
76
32
  write_service("#{line}\n")
77
33
  end
78
34
 
79
- # Service lines of the tool result (command/file output): an indented
80
- # block without a time prefix (action data) plus a completion line with
81
- # the HH:MM:SS prefix, name, verdict and duration. Very large output is
82
- # trimmed neatly with a summary note, errors are marked explicitly.
83
- # An empty result yields only the completion line.
84
- #
85
- # @param name [String] tool name
86
- # @param text [String] result text
87
- # @param error [Boolean] whether the execution failed
88
- def tool_result(name, text, error: false)
89
- out = String.new
90
- unless text.nil? || text.empty?
91
- lines = text.lines
92
- kept, truncated = truncate_result(text)
93
- kept.each_with_index do |line, index|
94
- line = line.chomp
95
- next if line.empty? && index == kept.length - 1 # no trailing empty line
96
-
97
- prefix = index.zero? ? (error ? " ✖ Error: " : " ") : " "
98
- out << "#{prefix}#{line}\n"
99
- end
100
- out << result_note(lines) if truncated
101
- end
102
- out << completion_line(name, error)
103
- write_service(out)
35
+ def tool_result(name, error: false)
36
+ write_service(completion_line(name, error))
104
37
  end
105
38
 
106
- # Finishes the output: if the answer did not end with a newline — adds
107
- # one so the next terminal output does not stick to the agent's answer.
108
39
  def finish
109
40
  return unless @last_char && @last_char != "\n"
110
41
 
@@ -114,156 +45,28 @@ module Letsdo
114
45
 
115
46
  private
116
47
 
117
- # Shared time prefix for all action lines: HH:MM:SS.
118
- #
119
- # @return [String] local time as hours:minutes:seconds
120
48
  def timestamp
121
- @clock.call.strftime("%H:%M:%S")
49
+ @clock.call.strftime('%H:%M:%S')
122
50
  end
123
51
 
124
- # Tool completion line: time prefix, verdict, name and action duration
125
- # (seconds between call and completion). The duration is not printed
126
- # when no action start was recorded (e.g. a result without a preceding
127
- # call).
128
- #
129
- # @param name [String] tool name
130
- # @param error [Boolean] whether the execution failed
131
- # @return [String] completion line with a trailing newline
132
52
  def completion_line(name, error)
133
- mark = error ? "" : ""
134
- verdict = error ? "error" : "done"
53
+ mark = error ? '' : ''
54
+ verdict = error ? 'error' : 'done'
135
55
  elapsed = elapsed_seconds
136
- duration = elapsed ? " (#{format_elapsed(elapsed)})" : ""
56
+ duration = elapsed ? " (#{format_elapsed(elapsed)})" : ''
137
57
  "#{timestamp} #{mark} #{name}: #{verdict}#{duration}\n"
138
58
  end
139
59
 
140
- # Seconds from the current action start to its completion.
141
- #
142
- # @return [Float, nil] duration, or nil if there was no start
143
60
  def elapsed_seconds
144
61
  return nil unless @action_started_at
145
62
 
146
63
  @clock.call - @action_started_at
147
64
  end
148
65
 
149
- # A neat duration: seconds with one decimal place up to 10s, whole
150
- # seconds after.
151
66
  def format_elapsed(seconds)
152
- seconds < 10 ? format("%.1fs", seconds) : "#{seconds.round}s"
153
- end
154
-
155
- # Truncates the result text by line and character limits.
156
- #
157
- # @param text [String] the whole result text
158
- # @return [Array(Array<String>, Boolean)] kept lines and truncation flag
159
- def truncate_result(text)
160
- lines = text.lines
161
- kept = []
162
- chars = 0
163
- truncated = false
164
- lines.each do |line|
165
- if kept.length >= MAX_RESULT_LINES || (!kept.empty? && chars + line.length > MAX_RESULT_CHARS)
166
- truncated = true
167
- break
168
- end
169
- kept << line
170
- chars += line.length
171
- end
172
- # A single line longer than the char limit — cut the line itself.
173
- if kept.first && kept.first.length > MAX_RESULT_CHARS
174
- kept[0] = kept[0].slice(0, MAX_RESULT_CHARS)
175
- truncated = true
176
- end
177
- [kept, truncated]
178
- end
179
-
180
- # Summary note about the truncated result.
181
- def result_note(lines)
182
- total_lines = lines.length
183
- total_chars = lines.sum(&:length)
184
- lines_word = plural(total_lines, "line", "lines")
185
- chars_word = plural(total_chars, "character", "characters")
186
- " … [output truncated: #{total_lines} #{lines_word}, #{total_chars} #{chars_word}]\n"
187
- end
188
-
189
- # English singular/plural for a noun after a number.
190
- def plural(count, singular, plural_form)
191
- count == 1 ? singular : plural_form
192
- end
193
-
194
- # Short one-line representation of the tool call arguments.
195
- #
196
- # @param name [String] tool name
197
- # @param args [Hash, String, nil] call arguments
198
- # @return [String, nil] arguments string or nil (print nothing)
199
- def summarize_args(name, args)
200
- case name
201
- when "bash", "powershell"
202
- one_line(args_value(args, "command"))
203
- when "read"
204
- path = args_value(args, "path") || args_value(args, "file_path")
205
- return nil unless path
206
-
207
- range = read_range(args)
208
- "#{path}#{range}"
209
- when "write", "edit", "find", "ls"
210
- args_value(args, "path") || args_value(args, "file_path")
211
- when "grep"
212
- pattern = one_line(args_value(args, "pattern"))
213
- path = args_value(args, "path") || args_value(args, "file_path")
214
- return nil unless pattern
215
-
216
- path ? "#{pattern} #{path}" : pattern
217
- else
218
- summarize_generic(args)
219
- end
67
+ seconds < 10 ? format('%.1fs', seconds) : "#{seconds.round}s"
220
68
  end
221
69
 
222
- # Read line range (offset/limit) as ":N-M", when given.
223
- def read_range(args)
224
- return nil unless args.is_a?(Hash)
225
-
226
- offset = args["offset"]
227
- return nil unless offset
228
-
229
- limit = args["limit"]
230
- limit ? ":#{offset}-#{offset + limit - 1}" : ":#{offset}"
231
- end
232
-
233
- # Tool argument value as a string; nil when absent.
234
- def args_value(args, key)
235
- return nil unless args.is_a?(Hash)
236
-
237
- value = args[key]
238
- value.nil? ? nil : value.to_s
239
- end
240
-
241
- # Universal arguments representation (other tools): short JSON on one line.
242
- def summarize_generic(args)
243
- return nil if args.nil? || (args.is_a?(Hash) && args.empty?)
244
-
245
- text =
246
- case args
247
- when String then args
248
- when Hash then JSON.generate(args)
249
- else args.to_s
250
- end
251
- one_line(text)
252
- end
253
-
254
- # Collapses whitespace into a single line and cuts by the limit.
255
- def one_line(value, max: MAX_ARGS_CHARS)
256
- return nil if value.nil?
257
-
258
- text = value.to_s.gsub(/\s+/, " ").strip
259
- return nil if text.empty?
260
-
261
- text.length > max ? "#{text.slice(0, max)}…" : text
262
- end
263
-
264
- # Writes a service line to stderr and flushes immediately so output
265
- # appears as it is produced; in TUI mode the line goes to the shared
266
- # log buffer instead.
267
70
  def write_service(text)
268
71
  if @log
269
72
  @log.write(text)
@@ -273,10 +76,10 @@ module Letsdo
273
76
  end
274
77
  end
275
78
 
276
- # Where the agent's answer text goes: the shared log in TUI mode,
277
- # stdout otherwise.
278
79
  def text_sink
279
80
  @log || @stdout
280
81
  end
281
82
  end
282
- end
83
+ end
84
+
85
+ require_relative 'output_streamer/arg_summary'
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Pi JSON event-stream handlers used by PiRunner.
5
+ module PiRunnerEvents
6
+ private
7
+
8
+ def read_pi_stream(out_r)
9
+ out_r.each_line { |line| handle_line(line) }
10
+ debug('stream: EOF')
11
+ end
12
+
13
+ def handle_line(line)
14
+ line = line.strip
15
+ return if line.empty?
16
+
17
+ event = parse_event(line)
18
+ dispatch_event(event) if event
19
+ end
20
+
21
+ def dispatch_event(event)
22
+ case event['type']
23
+ when 'message_update' then handle_message_update(event)
24
+ when 'tool_execution_start' then handle_tool_start(event)
25
+ when 'tool_execution_end' then handle_tool_execution_end(event)
26
+ when 'agent_end' then flush_pending_tools
27
+ end
28
+ end
29
+
30
+ def handle_tool_start(event)
31
+ @pending_tools.delete(event['toolCallId'])
32
+ @streamer.tool_start(event['toolName'] || 'tool', args: event['args'])
33
+ end
34
+
35
+ def handle_message_update(event)
36
+ payload = event['assistantMessageEvent']
37
+ return unless payload
38
+
39
+ handle_text_delta(payload) || remember_toolcall(event, payload)
40
+ end
41
+
42
+ def handle_text_delta(payload)
43
+ return unless payload['type'] == 'text_delta'
44
+
45
+ delta = payload['delta']
46
+ @streamer.text_delta(delta) if delta && !delta.empty?
47
+ true
48
+ end
49
+
50
+ def remember_toolcall(event, payload)
51
+ return unless payload['type'] == 'toolcall_start'
52
+
53
+ id = event['id'] || payload['id']
54
+ name = event['toolName'] || payload['toolName'] || 'tool'
55
+ @pending_tools[id] = name unless id.nil?
56
+ end
57
+
58
+ def handle_tool_execution_end(event)
59
+ name = event['toolName'] || 'tool'
60
+ error = event['isError'] == true
61
+ @streamer.tool_result(name, error: error)
62
+ end
63
+
64
+ def flush_pending_tools
65
+ @pending_tools.each_value { |name| @streamer.tool_start(name) }
66
+ @pending_tools.clear
67
+ end
68
+
69
+ def parse_event(line)
70
+ JSON.parse(line)
71
+ rescue JSON::ParserError
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Process-group control for a running pi child.
5
+ module PiRunnerProcess
6
+ def terminate(signal: 'TERM', grace: 3.0, tick: 0.05)
7
+ pid = @pid
8
+ return true unless pid
9
+
10
+ send_signal('CONT', pid)
11
+ send_signal(signal, pid)
12
+ wait_for_exit(pid, grace, tick)
13
+ end
14
+
15
+ private
16
+
17
+ def wait_for_exit(pid, grace, tick)
18
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
19
+ loop do
20
+ status = wait_status(pid, nonblock: true)
21
+ return status if status
22
+ break unless alive?(pid)
23
+ return kill_and_reap(pid, tick) if overdue_deadline?(deadline)
24
+
25
+ sleep(tick)
26
+ end
27
+ true
28
+ end
29
+
30
+ def overdue_deadline?(deadline)
31
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
+ end
33
+
34
+ def kill_and_reap(pid, tick)
35
+ send_signal('KILL', pid)
36
+ sleep(tick)
37
+ wait_status(pid, nonblock: true) || true
38
+ end
39
+
40
+ def wait_status(pid, nonblock: false)
41
+ _, status = Process.wait2(pid, nonblock ? Process::WNOHANG : 0)
42
+ @reaped_status = status if status
43
+ status
44
+ rescue Errno::ECHILD
45
+ @reaped_status
46
+ end
47
+
48
+ def send_signal(signal, pid)
49
+ Process.kill(signal, -pid)
50
+ rescue Errno::ESRCH, Errno::EPERM
51
+ nil
52
+ end
53
+
54
+ def alive?(pid)
55
+ Process.kill(0, pid)
56
+ true
57
+ rescue Errno::ESRCH, Errno::EPERM
58
+ false
59
+ end
60
+
61
+ def exit_code(status)
62
+ return 1 if status.nil?
63
+ return status.exitstatus if status.exitstatus
64
+
65
+ status.termsig ? 128 + status.termsig : 1
66
+ end
67
+ end
68
+ end