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/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