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/compact.py ADDED
@@ -0,0 +1,143 @@
1
+ """Conversation compaction — keeps long REPL sessions from blowing
2
+ through the model's context window.
3
+
4
+ The pattern:
5
+
6
+ 1. After each REPL turn, the REPL accumulates the cumulative token
7
+ count and a list of ``(user_prompt, agent_output)`` exchanges.
8
+ 2. When the cumulative count crosses a threshold (default: 80% of
9
+ the active model's context window; configurable per session via
10
+ the ``/compress_token_length`` slash command) the REPL hands
11
+ the exchanges to a :class:`Compactor`.
12
+ 3. The compactor is a separate, single-shot loomflow ``Agent`` —
13
+ no tools, dedicated prompt — that produces a dense prose
14
+ summary preserving what was attempted, what worked, what
15
+ failed, and any constraints the user expressed.
16
+ 4. The summary lands in ``agent.memory.update_block(
17
+ "session_summary", text)`` — a working block, which loomflow
18
+ auto-injects into every subsequent system prompt.
19
+ 5. The REPL resets ``session_id`` so the next turn starts with a
20
+ fresh conversation thread but immediately "remembers" the
21
+ session-so-far through the working block.
22
+
23
+ Pure loomflow primitives — no framework changes.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from loomflow import Agent
29
+
30
+ # Best-effort context-window lookup. Substring match against known
31
+ # model families; fallback for anything we don't recognise (local
32
+ # Ollama models, niche LiteLLM providers, future model names) —
33
+ # the user can always override via /compress_token_length.
34
+ #
35
+ # Keep this list short and obvious. We're not trying to be a
36
+ # tokenizer service — just give a sensible default that adapts to
37
+ # the model the user picked.
38
+ _KNOWN_CONTEXT_WINDOWS: dict[str, int] = {
39
+ # OpenAI 4.1 family — 1M context.
40
+ "gpt-4.1": 1_000_000,
41
+ # OpenAI 4o family — 128k.
42
+ "gpt-4o": 128_000,
43
+ # OpenAI o-series reasoning models — 200k.
44
+ "o4": 200_000,
45
+ "o3": 200_000,
46
+ # Anthropic 4.x family — 200k.
47
+ "claude-opus": 200_000,
48
+ "claude-sonnet": 200_000,
49
+ "claude-haiku": 200_000,
50
+ }
51
+
52
+ # Conservative default for unknown models — typical of small open
53
+ # local models (llama3 8B = 8k, qwen2.5 7B = 32k, etc.). Users on
54
+ # bigger local models or unusual providers can /compress_token_length
55
+ # upward.
56
+ _FALLBACK_CONTEXT_WINDOW = 32_000
57
+
58
+ # Trigger when cumulative usage crosses this fraction of the model's
59
+ # context window. 0.8 leaves headroom for the current turn's own
60
+ # prompt + tool I/O without bumping the actual limit.
61
+ _DEFAULT_TRIGGER_FRACTION = 0.8
62
+
63
+
64
+ def context_window_for(model: str) -> int:
65
+ """Best-effort context-window estimate for ``model``.
66
+
67
+ Substring match — ``"gpt-4.1-mini"`` and ``"gpt-4.1-nano"`` both
68
+ pick up the ``"gpt-4.1"`` entry. Returns
69
+ :data:`_FALLBACK_CONTEXT_WINDOW` when nothing matches; the user
70
+ can override via ``/compress_token_length`` if the fallback is
71
+ wrong for their model.
72
+ """
73
+ lower = model.lower()
74
+ for key, ctx in _KNOWN_CONTEXT_WINDOWS.items():
75
+ if key in lower:
76
+ return ctx
77
+ return _FALLBACK_CONTEXT_WINDOW
78
+
79
+
80
+ def default_compact_threshold(model: str) -> int:
81
+ """Default token threshold at which the REPL should compact —
82
+ 80% of the model's context window."""
83
+ return int(context_window_for(model) * _DEFAULT_TRIGGER_FRACTION)
84
+
85
+
86
+ _COMPACTOR_PROMPT = """\
87
+ You are the CONVERSATION COMPACTOR. A user has been in a long REPL
88
+ session with a coding agent and the session is approaching its
89
+ context limit. Your job: compress the history into a dense running
90
+ summary the next turn will use as its ONLY memory of everything
91
+ before it.
92
+
93
+ You will receive a list of (USER, AGENT) exchanges. Write ONE
94
+ dense paragraph (300-500 words). Preserve, in roughly this order:
95
+
96
+ 1. What the user is trying to accomplish — the overarching goal.
97
+ 2. Each concrete change attempted and the outcome — succeeded /
98
+ failed / partially done.
99
+ 3. Specific identifiers worth remembering: file paths, function
100
+ names, branch names, command outputs, error messages.
101
+ 4. Decisions or constraints the user expressed ("don't touch X",
102
+ "we agreed on Y", "the API is Z").
103
+ 5. Open questions or in-progress threads.
104
+
105
+ Do NOT:
106
+ - Include casual back-and-forth that didn't lead anywhere.
107
+ - Repeat boilerplate ("I'll help you with that...").
108
+ - Use markdown headers, bullet lists, or code blocks — just prose.
109
+ - Editorialise. Be factual.
110
+
111
+ Make it load-bearing. Future-you will reconstruct context from
112
+ this and nothing else.
113
+ """
114
+
115
+
116
+ class Compactor:
117
+ """A small loomflow ``Agent`` whose only job is to summarise a
118
+ conversation history. Builds once, reuses across compactions
119
+ in the same session."""
120
+
121
+ def __init__(self, *, model: str) -> None:
122
+ self._agent = Agent(
123
+ _COMPACTOR_PROMPT,
124
+ model=model,
125
+ # No tools — single-shot summarisation. prompt_caching
126
+ # helps if the agent runs multiple times in a session
127
+ # (the system prompt is stable).
128
+ prompt_caching=True,
129
+ )
130
+
131
+ async def compact(
132
+ self, exchanges: list[tuple[str, str]]
133
+ ) -> str:
134
+ """Run the compactor on a list of ``(user_prompt,
135
+ agent_output)`` pairs. Returns the summary as plain text."""
136
+ if not exchanges:
137
+ return ""
138
+ rendered = "\n\n".join(
139
+ f"USER:\n{user.strip()}\n\nAGENT:\n{out.strip()}"
140
+ for user, out in exchanges
141
+ )
142
+ result = await self._agent.run(rendered, user_id="loom-code")
143
+ return result.output.strip()
loom_code/consent.py ADDED
@@ -0,0 +1,47 @@
1
+ """Session registry of files the USER explicitly referenced.
2
+
3
+ The consent model in one line: **any path the user types or pastes IS
4
+ the permission to TARGET that file** — bare or ``@``-mentioned — but
5
+ not the permission to skip confirmation. A referenced file may be
6
+ ``edit``/``multi_edit``ed even outside the project root; the
7
+ ApprovalGate STILL forces a diff preview + confirm for every
8
+ outside-project edit, in EVERY mode (accept-edits / yolo / allow-all /
9
+ --yes), so no out-of-tree file is ever mutated without a human seeing
10
+ the change. That gate is why granting on a bare paste is safe: a path
11
+ that merely appears in a pasted stack trace becomes a candidate, but
12
+ the user still sees + rejects the diff prompt before anything is
13
+ written — it is never silently mutated.
14
+
15
+ Module-level on purpose: the REPL registers paths as it expands
16
+ mentions, and the edit tools — built once at agent construction,
17
+ long before any mention exists — consult it lazily per call. A
18
+ plain set beats threading a handle through six build functions.
19
+ Lifetime is the process (one REPL session); nothing persists.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ _granted: set[Path] = set()
27
+
28
+
29
+ def grant(path: Path | str) -> None:
30
+ """Record that the user explicitly referenced ``path``."""
31
+ try:
32
+ _granted.add(Path(path).expanduser().resolve())
33
+ except OSError:
34
+ pass
35
+
36
+
37
+ def is_granted(path: Path | str) -> bool:
38
+ """True if the user referenced exactly this file this session."""
39
+ try:
40
+ return Path(path).expanduser().resolve() in _granted
41
+ except OSError:
42
+ return False
43
+
44
+
45
+ def reset() -> None:
46
+ """Drop all grants (used by /clear and tests)."""
47
+ _granted.clear()