sift-cli 1.0.0__py3-none-any.whl
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.
- sift/__init__.py +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
sift/server.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""The MCP server: the same three answers, handed to a model instead of a person.
|
|
2
|
+
|
|
3
|
+
This is where the tool actually pays. At a terminal a shortened view saves
|
|
4
|
+
someone some scrolling. Over MCP it saves the caller's context window, and a
|
|
5
|
+
tool result is re-sent with every turn that follows it -- so a 5,000-line build
|
|
6
|
+
log kept out of the transcript is not paid for once and forgotten, it goes on
|
|
7
|
+
not being paid for the rest of the conversation.
|
|
8
|
+
|
|
9
|
+
Nothing here decides anything. Every tool calls the function the command line
|
|
10
|
+
calls, through the same ladder in `view.py`, so there is no second answer that
|
|
11
|
+
could be wrong on its own. What this module owns is the tool contract -- the
|
|
12
|
+
names, the parameters and the descriptions, which are the only documentation the
|
|
13
|
+
calling model ever reads -- and one thing the command line never has to think
|
|
14
|
+
about:
|
|
15
|
+
|
|
16
|
+
The footer has to move. `sift run` writes it to stderr, where the person sees
|
|
17
|
+
it. A client sees no stderr. Dropped, the caller cannot tell a view that a model
|
|
18
|
+
chose from the ends of a file shown because no model answered -- and would
|
|
19
|
+
believe the first when it was the second. So it travels inside the same string.
|
|
20
|
+
An error travels the same way, as text rather than as a raised exception: a tool
|
|
21
|
+
that raises hands the model nothing, and the third rule does not stop being true
|
|
22
|
+
because the reader is a machine.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from mcp.server.mcpserver import MCPServer
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
# A sentence, not a stack. `pip install sift-cli` brings nothing with it on
|
|
33
|
+
# purpose -- somebody who wants the command line should not be made to carry
|
|
34
|
+
# a protocol library for it -- so arriving here is an ordinary thing to do
|
|
35
|
+
# wrong, and the client that started this shows whatever comes out of it.
|
|
36
|
+
raise SystemExit(
|
|
37
|
+
"sift-mcp: the server half needs one more package.\n"
|
|
38
|
+
"\n"
|
|
39
|
+
' pip install "sift-cli[mcp]"\n'
|
|
40
|
+
"\n"
|
|
41
|
+
"The command line (`sift`) is already installed and works without it."
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
from sift import __version__, store
|
|
46
|
+
from sift.background import launch, seen, unread, wait_for
|
|
47
|
+
from sift.background import stop as stop_run
|
|
48
|
+
from sift.capture import run as run_command
|
|
49
|
+
from sift.distill import BUDGET
|
|
50
|
+
from sift.model import find_key, sending_on
|
|
51
|
+
from sift.peek import peek as peek_at
|
|
52
|
+
from sift.tools import BY_NAME, KNOWN, command_for
|
|
53
|
+
from sift.view import (
|
|
54
|
+
best_digest,
|
|
55
|
+
best_digests,
|
|
56
|
+
best_follow,
|
|
57
|
+
best_outline,
|
|
58
|
+
best_view,
|
|
59
|
+
follow_footer,
|
|
60
|
+
footer,
|
|
61
|
+
outline_footer,
|
|
62
|
+
peek_footer,
|
|
63
|
+
state,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# What a tool answers with when nobody has finished setting this up.
|
|
67
|
+
#
|
|
68
|
+
# The third rule says nothing here may break the caller's command, and that is
|
|
69
|
+
# why this is a sentence rather than an error, and why it says plainly that the
|
|
70
|
+
# command was not run. The caller has a shell of its own; the worst outcome is
|
|
71
|
+
# not "sift declined", it is "sift declined and the caller did not notice".
|
|
72
|
+
#
|
|
73
|
+
# Declining is not the same as failing open here, and the difference is which
|
|
74
|
+
# caller is reading. A person at a terminal can see a view built out of the ends
|
|
75
|
+
# of a file and judge it for what it is. A model cannot: it is handed a short
|
|
76
|
+
# text with a footer it has no reason to distrust, and a quietly worse answer is
|
|
77
|
+
# the one thing this project will not hand a reader who cannot check it.
|
|
78
|
+
NO_KEY = """\
|
|
79
|
+
sift is not set up on this machine, so it did nothing and ran nothing.
|
|
80
|
+
|
|
81
|
+
Run the command with your own shell tool instead. Nothing is in the way.
|
|
82
|
+
|
|
83
|
+
sift asks a free NVIDIA model which lines of an output matter. That needs a key,
|
|
84
|
+
and it has to be yours -- one is not shipped and one cannot be shared. Put it in
|
|
85
|
+
any of these and restart this server:
|
|
86
|
+
|
|
87
|
+
SIFT_API_KEY=... (environment)
|
|
88
|
+
NVIDIA_API_KEY=... (environment)
|
|
89
|
+
~/.config/nvidia/api_key (a file with the key in it)
|
|
90
|
+
|
|
91
|
+
A key is free at https://build.nvidia.com
|
|
92
|
+
|
|
93
|
+
If you meant to run without a model, set SIFT_NO_MODEL=1 and sift will work
|
|
94
|
+
without asking anything -- deterministically, and less well.\
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _unset() -> bool:
|
|
99
|
+
"""Whether this is a sift nobody finished setting up.
|
|
100
|
+
|
|
101
|
+
Deliberately not the same question as "can a model be reached". Three states
|
|
102
|
+
are worth telling apart and only one of them is this:
|
|
103
|
+
|
|
104
|
+
* `SIFT_NO_MODEL=1` -- switched off on purpose. That is a decision, it is
|
|
105
|
+
respected, and warning about it would be nagging somebody about a thing
|
|
106
|
+
they typed.
|
|
107
|
+
* no key at all -- nobody finished installing this. Nothing works as
|
|
108
|
+
advertised and saying so is the only useful thing to do.
|
|
109
|
+
* a key that the endpoint would not take, or an endpoint that is down --
|
|
110
|
+
the third rule's territory, and it falls back to the ends of the output.
|
|
111
|
+
"""
|
|
112
|
+
return sending_on() and find_key() is None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
INSTRUCTIONS = """\
|
|
116
|
+
Use `run` in place of a plain shell tool whenever a command may print more than
|
|
117
|
+
a few dozen lines: test suites, builds, installers, log tails, recursive greps.
|
|
118
|
+
It runs the command, keeps every byte on disk, and returns only the lines that
|
|
119
|
+
mattered, plus a handle for everything it left out.
|
|
120
|
+
|
|
121
|
+
Nothing in a result was written by a model. A judge is only ever asked which
|
|
122
|
+
line numbers matter; the text is printed from the local file byte for byte, so a
|
|
123
|
+
line you are shown is a line that was there. Every gap says how many lines stand
|
|
124
|
+
in it, and `peek` brings any of them back unchanged.
|
|
125
|
+
|
|
126
|
+
For a command with no natural end -- a dev server, a log tail, a build you want
|
|
127
|
+
to keep working during -- call `run` with `background` set. It returns a handle
|
|
128
|
+
straight away, and `follow` returns only what the command has printed since the
|
|
129
|
+
last time you asked, with the line numbers it has in the whole run. Nothing is
|
|
130
|
+
shown to you twice. When you are finished with one, call `follow` with `stop`
|
|
131
|
+
set: it ends the command, its children with it, and hands you the last of the
|
|
132
|
+
output.
|
|
133
|
+
|
|
134
|
+
`outline` is that same question asked about a file instead of a command: what
|
|
135
|
+
does this file declare, without its bodies. It holds no list of languages and
|
|
136
|
+
never looks at the suffix, so it can be asked about anything.
|
|
137
|
+
|
|
138
|
+
`digest` is the third question, and the one to reach for with a file you did not
|
|
139
|
+
produce: a log, a saved CI transcript, a crash dump, a long export. It asks what
|
|
140
|
+
happened in the file rather than what the file declares. Never read one of those
|
|
141
|
+
with a plain file-reading tool -- the whole thing lands in the conversation and
|
|
142
|
+
stays there. `digest_many` is the same for several files at once, and it is
|
|
143
|
+
worth reaching for whenever there is more than one: the waiting happens in
|
|
144
|
+
parallel and the answer is one tool result instead of four.
|
|
145
|
+
|
|
146
|
+
`tool` runs one of three dense programs -- `sg` (ast-grep), `diff`
|
|
147
|
+
(difftastic), `loc` (scc) -- and distils what it printed. Each of them answers a
|
|
148
|
+
question without opening a file, which is the cheapest way to answer anything:
|
|
149
|
+
reach for `sg` rather than reading candidate files to find where a shape occurs.
|
|
150
|
+
|
|
151
|
+
Two habits make all of this cheaper. Ask about several things in one call rather
|
|
152
|
+
than several calls -- `digest_many`, and `follow` with `everything` -- because
|
|
153
|
+
every tool result is re-sent on every later turn, so four answers cost four times
|
|
154
|
+
as much as one for the rest of the conversation. And when you are waiting on a
|
|
155
|
+
background command, pass `wait` rather than asking again in a moment: an answer
|
|
156
|
+
that says nothing happened costs the same as one that says something did.
|
|
157
|
+
|
|
158
|
+
The last line of every result says what you are looking at -- including whether
|
|
159
|
+
a model chose the lines or none could be reached.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
server = MCPServer(
|
|
163
|
+
name="sift",
|
|
164
|
+
version=__version__,
|
|
165
|
+
instructions=INSTRUCTIONS,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@server.tool(
|
|
170
|
+
name="run",
|
|
171
|
+
description=(
|
|
172
|
+
"Run a shell command and return only the lines that mattered instead of all "
|
|
173
|
+
"of its output. Every byte is kept on disk and never enters the conversation, "
|
|
174
|
+
"so a 5,000-line test run costs a few dozen lines of context -- and goes on "
|
|
175
|
+
"costing nothing on every later turn, because tool results are re-sent with "
|
|
176
|
+
"the rest of the transcript. The lines shown are the command's own, byte for "
|
|
177
|
+
"byte; each gap states how many lines it stands for, and `peek` with the "
|
|
178
|
+
"returned handle brings any range back in full. Prefer this over a plain "
|
|
179
|
+
"shell tool whenever the output may be long or noisy. When you already know "
|
|
180
|
+
"what you are looking for -- a symbol, a test name, an error code -- pass it "
|
|
181
|
+
"as `keep` and every line containing it comes back whatever else was chosen."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
def run(
|
|
185
|
+
command: str,
|
|
186
|
+
timeout: float | None = None,
|
|
187
|
+
background: bool = False,
|
|
188
|
+
cwd: str | None = None,
|
|
189
|
+
budget: int | None = None,
|
|
190
|
+
keep: str | None = None,
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Run `command` and return the lines that mattered.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
command: The command line. It is handed to the shell, so pipes, globs and
|
|
196
|
+
`&&` work the way they would if you had typed them.
|
|
197
|
+
timeout: Give up after this many seconds. Left unset, the command runs to
|
|
198
|
+
its own end. A command that is killed still returns what it printed
|
|
199
|
+
first, and the last line says it timed out.
|
|
200
|
+
background: Start the command and return a handle instead of waiting for
|
|
201
|
+
it. Use this for anything that does not end on its own -- a dev
|
|
202
|
+
server, a log tail -- and for a long build you want to keep working
|
|
203
|
+
during. Read it with `follow`, and end it with `follow` and `stop`.
|
|
204
|
+
cwd: The directory to run in. Left unset, the server's own.
|
|
205
|
+
budget: How many lines the view may cost. Left unset, a measured default
|
|
206
|
+
that suits most output. Raise it when you need more of a long run,
|
|
207
|
+
lower it when you only want the shape of one.
|
|
208
|
+
keep: A regular expression. Every line matching it is in the view,
|
|
209
|
+
whatever was chosen and whatever the budget says -- it is your
|
|
210
|
+
pattern, not a guess this tool made. Text that is not a valid
|
|
211
|
+
expression is searched for literally.
|
|
212
|
+
"""
|
|
213
|
+
if _unset():
|
|
214
|
+
return NO_KEY
|
|
215
|
+
|
|
216
|
+
if background:
|
|
217
|
+
return _start(command, timeout, cwd)
|
|
218
|
+
try:
|
|
219
|
+
capture = run_command([command], shell=True, timeout=timeout, cwd=cwd)
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
return f"sift: {exc}"
|
|
222
|
+
view, who = best_view(capture, BUDGET if budget is None else budget, keep)
|
|
223
|
+
return _answer(view.text, footer(capture, view, who))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _start(command: str, timeout: float | None, cwd: str | None = None) -> str:
|
|
227
|
+
"""Leave the command running, and say how to read it.
|
|
228
|
+
|
|
229
|
+
A timeout is refused rather than quietly dropped: nothing is waiting here to
|
|
230
|
+
enforce one, and a caller who believes a limit is in place when none is has
|
|
231
|
+
been told something untrue about their own command.
|
|
232
|
+
"""
|
|
233
|
+
if timeout is not None:
|
|
234
|
+
return (
|
|
235
|
+
"sift: timeout and background do not go together -- nothing is waiting to "
|
|
236
|
+
"enforce it. Start it without a timeout and end it with follow(stop=true)."
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
started = launch([command], shell=True, cwd=cwd)
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
return f"sift: {exc}"
|
|
242
|
+
return (
|
|
243
|
+
f"sift {started.handle} · started · nothing of its output has been shown yet."
|
|
244
|
+
f" Read it with follow(handle=\"{started.handle}\")."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@server.tool(
|
|
249
|
+
name="follow",
|
|
250
|
+
description=(
|
|
251
|
+
"Return what a background command has printed since the last time you asked, "
|
|
252
|
+
"and nothing you have already been shown. Use it with the handle from a "
|
|
253
|
+
"`run` call made with `background`. The lines keep the numbers they have in "
|
|
254
|
+
"the whole run, so `peek` on any of them returns that same line; a stretch "
|
|
255
|
+
"that was only progress comes back as a gap saying how many lines it stands "
|
|
256
|
+
"for, and a quiet minute comes back as nothing at all rather than as filler. "
|
|
257
|
+
"Set `stop` when you are done with the command: it ends it, and everything "
|
|
258
|
+
"it started, and returns the last of the output. Always stop a command you "
|
|
259
|
+
"are finished with -- a background command left alone keeps running.\n"
|
|
260
|
+
"With `everything` it answers about every run still going in one call, which "
|
|
261
|
+
"is what to use when you started three things and want to know where they "
|
|
262
|
+
"are. With `wait` it holds until something is actually said rather than "
|
|
263
|
+
"coming back empty: an empty answer is a tool result that stays in the "
|
|
264
|
+
"conversation for the rest of it, so waiting once costs less than asking "
|
|
265
|
+
"five times."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
def follow(
|
|
269
|
+
handle: str | None = None,
|
|
270
|
+
stop: bool = False,
|
|
271
|
+
everything: bool = False,
|
|
272
|
+
wait: float = 0.0,
|
|
273
|
+
) -> str:
|
|
274
|
+
"""Return what the run behind `handle` has said since the last look.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
handle: The handle from a `run` call made with `background`. Leave it
|
|
278
|
+
unset to follow the newest run.
|
|
279
|
+
stop: End the command first, then return whatever it printed last. A run
|
|
280
|
+
that has already finished is left alone; this is not an error.
|
|
281
|
+
everything: Answer about every run still going, in one call, asked at the
|
|
282
|
+
same time. `handle` and `stop` are ignored.
|
|
283
|
+
wait: Hold for up to this many seconds for something new rather than
|
|
284
|
+
answering that nothing has happened. A run that has already finished
|
|
285
|
+
is never waited for.
|
|
286
|
+
"""
|
|
287
|
+
if _unset():
|
|
288
|
+
return NO_KEY
|
|
289
|
+
|
|
290
|
+
if everything:
|
|
291
|
+
return _every_run(wait)
|
|
292
|
+
|
|
293
|
+
if handle is None:
|
|
294
|
+
going = store.started()
|
|
295
|
+
if not going:
|
|
296
|
+
return "sift: nothing is running"
|
|
297
|
+
handle = going[0].handle
|
|
298
|
+
|
|
299
|
+
if wait and not stop:
|
|
300
|
+
wait_for(handle, wait)
|
|
301
|
+
|
|
302
|
+
if stop:
|
|
303
|
+
stop_run(handle)
|
|
304
|
+
running = store.load_running(handle)
|
|
305
|
+
meta = store.load(handle)
|
|
306
|
+
if running is None and meta is None:
|
|
307
|
+
return f"sift: no such run: {handle}"
|
|
308
|
+
|
|
309
|
+
fresh, first, moved = unread(handle)
|
|
310
|
+
view, who = best_follow(handle, fresh, first)
|
|
311
|
+
# Only once the lines are in the answer: a look that raised on the way here
|
|
312
|
+
# is not a look the caller had, and the lines must still be theirs to ask for.
|
|
313
|
+
seen(handle, moved)
|
|
314
|
+
return _answer(view.text, follow_footer(handle, view, who, first, state(running, meta)))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@server.tool(
|
|
318
|
+
name="outline",
|
|
319
|
+
description=(
|
|
320
|
+
"Return what a file declares -- its types, functions, exports, targets and "
|
|
321
|
+
"settings -- without their bodies, so that reading a 2,000-line source file "
|
|
322
|
+
"costs a page. This is the machine behind `run` asked a different question: "
|
|
323
|
+
"it holds no table of languages and never reads the suffix, so it answers "
|
|
324
|
+
"about Rust, Haskell, a Makefile, a config file with no extension, or a "
|
|
325
|
+
"language that did not exist last year. Lines come from the file byte for "
|
|
326
|
+
"byte, and `peek` on the same path returns any range of it in full."
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
def outline(path: str, budget: int | None = None, keep: str | None = None) -> str:
|
|
330
|
+
"""Return what the file at `path` declares.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
path: The file to read. Any language, any name, no extension needed.
|
|
334
|
+
budget: How many lines the outline may cost. Left unset, a length that
|
|
335
|
+
keeps a table of contents to a screen.
|
|
336
|
+
keep: A regular expression. Every line matching it is in the outline
|
|
337
|
+
whatever else was chosen -- use it when you are looking for one
|
|
338
|
+
declaration in a file too large to outline whole.
|
|
339
|
+
"""
|
|
340
|
+
if _unset():
|
|
341
|
+
return NO_KEY
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
view, who = best_outline(path, budget, keep)
|
|
345
|
+
except OSError as exc: # the file cannot be read; there is no view to give
|
|
346
|
+
return f"sift: {exc}"
|
|
347
|
+
return _answer(view.text, outline_footer(path, view, who))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@server.tool(
|
|
351
|
+
name="digest",
|
|
352
|
+
description=(
|
|
353
|
+
"Read a file and return a distilled view of what is in it instead of its "
|
|
354
|
+
"text. This is for anything already written down that would flood the "
|
|
355
|
+
"conversation if opened whole: a log, a saved build or CI transcript, a test "
|
|
356
|
+
"report, a crash dump, a long JSON export. The server opens the file, so its "
|
|
357
|
+
"contents never enter the conversation -- a 40,000-line log costs a screenful "
|
|
358
|
+
"-- and every line shown is the file's own, byte for byte, with `peek` on the "
|
|
359
|
+
"same path returning any range in full. Use `outline` instead when the file "
|
|
360
|
+
"is source code and the question is what it declares."
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
def digest(path: str, budget: int | None = None, keep: str | None = None) -> str:
|
|
364
|
+
"""Return a distilled view of the file at `path`.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
path: The file to read. Any format, any language, no extension needed.
|
|
368
|
+
budget: How many lines the view may cost. Left unset, a measured default.
|
|
369
|
+
keep: A regular expression. Every line matching it is in the view
|
|
370
|
+
whatever else was chosen -- use it when you already know the error
|
|
371
|
+
code, the test name or the timestamp you are looking for.
|
|
372
|
+
"""
|
|
373
|
+
if _unset():
|
|
374
|
+
return NO_KEY
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
view, who = best_digest(path, budget, keep)
|
|
378
|
+
except OSError as exc: # the file cannot be read; there is no view to give
|
|
379
|
+
return f"sift: {exc}"
|
|
380
|
+
return _answer(view.text, outline_footer(path, view, who))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@server.tool(
|
|
386
|
+
name="tool",
|
|
387
|
+
description=(
|
|
388
|
+
"Run one of three dense tools and return a distilled view of what it printed: "
|
|
389
|
+
"`sg` (ast-grep) for structural search, `diff` (difftastic) for a diff that "
|
|
390
|
+
"can tell a reindent from a change, `loc` (scc) for the size of a tree. Each "
|
|
391
|
+
"of them answers a question *without opening the file* -- reach for `sg` "
|
|
392
|
+
"instead of reading candidates to find where a shape occurs, and `loc` "
|
|
393
|
+
"instead of listing a directory to size it. Their output is large by nature "
|
|
394
|
+
"and is distilled like anything else, so a 4,000-line structural search costs "
|
|
395
|
+
"a screenful with every byte still reachable through `peek`. A tool this "
|
|
396
|
+
"machine does not have says so and says what it is called; nothing is "
|
|
397
|
+
"installed for you."
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
def tool(name: str, args: list[str] | None = None) -> str:
|
|
401
|
+
"""Run the dense tool called `name` and return what mattered.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
name: One of `sg`, `diff` or `loc`.
|
|
405
|
+
args: What to pass it, as separate words.
|
|
406
|
+
"""
|
|
407
|
+
if _unset():
|
|
408
|
+
return NO_KEY
|
|
409
|
+
|
|
410
|
+
line = command_for(name, list(args or []))
|
|
411
|
+
if line is None:
|
|
412
|
+
known = ", ".join(one.name for one in KNOWN)
|
|
413
|
+
return f"sift: no such tool: {name} (there is {known})"
|
|
414
|
+
|
|
415
|
+
known = BY_NAME[name]
|
|
416
|
+
if not known.here:
|
|
417
|
+
return (
|
|
418
|
+
f"sift: {known.known_as} is not on this machine. It is what `{name}` runs,"
|
|
419
|
+
f" and it replaces {known.replaces}."
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
capture = run_command(line)
|
|
424
|
+
except OSError as exc:
|
|
425
|
+
return f"sift: {exc}"
|
|
426
|
+
view, who = best_view(capture)
|
|
427
|
+
return _answer(view.text, footer(capture, view, who))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@server.tool(
|
|
431
|
+
name="digest_many",
|
|
432
|
+
description=(
|
|
433
|
+
"Digest several files in one call, asked at the same time. Use it whenever "
|
|
434
|
+
"there is more than one file to read: four logs cost four waits asked one by "
|
|
435
|
+
"one and roughly one wait asked together, and come back as one tool result "
|
|
436
|
+
"instead of four. Each file keeps its own last line saying which it is, and "
|
|
437
|
+
"a file that cannot be read says so in its place rather than taking the "
|
|
438
|
+
"others down with it."
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
def digest_many(paths: list[str], budget: int | None = None, keep: str | None = None) -> str:
|
|
442
|
+
"""Return a distilled view of each file in `paths`.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
paths: The files to read, answered in the order given.
|
|
446
|
+
budget: How many lines each view may cost.
|
|
447
|
+
keep: A regular expression kept in every one of them.
|
|
448
|
+
"""
|
|
449
|
+
if _unset():
|
|
450
|
+
return NO_KEY
|
|
451
|
+
|
|
452
|
+
if not paths:
|
|
453
|
+
return "sift: digest_many needs at least one path"
|
|
454
|
+
return "\n\n".join(
|
|
455
|
+
_answer(built.text, outline_footer(path, built, who))
|
|
456
|
+
for path, built, who in best_digests(paths, budget, keep)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@server.tool(
|
|
460
|
+
name="peek",
|
|
461
|
+
description=(
|
|
462
|
+
"Return the exact original lines of a capture or a file, byte for byte, with "
|
|
463
|
+
"the line numbers they had there. Use it with the handle at the end of a "
|
|
464
|
+
"`run` result, or with any path, to open up a gap that a view left behind. "
|
|
465
|
+
"A range reaching past the end is clamped rather than refused, and with no "
|
|
466
|
+
"range at all it returns the whole thing. With `grep` it searches instead: "
|
|
467
|
+
"every line matching your pattern comes back with a few lines of context "
|
|
468
|
+
"around it, which is the way into a gap when you know the word you want but "
|
|
469
|
+
"not the line number."
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
def peek(
|
|
473
|
+
handle: str,
|
|
474
|
+
first: int | None = None,
|
|
475
|
+
last: int | None = None,
|
|
476
|
+
grep: str | None = None,
|
|
477
|
+
around: int = 3,
|
|
478
|
+
cap: int = 200,
|
|
479
|
+
) -> str:
|
|
480
|
+
"""Return lines `first` to `last` of a capture or a file, unchanged.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
handle: A handle from a `run` result, or the path to a file.
|
|
484
|
+
first: The first line to return, counting from 1. Unset means the start.
|
|
485
|
+
last: The last line to return. Unset means the end.
|
|
486
|
+
grep: A regular expression. Given one, `first` and `last` become the
|
|
487
|
+
window to search rather than the answer, and every matching line
|
|
488
|
+
comes back. Text that is not a valid expression is searched for
|
|
489
|
+
literally.
|
|
490
|
+
around: How many lines to show on each side of a match, so it can be
|
|
491
|
+
read in context.
|
|
492
|
+
cap: The most lines a search may return. Matches past it are left out
|
|
493
|
+
and the last line says how many matched in total.
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
found = peek_at(handle, first, last, grep, around, cap)
|
|
497
|
+
except (OSError, ValueError) as exc:
|
|
498
|
+
return f"sift: {exc}"
|
|
499
|
+
return _answer(found.text, peek_footer(found))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _every_run(wait: float = 0.0) -> str:
|
|
503
|
+
"""Every command still going, in one answer.
|
|
504
|
+
|
|
505
|
+
Three builds should not cost three tool calls and three results that stay in
|
|
506
|
+
the transcript for the rest of the conversation. The waiting is done once, on
|
|
507
|
+
whichever of them speaks first: waiting on each in turn would add their
|
|
508
|
+
timeouts together and answer late about all of them.
|
|
509
|
+
"""
|
|
510
|
+
going = store.started()
|
|
511
|
+
if not going:
|
|
512
|
+
return "sift: nothing is running"
|
|
513
|
+
|
|
514
|
+
if wait:
|
|
515
|
+
deadline = store.now() + wait
|
|
516
|
+
while store.now() < deadline:
|
|
517
|
+
if any(unread(one.handle)[0] for one in going):
|
|
518
|
+
break
|
|
519
|
+
time.sleep(0.1)
|
|
520
|
+
|
|
521
|
+
return "\n\n".join(_one_run(one.handle) for one in going)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _one_run(handle: str) -> str:
|
|
525
|
+
"""One run's slice, footer and all, ready to be put beside another's."""
|
|
526
|
+
running = store.load_running(handle)
|
|
527
|
+
meta = store.load(handle)
|
|
528
|
+
if running is None and meta is None:
|
|
529
|
+
return f"sift: no such run: {handle}"
|
|
530
|
+
|
|
531
|
+
fresh, first, moved = unread(handle)
|
|
532
|
+
built, who = best_follow(handle, fresh, first)
|
|
533
|
+
seen(handle, moved)
|
|
534
|
+
return _answer(built.text, follow_footer(handle, built, who, first, state(running, meta)))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _answer(text: str, note: str) -> str:
|
|
538
|
+
"""The view and the line that says what it is, as one string.
|
|
539
|
+
|
|
540
|
+
Joined rather than returned side by side because a client shows one block of
|
|
541
|
+
text per call, and a note that arrives anywhere else does not arrive.
|
|
542
|
+
"""
|
|
543
|
+
return f"{text}\n\n{note}" if text else note
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def main() -> None:
|
|
547
|
+
"""Console-script entry point: serve over stdio."""
|
|
548
|
+
server.run("stdio")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
if __name__ == "__main__":
|
|
552
|
+
main()
|