loom-code 0.1.1__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.
Files changed (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/cli.py ADDED
@@ -0,0 +1,495 @@
1
+ """loom-code CLI entry point.
2
+
3
+ Two modes:
4
+
5
+ * ``loom-code "do X"`` — one-shot. Detects the project, builds the
6
+ loomflow Agent, streams one run, exits.
7
+ * ``loom-code`` (no args) — interactive REPL. Chat, code, approve,
8
+ repeat — with conversation continuity across turns.
9
+
10
+ Both go through the same project detection + agent build; the REPL
11
+ just loops and adds slash commands.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import functools
18
+ import subprocess
19
+ import sys
20
+
21
+ import anyio
22
+
23
+ from .agent import DEFAULT_MODEL, build_agent
24
+ from .approval import ApprovalGate
25
+ from .credentials import ensure_key_for_model, load_credentials
26
+ from .project import Project, detect_project
27
+ from .render import StreamRenderer, banner, console
28
+ from .repl import run_repl
29
+
30
+ # Substrings we treat as "this bash call was a verify step." Hit
31
+ # anywhere in the lowercased command. Detected, not declared by the
32
+ # agent — we sniff the tool stream so the summary is honest about
33
+ # what was actually run rather than what the agent claims.
34
+ _VERIFY_PATTERNS = (
35
+ "pytest", "py.test", "jest", "vitest", "mocha",
36
+ "cargo test", "go test", "mvn test", "gradle test",
37
+ "rake test", "phpunit", "python -m unittest", "tox",
38
+ "make test", "make check", "npm test", "yarn test",
39
+ "pnpm test", "bundle exec",
40
+ )
41
+
42
+
43
+ async def _run_once(
44
+ prompt: str,
45
+ model: str,
46
+ yes: bool,
47
+ *,
48
+ sandbox: bool = False,
49
+ sandbox_allow_network: bool = False,
50
+ output_format: str = "text",
51
+ ) -> int:
52
+ """Detect project, build agent, stream one run. Returns an
53
+ exit code.
54
+
55
+ ``yes`` swaps the interactive approval gate for the
56
+ auto-approve handler — for unattended runs (CI, scripted use)
57
+ where there's no TTY to answer prompts.
58
+
59
+ ``output_format="json"`` silences the Rich UI and prints ONE
60
+ machine-readable envelope on stdout when the run ends — for
61
+ CI / scripting / benchmark harnesses. Usually paired with
62
+ ``--yes`` (there's no visible approval prompt in quiet mode).
63
+
64
+ Consumed via :func:`anyio.run` (NOT ``asyncio.run``) — the
65
+ streaming generator from ``Agent.stream`` opens internal
66
+ ``anyio`` task groups, and exiting those cleanly requires the
67
+ anyio event loop. loomflow's rule: anyio everywhere.
68
+ """
69
+ json_mode = output_format == "json"
70
+ if json_mode:
71
+ # Silence every Rich print (banner, stream, summary) — the
72
+ # envelope at the end is the only stdout. ``console`` is the
73
+ # shared module singleton, so this covers render.py too.
74
+ console.quiet = True
75
+
76
+ project = detect_project()
77
+ banner(
78
+ model,
79
+ str(project.root),
80
+ project.is_git,
81
+ sandbox=sandbox,
82
+ sandbox_allow_network=sandbox_allow_network,
83
+ )
84
+ if project.context_file:
85
+ console.print(
86
+ f" [dim]loaded context: "
87
+ f"{project.context_file.name}[/dim]\n"
88
+ )
89
+
90
+ # --yes routes through the gate in YOLO mode rather than the raw
91
+ # auto_approve — so settings.toml ``deny`` rules (e.g.
92
+ # deny "edit(*.env)") and the irreversible-danger gate STILL fire
93
+ # in unattended runs, instead of a blanket allow-everything.
94
+ from pathlib import Path as _Path
95
+
96
+ from .permissions import Mode, load_rules
97
+
98
+ rules = load_rules(
99
+ [_Path.home() / ".loom-code", project.root / ".loom"]
100
+ )
101
+ gate = ApprovalGate(
102
+ rules=rules,
103
+ mode=Mode.YOLO if yes else Mode.DEFAULT,
104
+ project_root=project.root,
105
+ )
106
+ handler = gate.handler
107
+ agent, workspace = build_agent(
108
+ project,
109
+ model=model,
110
+ approval_handler=handler,
111
+ sandbox=sandbox,
112
+ sandbox_allow_network=sandbox_allow_network,
113
+ )
114
+ renderer = StreamRenderer(sandbox=sandbox)
115
+ # "thinking..." spinner until the first user-visible event —
116
+ # same pattern as the REPL so one-shot mode has the same
117
+ # responsiveness feedback. ``started`` is internal framing;
118
+ # the spinner drops on the first non-started event.
119
+ status = console.status(
120
+ "[dim]thinking...[/dim]", spinner="dots"
121
+ )
122
+ status.start()
123
+ spinner_dropped = False
124
+
125
+ def _drop_spinner() -> None:
126
+ nonlocal spinner_dropped
127
+ if not spinner_dropped:
128
+ status.stop()
129
+ spinner_dropped = True
130
+
131
+ try:
132
+ async for event in agent.stream(prompt, user_id="loom-code"):
133
+ if str(event.kind) != "started":
134
+ _drop_spinner()
135
+ renderer.handle(event)
136
+ except KeyboardInterrupt:
137
+ _drop_spinner()
138
+ console.print("\n[yellow]interrupted[/yellow]")
139
+ if json_mode:
140
+ _emit_json_envelope(
141
+ project, renderer, exit_code=130, error="interrupted"
142
+ )
143
+ return 130
144
+ except Exception as exc: # noqa: BLE001 — top-level guard
145
+ _drop_spinner()
146
+ # anyio task groups surface as an opaque ExceptionGroup —
147
+ # flatten to the real cause(s) and add an actionable hint,
148
+ # same presentation as the REPL's turn errors.
149
+ from .repl import _flatten_exception_group, friendly_error_hint
150
+
151
+ inners = (
152
+ _flatten_exception_group(exc)
153
+ if isinstance(exc, BaseExceptionGroup)
154
+ else [exc]
155
+ )
156
+ for inner in inners:
157
+ console.print(
158
+ f"\n[bold red]fatal: "
159
+ f"{type(inner).__name__}: {inner}[/bold red]"
160
+ )
161
+ hint = friendly_error_hint(inner)
162
+ if hint:
163
+ console.print(f" [yellow]→ {hint}[/yellow]")
164
+ if json_mode:
165
+ first = inners[0] if inners else exc
166
+ _emit_json_envelope(
167
+ project,
168
+ renderer,
169
+ exit_code=1,
170
+ error=f"{type(first).__name__}: {first}",
171
+ )
172
+ return 1
173
+ finally:
174
+ _drop_spinner()
175
+
176
+ # Self-improvement loop: a clean one-shot completion is treated
177
+ # as success — credit the notes the agent read so future runs
178
+ # rank them higher. (The REPL is more nuanced: it waits for a
179
+ # /good / /bad signal or "moved on" before attributing.)
180
+ result = renderer.last_result
181
+ if result and not result.get("interrupted"):
182
+ slugs = result.get("cited_slugs") or []
183
+ if slugs:
184
+ try:
185
+ n = await workspace.attribute_outcome(
186
+ success=True, slugs=slugs, user_id="loom-code"
187
+ )
188
+ if n:
189
+ console.print(
190
+ f" [dim]credited {n} note(s) — future "
191
+ f"runs will rank them higher[/dim]"
192
+ )
193
+ except Exception: # noqa: BLE001 — best-effort
194
+ pass
195
+
196
+ # End-of-run summary — what changed on disk, what was verified,
197
+ # what the agent captured. Detected from the event stream + git;
198
+ # the agent doesn't have to report any of it itself.
199
+ _print_run_summary(project, renderer)
200
+ if json_mode:
201
+ _emit_json_envelope(project, renderer, exit_code=0)
202
+ return 0
203
+
204
+
205
+ def _emit_json_envelope(
206
+ project: Project,
207
+ renderer: StreamRenderer,
208
+ *,
209
+ exit_code: int,
210
+ error: str | None = None,
211
+ ) -> None:
212
+ """The single machine-readable line ``--output-format json``
213
+ promises: printed with plain ``print`` (the Rich console is quiet
214
+ in json mode), one JSON object, always the last stdout of the run.
215
+ Append-only contract — add fields, never rename them, so CI
216
+ parsers don't break."""
217
+ import json
218
+
219
+ result = renderer.last_result or {}
220
+ envelope = {
221
+ "is_error": exit_code != 0,
222
+ "exit_code": exit_code,
223
+ "error": error,
224
+ "output": str(result.get("output") or ""),
225
+ "turns": int(result.get("turns", 0)),
226
+ "tokens_in": int(result.get("tokens_in", 0)),
227
+ "cached_tokens_in": int(result.get("cached_tokens_in", 0)),
228
+ "tokens_out": int(result.get("tokens_out", 0)),
229
+ "cost_usd": float(result.get("cost_usd", 0.0)),
230
+ "files_changed": (
231
+ _git_changes(project) if project.is_git else []
232
+ ),
233
+ "verify_commands": [
234
+ c
235
+ for c in renderer.bash_commands
236
+ if _is_verify_command(c)
237
+ ],
238
+ "notes_written": [
239
+ {"kind": k, "title": t}
240
+ for k, t in renderer.notes_written
241
+ ],
242
+ }
243
+ print(json.dumps(envelope))
244
+
245
+
246
+ def _is_verify_command(cmd: str) -> bool:
247
+ """True for bash commands that look like a project's test /
248
+ build runner — what the agent's VERIFY step should produce."""
249
+ head = cmd.lstrip().lower()
250
+ return any(p in head for p in _VERIFY_PATTERNS)
251
+
252
+
253
+ def _git_changes(project: Project) -> list[str]:
254
+ """Lines from ``git status --short`` for the user's repo, with
255
+ loom-code's own ``.loom/`` state filtered out (it's runtime
256
+ chatter, not part of what the agent changed for the user)."""
257
+ res = subprocess.run(
258
+ ["git", "-C", str(project.root), "status", "--short"],
259
+ capture_output=True, text=True,
260
+ )
261
+ out: list[str] = []
262
+ for raw in res.stdout.splitlines():
263
+ if not raw.strip():
264
+ continue
265
+ # Status lines are ``XY path`` — split off the path so we
266
+ # can filter on it.
267
+ parts = raw.split(maxsplit=1)
268
+ if len(parts) < 2:
269
+ continue
270
+ path = parts[1].strip().strip('"')
271
+ if path == ".loom" or path == ".loom/" or path.startswith(".loom/"):
272
+ continue
273
+ out.append(raw)
274
+ return out
275
+
276
+
277
+ def _print_run_summary(
278
+ project: Project, renderer: StreamRenderer
279
+ ) -> None:
280
+ """Append a structured summary after the agent finishes —
281
+ cost line (one-shot owns this since there's no REPL pre-prompt
282
+ status to show it), then files changed / verified / notes."""
283
+ result = renderer.last_result or {}
284
+ turns = result.get("turns", 0)
285
+ cost = float(result.get("cost_usd", 0.0))
286
+ tin = int(result.get("tokens_in", 0))
287
+ cached = int(result.get("cached_tokens_in", 0))
288
+ tout = int(result.get("tokens_out", 0))
289
+ if turns or cost or tin or tout:
290
+ from rich.text import Text # local — keeps cli.py top tidy
291
+ console.print(
292
+ Text.assemble(
293
+ (" ", ""),
294
+ (f"{turns} turns", "dim"),
295
+ (" · ", "dim"),
296
+ (f"{tin:,}+{cached:,} in / {tout:,} out", "dim"),
297
+ (" · ", "dim"),
298
+ (f"${cost:.4f}", "dim green"),
299
+ )
300
+ )
301
+ # Stable, machine-parseable usage marker on its own line. The
302
+ # pretty line above is for humans (Rich markup, commas); this one
303
+ # is for tooling — a benchmark harness / script greps LOOM_USAGE
304
+ # and reads plain ints, no formatting to unpick. Kept minimal and
305
+ # append-only so parsers don't break when fields are added.
306
+ console.print(
307
+ f"LOOM_USAGE turns={turns} "
308
+ f"tokens_in={tin} cached_in={cached} "
309
+ f"tokens_out={tout} cost_usd={cost:.6f}"
310
+ )
311
+
312
+ if project.is_git:
313
+ changes = _git_changes(project)
314
+ if changes:
315
+ console.print()
316
+ console.print(" [bold]Files changed:[/bold]")
317
+ for line in changes[:20]: # cap noise on huge changesets
318
+ console.print(f" [dim]{line}[/dim]")
319
+ if len(changes) > 20:
320
+ console.print(
321
+ f" [dim]... (+{len(changes) - 20} more)[/dim]"
322
+ )
323
+
324
+ verify = [c for c in renderer.bash_commands if _is_verify_command(c)]
325
+ if verify:
326
+ console.print()
327
+ console.print(" [bold]What was verified:[/bold]")
328
+ for cmd in verify[:5]:
329
+ short = cmd if len(cmd) <= 80 else cmd[:80] + "…"
330
+ console.print(f" [dim]$[/dim] {short}")
331
+
332
+ if renderer.notes_written:
333
+ console.print()
334
+ console.print(" [bold]Notes captured:[/bold]")
335
+ for kind, title in renderer.notes_written[:5]:
336
+ tag = f"[dim]{kind}:[/dim] " if kind else ""
337
+ short = title if len(title) <= 80 else title[:80] + "…"
338
+ console.print(f" • {tag}{short}")
339
+
340
+
341
+ def main() -> None:
342
+ """``loom-code`` console-script entry point."""
343
+ parser = argparse.ArgumentParser(
344
+ prog="loom-code",
345
+ description=(
346
+ "loom-code — a loomflow-native terminal coding agent"
347
+ ),
348
+ )
349
+ parser.add_argument(
350
+ "prompt",
351
+ nargs="*",
352
+ help=(
353
+ "The task. Omit it to drop into the interactive REPL."
354
+ ),
355
+ )
356
+ parser.add_argument(
357
+ "--model",
358
+ default=None,
359
+ help=(
360
+ f"Model to use (default: last-used, else {DEFAULT_MODEL}). "
361
+ "Accepts any "
362
+ "string loomflow's resolver routes: Anthropic names "
363
+ "(claude-sonnet-4-6, claude-opus-4-7, ...), OpenAI "
364
+ "names (gpt-4.1-mini, gpt-4.1, o4-mini, ...), local "
365
+ "Ollama (ollama/llama3, ollama/qwen2.5-coder), or "
366
+ "force LiteLLM with litellm/<anything>."
367
+ ),
368
+ )
369
+ parser.add_argument(
370
+ "--yes",
371
+ "-y",
372
+ action="store_true",
373
+ help=(
374
+ "Auto-approve all destructive tool calls — no prompts. "
375
+ "For unattended / scripted runs on a disposable tree. "
376
+ "One-shot mode only."
377
+ ),
378
+ )
379
+ parser.add_argument(
380
+ "--sandbox",
381
+ action="store_true",
382
+ help=(
383
+ "Run the coder's bash inside an OS sandbox (macOS Seatbelt "
384
+ "/ Linux bwrap): commands may read anywhere but only WRITE "
385
+ "under the project root, with no network. edit/write keep "
386
+ "the approval gate. Off by default."
387
+ ),
388
+ )
389
+ parser.add_argument(
390
+ "--sandbox-allow-network",
391
+ action="store_true",
392
+ help="With --sandbox, permit network access from bash commands.",
393
+ )
394
+ parser.add_argument(
395
+ "--output-format",
396
+ choices=("text", "json"),
397
+ default="text",
398
+ help=(
399
+ "One-shot output format. 'json' silences the UI and "
400
+ "prints one machine-readable envelope on stdout (output, "
401
+ "tokens, cost, files changed, exit_code) — for CI and "
402
+ "scripting; usually paired with --yes. Ignored in the "
403
+ "interactive REPL."
404
+ ),
405
+ )
406
+ parser.add_argument(
407
+ "--continue",
408
+ dest="continue_",
409
+ action="store_true",
410
+ help=(
411
+ "Start the REPL resumed on this project's most recent "
412
+ "session (same as typing /resume first)."
413
+ ),
414
+ )
415
+ parser.add_argument(
416
+ "--resume",
417
+ action="store_true",
418
+ help=(
419
+ "Start the REPL with a picker of recent sessions to "
420
+ "resume (same as typing /resume pick first)."
421
+ ),
422
+ )
423
+ args = parser.parse_args()
424
+
425
+ # 1. Read ~/.loom-code/credentials so any keys saved on a
426
+ # previous run are available without the user having to
427
+ # `export` again.
428
+ # 2. If the chosen model still needs a key, prompt for it
429
+ # inline (hidden input), save it, and continue.
430
+ # Both happen BEFORE any Agent is constructed — loomflow's
431
+ # model adapter would crash at init on a missing key otherwise.
432
+ load_credentials()
433
+ # Resolve the startup model: an explicit --model wins; else the model
434
+ # the user last chose (persisted via /model or /set_model); else the
435
+ # built-in default. This makes the chosen model STICK across launches.
436
+ if args.model is None:
437
+ from .credentials import load_preferred_model
438
+
439
+ args.model = load_preferred_model() or DEFAULT_MODEL
440
+ # Expand friendly provider aliases (e.g. ``nvidia/nemotron-...`` →
441
+ # ``litellm/nvidia_nim/nvidia/nemotron-...``) so the rest of the
442
+ # pipeline — key prompt, resolver, persistence — sees the canonical
443
+ # form loomflow understands.
444
+ from .credentials import normalize_model, quiet_litellm_model_warnings
445
+
446
+ args.model = normalize_model(args.model)
447
+ # litellm-routed models trigger loomflow "unknown model" warnings
448
+ # (context window + pricing) that loom-code already handles; hush
449
+ # them so the startup output stays clean. Native models are
450
+ # untouched, so a real misconfig there still surfaces.
451
+ quiet_litellm_model_warnings(args.model)
452
+ if not ensure_key_for_model(args.model, console):
453
+ sys.exit(1)
454
+ # Remember it so the next launch starts here too.
455
+ from .credentials import save_preferred_model
456
+
457
+ save_preferred_model(args.model)
458
+
459
+ if not args.prompt:
460
+ # No task → interactive REPL. --yes is meaningless here
461
+ # (the REPL is interactive by definition); ignore it.
462
+ project = detect_project()
463
+ resume = (
464
+ "pick" if args.resume else "last" if args.continue_ else None
465
+ )
466
+ exit_code = anyio.run(
467
+ functools.partial(
468
+ run_repl,
469
+ project,
470
+ args.model,
471
+ sandbox=args.sandbox,
472
+ sandbox_allow_network=args.sandbox_allow_network,
473
+ resume=resume,
474
+ )
475
+ )
476
+ sys.exit(exit_code)
477
+
478
+ # One-shot mode.
479
+ prompt = " ".join(args.prompt)
480
+ exit_code = anyio.run(
481
+ functools.partial(
482
+ _run_once,
483
+ prompt,
484
+ args.model,
485
+ args.yes,
486
+ sandbox=args.sandbox,
487
+ sandbox_allow_network=args.sandbox_allow_network,
488
+ output_format=args.output_format,
489
+ )
490
+ )
491
+ sys.exit(exit_code)
492
+
493
+
494
+ if __name__ == "__main__":
495
+ main()