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/trust.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Trust gating for project-scope hooks.
|
|
2
|
+
|
|
3
|
+
A user-scope hook (``~/.loom-code/settings.toml``) is something YOU
|
|
4
|
+
wrote — it runs without ceremony. A project-scope hook
|
|
5
|
+
(``<repo>/.loom/settings.toml``) is a stranger's code: clone a repo,
|
|
6
|
+
open it in loom-code, and its hooks would otherwise run shell commands
|
|
7
|
+
on your machine automatically. That's a real code-execution vector, so
|
|
8
|
+
project hooks are gated:
|
|
9
|
+
|
|
10
|
+
* The first time loom-code sees a repo's project hooks — or whenever
|
|
11
|
+
the set of commands changes — it shows you the commands and asks
|
|
12
|
+
whether to trust them.
|
|
13
|
+
* Your answer is remembered (keyed by repo path + a fingerprint of the
|
|
14
|
+
hook commands) in ``~/.loom-code/trusted_hooks.json``, so you're
|
|
15
|
+
asked again only if the hooks change.
|
|
16
|
+
|
|
17
|
+
Skills and subagents are NOT gated here: they run only when the model
|
|
18
|
+
invokes them, and every tool they call still goes through the normal
|
|
19
|
+
approval gate. Hooks are the one thing that fires automatically, so
|
|
20
|
+
they're the one thing that needs up-front consent.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .extensions import Extensions, HookSpec, McpEntry
|
|
31
|
+
from .extensions import discover as _discover
|
|
32
|
+
|
|
33
|
+
_DEFAULT_TRUST_STORE = Path.home() / ".loom-code" / "trusted_hooks.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def deny_untrusted(_specs: list[HookSpec]) -> bool:
|
|
37
|
+
"""A non-interactive trust prompt that always declines.
|
|
38
|
+
|
|
39
|
+
The secure default for callers that can't show a prompt (the
|
|
40
|
+
desktop sidecar, scripts, ``build_agent``'s self-discovery path):
|
|
41
|
+
project hooks run ONLY if already recorded as trusted; everything
|
|
42
|
+
not-yet-trusted is dropped rather than auto-run."""
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def discover_trusted(
|
|
47
|
+
project_root: Path,
|
|
48
|
+
*,
|
|
49
|
+
prompt: Callable[[list[HookSpec]], bool] = deny_untrusted,
|
|
50
|
+
user_dir: Path | None = None,
|
|
51
|
+
trust_store: Path | None = None,
|
|
52
|
+
) -> Extensions:
|
|
53
|
+
"""Discover ``.loom`` extensions and apply the project-hook trust
|
|
54
|
+
gate in one call.
|
|
55
|
+
|
|
56
|
+
The convenience entry every non-REPL caller should use: it returns
|
|
57
|
+
a bundle whose project hooks are already trust-filtered. ``prompt``
|
|
58
|
+
defaults to :func:`deny_untrusted` (secure, non-interactive); pass
|
|
59
|
+
an interactive callback to ask the user."""
|
|
60
|
+
ext = _discover(project_root, user_dir=user_dir)
|
|
61
|
+
return filter_trusted_hooks(
|
|
62
|
+
ext,
|
|
63
|
+
project_root=project_root,
|
|
64
|
+
prompt=prompt,
|
|
65
|
+
trust_store=trust_store,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def filter_trusted_hooks(
|
|
70
|
+
extensions: Extensions,
|
|
71
|
+
*,
|
|
72
|
+
project_root: Path,
|
|
73
|
+
prompt: Callable[[list[HookSpec]], bool],
|
|
74
|
+
trust_store: Path | None = None,
|
|
75
|
+
) -> Extensions:
|
|
76
|
+
"""Return ``extensions`` with untrusted project hooks removed.
|
|
77
|
+
|
|
78
|
+
User-scope hooks always pass through. Project-scope hooks pass only
|
|
79
|
+
when their fingerprint is already recorded as trusted for
|
|
80
|
+
``project_root``, or when ``prompt(project_specs)`` returns True
|
|
81
|
+
(in which case the fingerprint is recorded so we don't ask again).
|
|
82
|
+
On denial the project hooks are dropped — skills, subagents, and
|
|
83
|
+
user hooks are kept untouched.
|
|
84
|
+
|
|
85
|
+
Project-scope MCP servers are gated the SAME way: connecting one runs
|
|
86
|
+
external code / hits an external endpoint, so a cloned repo's MCP
|
|
87
|
+
servers load only if the repo is trusted. Trust is one decision over
|
|
88
|
+
the combined (hooks + MCP) fingerprint; on denial both project hooks
|
|
89
|
+
and project MCP are dropped.
|
|
90
|
+
|
|
91
|
+
``trust_store`` overrides the on-disk record path (tests pass a tmp
|
|
92
|
+
file)."""
|
|
93
|
+
project_hooks = [
|
|
94
|
+
h for h in extensions.hook_specs if h.source == "project"
|
|
95
|
+
]
|
|
96
|
+
project_mcp = [
|
|
97
|
+
m for m in extensions.mcp_specs if m.source == "project"
|
|
98
|
+
]
|
|
99
|
+
if not project_hooks and not project_mcp:
|
|
100
|
+
return extensions
|
|
101
|
+
|
|
102
|
+
if is_trusted(
|
|
103
|
+
project_root, project_hooks, project_mcp, trust_store=trust_store
|
|
104
|
+
):
|
|
105
|
+
return extensions # already trusted, unchanged
|
|
106
|
+
|
|
107
|
+
if prompt(project_hooks):
|
|
108
|
+
record_trust(
|
|
109
|
+
project_root,
|
|
110
|
+
project_hooks,
|
|
111
|
+
project_mcp,
|
|
112
|
+
trust_store=trust_store,
|
|
113
|
+
)
|
|
114
|
+
return extensions
|
|
115
|
+
|
|
116
|
+
# Denied — strip project hooks AND project MCP, keep everything else
|
|
117
|
+
# (skills, subagents, user-scope hooks + MCP).
|
|
118
|
+
kept_hooks = [h for h in extensions.hook_specs if h.source != "project"]
|
|
119
|
+
kept_mcp = [m for m in extensions.mcp_specs if m.source != "project"]
|
|
120
|
+
return Extensions(
|
|
121
|
+
skill_paths=extensions.skill_paths,
|
|
122
|
+
agent_specs=extensions.agent_specs,
|
|
123
|
+
hook_specs=kept_hooks,
|
|
124
|
+
mcp_specs=kept_mcp,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_trusted(
|
|
129
|
+
project_root: Path,
|
|
130
|
+
project_hooks: list[HookSpec],
|
|
131
|
+
project_mcp: list[McpEntry] | None = None,
|
|
132
|
+
*,
|
|
133
|
+
trust_store: Path | None = None,
|
|
134
|
+
) -> bool:
|
|
135
|
+
"""Has the user already trusted *exactly* this repo's gated config
|
|
136
|
+
(project hooks AND MCP servers)?
|
|
137
|
+
|
|
138
|
+
True when the combined fingerprint matches the recorded one for
|
|
139
|
+
``project_root`` (or when there's nothing gated to trust). Lets an
|
|
140
|
+
async caller (the desktop sidecar) decide whether to prompt without
|
|
141
|
+
going through the sync ``filter_trusted_hooks`` callback. ``project_mcp``
|
|
142
|
+
defaults to ``None`` so existing hook-only callers are unchanged."""
|
|
143
|
+
if not project_hooks and not project_mcp:
|
|
144
|
+
return True
|
|
145
|
+
store = trust_store if trust_store is not None else _DEFAULT_TRUST_STORE
|
|
146
|
+
key = str(project_root.resolve())
|
|
147
|
+
return _load(store).get(key) == _fingerprint(project_hooks, project_mcp)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def record_trust(
|
|
151
|
+
project_root: Path,
|
|
152
|
+
project_hooks: list[HookSpec],
|
|
153
|
+
project_mcp: list[McpEntry] | None = None,
|
|
154
|
+
*,
|
|
155
|
+
trust_store: Path | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Record the user's decision to trust exactly this repo's gated
|
|
158
|
+
config (project hooks AND MCP servers), so they aren't re-prompted
|
|
159
|
+
until either changes."""
|
|
160
|
+
if not project_hooks and not project_mcp:
|
|
161
|
+
return
|
|
162
|
+
store = trust_store if trust_store is not None else _DEFAULT_TRUST_STORE
|
|
163
|
+
_record(
|
|
164
|
+
store,
|
|
165
|
+
str(project_root.resolve()),
|
|
166
|
+
_fingerprint(project_hooks, project_mcp),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fingerprint(
|
|
171
|
+
specs: list[HookSpec], mcp: list[McpEntry] | None = None
|
|
172
|
+
) -> str:
|
|
173
|
+
"""A stable hash over a repo's gated executable surface — hooks AND
|
|
174
|
+
MCP servers. Changing any hook command/matcher/event/timeout, or any
|
|
175
|
+
MCP server's name/transport/command/args/url, invalidates trust
|
|
176
|
+
(re-prompts); reordering does not. ``mcp`` defaults to ``None`` so
|
|
177
|
+
existing hook-only callers keep the same fingerprint."""
|
|
178
|
+
hook_payload = sorted(
|
|
179
|
+
("hook", s.event, s.matcher, s.command, s.timeout) for s in specs
|
|
180
|
+
)
|
|
181
|
+
mcp_payload = sorted(
|
|
182
|
+
(
|
|
183
|
+
"mcp",
|
|
184
|
+
m.spec.name,
|
|
185
|
+
m.spec.transport,
|
|
186
|
+
m.spec.command or "",
|
|
187
|
+
tuple(m.spec.args),
|
|
188
|
+
m.spec.url or "",
|
|
189
|
+
)
|
|
190
|
+
for m in (mcp or [])
|
|
191
|
+
)
|
|
192
|
+
return hashlib.sha256(
|
|
193
|
+
repr(hook_payload + mcp_payload).encode("utf-8")
|
|
194
|
+
).hexdigest()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _load(store: Path) -> dict[str, str]:
|
|
198
|
+
if not store.exists():
|
|
199
|
+
return {}
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(store.read_text(encoding="utf-8"))
|
|
202
|
+
except (OSError, json.JSONDecodeError):
|
|
203
|
+
return {}
|
|
204
|
+
return data if isinstance(data, dict) else {}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _record(store: Path, key: str, fingerprint: str) -> None:
|
|
208
|
+
data = _load(store)
|
|
209
|
+
data[key] = fingerprint
|
|
210
|
+
try:
|
|
211
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
store.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
213
|
+
except OSError:
|
|
214
|
+
# A failed write just means we'll ask again next time — never
|
|
215
|
+
# fatal to the session.
|
|
216
|
+
pass
|
loom_code/turn.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Shared per-turn pipeline pieces — ONE home for the learning loop.
|
|
2
|
+
|
|
3
|
+
loom-code has two frontends that each run their own turn loop: the
|
|
4
|
+
terminal REPL (``repl.py``) and loomflow-desktop's sidecar. Per-turn
|
|
5
|
+
behaviour written inside either frontend exists only there and the
|
|
6
|
+
copies drift — the desktop shipped with active recall present but the
|
|
7
|
+
credit signal dormant, exactly that failure. Anything that should
|
|
8
|
+
happen "around every turn" regardless of surface belongs HERE; the
|
|
9
|
+
frontends keep only their surface-specific concerns (console output,
|
|
10
|
+
RPC events, pending-state storage).
|
|
11
|
+
|
|
12
|
+
Current residents:
|
|
13
|
+
|
|
14
|
+
* :func:`learned_notes_block` / :func:`inject_learned_notes` — ACTIVE
|
|
15
|
+
recall: surface the top success-credited notebook notes relevant to
|
|
16
|
+
this prompt as the ``learned_notes`` working block.
|
|
17
|
+
* :func:`attribute_turn` — the credit signal: flush a finished turn's
|
|
18
|
+
cited note slugs (+ file touches) to the workspace / file history.
|
|
19
|
+
Frontends decide *when* (explicit /good–/bad, or the moved-on
|
|
20
|
+
heuristic: a new task arriving without complaint credits the last
|
|
21
|
+
turn); this owns *what crediting means*.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import file_history
|
|
30
|
+
|
|
31
|
+
# Bounded injection: top-3 proven notes, snippet-length excerpts —
|
|
32
|
+
# a few hundred tokens, not a notebook dump.
|
|
33
|
+
_RECALL_LIMIT = 8
|
|
34
|
+
_INJECT_TOP = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def learned_notes_block(
|
|
38
|
+
workspace: Any, prompt: str, *, user_id: str | None
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Render the ``learned_notes`` working-block body for ``prompt``.
|
|
41
|
+
|
|
42
|
+
Empty string when no PROVEN note matches — callers must still
|
|
43
|
+
write the empty block so stale advice from a prior prompt never
|
|
44
|
+
lingers. Slugs are shown so the agent can ``read_note(slug)`` for
|
|
45
|
+
full detail, which also keeps the citation-credit chain alive.
|
|
46
|
+
"""
|
|
47
|
+
matches = await workspace.search_notes(
|
|
48
|
+
prompt,
|
|
49
|
+
user_id=user_id,
|
|
50
|
+
boost_relevance=True,
|
|
51
|
+
limit=_RECALL_LIMIT,
|
|
52
|
+
)
|
|
53
|
+
proven = [m for m in matches if m.summary.success_count > 0][
|
|
54
|
+
:_INJECT_TOP
|
|
55
|
+
]
|
|
56
|
+
if not proven:
|
|
57
|
+
return ""
|
|
58
|
+
lines = [
|
|
59
|
+
"# Learned notes (proven on past turns)",
|
|
60
|
+
"Notes from earlier work on THIS project that were used in "
|
|
61
|
+
"turns the user accepted. Trust but verify — "
|
|
62
|
+
"`read_note(slug)` for the full note.",
|
|
63
|
+
"",
|
|
64
|
+
]
|
|
65
|
+
lines.extend(
|
|
66
|
+
f"- [{m.summary.slug}] (worked "
|
|
67
|
+
f"{m.summary.success_count}x) {m.snippet}"
|
|
68
|
+
for m in proven
|
|
69
|
+
)
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def update_block_if_changed(
|
|
74
|
+
memory: Any,
|
|
75
|
+
name: str,
|
|
76
|
+
content: str,
|
|
77
|
+
*,
|
|
78
|
+
user_id: str | None,
|
|
79
|
+
block_hashes: dict[str, str] | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Write a working block only when its content changed since the
|
|
82
|
+
last write this session — the one dirty-check both the REPL and
|
|
83
|
+
:func:`inject_learned_notes` share.
|
|
84
|
+
|
|
85
|
+
``block_hashes`` (caller-owned) is the per-session content-hash
|
|
86
|
+
map; ``None`` disables the check (always writes). The hash is
|
|
87
|
+
recorded only AFTER a successful write, so a failed write can't
|
|
88
|
+
convince a later turn the block is up to date. Rationale: skips a
|
|
89
|
+
redundant sqlite write + the churn of re-serialising an unchanged
|
|
90
|
+
block every turn (loomflow's update_block is a plain UPSERT)."""
|
|
91
|
+
if block_hashes is None:
|
|
92
|
+
await memory.update_block(name, content, user_id=user_id)
|
|
93
|
+
return
|
|
94
|
+
import hashlib
|
|
95
|
+
|
|
96
|
+
h = hashlib.sha1(content.encode("utf-8")).hexdigest()
|
|
97
|
+
if block_hashes.get(name) == h:
|
|
98
|
+
return
|
|
99
|
+
await memory.update_block(name, content, user_id=user_id)
|
|
100
|
+
block_hashes[name] = h
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def inject_learned_notes(
|
|
104
|
+
workspace: Any,
|
|
105
|
+
memory: Any,
|
|
106
|
+
prompt: str,
|
|
107
|
+
*,
|
|
108
|
+
user_id: str | None,
|
|
109
|
+
block_hashes: dict[str, str] | None = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Write this turn's active-recall block into ``memory``.
|
|
112
|
+
|
|
113
|
+
Best-effort by contract: memory/workspace I/O failing must never
|
|
114
|
+
kill a turn, so callers can ``await`` this bare. ``block_hashes``
|
|
115
|
+
(optional) enables the shared dirty-check — see
|
|
116
|
+
:func:`update_block_if_changed`."""
|
|
117
|
+
try:
|
|
118
|
+
body = await learned_notes_block(
|
|
119
|
+
workspace, prompt, user_id=user_id
|
|
120
|
+
)
|
|
121
|
+
await update_block_if_changed(
|
|
122
|
+
memory,
|
|
123
|
+
"learned_notes",
|
|
124
|
+
body,
|
|
125
|
+
user_id=user_id,
|
|
126
|
+
block_hashes=block_hashes,
|
|
127
|
+
)
|
|
128
|
+
except Exception: # noqa: BLE001 — recall is best-effort
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def attribute_turn(
|
|
133
|
+
workspace: Any,
|
|
134
|
+
root: Path | str,
|
|
135
|
+
*,
|
|
136
|
+
success: bool,
|
|
137
|
+
slugs: list[str],
|
|
138
|
+
files: list[str],
|
|
139
|
+
user_id: str | None,
|
|
140
|
+
) -> int:
|
|
141
|
+
"""Flush one finished turn's learning signal.
|
|
142
|
+
|
|
143
|
+
* ``files`` — paths the turn wrote; their file-history records
|
|
144
|
+
are revised from "unknown" to the now-known outcome
|
|
145
|
+
(independent of the slug path: a turn can edit files without
|
|
146
|
+
citing notes).
|
|
147
|
+
* ``slugs`` — notes the agent read during the turn (the run
|
|
148
|
+
result's ``cited_slugs``); each gets ``cited_count`` += 1 and,
|
|
149
|
+
when ``success``, ``success_count`` += 1 — which is what makes
|
|
150
|
+
it eligible for future active recall.
|
|
151
|
+
|
|
152
|
+
Returns the number of notes credited/debited (0 on failure —
|
|
153
|
+
best-effort by contract)."""
|
|
154
|
+
if files:
|
|
155
|
+
try:
|
|
156
|
+
file_history.update_last_outcome(
|
|
157
|
+
root, files, "success" if success else "fail"
|
|
158
|
+
)
|
|
159
|
+
except Exception: # noqa: BLE001 — history is best-effort
|
|
160
|
+
pass
|
|
161
|
+
if not slugs:
|
|
162
|
+
return 0
|
|
163
|
+
try:
|
|
164
|
+
n = await workspace.attribute_outcome(
|
|
165
|
+
success=success, slugs=slugs, user_id=user_id
|
|
166
|
+
)
|
|
167
|
+
return int(n or 0)
|
|
168
|
+
except Exception: # noqa: BLE001 — crediting is best-effort
|
|
169
|
+
return 0
|