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/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()