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
@@ -0,0 +1,467 @@
1
+ """Act on an element by its stable ``data-loom-id``.
2
+
3
+ Robustness measures (each targets a failure we hit on Google Flights):
4
+
5
+ * Re-select FRESH every call via ``[data-loom-id="N"]`` — never a stale
6
+ handle. (fixes ``Ref e145 not found``)
7
+ * scroll_into_view + wait before acting.
8
+ * On a click that's intercepted by an overlay/dialog (the
9
+ ``subtree intercepts pointer events`` timeout), fall back to a direct
10
+ JS ``.click()`` dispatch which ignores pointer-event interception.
11
+ * For typing: focus, clear, type, and for autocompletes optionally press
12
+ Enter — then the caller re-observes + can page_check the value stuck.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+
20
+ def _sel(loom_id: int | str) -> str:
21
+ return f'[data-loom-id="{loom_id}"]'
22
+
23
+
24
+ async def act(
25
+ page: Any,
26
+ loom_id: int | str,
27
+ action: str,
28
+ text: str = "",
29
+ ) -> str:
30
+ """Perform ``action`` on the element tagged ``loom_id``.
31
+
32
+ Actions: click | type | clear | press_enter | select (text=option).
33
+ Returns a short human-readable result string."""
34
+ sel = _sel(loom_id)
35
+ locator = page.locator(sel)
36
+ try:
37
+ count = await locator.count()
38
+ except Exception as exc: # noqa: BLE001
39
+ return f"error locating [{loom_id}]: {exc}"
40
+ if count == 0:
41
+ return (
42
+ f"element [{loom_id}] is no longer on the page — call "
43
+ "page_observe to get the current elements + ids."
44
+ )
45
+ el = locator.first
46
+
47
+ # Bring it into view; ignore failures (some elements report unstable).
48
+ try:
49
+ await el.scroll_into_view_if_needed(timeout=3000)
50
+ except Exception: # noqa: BLE001
51
+ pass
52
+
53
+ action = action.strip().lower()
54
+
55
+ if action == "click":
56
+ # Try a normal click first (respects real UX); on interception /
57
+ # timeout, fall back to a JS click that bypasses overlays.
58
+ try:
59
+ await el.click(timeout=4000)
60
+ return f"clicked [{loom_id}]"
61
+ except Exception: # noqa: BLE001 — overlay / unstable; JS fallback
62
+ try:
63
+ await el.evaluate("e => e.click()")
64
+ return (
65
+ f"clicked [{loom_id}] "
66
+ "(via JS — an overlay was in the way)"
67
+ )
68
+ except Exception as exc: # noqa: BLE001
69
+ return (
70
+ f"could not click [{loom_id}]: {exc}. An overlay may be "
71
+ "blocking it — try pressing Escape (page_act on a close "
72
+ "button) or re-observe."
73
+ )
74
+
75
+ if action in ("type", "fill"):
76
+ try:
77
+ await el.click(timeout=3000)
78
+ except Exception: # noqa: BLE001
79
+ try:
80
+ await el.evaluate("e => e.focus()")
81
+ except Exception: # noqa: BLE001
82
+ pass
83
+ try:
84
+ await el.fill("") # clear
85
+ except Exception: # noqa: BLE001
86
+ pass
87
+ try:
88
+ await el.fill(text)
89
+ return (
90
+ f'typed "{text}" into [{loom_id}]. If it is an '
91
+ "autocomplete, "
92
+ "re-observe and CLICK the matching suggestion; "
93
+ "then page_check the field value stuck."
94
+ )
95
+ except Exception: # noqa: BLE001 — fall back to keyboard typing
96
+ try:
97
+ await el.press_sequentially(text, delay=20)
98
+ return f'typed "{text}" into [{loom_id}] (key-by-key)'
99
+ except Exception as exc: # noqa: BLE001
100
+ return f"could not type into [{loom_id}]: {exc}"
101
+
102
+ if action == "clear":
103
+ try:
104
+ await el.fill("")
105
+ return f"cleared [{loom_id}]"
106
+ except Exception as exc: # noqa: BLE001
107
+ return f"could not clear [{loom_id}]: {exc}"
108
+
109
+ if action in ("press_enter", "enter", "submit"):
110
+ try:
111
+ await el.press("Enter")
112
+ return f"pressed Enter on [{loom_id}]"
113
+ except Exception as exc: # noqa: BLE001
114
+ return f"could not press Enter on [{loom_id}]: {exc}"
115
+
116
+ if action == "select":
117
+ try:
118
+ await el.select_option(label=text)
119
+ return f'selected "{text}" in [{loom_id}]'
120
+ except Exception as exc: # noqa: BLE001
121
+ return f"could not select in [{loom_id}]: {exc}"
122
+
123
+ return (
124
+ f"unknown action '{action}'. Use: click | type (with text) | clear | "
125
+ "press_enter | select (with text=option)."
126
+ )
127
+
128
+
129
+ async def set_date(page: Any, loom_id: int | str, date_text: str) -> str:
130
+ """Pick a date in a calendar/date-picker widget.
131
+
132
+ Date pickers are a grid of day cells — guessing cell ids fails. The
133
+ robust path: open the picker (click the date field), then find the day
134
+ cell whose accessible name matches the target date and click it. Day
135
+ cells almost always carry an aria-label / data-iso with the FULL date
136
+ ("Saturday, June 9, 2026" or "2026-06-09"), so we match on that —
137
+ navigating forward through months if the target isn't visible yet.
138
+
139
+ ``date_text`` accepts forms like "2026-06-09", "June 9 2026",
140
+ "9 June 2026". We derive both an ISO and a long-form to match against.
141
+ """
142
+ import asyncio
143
+ import re
144
+
145
+ # Parse the date loosely into (year, month, day).
146
+ months = {m: i for i, m in enumerate(
147
+ ["january","february","march","april","may","june","july","august",
148
+ "september","october","november","december"], start=1)}
149
+ t = date_text.strip().lower()
150
+ y = m = d = None
151
+ iso = re.match(r"(\d{4})-(\d{1,2})-(\d{1,2})", t)
152
+ if iso:
153
+ y, m, d = int(iso[1]), int(iso[2]), int(iso[3])
154
+ else:
155
+ ym = re.search(r"(20\d{2})", t)
156
+ if ym:
157
+ y = int(ym[1])
158
+ for name, num in months.items():
159
+ if name[:3] in t:
160
+ m = num
161
+ break
162
+ dm = re.search(r"\b(\d{1,2})\b", t.replace(str(y or ""), ""))
163
+ if dm:
164
+ d = int(dm[1])
165
+ if not (y and m and d):
166
+ return (
167
+ f'could not parse the date "{date_text}" — use a form like '
168
+ '"2026-06-09" or "June 9 2026".'
169
+ )
170
+
171
+ iso_target = f"{y:04d}-{m:02d}-{d:02d}"
172
+ month_name = [k for k, v in months.items() if v == m][0].capitalize()
173
+ # Long-form fragment most aria-labels contain, e.g. "June 9, 2026".
174
+ long_frag = f"{month_name} {d}, {y}"
175
+ long_frag_alt = f"{month_name} {d} {y}"
176
+
177
+ # 1. Open the picker.
178
+ try:
179
+ await page.locator(_sel(loom_id)).first.click(timeout=4000)
180
+ except Exception: # noqa: BLE001
181
+ pass
182
+ await asyncio.sleep(0.5)
183
+
184
+ # JS to find + click a day cell matching the target across the open
185
+ # calendar; returns "clicked" | "not-found". Tries data-iso / aria.
186
+ _CLICK_DAY_JS = r"""
187
+ (args) => {
188
+ const {iso, longA, longB} = args;
189
+ const cands = document.querySelectorAll(
190
+ '[role="gridcell"], [data-iso], [aria-label], [jsname] div, button');
191
+ for (const el of cands) {
192
+ const al = (el.getAttribute('aria-label') || '').trim();
193
+ const di = (el.getAttribute('data-iso') ||
194
+ el.getAttribute('data-date') || '').trim();
195
+ if (di === iso ||
196
+ (al && (al.includes(longA) || al.includes(longB)))) {
197
+ const r = el.getBoundingClientRect();
198
+ if (r.width > 2 && r.height > 2) { el.click(); return 'clicked'; }
199
+ }
200
+ }
201
+ return 'not-found';
202
+ }
203
+ """
204
+
205
+ # JS to close the open date dialog: click a "Done" (or confirm/search)
206
+ # button that lives inside the calendar dialog. Returns "clicked" |
207
+ # "none". We scope to a dialog/popup so we don't hit the page's main
208
+ # Search prematurely — but a "Done" anywhere visible is fine.
209
+ _CLOSE_CALENDAR_JS = r"""
210
+ () => {
211
+ const norm = (s) => (s || '').replace(/\s+/g,' ').trim().toLowerCase();
212
+ const wants = ['done', 'apply', 'ok', 'select', 'confirm'];
213
+ const els = document.querySelectorAll(
214
+ 'button, [role="button"], [jsname]');
215
+ for (const el of els) {
216
+ const t = norm(el.innerText || el.textContent);
217
+ const al = norm(el.getAttribute('aria-label'));
218
+ if (wants.includes(t) || wants.includes(al)) {
219
+ const r = el.getBoundingClientRect();
220
+ if (r.width > 2 && r.height > 2) { el.click(); return 'clicked'; }
221
+ }
222
+ }
223
+ return 'none';
224
+ }
225
+ """
226
+
227
+ # Try to find the day; if not visible, click a "next month" control and
228
+ # retry a few times (the target month may be ahead).
229
+ for _attempt in range(8):
230
+ try:
231
+ res = await page.evaluate(
232
+ _CLICK_DAY_JS,
233
+ {
234
+ "iso": iso_target,
235
+ "longA": long_frag,
236
+ "longB": long_frag_alt,
237
+ },
238
+ )
239
+ except Exception as exc: # noqa: BLE001
240
+ return f"date-pick failed: {exc}"
241
+ if res == "clicked":
242
+ await asyncio.sleep(0.4)
243
+ # AUTO-CLOSE the calendar so the Search button becomes
244
+ # reachable. The model reliably fails to click "Done" itself
245
+ # (it clicks calendar arrows instead and gets stuck), so the
246
+ # tool does it: click a Done/Search/confirm control if one is
247
+ # in the open dialog, else press Escape.
248
+ closed = "no"
249
+ try:
250
+ closed = await page.evaluate(_CLOSE_CALENDAR_JS)
251
+ except Exception: # noqa: BLE001
252
+ pass
253
+ if closed != "clicked":
254
+ try:
255
+ await page.keyboard.press("Escape")
256
+ except Exception: # noqa: BLE001
257
+ pass
258
+ await asyncio.sleep(0.4)
259
+ return (
260
+ f'selected {iso_target} ({long_frag}) and closed the date '
261
+ "picker. page_observe → click the Search button to load "
262
+ "results, then page_read."
263
+ )
264
+ # Advance to the next month and retry.
265
+ try:
266
+ await page.evaluate(r"""
267
+ () => {
268
+ const next = document.querySelector(
269
+ '[aria-label*="Next" i], [aria-label*="next month" i], '
270
+ + 'button[jsname][aria-label*="forward" i]');
271
+ if (next) next.click();
272
+ }
273
+ """)
274
+ except Exception: # noqa: BLE001
275
+ pass
276
+ await asyncio.sleep(0.4)
277
+
278
+ return (
279
+ f'could not find {iso_target} in the calendar after paging forward. '
280
+ "The date may be too far out, or the picker uses an unusual layout — "
281
+ "page_check to see it, or proceed with flexible dates."
282
+ )
283
+
284
+
285
+ async def press_key(page: Any, key: str) -> str:
286
+ """Press a global key (e.g. Escape to dismiss an overlay)."""
287
+ try:
288
+ await page.keyboard.press(key)
289
+ return f"pressed {key}"
290
+ except Exception as exc: # noqa: BLE001
291
+ return f"could not press {key}: {exc}"
292
+
293
+
294
+ async def scroll(page: Any, direction: str = "down", amount: int = 1) -> str:
295
+ """Scroll the page so lazy-loaded content (search results, listings,
296
+ infinite feeds) renders + becomes readable. direction: down | up |
297
+ top | bottom. amount = number of viewport-heights to scroll."""
298
+ import asyncio
299
+
300
+ direction = direction.strip().lower()
301
+ try:
302
+ if direction == "top":
303
+ await page.evaluate("window.scrollTo(0, 0)")
304
+ elif direction == "bottom":
305
+ await page.evaluate(
306
+ "window.scrollTo(0, document.body.scrollHeight)"
307
+ )
308
+ else:
309
+ sign = -1 if direction == "up" else 1
310
+ for _ in range(max(1, amount)):
311
+ await page.evaluate(
312
+ "(s) => window.scrollBy(0, s * window.innerHeight * 0.9)",
313
+ sign,
314
+ )
315
+ await asyncio.sleep(0.4) # let content load between scrolls
316
+ await asyncio.sleep(0.5)
317
+ return f"scrolled {direction}"
318
+ except Exception as exc: # noqa: BLE001
319
+ return f"could not scroll: {exc}"
320
+
321
+
322
+ # JS: count visible autocomplete-suggestion options currently on screen.
323
+ # Covers the common patterns (role=option, listbox children, *li in a
324
+ # popup). Used to WAIT for the dropdown to render before selecting.
325
+ _COUNT_SUGGESTIONS_JS = r"""
326
+ () => {
327
+ const sels = [
328
+ '[role="option"]',
329
+ '[role="listbox"] li',
330
+ '[role="listbox"] [role="option"]',
331
+ 'ul[role="listbox"] *[role]',
332
+ '.autocomplete-suggestion',
333
+ ];
334
+ let n = 0;
335
+ const seen = new Set();
336
+ for (const s of sels) {
337
+ for (const el of document.querySelectorAll(s)) {
338
+ if (seen.has(el)) continue;
339
+ seen.add(el);
340
+ const r = el.getBoundingClientRect();
341
+ if (r.width > 2 && r.height > 2) n++;
342
+ }
343
+ }
344
+ return n;
345
+ }
346
+ """
347
+
348
+
349
+ # JS: type a value into the CURRENTLY FOCUSED element (the real input
350
+ # that appeared after we clicked the field) + dispatch input/keyboard
351
+ # events so the site's JS reacts. This is the key browser-use trick:
352
+ # operate document.activeElement, not the display box we clicked.
353
+ _FOCUSED_VALUE_JS = r"""
354
+ () => {
355
+ const a = document.activeElement;
356
+ if (!a) return "(no focused element)";
357
+ return (a.value != null ? a.value : (a.innerText || ""));
358
+ }
359
+ """
360
+
361
+
362
+ async def fill_combobox(page: Any, loom_id: int | str, value: str) -> str:
363
+ """Fill an autocomplete/combobox the way a human does — the pattern
364
+ that breaks naive 'type into the box' (flights, maps, address, etc.).
365
+
366
+ The crucial fix (what browser-use does, what we were missing): the
367
+ visible field is often a DISPLAY box that, when CLICKED, opens a
368
+ separate dialog with the REAL <input>. So:
369
+ 1. click the field → opens the widget.
370
+ 2. WAIT for it to settle (the real input gets focus).
371
+ 3. type into the FOCUSED element (page.keyboard), not the display
372
+ box — slowly, so suggestions load.
373
+ 4. WAIT for the suggestion dropdown to render.
374
+ 5. ArrowDown + Enter to commit the first suggestion.
375
+ 6. read back the committed value.
376
+ """
377
+ import asyncio
378
+
379
+ sel = _sel(loom_id)
380
+ locator = page.locator(sel)
381
+ try:
382
+ if await locator.count() == 0:
383
+ return (
384
+ f"combobox [{loom_id}] not found — page_observe for current "
385
+ "ids."
386
+ )
387
+ except Exception as exc: # noqa: BLE001
388
+ return f"error locating [{loom_id}]: {exc}"
389
+ el = locator.first
390
+
391
+ try:
392
+ await el.scroll_into_view_if_needed(timeout=3000)
393
+ except Exception: # noqa: BLE001
394
+ pass
395
+
396
+ # 1. CLICK to open the widget (this is what reveals the real input).
397
+ try:
398
+ await el.click(timeout=4000)
399
+ except Exception: # noqa: BLE001
400
+ try:
401
+ await el.evaluate("e => e.click()")
402
+ except Exception: # noqa: BLE001
403
+ pass
404
+
405
+ # 2. Wait for the widget to settle + the real input to take focus.
406
+ await asyncio.sleep(0.6)
407
+
408
+ # 3. Clear whatever's focused, then type into the FOCUSED element via
409
+ # the keyboard (so we hit the real input, not the display box).
410
+ try:
411
+ # Select-all + delete clears the focused input cross-platform.
412
+ await page.keyboard.press("Control+A")
413
+ await page.keyboard.press("Meta+A") # mac
414
+ await page.keyboard.press("Backspace")
415
+ except Exception: # noqa: BLE001
416
+ pass
417
+ try:
418
+ await page.keyboard.type(value, delay=70)
419
+ except Exception as exc: # noqa: BLE001
420
+ return f"could not type into the focused input for [{loom_id}]: {exc}"
421
+
422
+ # 4. Wait for the suggestion dropdown to render (poll).
423
+ appeared = False
424
+ for _ in range(16): # ~4s total
425
+ try:
426
+ n = await page.evaluate(_COUNT_SUGGESTIONS_JS)
427
+ except Exception: # noqa: BLE001
428
+ n = 0
429
+ if n and n > 0:
430
+ appeared = True
431
+ break
432
+ await asyncio.sleep(0.25)
433
+
434
+ # 5. Commit the first suggestion via keyboard (robust vs portal DOM).
435
+ try:
436
+ await page.keyboard.press("ArrowDown")
437
+ await asyncio.sleep(0.2)
438
+ await page.keyboard.press("Enter")
439
+ except Exception: # noqa: BLE001
440
+ try:
441
+ await page.keyboard.press("Enter")
442
+ except Exception: # noqa: BLE001
443
+ pass
444
+
445
+ await asyncio.sleep(0.5)
446
+
447
+ # 6. Read what committed — try the original element, else the focused
448
+ # element (the dialog input may differ from the display box).
449
+ committed = ""
450
+ try:
451
+ committed = await el.input_value()
452
+ except Exception: # noqa: BLE001
453
+ pass
454
+ if not committed:
455
+ try:
456
+ committed = await page.evaluate(_FOCUSED_VALUE_JS)
457
+ except Exception: # noqa: BLE001
458
+ committed = "(unknown)"
459
+
460
+ note = "" if appeared else (
461
+ " (no suggestion list detected — if the value is wrong, the field "
462
+ "may need a different element; re-observe and check)"
463
+ )
464
+ return (
465
+ f'filled [{loom_id}] with "{value}" → now shows "{committed}"{note}. '
466
+ "page_check to confirm it stuck before the next field."
467
+ )