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
data/lib/letsdo/cli.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'shellwords'
|
|
4
|
+
require_relative 'cli/launch'
|
|
5
|
+
require_relative 'cli/init'
|
|
4
6
|
|
|
5
7
|
module Letsdo
|
|
6
8
|
# Command-line argument parsing and running an agent orchestrator loop.
|
|
@@ -10,46 +12,26 @@ module Letsdo
|
|
|
10
12
|
# letsdo --version — version, exit code 0;
|
|
11
13
|
# letsdo --help — usage, exit code 0;
|
|
12
14
|
# letsdo <name> — run the <name> agent in the orchestrator
|
|
13
|
-
# loop until SIGINT/SIGTERM
|
|
14
|
-
#
|
|
15
|
-
# one
|
|
16
|
-
#
|
|
17
|
-
# letsdo <
|
|
18
|
-
#
|
|
19
|
-
# letsdo <
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# Environment:
|
|
23
|
-
# LETSDO_ROOT project root (agents/ lives there); default — pwd.
|
|
24
|
-
# LETSDO_PI_FLAGS extra pi flags (split on whitespace; if unset —
|
|
25
|
-
# AGENT_PI_FLAGS is used for bin/agent
|
|
26
|
-
# compatibility).
|
|
27
|
-
# LETSDO_PI_COMMAND the pi command (default "pi"); overridable for
|
|
28
|
-
# tests/fake pi.
|
|
29
|
-
# AGENT_ASSIGNEE_HANDLE the agent's backlog assignee handle; default
|
|
30
|
-
# "@<name>" (the one rule: handle = name).
|
|
31
|
-
# LETSDO_WAIT_SECONDS retry interval when no tasks are open (if
|
|
32
|
-
# unset — AGENT_WAIT_SECONDS, default 10).
|
|
33
|
-
# LETSDO_BACKLOG_COMMAND the backlog CLI command (default "backlog");
|
|
34
|
-
# overridable for tests/fake backlog.
|
|
15
|
+
# loop until SIGINT/SIGTERM;
|
|
16
|
+
# letsdo <name> (no prompt) — same, but on the built-in default prompt;
|
|
17
|
+
# one-time notification on stderr
|
|
18
|
+
# (path checked + 'letsdo <name> --init' hint);
|
|
19
|
+
# letsdo <name> --init — create agents/<name>.md with the starter
|
|
20
|
+
# default prompt, never runs the agent;
|
|
21
|
+
# letsdo --init <name> — same, flag-first form;
|
|
22
|
+
# letsdo <unknown option> — "letsdo: unknown option: X" + usage, exit 1.
|
|
35
23
|
class CLI
|
|
24
|
+
include CLILaunch
|
|
25
|
+
include CLIInit
|
|
26
|
+
|
|
36
27
|
USAGE = 'Usage: letsdo <agent_name>'
|
|
37
28
|
AGENTS_HEADER = 'Available agents:'
|
|
38
29
|
DEFAULT_WAIT_SECONDS = 10.0
|
|
39
30
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# @param stdout [IO] stream for normal output (usage, --help, list)
|
|
45
|
-
# @param stderr [IO] stream for service output
|
|
46
|
-
# @param stdin [IO] keyboard stream (the TUI reads keys from it)
|
|
47
|
-
# @param sleeper [Proc, nil] waiting procedure for the loop (callable
|
|
48
|
-
# with the interval); injected in tests for deterministic stops
|
|
49
|
-
# @return [Integer] exit code: 0 — success (incl. loop stop), 1 — error,
|
|
50
|
-
# otherwise — pi exit code
|
|
51
|
-
def self.run(argv, env: ENV, stdout: $stdout, stderr: $stderr, stdin: $stdin, sleeper: nil)
|
|
52
|
-
new(env: env, stdout: stdout, stderr: stderr, stdin: stdin, sleeper: sleeper).run(argv)
|
|
31
|
+
def self.run(argv, **opts)
|
|
32
|
+
new(env: opts.fetch(:env, ENV), stdout: opts.fetch(:stdout, $stdout),
|
|
33
|
+
stderr: opts.fetch(:stderr, $stderr), stdin: opts.fetch(:stdin, $stdin),
|
|
34
|
+
sleeper: opts[:sleeper]).run(argv)
|
|
53
35
|
end
|
|
54
36
|
|
|
55
37
|
def initialize(env:, stdout:, stderr:, stdin: $stdin, sleeper: nil)
|
|
@@ -61,103 +43,66 @@ module Letsdo
|
|
|
61
43
|
@root = env.fetch('LETSDO_ROOT', Dir.pwd)
|
|
62
44
|
end
|
|
63
45
|
|
|
64
|
-
# @param argv [Array<String>] command-line arguments
|
|
65
|
-
# @return [Integer] exit code
|
|
66
46
|
def run(argv)
|
|
67
47
|
arg = argv[0]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
when nil
|
|
76
|
-
print_usage(@stderr)
|
|
77
|
-
1
|
|
78
|
-
else
|
|
79
|
-
if arg.start_with?('-')
|
|
80
|
-
@stderr.puts("letsdo: unknown option: #{arg}")
|
|
81
|
-
print_usage(@stderr)
|
|
82
|
-
1
|
|
83
|
-
else
|
|
84
|
-
run_agent(arg)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
48
|
+
return print_version if version_flag?(arg)
|
|
49
|
+
return print_help if help_flag?(arg)
|
|
50
|
+
return usage_error if arg.nil?
|
|
51
|
+
return init_command(argv) if argv.include?('--init')
|
|
52
|
+
return unknown_option(arg) if arg.start_with?('-')
|
|
53
|
+
|
|
54
|
+
run_agent(arg)
|
|
87
55
|
end
|
|
88
56
|
|
|
89
57
|
private
|
|
90
58
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
59
|
+
def version_flag?(arg)
|
|
60
|
+
['--version', '-v'].include?(arg)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def help_flag?(arg)
|
|
64
|
+
['--help', '-h'].include?(arg)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def print_version
|
|
68
|
+
@stdout.puts(VERSION)
|
|
69
|
+
0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def print_help
|
|
73
|
+
print_usage(@stdout)
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def usage_error
|
|
78
|
+
print_usage(@stderr)
|
|
79
|
+
1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unknown_option(arg)
|
|
83
|
+
@stderr.puts("letsdo: unknown option: #{arg}")
|
|
84
|
+
print_usage(@stderr)
|
|
104
85
|
1
|
|
105
86
|
end
|
|
106
87
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# The interactive path: full-screen TUI over the same orchestrator loop.
|
|
123
|
-
# The streamer and the loop's service messages land in one combined log
|
|
124
|
-
# buffer; the loop driver feeds the header metrics facade; the session
|
|
125
|
-
# controller renders everything from a background input thread and
|
|
126
|
-
# restores the terminal on every exit path. The loop itself runs on the
|
|
127
|
-
# calling thread exactly as in plain mode.
|
|
128
|
-
def run_agent_tui(name)
|
|
129
|
-
clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
130
|
-
handle = assignee_handle(name)
|
|
131
|
-
log = Tui::LogBuffer.new
|
|
132
|
-
metrics = Tui::Metrics.new(name: name, handle: handle, clock: clock,
|
|
133
|
-
on_run_start: ->(_label) { log.divider })
|
|
134
|
-
terminal = Tui::Terminal.new(stream: @stdout)
|
|
135
|
-
input = Tui::Input.new(stdin: @stdin)
|
|
136
|
-
streamer = OutputStreamer.new(log: log)
|
|
137
|
-
agent = Agent.new(name: name, root: @root, flags: parse_pi_flags, streamer: streamer,
|
|
138
|
-
command: pi_command)
|
|
139
|
-
provider = BacklogTasks.new(handle: handle, command: backlog_command, cwd: @root,
|
|
140
|
-
env: ENV.to_h.merge(@env))
|
|
141
|
-
loop = AgentLoop.new(name: name, handle: handle, agent: agent,
|
|
142
|
-
task_provider: -> { provider.call },
|
|
143
|
-
wait_seconds: wait_seconds, sleeper: @sleeper, stderr: log,
|
|
144
|
-
metrics: metrics)
|
|
145
|
-
session = Tui::Session.new(name: name, handle: handle, log: log, metrics: metrics,
|
|
146
|
-
terminal: terminal, input: input,
|
|
147
|
-
refresh: -> { provider.call },
|
|
148
|
-
wait_seconds: wait_seconds, clock: clock)
|
|
149
|
-
session.run { loop.run }
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# The TUI is engaged only when stdout and stdin are terminals and TERM
|
|
153
|
-
# is not dumb; otherwise the plain line-stream output (pipes, CI,
|
|
154
|
-
# tests) — no escape codes, no TUI.
|
|
88
|
+
def run_agent(name)
|
|
89
|
+
store = PromptStore.new(root: @root)
|
|
90
|
+
announce_default_prompt(name, store) if store.read(name).nil?
|
|
91
|
+
tui? ? run_agent_tui(name) : run_agent_plain(name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One-time fallback notification (before the first loop message): names
|
|
95
|
+
# the exact path checked and the placement hint. The agent still starts
|
|
96
|
+
# — Letsdo::Agent falls back to the built-in default prompt itself.
|
|
97
|
+
def announce_default_prompt(name, store)
|
|
98
|
+
@stderr.puts("letsdo: no prompt for #{name} at #{store.agent_path(name)}")
|
|
99
|
+
@stderr.puts("letsdo: using the built-in default prompt (create a prompt file with 'letsdo #{name} --init')")
|
|
100
|
+
end
|
|
101
|
+
|
|
155
102
|
def tui?
|
|
156
103
|
@stdout.tty? && @stdin.tty? && @env['TERM'].to_s != 'dumb'
|
|
157
104
|
end
|
|
158
105
|
|
|
159
|
-
# The agent's assignee handle: AGENT_ASSIGNEE_HANDLE override, otherwise
|
|
160
|
-
# the one rule — '@' + agent name.
|
|
161
106
|
def assignee_handle(name)
|
|
162
107
|
env_handle = @env['AGENT_ASSIGNEE_HANDLE']
|
|
163
108
|
env_handle && !env_handle.strip.empty? ? env_handle : "@#{name}"
|
|
@@ -167,8 +112,6 @@ module Letsdo
|
|
|
167
112
|
@env.fetch('LETSDO_BACKLOG_COMMAND', 'backlog')
|
|
168
113
|
end
|
|
169
114
|
|
|
170
|
-
# The retry interval when no tasks are open: LETSDO_WAIT_SECONDS, then
|
|
171
|
-
# AGENT_WAIT_SECONDS (bin/agent-loop compatibility), default 10 seconds.
|
|
172
115
|
def wait_seconds
|
|
173
116
|
value = @env['LETSDO_WAIT_SECONDS'].to_s.strip
|
|
174
117
|
value = @env['AGENT_WAIT_SECONDS'].to_s.strip if value.empty?
|
|
@@ -193,9 +136,6 @@ module Letsdo
|
|
|
193
136
|
PromptStore.new(root: @root).list.each { |name| @stdout.puts(" #{name}") }
|
|
194
137
|
end
|
|
195
138
|
|
|
196
|
-
# LETSDO_PI_FLAGS → array of flags; empty value = no flags.
|
|
197
|
-
# For bin/agent compatibility, when LETSDO_PI_FLAGS is absent
|
|
198
|
-
# AGENT_PI_FLAGS is used.
|
|
199
139
|
def parse_pi_flags
|
|
200
140
|
value = @env['LETSDO_PI_FLAGS'].to_s
|
|
201
141
|
value = @env['AGENT_PI_FLAGS'].to_s if value.strip.empty?
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Agent-control primitives shared by the TUI and the orchestrator loop
|
|
5
|
+
# (TASK-67 control model): pausing between runs.
|
|
6
|
+
module Control
|
|
7
|
+
# A thread-safe pause flag polled by Letsdo::AgentLoop between runs.
|
|
8
|
+
#
|
|
9
|
+
# Mid-run suspension is handled by Letsdo::PiRunner#pause (SIGSTOP to
|
|
10
|
+
# the pi group, kernel-level freeze). Between runs there is no pi to
|
|
11
|
+
# stop, so the pause lives in this gate: while #paused? is true the
|
|
12
|
+
# loop must not start a new run, and it waits until #resume.
|
|
13
|
+
#
|
|
14
|
+
# The flag is toggled from the TUI input thread ('p' key) and polled
|
|
15
|
+
# from the loop's main thread (AgentLoop#wrapped_run), so every access
|
|
16
|
+
# is mutex-guarded.
|
|
17
|
+
class PauseGate
|
|
18
|
+
def initialize
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@paused = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sets the paused state (a no-op when already paused).
|
|
24
|
+
def pause
|
|
25
|
+
@mutex.synchronize { @paused = true }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clears the paused state (a no-op when not paused).
|
|
29
|
+
def resume
|
|
30
|
+
@mutex.synchronize { @paused = false }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether the gate is paused
|
|
34
|
+
def paused?
|
|
35
|
+
@mutex.synchronize { @paused }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# The built-in default prompt (canonical text from the TASK-41 spike,
|
|
5
|
+
# comment "BUILT-IN DEFAULT PROMPT").
|
|
6
|
+
#
|
|
7
|
+
# Process-only: no role, project, or language specifics; one task per run
|
|
8
|
+
# (the orchestrator loop supplies one task per agent run — the prompt
|
|
9
|
+
# never picks multiple). Used as:
|
|
10
|
+
# 1. the fallback prompt when agents/<name>.md does not exist — every
|
|
11
|
+
# agent name is a valid worker, the file is an optimization (custom
|
|
12
|
+
# instructions), not a precondition;
|
|
13
|
+
# 2. the template that `letsdo <name> --init` writes to agents/<name>.md
|
|
14
|
+
# (TASK-44) — a single source of truth so an initialized file always
|
|
15
|
+
# matches what a fallback run uses.
|
|
16
|
+
module DefaultPrompt
|
|
17
|
+
# The canonical default prompt text.
|
|
18
|
+
TEXT = <<~'PROMPT'
|
|
19
|
+
# Task agent
|
|
20
|
+
|
|
21
|
+
You are an autonomous task agent. You pick up the tasks assigned to you and
|
|
22
|
+
execute them one at a time following the task-work process below. You do not
|
|
23
|
+
implement features or designs on your own initiative: your work is defined
|
|
24
|
+
by the assigned tasks, one task per run.
|
|
25
|
+
|
|
26
|
+
## Main rule: exactly one task per run
|
|
27
|
+
|
|
28
|
+
In a single run you pick up and complete exactly one task assigned to you,
|
|
29
|
+
then stop. The next task is started only in the next run of the orchestrator
|
|
30
|
+
loop.
|
|
31
|
+
|
|
32
|
+
If there are no tasks assigned to you — do not invent work and do not create
|
|
33
|
+
tasks yourself. End the run with a message that there are no tasks.
|
|
34
|
+
|
|
35
|
+
## Choosing a task
|
|
36
|
+
|
|
37
|
+
Take the highest priority task assigned to you in order:
|
|
38
|
+
|
|
39
|
+
0. if a task is already in progress
|
|
40
|
+
1. priority
|
|
41
|
+
2. order (ordinal), if priorities are equal
|
|
42
|
+
|
|
43
|
+
If the chosen task is currently blocked, take the task that blocks it into
|
|
44
|
+
work, using the same selection algorithm: first the highest priority, then
|
|
45
|
+
in order.
|
|
46
|
+
|
|
47
|
+
## Task-work process
|
|
48
|
+
|
|
49
|
+
Execute the task according to the established protocol:
|
|
50
|
+
|
|
51
|
+
1. **Start**: read the task instructions, check its status and Acceptance
|
|
52
|
+
Criteria, move it to an active status and assign it to yourself.
|
|
53
|
+
2. **Plan**: study the current state of the system, draft an implementation
|
|
54
|
+
plan and record it in the task.
|
|
55
|
+
3. **Work**: do the work in short iterations, checking intermediate results
|
|
56
|
+
and recording progress in the task as you go.
|
|
57
|
+
4. **Completion**: verify each Acceptance Criterion with objective evidence,
|
|
58
|
+
mark the completed items, write a final summary and move the task to the
|
|
59
|
+
terminal status.
|
|
60
|
+
5. Commit your changes, including any project bookkeeping affected by the
|
|
61
|
+
task.
|
|
62
|
+
|
|
63
|
+
## Prohibitions
|
|
64
|
+
|
|
65
|
+
- Do not take work that is not assigned to you.
|
|
66
|
+
- Do not complete several tasks in one run.
|
|
67
|
+
PROMPT
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/letsdo/errors.rb
CHANGED
|
@@ -4,19 +4,9 @@ module Letsdo
|
|
|
4
4
|
# Base error of the package.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
-
# An agent with this name was not found in agents/.
|
|
8
|
-
class UnknownAgentError < Error
|
|
9
|
-
attr_reader :name
|
|
10
|
-
|
|
11
|
-
def initialize(name)
|
|
12
|
-
@name = name
|
|
13
|
-
super("Unknown agent: #{name}")
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
7
|
# Raised by the signal handler to interrupt whatever the main thread is
|
|
18
8
|
# doing (reading pi output, waiting for tasks, ...) so the loop unwinds
|
|
19
9
|
# cleanly. Not a StandardError — nothing rescues it accidentally.
|
|
20
|
-
class Stopped <
|
|
10
|
+
class Stopped < StandardError
|
|
21
11
|
end
|
|
22
|
-
end
|
|
12
|
+
end
|
data/lib/letsdo/loop.rb
CHANGED
|
@@ -38,21 +38,37 @@ module Letsdo
|
|
|
38
38
|
# @return [Integer] number of completed agent runs
|
|
39
39
|
def run
|
|
40
40
|
runs = 0
|
|
41
|
-
until @stopped
|
|
42
|
-
tasks = @task_provider.call
|
|
43
|
-
if tasks.nil? || tasks.empty?
|
|
44
|
-
@sleeper.call(@wait_seconds)
|
|
45
|
-
next
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
tasks.each do |task|
|
|
49
|
-
break if @stopped
|
|
50
|
-
|
|
51
|
-
@run_task.call(task)
|
|
52
|
-
runs += 1
|
|
53
|
-
end
|
|
54
|
-
end
|
|
41
|
+
runs += process_tasks_batch(@task_provider.call) until @stopped
|
|
55
42
|
runs
|
|
56
43
|
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# One provider batch: when there are no tasks the loop waits and
|
|
48
|
+
# retries; otherwise it runs the agent once per task.
|
|
49
|
+
#
|
|
50
|
+
# @return [Integer] number of agent runs completed in this batch
|
|
51
|
+
def process_tasks_batch(tasks)
|
|
52
|
+
return wait_for_tasks if tasks.nil? || tasks.empty?
|
|
53
|
+
|
|
54
|
+
run_batch(tasks)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wait_for_tasks
|
|
58
|
+
@sleeper.call(@wait_seconds)
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs the agent once per task, stopping early if the loop is stopped.
|
|
63
|
+
def run_batch(tasks)
|
|
64
|
+
batch_runs = 0
|
|
65
|
+
tasks.each do |task|
|
|
66
|
+
break if @stopped
|
|
67
|
+
|
|
68
|
+
@run_task.call(task)
|
|
69
|
+
batch_runs += 1
|
|
70
|
+
end
|
|
71
|
+
batch_runs
|
|
72
|
+
end
|
|
57
73
|
end
|
|
58
|
-
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
class OutputStreamer
|
|
7
|
+
# Short one-line representations of tool-call arguments.
|
|
8
|
+
class ArgSummary
|
|
9
|
+
MAX_ARGS_CHARS = OutputStreamer::MAX_ARGS_CHARS
|
|
10
|
+
|
|
11
|
+
def self.call(name, args)
|
|
12
|
+
new(name, args).to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name, args)
|
|
16
|
+
@name = name
|
|
17
|
+
@args = args
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
case @name
|
|
22
|
+
when 'bash', 'powershell' then one_line(value('command'))
|
|
23
|
+
when 'read' then read_summary
|
|
24
|
+
when 'write', 'edit', 'find', 'ls' then value('path') || value('file_path')
|
|
25
|
+
when 'grep' then grep_summary
|
|
26
|
+
else generic
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def read_summary
|
|
31
|
+
path = value('path') || value('file_path')
|
|
32
|
+
path && "#{path}#{read_range}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def grep_summary
|
|
36
|
+
pattern = one_line(value('pattern'))
|
|
37
|
+
return nil unless pattern
|
|
38
|
+
|
|
39
|
+
path = value('path') || value('file_path')
|
|
40
|
+
path ? "#{pattern} #{path}" : pattern
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generic
|
|
44
|
+
return nil if @args.nil? || (@args.is_a?(Hash) && @args.empty?)
|
|
45
|
+
|
|
46
|
+
one_line(generic_text)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def generic_text
|
|
50
|
+
case @args
|
|
51
|
+
when String then @args
|
|
52
|
+
when Hash then JSON.generate(@args)
|
|
53
|
+
else @args.to_s
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_range
|
|
58
|
+
return nil unless @args.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
offset = @args['offset']
|
|
61
|
+
return nil unless offset
|
|
62
|
+
|
|
63
|
+
limit = @args['limit']
|
|
64
|
+
limit ? ":#{offset}-#{offset + limit - 1}" : ":#{offset}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def value(key)
|
|
68
|
+
@args.is_a?(Hash) ? @args[key]&.to_s : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def one_line(raw, max: MAX_ARGS_CHARS)
|
|
72
|
+
return nil if raw.nil?
|
|
73
|
+
|
|
74
|
+
text = raw.to_s.gsub(/\s+/, ' ').strip
|
|
75
|
+
return nil if text.empty?
|
|
76
|
+
|
|
77
|
+
text.length > max ? "#{text.slice(0, max)}…" : text
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|