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.
- checksums.yaml +4 -4
- data/README.md +4 -4
- data/bin/letsdo +20 -12
- data/lib/letsdo/agent.rb +6 -4
- data/lib/letsdo/agent_loop/tasks.rb +62 -0
- data/lib/letsdo/agent_loop.rb +41 -126
- data/lib/letsdo/backlog_tasks.rb +10 -11
- data/lib/letsdo/capture.rb +89 -0
- data/lib/letsdo/cli/init.rb +33 -0
- data/lib/letsdo/cli/launch.rb +82 -0
- data/lib/letsdo/cli.rb +64 -124
- data/lib/letsdo/control.rb +39 -0
- data/lib/letsdo/default_prompt.rb +69 -0
- data/lib/letsdo/errors.rb +2 -12
- data/lib/letsdo/loop.rb +31 -15
- data/lib/letsdo/output_streamer/arg_summary.rb +81 -0
- data/lib/letsdo/output_streamer.rb +13 -210
- data/lib/letsdo/pi_runner/events.rb +75 -0
- data/lib/letsdo/pi_runner/process.rb +68 -0
- data/lib/letsdo/pi_runner.rb +62 -221
- data/lib/letsdo/prompt_store.rb +47 -8
- data/lib/letsdo/tui/input.rb +4 -4
- data/lib/letsdo/tui/log_buffer.rb +3 -3
- data/lib/letsdo/tui/metrics.rb +1 -1
- data/lib/letsdo/tui/renderer.rb +103 -80
- data/lib/letsdo/tui/session/keys.rb +121 -0
- data/lib/letsdo/tui/session/view.rb +89 -0
- data/lib/letsdo/tui/session.rb +40 -183
- data/lib/letsdo/tui/terminal.rb +7 -4
- data/lib/letsdo/tui.rb +6 -6
- data/lib/letsdo/version.rb +2 -2
- data/lib/letsdo.rb +19 -13
- metadata +31 -1
|
@@ -1,45 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Letsdo
|
|
6
|
-
# Routes pi output
|
|
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 =
|
|
30
|
+
summary = ArgSummary.call(name, args)
|
|
75
31
|
line << ": #{summary}" if summary
|
|
76
32
|
write_service("#{line}\n")
|
|
77
33
|
end
|
|
78
34
|
|
|
79
|
-
|
|
80
|
-
|
|
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(
|
|
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 ?
|
|
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(
|
|
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
|