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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|