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/grep_tool.py ADDED
@@ -0,0 +1,430 @@
1
+ """Enhanced ``grep`` tool for loom-code agents.
2
+
3
+ Same role as ``loomflow.tools.grep_tool`` — find regex matches under a
4
+ working directory — but with structured output the agent can actually
5
+ USE without N follow-up reads:
6
+
7
+ * **Surrounding context** — ±N lines around each match so the agent
8
+ sees the match in context (loomflow's default is just the matching
9
+ line, which forces a separate ``read`` of every interesting hit).
10
+ * **Grouped by file** — all matches for one file in a single block
11
+ with the path as a header. Easier to scan than 50 ``path:line:``
12
+ prefixes.
13
+ * **Test-file collapsing** — hits in ``tests/`` / ``test_*.py`` /
14
+ ``*_test.py`` collapse into a one-line "+N matches in test files"
15
+ summary by default. Keeps prod code in focus; agent opts in to
16
+ test matches with ``include_tests=True``.
17
+ * **Optional language filter** — ``type=("py", "ts")`` restricts to
18
+ those extensions.
19
+
20
+ Default behaviour is the enhanced form so the agent's default grep is
21
+ the good one. Pass ``raw=True`` for the old flat-line format if a
22
+ tight one-line-per-match shape is needed.
23
+
24
+ Why this lives in loom-code (not loomflow): loom-code is opinionated
25
+ about the SHAPE of grep output for coding-agent UX. The framework's
26
+ ``grep_tool`` is a sensible generic; this wrapper adds the loom-code
27
+ ergonomics without forking the framework.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from loomflow import tool
40
+ from loomflow.tools.registry import Tool
41
+
42
+
43
+ def _as_int(value: Any, default: int) -> int:
44
+ """Coerce a model-supplied value to int. The tool-call layer
45
+ often serialises ``context=2`` as the STRING ``"2"`` (and some
46
+ providers send floats), so a typed ``int`` param arrives as a
47
+ str and ``lineno - context`` crashes with 'int - str'. Coerce
48
+ leniently; fall back to ``default`` on anything unparseable."""
49
+ if isinstance(value, bool):
50
+ # bool is an int subclass — don't let True become 1 silently
51
+ # for a numeric arg; treat as default.
52
+ return default
53
+ if isinstance(value, int):
54
+ return value
55
+ try:
56
+ return int(str(value).strip())
57
+ except (TypeError, ValueError):
58
+ return default
59
+
60
+
61
+ def _as_bool(value: Any, default: bool = False) -> bool:
62
+ """Coerce a model-supplied value to bool. Weak models send
63
+ ``ignore_case=true`` as the STRING ``"true"`` (or ``"1"`` /
64
+ ``"yes"``); a typed ``bool`` param then arrives truthy-non-empty
65
+ for ANY non-empty string including ``"false"``. Parse the
66
+ common truthy/falsy spellings explicitly."""
67
+ if isinstance(value, bool):
68
+ return value
69
+ if value is None:
70
+ return default
71
+ s = str(value).strip().lower()
72
+ if s in ("true", "1", "yes", "y", "on"):
73
+ return True
74
+ if s in ("false", "0", "no", "n", "off", ""):
75
+ return False
76
+ return default
77
+
78
+
79
+ # Directories we never search — matches loomflow's grep_tool noise
80
+ # list so behaviour around big virtualenvs / build outputs is the
81
+ # same as the framework default. Keeps walk time bounded on real
82
+ # projects with .venv / node_modules / etc.
83
+ _NOISE_DIRS = frozenset({
84
+ ".git", "node_modules", "__pycache__", ".venv", "venv",
85
+ ".env", ".tox", "dist", "build", ".pytest_cache", ".ruff_cache",
86
+ ".mypy_cache", "graphify-out", ".loom",
87
+ })
88
+
89
+ # Heuristics for "is this a test file?". Anything under a ``tests``
90
+ # folder, or named ``test_*.py`` / ``*_test.py`` / ``*.test.*``.
91
+ _TEST_DIR_NAMES = frozenset({"tests", "test", "__tests__"})
92
+
93
+
94
+ def _is_test_path(rel: Path) -> bool:
95
+ """True if the relative path is a test file by directory or
96
+ filename convention."""
97
+ parts = set(rel.parts)
98
+ if parts & _TEST_DIR_NAMES:
99
+ return True
100
+ name = rel.name.lower()
101
+ if name.startswith("test_") or name.endswith("_test.py"):
102
+ return True
103
+ if ".test." in name or ".spec." in name:
104
+ return True
105
+ return False
106
+
107
+
108
+ def _walk_files(
109
+ root: Path,
110
+ glob: str,
111
+ type_filter: tuple[str, ...] | None,
112
+ ) -> list[Path]:
113
+ """Walk ``root`` honouring ``_NOISE_DIRS`` + the user's glob +
114
+ optional extension allowlist. Returns absolute paths."""
115
+ out: list[Path] = []
116
+ for path in root.rglob(glob):
117
+ if not path.is_file():
118
+ continue
119
+ if any(p in _NOISE_DIRS for p in path.parts):
120
+ continue
121
+ if type_filter is not None:
122
+ suffix = path.suffix.lstrip(".").lower()
123
+ if suffix not in type_filter:
124
+ continue
125
+ out.append(path)
126
+ return out
127
+
128
+
129
+ def _rg_path() -> str | None:
130
+ """Absolute path to the real ripgrep binary, or None if absent.
131
+
132
+ ``shutil.which`` finds the executable on PATH — not any shell
133
+ function shim (subprocess never sees shell functions anyway)."""
134
+ return shutil.which("rg")
135
+
136
+
137
+ def _collect_with_ripgrep(
138
+ target: Path,
139
+ pattern: str,
140
+ *,
141
+ ignore_case: bool,
142
+ glob: str,
143
+ type_filter: tuple[str, ...] | None,
144
+ max_files: int,
145
+ max_per_file: int,
146
+ ) -> dict[Path, list[tuple[int, str]]] | None:
147
+ """Fast path: ripgrep does the matching; we parse its --json stream.
148
+
149
+ Returns ``matches_by_file`` (same shape the Python walk produces), or
150
+ ``None`` to signal "fall back to Python" — when rg is absent, the
151
+ pattern uses a feature rg's Rust regex rejects (lookahead /
152
+ backrefs → exit 2), or rg errors. Never raises, so the caller's
153
+ fallback stays a simple ``is None`` check.
154
+
155
+ rg respects .gitignore (correct for a code tool); we ALSO pass the
156
+ historical _NOISE_DIRS as --glob excludes so a non-git tree or an
157
+ un-ignored .venv stays quiet — a superset of the old walk, never a
158
+ regression."""
159
+ rg = _rg_path()
160
+ if rg is None:
161
+ return None
162
+ argv = [rg, "--json", "--no-messages"]
163
+ if ignore_case:
164
+ argv.append("--ignore-case")
165
+ if glob and glob != "*":
166
+ argv += ["--glob", glob]
167
+ for noise in _NOISE_DIRS:
168
+ argv += ["--glob", f"!**/{noise}/**"]
169
+ if type_filter:
170
+ # rg has no arbitrary-extension flag — express each as a glob.
171
+ for ext in type_filter:
172
+ argv += ["--glob", f"*.{ext}"]
173
+ argv += ["--regexp", pattern, str(target)]
174
+ try:
175
+ proc = subprocess.run(
176
+ argv, capture_output=True, text=True, timeout=30
177
+ )
178
+ except (OSError, subprocess.SubprocessError):
179
+ return None
180
+ # rg exit codes: 0 = matches, 1 = no matches (NOT an error),
181
+ # 2 = real error (bad/unsupported regex) → fall back to the Python
182
+ # engine, which may accept the pattern.
183
+ if proc.returncode == 2:
184
+ return None
185
+ matches_by_file: dict[Path, list[tuple[int, str]]] = {}
186
+ for raw_line in proc.stdout.splitlines():
187
+ if not raw_line:
188
+ continue
189
+ try:
190
+ evt = json.loads(raw_line)
191
+ except json.JSONDecodeError:
192
+ continue
193
+ if evt.get("type") != "match":
194
+ continue
195
+ data = evt["data"]
196
+ fpath = Path(data["path"]["text"])
197
+ lineno = int(data["line_number"])
198
+ text = str(data["lines"]["text"]).rstrip("\n")
199
+ bucket = matches_by_file.setdefault(fpath, [])
200
+ if len(bucket) < max_per_file:
201
+ bucket.append((lineno, text))
202
+ # Honour the file cap deterministically (rg emits in walk order).
203
+ if len(matches_by_file) > max_files:
204
+ keep = sorted(matches_by_file)[:max_files]
205
+ matches_by_file = {k: matches_by_file[k] for k in keep}
206
+ return matches_by_file
207
+
208
+
209
+ def _render_grouped(
210
+ matches_by_file: dict[Path, list[tuple[int, str]]],
211
+ file_lines: dict[Path, list[str]],
212
+ *,
213
+ context: int,
214
+ root: Path,
215
+ ) -> str:
216
+ """Render the structured per-file output with context lines."""
217
+ if not matches_by_file:
218
+ return "no matches"
219
+ sections: list[str] = []
220
+ for path in sorted(matches_by_file):
221
+ hits = matches_by_file[path]
222
+ lines = file_lines[path]
223
+ rel = path.relative_to(root)
224
+ sections.append(
225
+ f"─ {rel} ({len(hits)} match{'es' if len(hits) != 1 else ''}) "
226
+ + "─" * max(0, 40 - len(str(rel)))
227
+ )
228
+ # For each hit, show context lines. If multiple hits are
229
+ # close together their context windows merge naturally —
230
+ # we DON'T deduplicate here because that'd hide hit
231
+ # boundaries; instead we render each hit's window. Agent
232
+ # gets a slight redundancy in exchange for clearer per-
233
+ # hit framing.
234
+ for lineno, _ in hits:
235
+ start = max(0, lineno - 1 - context)
236
+ end = min(len(lines), lineno + context)
237
+ for i in range(start, end):
238
+ marker = "▸ " if i + 1 == lineno else " "
239
+ sections.append(
240
+ f" {marker}{i + 1:4d} │ {lines[i].rstrip()}"
241
+ )
242
+ sections.append("") # blank line between hit windows
243
+ # Remove trailing blank for tidiness.
244
+ if sections and sections[-1] == "":
245
+ sections.pop()
246
+ return "\n".join(sections)
247
+
248
+
249
+ def enhanced_grep_tool(
250
+ workdir: Path | str,
251
+ *,
252
+ max_files_with_matches: int = 30,
253
+ max_matches_per_file: int = 10,
254
+ default_context: int = 2,
255
+ ) -> Tool:
256
+ """Build the loom-code grep tool. Sees the model with:
257
+
258
+ grep(pattern, path=".", glob="*",
259
+ ignore_case=False, context=2,
260
+ include_tests=False, raw=False,
261
+ type="")
262
+
263
+ ``pattern`` is a Python regex. ``path`` is relative to the agent's
264
+ workdir. ``context`` is ±N lines around each match (default 2).
265
+ ``include_tests=True`` un-collapses test-file hits. ``raw=True``
266
+ drops the grouped/contextual rendering and falls back to flat
267
+ ``path:lineno: line`` lines (loomflow's classic shape) for
268
+ consumers that want one-line-per-match. ``type`` is a
269
+ comma-separated extension filter (e.g. ``"py,ts"``) — empty
270
+ means no filter.
271
+ """
272
+ root = Path(workdir).resolve()
273
+
274
+ async def grep(
275
+ pattern: str,
276
+ path: str = ".",
277
+ glob: str = "*",
278
+ ignore_case: bool = False,
279
+ context: int = default_context,
280
+ include_tests: bool = False,
281
+ raw: bool = False,
282
+ type: str = "", # noqa: A002 — model-facing arg name; matches CLI ergonomics
283
+ ) -> str:
284
+ """Find regex matches under ``path`` and return grouped,
285
+ context-rich results. See module docstring for the full
286
+ contract."""
287
+ # Coerce model-supplied args defensively — the tool-call
288
+ # layer serialises typed params as strings ("2", "true"),
289
+ # which crashed the line-window math ('int - str') and
290
+ # made bool flags always-truthy. Same lenient coercion
291
+ # loomflow's plan_write does for weak-model serialisation.
292
+ context = _as_int(context, default_context)
293
+ ignore_case = _as_bool(ignore_case, default=False)
294
+ include_tests = _as_bool(include_tests, default=False)
295
+ raw = _as_bool(raw, default=False)
296
+
297
+ # Resolve and bounds-check ``path``. Must stay under root.
298
+ target = (root / path).resolve()
299
+ try:
300
+ target.relative_to(root)
301
+ except ValueError:
302
+ return f"grep: refusing to search outside workdir: {target}"
303
+ if not target.exists():
304
+ return f"grep: path not found: {path}"
305
+
306
+ # Pre-compile the pattern; surface regex errors to the agent
307
+ # so it can fix the call instead of getting empty output.
308
+ flags = re.IGNORECASE if ignore_case else 0
309
+ try:
310
+ regex = re.compile(pattern, flags)
311
+ except re.error as exc:
312
+ return f"grep: invalid regex {pattern!r}: {exc}"
313
+
314
+ # Normalise the type filter.
315
+ type_filter: tuple[str, ...] | None = None
316
+ if type:
317
+ type_filter = tuple(
318
+ t.strip().lower() for t in type.split(",") if t.strip()
319
+ )
320
+
321
+ # Collect raw per-file hits. FAST PATH: ripgrep (respects
322
+ # .gitignore, Rust-fast). FALLBACK: the pure-Python walk below,
323
+ # used when rg is absent or rejects the pattern (lookahead /
324
+ # backrefs) — so capability never regresses, only speed varies.
325
+ raw_hits: dict[Path, list[tuple[int, str]]] = {}
326
+ rg_result = _collect_with_ripgrep(
327
+ target,
328
+ pattern,
329
+ ignore_case=ignore_case,
330
+ glob=glob,
331
+ type_filter=type_filter,
332
+ max_files=max_files_with_matches * 4,
333
+ max_per_file=max_matches_per_file,
334
+ )
335
+ if rg_result is not None:
336
+ raw_hits = rg_result
337
+ else:
338
+ # Pure-Python walk: read every candidate file, regex each
339
+ # line. Identical regex dialect to the agent's `pattern`.
340
+ for fpath in _walk_files(target, glob, type_filter):
341
+ try:
342
+ text = fpath.read_text(
343
+ encoding="utf-8", errors="replace"
344
+ )
345
+ except OSError:
346
+ continue
347
+ hits: list[tuple[int, str]] = []
348
+ for i, line in enumerate(text.splitlines(), start=1):
349
+ if regex.search(line):
350
+ hits.append((i, line))
351
+ if len(hits) >= max_matches_per_file:
352
+ break
353
+ if hits:
354
+ raw_hits[fpath] = hits
355
+
356
+ # Shared post-process: apply the test-file collapse + file cap +
357
+ # build the context cache. Runs identically for both paths so rg
358
+ # and Python output are byte-for-byte the same.
359
+ matches_by_file: dict[Path, list[tuple[int, str]]] = {}
360
+ file_lines_cache: dict[Path, list[str]] = {}
361
+ test_file_count = 0
362
+ test_match_count = 0
363
+ for fpath in sorted(raw_hits):
364
+ hits = raw_hits[fpath]
365
+ rel = fpath.relative_to(root)
366
+ if (not include_tests) and _is_test_path(rel):
367
+ test_file_count += 1
368
+ test_match_count += len(hits)
369
+ continue
370
+ try:
371
+ lines = fpath.read_text(
372
+ encoding="utf-8", errors="replace"
373
+ ).splitlines()
374
+ except OSError:
375
+ continue
376
+ matches_by_file[fpath] = hits
377
+ file_lines_cache[fpath] = lines
378
+ if len(matches_by_file) >= max_files_with_matches:
379
+ break
380
+
381
+ if raw:
382
+ # Old flat shape — loomflow's classic output. Kept as
383
+ # an escape hatch for one-line-per-match consumers.
384
+ out: list[str] = []
385
+ for fpath in sorted(matches_by_file):
386
+ rel = fpath.relative_to(root)
387
+ for lineno, line in matches_by_file[fpath]:
388
+ out.append(f"{rel}:{lineno}: {line}")
389
+ if test_match_count and not include_tests:
390
+ out.append(
391
+ f"... +{test_match_count} match(es) in "
392
+ f"{test_file_count} test file(s) "
393
+ "(pass include_tests=True to show)"
394
+ )
395
+ return "\n".join(out) if out else "no matches"
396
+
397
+ # Default rendering: grouped + with context.
398
+ body = _render_grouped(
399
+ matches_by_file,
400
+ file_lines_cache,
401
+ context=context,
402
+ root=root,
403
+ )
404
+ if test_match_count and not include_tests:
405
+ body += (
406
+ f"\n\n+ {test_match_count} match(es) in "
407
+ f"{test_file_count} test file(s) — "
408
+ "pass include_tests=True to show"
409
+ )
410
+ return body
411
+
412
+ # Use the @tool decorator pattern by promoting the closure
413
+ # into a Tool with a manually-built schema. We can't use the
414
+ # bare @tool decorator on a nested function because the
415
+ # decorator-derived description would lose the loom-code-
416
+ # specific guidance the agent needs to pick the right args.
417
+ return tool(
418
+ name="grep",
419
+ description=(
420
+ "Search file contents for a regex. Returns grouped "
421
+ "results: one block per file, with ±2 lines of "
422
+ "context around each hit. Test-file matches are "
423
+ "collapsed by default — pass include_tests=True to "
424
+ "show. Args: pattern (regex), path='.', glob='*', "
425
+ "ignore_case=False, context=2 (±N lines), "
426
+ "include_tests=False, raw=False (raw=True for "
427
+ "flat one-line-per-match output), type='' "
428
+ "(comma-separated extensions e.g. 'py,ts')."
429
+ ),
430
+ )(grep)