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/mcp_host.py ADDED
@@ -0,0 +1,79 @@
1
+ """Compose loom-code's static coder tools with MCP servers.
2
+
3
+ loom-code's coder is built with a static ``list[Tool]`` (read/write/
4
+ edit/bash/...). MCP servers, by contrast, are a *dynamic* ``ToolHost``
5
+ (``MCPRegistry``) whose tool list is only known after connecting. The
6
+ framework's ``Agent(tools=)`` accepts either a list (wrapped in an
7
+ ``InProcessToolHost``) or a ready ``ToolHost`` — but not a list *plus* a
8
+ host.
9
+
10
+ :class:`McpAugmentedHost` bridges the two: it fronts the static
11
+ ``InProcessToolHost`` and the ``MCPRegistry`` as one ``ToolHost``,
12
+ resolving MCP tools **lazily** (``MCPRegistry.list_tools`` / ``call``
13
+ auto-connect on first use, so building the agent costs nothing and a
14
+ down server only surfaces when a tool is actually used). Static tools
15
+ win on a name collision — an MCP server can't shadow ``edit`` / ``bash``.
16
+
17
+ The agent loop needs ``list_tools`` + ``call`` (``watch`` optional); we
18
+ implement all three. Lifecycle: the caller owns the registry and must
19
+ ``await registry.aclose()`` on shutdown (the REPL does this on exit).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import AsyncIterator, Mapping
25
+ from typing import Any
26
+
27
+ from loomflow.core.types import ToolDef, ToolEvent, ToolResult
28
+ from loomflow.tools.registry import InProcessToolHost
29
+
30
+
31
+ class McpAugmentedHost:
32
+ """A ``ToolHost`` = static in-process tools + a lazy MCP registry."""
33
+
34
+ def __init__(self, base: InProcessToolHost, mcp: Any) -> None:
35
+ """``base`` holds the coder's static tools; ``mcp`` is an
36
+ ``MCPRegistry`` (typed ``Any`` so importing this module never
37
+ requires the ``mcp`` extra)."""
38
+ self._base = base
39
+ self._mcp = mcp
40
+
41
+ @property
42
+ def base(self) -> InProcessToolHost:
43
+ return self._base
44
+
45
+ async def list_tools(self, *, query: str | None = None) -> list[ToolDef]:
46
+ base_defs = await self._base.list_tools(query=query)
47
+ base_names = {d.name for d in base_defs}
48
+ try:
49
+ mcp_defs = await self._mcp.list_tools(query=query)
50
+ except Exception: # noqa: BLE001 — a down MCP server must not
51
+ # break tool listing; the static tools still work.
52
+ mcp_defs = []
53
+ # Static tools win on collision — an MCP server can't shadow a
54
+ # builtin like ``edit`` or ``bash``.
55
+ merged = list(base_defs)
56
+ merged.extend(d for d in mcp_defs if d.name not in base_names)
57
+ return merged
58
+
59
+ async def call(
60
+ self,
61
+ tool: str,
62
+ args: Mapping[str, Any],
63
+ *,
64
+ call_id: str = "",
65
+ ) -> ToolResult:
66
+ # Static tools take precedence; only fall through to MCP for a
67
+ # name the base host doesn't know.
68
+ if self._base.get(tool) is not None:
69
+ return await self._base.call(tool, args, call_id=call_id)
70
+ # ``self._mcp`` is typed ``Any`` (lazy mcp-extra), so annotate
71
+ # the result for mypy --strict.
72
+ result: ToolResult = await self._mcp.call(
73
+ tool, args, call_id=call_id
74
+ )
75
+ return result
76
+
77
+ async def watch(self) -> AsyncIterator[ToolEvent]:
78
+ async for event in self._base.watch():
79
+ yield event
loom_code/operator.py ADDED
@@ -0,0 +1,449 @@
1
+ """Computer Operator mode for loom-code (the ``/computer`` command).
2
+
3
+ Where the default loom-code agent is a *software-engineering* team, the
4
+ Operator is a **computer-use agent**: it operates the user's whole
5
+ machine like a human — files, shell, the web browser, and media/apps —
6
+ under one "you are operating this computer" prompt + an approval gate on
7
+ irreversible real-world actions.
8
+
9
+ Design (see also the project memory ``/computer = full computer
10
+ control``):
11
+
12
+ * The Operator is a ``Team.supervisor`` whose **coordinator holds the
13
+ action tools directly** (not buried on a delegate worker). This is the
14
+ fix for the original bug where browser tools sat on the coder and the
15
+ coordinator that talks to the user couldn't reach them. Workers exist
16
+ for genuinely parallel sub-tasks; the coordinator itself can act.
17
+
18
+ * Capabilities are layered:
19
+ - Tier 0 (reuse): read / write / edit / bash / grep / ls / find /
20
+ web_fetch — loom-code already has these.
21
+ - Tier 1: browser control via the Playwright MCP server (visible
22
+ Chromium) — wired by the REPL's ``/computer`` handler as a
23
+ built-in MCP server, composed in via ``McpAugmentedHost``.
24
+ - Tier 2 (this module): native media + app control — open apps,
25
+ play/pause music, volume, notifications, timers. macOS first
26
+ (``osascript`` / ``open``); per-OS dispatch with stubs elsewhere.
27
+
28
+ * Safety: the operator prompt forbids irreversible actions (purchase,
29
+ delete-outside-workspace, send/post) without explicit confirmation,
30
+ and those route through the same ``approval_handler`` the coding agent
31
+ uses. The browser runs HEADED so the user watches and can interrupt.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import platform
38
+ import shutil
39
+
40
+ from loomflow import tool
41
+ from loomflow.tools.registry import Tool
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Tier 2 — native media + app control tools.
45
+ #
46
+ # These wrap OS-native commands. macOS is the primary target (osascript +
47
+ # open); Windows/Linux get best-effort fallbacks so the tool exists
48
+ # everywhere but degrades with a clear message where unsupported.
49
+ # ---------------------------------------------------------------------------
50
+
51
+ _OS = platform.system() # "Darwin" | "Windows" | "Linux"
52
+
53
+
54
+ async def _run(
55
+ cmd: list[str],
56
+ timeout: float = 20.0, # noqa: ASYNC109 — applied via wait_for below
57
+ ) -> tuple[int, str, str]:
58
+ """Run a command, return (rc, stdout, stderr). Never raises."""
59
+ try:
60
+ proc = await asyncio.create_subprocess_exec(
61
+ *cmd,
62
+ stdout=asyncio.subprocess.PIPE,
63
+ stderr=asyncio.subprocess.PIPE,
64
+ )
65
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
66
+ return (
67
+ proc.returncode or 0,
68
+ out.decode("utf-8", "replace").strip(),
69
+ err.decode("utf-8", "replace").strip(),
70
+ )
71
+ except (TimeoutError, OSError) as exc:
72
+ return 1, "", str(exc)
73
+
74
+
75
+ async def _osascript(script: str) -> tuple[int, str, str]:
76
+ return await _run(["osascript", "-e", script])
77
+
78
+
79
+ def _open_app_tool() -> Tool:
80
+ async def open_app(name: str) -> str:
81
+ """Launch / focus a desktop application by name."""
82
+ name = name.strip()
83
+ if not name:
84
+ return "error: no app name given"
85
+ if _OS == "Darwin":
86
+ rc, _out, err = await _run(["open", "-a", name])
87
+ if rc == 0:
88
+ return f"opened {name}"
89
+ return f"could not open {name}: {err}"
90
+ if _OS == "Windows":
91
+ rc, _out, err = await _run(["cmd", "/c", "start", "", name])
92
+ if rc == 0:
93
+ return f"opened {name}"
94
+ return f"could not open {name}: {err}"
95
+ # Linux: try the binary name directly, then xdg-open.
96
+ if shutil.which(name):
97
+ rc, _o, err = await _run([name])
98
+ elif shutil.which("xdg-open"):
99
+ rc, _o, err = await _run(["xdg-open", name])
100
+ else:
101
+ return f"could not open {name}: no launcher found on this OS"
102
+ return f"opened {name}" if rc == 0 else f"could not open {name}: {err}"
103
+
104
+ return tool(
105
+ name="open_app",
106
+ description=(
107
+ "Launch or focus a desktop application by name (e.g. 'Spotify', "
108
+ "'Safari', 'Notes', 'Calculator'). Use when the user wants to "
109
+ "open or switch to an app. Arg: name (the app's display name)."
110
+ ),
111
+ )(open_app)
112
+
113
+
114
+ def _media_control_tool() -> Tool:
115
+ async def media_control(
116
+ action: str, amount: int = 0, query: str = ""
117
+ ) -> str:
118
+ """Control media playback / system volume."""
119
+ action = action.strip().lower()
120
+ if _OS != "Darwin":
121
+ return (
122
+ f"media_control is macOS-only for now (this is {_OS}). "
123
+ "Ask me to use the browser or an app instead."
124
+ )
125
+ # System media keys via AppleScript. Works for the active player
126
+ # (Music/Spotify/browser media) on macOS.
127
+ # Play a SPECIFIC artist/song/playlist in Apple Music, if asked.
128
+ if action in ("play", "playpause", "toggle") and query.strip():
129
+ q = query.replace('"', '\\"')
130
+ # Open Music, search the catalog/library for the query, and
131
+ # play the first matching track. Uses Music's AppleScript
132
+ # play-by-search; falls back to a library artist filter.
133
+ script = (
134
+ 'tell application "Music"\n'
135
+ ' activate\n'
136
+ ' try\n'
137
+ f' play (every track whose artist contains "{q}")\n'
138
+ ' on error\n'
139
+ ' try\n'
140
+ f' play (every track whose name contains "{q}")\n'
141
+ ' end try\n'
142
+ ' end try\n'
143
+ 'end tell'
144
+ )
145
+ rc, _o, err = await _osascript(script)
146
+ if rc == 0:
147
+ return f'playing "{query}" in Apple Music'
148
+ return (
149
+ f'could not play "{query}" from your Music library '
150
+ f"({err or 'not found'}). It may not be in your library — "
151
+ "open Apple Music and search, or use the browser "
152
+ "(YouTube Music) instead."
153
+ )
154
+ if action in ("play", "pause", "playpause", "toggle"):
155
+ rc, _o, err = await _osascript(
156
+ 'tell application "System Events" to key code 16 using {}'
157
+ ) # F-key media play/pause (key code 16 = playpause on most)
158
+ # Fallback to Music/Spotify direct control if key code is a no-op.
159
+ if rc != 0:
160
+ await _osascript(
161
+ 'tell application "Spotify" to playpause'
162
+ )
163
+ return f"media: {action}"
164
+ if action in ("next", "skip"):
165
+ await _osascript('tell application "Spotify" to next track')
166
+ return "media: next track"
167
+ if action in ("previous", "prev", "back"):
168
+ await _osascript('tell application "Spotify" to previous track')
169
+ return "media: previous track"
170
+ if action in ("volume", "setvolume"):
171
+ vol = max(0, min(100, amount))
172
+ rc, _o, err = await _osascript(f"set volume output volume {vol}")
173
+ return (
174
+ f"volume set to {vol}" if rc == 0 else f"volume failed: {err}"
175
+ )
176
+ if action in ("mute",):
177
+ await _osascript("set volume with output muted")
178
+ return "muted"
179
+ if action in ("unmute",):
180
+ await _osascript("set volume without output muted")
181
+ return "unmuted"
182
+ return (
183
+ f"unknown media action '{action}'. Use: play | pause | next | "
184
+ "previous | volume (with amount 0-100) | mute | unmute."
185
+ )
186
+
187
+ return tool(
188
+ name="media_control",
189
+ description=(
190
+ "Control music / media playback + system volume (macOS). "
191
+ "Actions: play, pause, next, previous, volume (amount 0-100), "
192
+ "mute, unmute. To play a SPECIFIC artist/song, use "
193
+ "action='play' WITH query='Taylor Swift' — it searches your "
194
+ "Apple Music library and plays the match (if it's not in your "
195
+ "library, use the browser / YouTube Music instead). Args: "
196
+ "action; amount (for volume); query (artist/song to play)."
197
+ ),
198
+ )(media_control)
199
+
200
+
201
+ def _notify_tool() -> Tool:
202
+ async def notify(message: str, title: str = "loom-code") -> str:
203
+ """Show a desktop notification."""
204
+ message = message.strip()
205
+ if not message:
206
+ return "error: empty message"
207
+ if _OS == "Darwin":
208
+ safe_msg = message.replace('"', '\\"')
209
+ safe_title = title.replace('"', '\\"')
210
+ await _osascript(
211
+ f'display notification "{safe_msg}" with title "{safe_title}"'
212
+ )
213
+ return "notification shown"
214
+ if _OS == "Linux" and shutil.which("notify-send"):
215
+ await _run(["notify-send", title, message])
216
+ return "notification shown"
217
+ return f"notifications not supported on {_OS}"
218
+
219
+ return tool(
220
+ name="notify",
221
+ description=(
222
+ "Show a desktop notification to the user. Use to surface a "
223
+ "result, a reminder, or a 'done' signal. Args: message; title "
224
+ "(optional)."
225
+ ),
226
+ )(notify)
227
+
228
+
229
+ def _reveal_tool() -> Tool:
230
+ async def reveal_in_finder(path: str) -> str:
231
+ """Open the file manager with the given file/folder highlighted."""
232
+ from pathlib import Path as _P
233
+
234
+ p = _P(path).expanduser() # noqa: ASYNC240 — pure string math, no disk I/O
235
+ # Resolve ~-relative + bare names against home so "Downloads/x"
236
+ # works like a human means it.
237
+ if not p.is_absolute():
238
+ p = _P.home() / path
239
+ target = str(p)
240
+ if _OS == "Darwin":
241
+ rc, _o, err = await _run(["open", "-R", target])
242
+ if rc != 0: # -R fails if path missing; open the parent dir
243
+ await _run(["open", str(p.parent)])
244
+ return f"revealed {target} in Finder"
245
+ if _OS == "Windows":
246
+ await _run(["explorer", "/select,", target])
247
+ return f"revealed {target} in Explorer"
248
+ # Linux: open the containing folder.
249
+ if shutil.which("xdg-open"):
250
+ await _run(["xdg-open", str(p.parent)])
251
+ return f"opened {p.parent} in the file manager"
252
+ return f"file manager reveal not supported on {_OS}"
253
+
254
+ return tool(
255
+ name="reveal_in_finder",
256
+ description=(
257
+ "Open the file manager (Finder/Explorer) with a file or folder "
258
+ "highlighted, so the user SEES it. Use after creating or "
259
+ "changing a file the user will want to look at (e.g. after "
260
+ "writing ~/Downloads/test.py, reveal it). Arg: path."
261
+ ),
262
+ )(reveal_in_finder)
263
+
264
+
265
+ def media_app_tools() -> list[Tool]:
266
+ """The Tier 2 native media + app tools for the Operator."""
267
+ return [
268
+ _open_app_tool(),
269
+ _media_control_tool(),
270
+ _notify_tool(),
271
+ _reveal_tool(),
272
+ ]
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Operator system prompt.
277
+ # ---------------------------------------------------------------------------
278
+
279
+ OPERATOR_PROMPT = """\
280
+ You are loom-code in COMPUTER OPERATOR mode. You operate the user's
281
+ computer for them like a capable human assistant at the keyboard — using
282
+ whatever tool fits each step:
283
+
284
+ - Web tasks → the browser tools. ALWAYS page_observe to see the page
285
+ before acting; act on the [ids] from the LATEST observe; re-observe
286
+ after navigation. When DOM text isn't enough (reading prices/results,
287
+ understanding layout, or you're about to say "nothing's there"),
288
+ page_look — it SCREENSHOTS the page and a vision model tells you what's
289
+ actually on screen. Reach for page_look BEFORE giving up on finding
290
+ info; seeing beats guessing.
291
+ - Files / folders → read/write/edit/ls/find. These reach the user's
292
+ WHOLE machine (rooted at the home dir) — Downloads, Documents, Desktop,
293
+ anywhere. "create test.py in Downloads" → write ~/Downloads/test.py.
294
+ After CREATING a file the user will want to look at, reveal_in_finder
295
+ it so they SEE it pop up (writing is silent otherwise).
296
+ - System / programs → bash (the user's real shell — use it freely for
297
+ system tasks), open apps with open_app, control music with
298
+ media_control (action='play', query='<artist>'), surface results with
299
+ notify.
300
+
301
+ You CAN operate this computer. Don't refuse system tasks with "I don't
302
+ have permission" — you have a real shell and home-rooted file access. For
303
+ e.g. "check for system updates" run bash `softwareupdate -l`; "what's
304
+ running" → `ps aux`; "free disk" → `df -h`. Actually DO the task with the
305
+ tools; only the approval gate (which the user answers) can stop a
306
+ destructive action. The one hard line is the SAFETY list below
307
+ (purchases / mass-delete / sending on the user's behalf) — confirm first.
308
+
309
+ How to work:
310
+ - Break the request into small, observable steps and narrate each ("I'm
311
+ opening the flights site… I see the origin field… typing Delhi…").
312
+ - Observe before you act (snapshot the page, ls the folder) so you act on
313
+ reality, not assumption.
314
+ - Prefer the most direct tool: don't write a script to do what a browser
315
+ click or an app launch does. NEVER use the bash tool to print an excuse
316
+ or a status message — if a tool fails, say so in plain text and try a
317
+ different approach.
318
+
319
+ BROWSER TOOLS — use page_open / page_observe / page_act / page_check /
320
+ page_press / page_back. Element [ids] from page_observe are STABLE (they
321
+ ride the DOM), but the page CONTENT changes, so:
322
+
323
+ UNDERSTAND THE PAGE BEFORE ACTING. Do not type into a field until you
324
+ know what it IS. page_observe lists every element as:
325
+ [15] input "Where from?" (value="…")
326
+ [17] input "Where to?"
327
+ READ the labels. Match the RIGHT field to the RIGHT value — e.g. origin
328
+ goes in "Where from?", destination in "Where to?". If a label is missing
329
+ or unclear, do NOT guess: pick the most likely one, fill it, then VERIFY.
330
+
331
+ Choosing the right fill tool:
332
+ - AUTOCOMPLETE / combobox field (shows a suggestion dropdown as you
333
+ type — flight origin/destination, Google Maps, address, "search with
334
+ suggestions"): use page_fill(id, value). It types, waits for the
335
+ dropdown, and selects the match so the value COMMITS. Plain page_act
336
+ "type" REVERTS on these (e.g. origin snaps back to "Kathmandu") — so
337
+ do NOT use page_act type for fields with suggestions.
338
+ - Plain text input / textarea (no dropdown): page_act(id, "type", val).
339
+ - DATE / calendar field: page_set_date(id, "2026-06-09"). NEVER click
340
+ day cells by guessing ids with page_act — that flails. page_set_date
341
+ opens the calendar and clicks the right day for you.
342
+
343
+ ONE FIELD AT A TIME. Never call two fill tools in the same step — fill
344
+ ONE field, wait for its result, page_check it, THEN do the next. Filling
345
+ origin and destination together races and both fail.
346
+
347
+ The required loop for EACH field, done sequentially:
348
+ 1. page_observe — read labels, pick the field by its label.
349
+ 2. Fill it with the RIGHT tool (page_fill for autocomplete, else
350
+ page_act type). Match the value to the field's purpose: origin →
351
+ "Where from?", destination → "Where to?". page_fill clicks the field
352
+ to open it, types into the real input, and picks the suggestion — so
353
+ give it the field's id and let it do the whole dance.
354
+ 3. page_check("is <value> set as <the field's purpose>?") — returns the
355
+ field's REAL current value. Confirm it matches BEFORE the next field.
356
+ Sites often pre-fill origin with your location — so always set BOTH
357
+ origin and destination explicitly and verify each.
358
+ 4. If page_check shows the wrong value, page_observe again (the widget
359
+ may have changed the ids) and redo step 2 with page_fill.
360
+
361
+ SUBMIT correctly — this is the #1 reason results don't appear. Pressing
362
+ Enter on a field usually does NOT load results; you must CLICK the real
363
+ Search button. The exact sequence after filling fields + dates:
364
+ 1. The date calendar is probably still open. Dismiss it: find a "Done"
365
+ button in page_observe and page_act click it (or page_press Escape).
366
+ 2. page_observe again — now the "Search" button is visible.
367
+ 3. page_act CLICK the button literally labeled "Search" (or "Search
368
+ flights"/"Submit"/"Go"). Do NOT use press_enter for this.
369
+ 4. Wait, then page_read / page_scroll + page_read to get the RESULTS
370
+ LIST (airlines, times, per-flight prices).
371
+ If after a Search click you still only see a price CALENDAR / a "from
372
+ $X" graph (not a list of individual flights), you're on the explore view
373
+ — look for and click a "Search" or "Done" that switches to the results
374
+ list. The calendar's "from <price>" is the summary; the LIST has the
375
+ airlines. Optional fields (return date, filters) aren't required.
376
+
377
+ CLOSE pop-ups/overlays before reading. After picking dates a calendar
378
+ dialog stays open with a "Done" button — page_observe, click "Done" (or
379
+ the primary confirm), THEN the results show. A lingering date dialog is
380
+ the usual reason results "don't appear".
381
+
382
+ READ the results — this is how you ANSWER the user. After submitting/
383
+ confirming, call page_read to get the page's actual text. page_observe
384
+ only lists CLICKABLE elements, so a results page looks "empty" there even
385
+ when full of prices — NEVER conclude from page_observe; use page_read.
386
+
387
+ CRITICAL — believe what page_read shows. If page_read contains numbers
388
+ that look like prices (e.g. "120K", "99K", "$612", "NPR 120,421", a
389
+ "from <price>" line), those ARE the fares — REPORT THEM. Do NOT invent
390
+ excuses like "fares aren't released yet" / "too far in advance" / "no
391
+ detailed listings this far ahead" — those are FALSE; flights are
392
+ bookable a year out. If you see prices, the data exists; dig into it.
393
+
394
+ Get the FULL listings (names + details), not just the summary price. On a
395
+ flight/shop search the first view is often a price CALENDAR or a "from
396
+ $X" teaser — that is NOT the results list. To reach the actual listings
397
+ (airline names, times, per-option prices):
398
+ 1. Close any open calendar/dialog (click Done), then page_observe and
399
+ click the main "Search" button (or the selected date's "Done").
400
+ 2. Wait, then page_scroll("down") and page_read — repeat scrolling +
401
+ reading until you've captured the individual options (e.g.
402
+ "British Airways · 7h 30m · NPR 121,000").
403
+ 3. Report the concrete cheapest option WITH its airline/details.
404
+ Only say a detail is unavailable if it's genuinely absent after you
405
+ reached the results list and scroll-read it.
406
+
407
+ WHEN IN DOUBT, LOOK. If page_read/page_observe seem to miss content, or
408
+ you're tempted to say "nothing's there" / "not available", call
409
+ page_look first — it screenshots the page (with the [id] boxes) and a
410
+ vision model tells you what's actually on screen. SEEING beats guessing;
411
+ use it to read prices/results off a visual page before any negative
412
+ conclusion.
413
+
414
+ Recovery:
415
+ - "element no longer on the page" → page_observe to get current ids.
416
+ - a click is blocked / times out → page_press("Escape") to dismiss an
417
+ overlay, page_observe, retry.
418
+ - a date/calendar widget is fiddly → SKIP it (search with default dates)
419
+ unless the user asked for specific dates.
420
+ - If a complex site keeps fighting after ~3 corrected attempts, switch
421
+ approaches: open a plain web search ("flights London to New York
422
+ price"), or Kayak, and read the results. Getting the user the answer
423
+ beats wrestling one stubborn page.
424
+
425
+ SAFETY — never do anything irreversible without the user explicitly
426
+ confirming first:
427
+ - purchases / payments (Buy, Pay, Place order, Confirm, checkout),
428
+ - deleting or overwriting files the user didn't ask you to,
429
+ - sending messages / emails / posts on the user's behalf.
430
+ For these, stop and ask: "Ready to <action>? Confirm and I'll proceed."
431
+
432
+ Be transparent: report what you see and what you did at every step.
433
+ """
434
+
435
+
436
+ def build_operator_prompt() -> str:
437
+ """The operator prompt with TODAY'S date prepended, so relative dates
438
+ ("tomorrow", "next week", "in 3 days") resolve correctly. Without
439
+ this the model guesses the date (it picked 2024 for "tomorrow")."""
440
+ import datetime as _dt
441
+
442
+ today = _dt.date.today()
443
+ header = (
444
+ f"Today's date is {today:%A, %B %d, %Y} ({today:%Y-%m-%d}). "
445
+ "Resolve any relative dates the user gives — 'tomorrow', 'next "
446
+ "Friday', 'in 3 days' — against THIS date, and pass concrete "
447
+ "YYYY-MM-DD dates to page_set_date.\n\n"
448
+ )
449
+ return header + OPERATOR_PROMPT
loom_code/paste.py ADDED
@@ -0,0 +1,97 @@
1
+ """Bracketed-paste collapsing for the REPL.
2
+
3
+ Pasting a large block (a stack trace, a file, etc.) into the input
4
+ line used to dump the whole thing into the visible prompt — noisy,
5
+ hard to keep editing alongside it, and not what Claude Code does.
6
+ This module gives prompt_toolkit a binding that:
7
+
8
+ 1. Stashes the full paste in a module-level list.
9
+ 2. Inserts a short placeholder — ``[paste-N: <lines>, <chars>]`` —
10
+ into the input line in its place.
11
+ 3. On submit, :func:`expand_pastes` rewrites those placeholders
12
+ back to the full text BEFORE the line goes to the agent. So
13
+ the agent still sees everything; only the visible input line
14
+ is collapsed.
15
+
16
+ The stash lives for the whole REPL session so pastes survive
17
+ multiple turns; ``/clear`` (in the REPL) drops them.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+
24
+ from prompt_toolkit.key_binding import KeyBindings
25
+ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
26
+ from prompt_toolkit.keys import Keys
27
+
28
+ # Triggers: paste is "big" if it's longer than this many chars OR
29
+ # contains more than this many newlines. The OR catches both
30
+ # "huge single-line log" and "30-line code snippet" — both are
31
+ # noisy in the prompt.
32
+ _PASTE_CHAR_THRESHOLD = 500
33
+ _PASTE_LINE_THRESHOLD = 4
34
+
35
+ # (paste_id, full_text) in submission order. Module-level so the
36
+ # binding closure and ``expand_pastes`` share the same state
37
+ # without the REPL having to thread the stash through.
38
+ _pastes: list[str] = []
39
+
40
+ _PLACEHOLDER_RE = re.compile(r"\[paste-(\d+):[^\]]*\]")
41
+
42
+
43
+ def build_paste_keybindings() -> KeyBindings:
44
+ """Return a ``KeyBindings`` registered with a handler for
45
+ ``Keys.BracketedPaste`` — short pastes pass through, long
46
+ pastes are stashed + replaced with a placeholder."""
47
+ kb = KeyBindings()
48
+
49
+ @kb.add(Keys.BracketedPaste)
50
+ def _on_paste(event: KeyPressEvent) -> None:
51
+ text = event.data
52
+ lines = text.count("\n") + 1 if text else 0
53
+ is_big = (
54
+ len(text) > _PASTE_CHAR_THRESHOLD
55
+ or text.count("\n") > _PASTE_LINE_THRESHOLD
56
+ )
57
+ if not is_big:
58
+ # Short paste — insert verbatim, no collapsing.
59
+ event.current_buffer.insert_text(text)
60
+ return
61
+ # Stash + insert a placeholder. The placeholder syntax is
62
+ # readable to the user AND grep-able by ``expand_pastes``.
63
+ _pastes.append(text)
64
+ idx = len(_pastes)
65
+ placeholder = (
66
+ f"[paste-{idx}: {lines} lines, {len(text)} chars]"
67
+ )
68
+ event.current_buffer.insert_text(placeholder)
69
+
70
+ return kb
71
+
72
+
73
+ def expand_pastes(line: str) -> str:
74
+ """Replace any ``[paste-N: ...]`` placeholder in ``line`` with
75
+ the full text we stashed for that paste. Unknown indices
76
+ (e.g. user typed one by hand) are left as-is."""
77
+
78
+ def _replace(match: re.Match[str]) -> str:
79
+ idx = int(match.group(1)) - 1
80
+ if 0 <= idx < len(_pastes):
81
+ return _pastes[idx]
82
+ return match.group(0)
83
+
84
+ return _PLACEHOLDER_RE.sub(_replace, line)
85
+
86
+
87
+ def reset_paste_stash() -> None:
88
+ """Drop every stashed paste — called by ``/clear`` so the user
89
+ can start a fresh conversation thread without yesterday's
90
+ pastes silently lurking under old placeholder indices."""
91
+ _pastes.clear()
92
+
93
+
94
+ def stash_size() -> int:
95
+ """How many pastes are currently stashed. Used by /pastes or
96
+ similar diagnostic commands (none yet)."""
97
+ return len(_pastes)
loom_code/paths.py ADDED
@@ -0,0 +1,52 @@
1
+ """Shared file-path resolution — the ``expandPath`` equivalent.
2
+
3
+ loom-code follows Claude Code's boundary model: the file *tools*
4
+ resolve any path the OS can name (``~``-expansion, cwd-relative,
5
+ absolute), and the SECURITY boundary lives in the permission layer
6
+ (:mod:`loom_code.permissions` + the approval gate), NOT in a hard
7
+ cwd wall inside the tool. This is the opposite of loomflow's built-in
8
+ tools, whose ``_resolve_within`` raises on anything outside the
9
+ workdir — which is why pointing loom-code at a file one directory up
10
+ used to fail with "file not found" no matter the path.
11
+
12
+ ``resolve_path`` is the single canonical resolver every loom-code
13
+ file tool runs its ``path`` argument through:
14
+
15
+ * ``~`` / ``~user`` → home expansion,
16
+ * a RELATIVE path → resolved against the project root (so a bare
17
+ ``loom_code/agent.py`` still means the project file, the common
18
+ case),
19
+ * an ABSOLUTE path → taken as-is (normalised).
20
+
21
+ It does NOT decide whether the path is allowed — that's the gate's
22
+ job. It only turns "what the model typed" into a concrete absolute
23
+ path for the gate to rule on and the tool to act on.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+
31
+ def resolve_path(path: str, project_root: Path | str) -> Path:
32
+ """Resolve ``path`` to an absolute :class:`Path`.
33
+
34
+ Relative paths anchor to ``project_root`` (bare names stay
35
+ project files); ``~`` expands to home; absolute paths pass
36
+ through. Always returns a resolved (symlink- and ``..``-collapsed)
37
+ absolute path so the caller can permission-check the REAL target.
38
+ """
39
+ p = Path(path).expanduser()
40
+ if not p.is_absolute():
41
+ p = Path(project_root) / p
42
+ return p.resolve()
43
+
44
+
45
+ def is_within(path: Path | str, root: Path | str) -> bool:
46
+ """True if ``path`` is inside ``root`` (or equal). Both are
47
+ resolved first, so symlinks / ``..`` can't fake containment."""
48
+ try:
49
+ Path(path).resolve().relative_to(Path(root).resolve())
50
+ return True
51
+ except (ValueError, OSError):
52
+ return False