localcode 0.4.0__py3-none-macosx_13_0_arm64.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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- localcode-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""read_file — read a file with optional line offset/limit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import ToolContext
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_LIMIT = 240
|
|
8
|
+
MAX_DEFAULT_CHARS = 12_000
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dynamic_read_defaults(ctx: ToolContext) -> tuple[int, int]:
|
|
12
|
+
"""(default_line_limit, max_default_chars), scaled to the model's real
|
|
13
|
+
context window. read_file is the primary code-ingestion path: on a 256K
|
|
14
|
+
window the fixed 240-line / 12K-char default forces needless pagination and
|
|
15
|
+
re-reads where the machine has the MOST room. Scale up to ~6% of the window
|
|
16
|
+
in chars (and lines proportionally), floored at the static defaults so a
|
|
17
|
+
16 GB Mac (small window) is byte-identical to before.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
ctx_tokens = int(ctx.app.engine._target_num_ctx())
|
|
21
|
+
except Exception:
|
|
22
|
+
ctx_tokens = 0
|
|
23
|
+
if not ctx_tokens:
|
|
24
|
+
return DEFAULT_LIMIT, MAX_DEFAULT_CHARS
|
|
25
|
+
max_chars = max(MAX_DEFAULT_CHARS, int(ctx_tokens * 3.5 * 0.06))
|
|
26
|
+
# Keep the historical ~50 chars/line ratio between the two defaults.
|
|
27
|
+
line_limit = max(DEFAULT_LIMIT, max_chars // 50)
|
|
28
|
+
return line_limit, max_chars
|
|
29
|
+
|
|
30
|
+
SCHEMA = {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "read_file",
|
|
34
|
+
"description": "Read a file. You MUST read before editing. Returns content with line numbers.",
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"path": {"type": "string", "description": "File path relative to repo root"},
|
|
39
|
+
"offset": {"type": "integer", "description": "Start line (0-based). Optional."},
|
|
40
|
+
"limit": {
|
|
41
|
+
"type": "integer",
|
|
42
|
+
"description": (
|
|
43
|
+
"Max lines. Default 240. Use a small targeted range "
|
|
44
|
+
"for large files; only request a larger limit when the "
|
|
45
|
+
"whole file is genuinely needed."
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"required": ["path"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Fuzzy-match ratio at/above which we AUTO-READ the real file instead of
|
|
56
|
+
# merely suggesting it. 0.85 is high enough that only genuine typos /
|
|
57
|
+
# case-variants of one real name match (e.g. a dropped letter or wrong
|
|
58
|
+
# case), not two distinct files that happen to be somewhat similar.
|
|
59
|
+
AUTO_READ_RATIO = 0.85
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _list_dir_candidates(missing):
|
|
63
|
+
"""(candidates, base) — every file under the nearest existing ancestor of
|
|
64
|
+
`missing`. Walk up to 6 levels so a wrong path segment deep in the tree
|
|
65
|
+
still resolves to a searchable directory. Capped at 4000 files."""
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
missing = Path(missing)
|
|
68
|
+
base = missing.parent
|
|
69
|
+
for _ in range(6):
|
|
70
|
+
if base.exists():
|
|
71
|
+
break
|
|
72
|
+
base = base.parent
|
|
73
|
+
if not base.exists():
|
|
74
|
+
return [], None
|
|
75
|
+
candidates = []
|
|
76
|
+
for p in base.rglob("*"):
|
|
77
|
+
if p.is_file():
|
|
78
|
+
candidates.append(p)
|
|
79
|
+
if len(candidates) > 4000:
|
|
80
|
+
break
|
|
81
|
+
return candidates, base
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _confident_match(missing):
|
|
85
|
+
"""Return the ONE real file `missing` is almost certainly a typo of, else
|
|
86
|
+
None. Authoritative (used to auto-read), so it only fires when unambiguous:
|
|
87
|
+
|
|
88
|
+
• exactly one file whose basename equals `missing`'s case-insensitively
|
|
89
|
+
(a pure case/typo of a unique name — gitHub→Github, README.MD→readme.md), OR
|
|
90
|
+
• exactly one file whose basename fuzzy-matches at ratio ≥ AUTO_READ_RATIO.
|
|
91
|
+
|
|
92
|
+
Requiring the winner to be UNIQUE prevents auto-reading the wrong one of
|
|
93
|
+
several same-named files in different dirs — that stays a soft suggestion.
|
|
94
|
+
"""
|
|
95
|
+
import difflib
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
try:
|
|
98
|
+
candidates, _ = _list_dir_candidates(missing)
|
|
99
|
+
if not candidates:
|
|
100
|
+
return None
|
|
101
|
+
name = Path(missing).name
|
|
102
|
+
# Pure case/typo of a unique basename → definitely that file.
|
|
103
|
+
same = [p for p in candidates if p.name.lower() == name.lower()]
|
|
104
|
+
if len(same) == 1:
|
|
105
|
+
return same[0]
|
|
106
|
+
if len(same) > 1:
|
|
107
|
+
return None # ambiguous by name — don't auto-pick.
|
|
108
|
+
# High-confidence fuzzy basename match, and it must be unique.
|
|
109
|
+
names = [p.name for p in candidates]
|
|
110
|
+
close = difflib.get_close_matches(name, names, n=1, cutoff=AUTO_READ_RATIO)
|
|
111
|
+
if close:
|
|
112
|
+
hits = [p for p in candidates if p.name == close[0]]
|
|
113
|
+
if len(hits) == 1:
|
|
114
|
+
return hits[0]
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _suggest_path(missing) -> str:
|
|
121
|
+
"""Suggest the real file when the model gave a typo'd/wrong-case path.
|
|
122
|
+
|
|
123
|
+
Walk up to the nearest existing ancestor dir and fuzzy-match the missing
|
|
124
|
+
filename (and its path segments) against what's actually there. Lower-
|
|
125
|
+
confidence sibling of `_confident_match`: used only when we're NOT sure
|
|
126
|
+
enough to auto-read, so it stays a hint the model can act on.
|
|
127
|
+
"""
|
|
128
|
+
import difflib
|
|
129
|
+
from pathlib import Path
|
|
130
|
+
try:
|
|
131
|
+
missing = Path(missing)
|
|
132
|
+
name = missing.name
|
|
133
|
+
candidates, base = _list_dir_candidates(missing)
|
|
134
|
+
if not candidates:
|
|
135
|
+
return ""
|
|
136
|
+
names = [p.name for p in candidates]
|
|
137
|
+
close = difflib.get_close_matches(name, names, n=1, cutoff=0.6)
|
|
138
|
+
if close:
|
|
139
|
+
match = next(p for p in candidates if p.name == close[0])
|
|
140
|
+
return f"Did you mean: {match} — read THAT exact path (don't guess variants)."
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def execute(ctx: ToolContext, args: dict) -> str:
|
|
147
|
+
if "path" not in args:
|
|
148
|
+
return "Error: 'path' argument is required for read_file."
|
|
149
|
+
requested = args["path"]
|
|
150
|
+
path = ctx.resolve_path(requested)
|
|
151
|
+
note = ""
|
|
152
|
+
source_disp = str(requested)
|
|
153
|
+
if not path.exists():
|
|
154
|
+
# A small model invents misspelled/wrong-case paths (a dropped letter,
|
|
155
|
+
# gitHub/github/Github) and, getting a bare "not found", retries with
|
|
156
|
+
# ANOTHER wrong variant forever (dedup can't collapse differing typos).
|
|
157
|
+
real = _confident_match(path)
|
|
158
|
+
if real is None:
|
|
159
|
+
# Not confident enough to auto-read — hand back the best soft hint.
|
|
160
|
+
hint = _suggest_path(path)
|
|
161
|
+
return f"File not found: {requested}" + (f"\n{hint}" if hint else "")
|
|
162
|
+
# AUTHORITATIVE recovery: `requested` is unambiguously a typo/case-
|
|
163
|
+
# variant of exactly one real file. Read THAT file now instead of
|
|
164
|
+
# bouncing the model back with a suggestion it can ignore (which is
|
|
165
|
+
# what fuels the 20+-retry not-found death loop). The note pins the
|
|
166
|
+
# correct path so the next call uses it directly.
|
|
167
|
+
try:
|
|
168
|
+
source_disp = str(real.relative_to(ctx.repo))
|
|
169
|
+
except Exception:
|
|
170
|
+
source_disp = str(real)
|
|
171
|
+
note = (
|
|
172
|
+
f"[read {source_disp} instead of '{requested}' — that path does not "
|
|
173
|
+
f"exist and this is the only close match. Use '{source_disp}' "
|
|
174
|
+
"exactly from now on; do NOT guess more spellings.]\n\n"
|
|
175
|
+
)
|
|
176
|
+
path = real
|
|
177
|
+
if path.is_dir():
|
|
178
|
+
# The model routinely read_file's a directory (confusing it with
|
|
179
|
+
# list_files), gets IsADirectoryError, and burns a whole round before
|
|
180
|
+
# retrying with list_files. Just hand back the listing + a nudge so the
|
|
181
|
+
# round is productive instead of wasted.
|
|
182
|
+
try:
|
|
183
|
+
entries = sorted(
|
|
184
|
+
p.name + ("/" if p.is_dir() else "") for p in path.iterdir()
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return f"Error: '{source_disp}' is a directory and could not be listed: {e}"
|
|
188
|
+
shown = entries[:200]
|
|
189
|
+
more = f"\n … ({len(entries) - len(shown)} more)" if len(entries) > len(shown) else ""
|
|
190
|
+
body = "\n".join(f" {e}" for e in shown) or " (empty)"
|
|
191
|
+
return (
|
|
192
|
+
f"'{source_disp}' is a directory, not a file ({len(entries)} entries). "
|
|
193
|
+
f"read_file needs a FILE path; use list_files for directories. Contents:\n"
|
|
194
|
+
f"{body}{more}"
|
|
195
|
+
)
|
|
196
|
+
# Binary guard: reading an image / .gguf / compiled artifact would dump raw
|
|
197
|
+
# bytes into the model's context (garbage that wastes tokens and can derail
|
|
198
|
+
# it). Detect via a NUL byte in the head — the standard text/binary
|
|
199
|
+
# heuristic — reading only the first 8 KB so a multi-GB file stays cheap.
|
|
200
|
+
try:
|
|
201
|
+
with open(path, "rb") as _fh:
|
|
202
|
+
_head = _fh.read(8192)
|
|
203
|
+
if b"\x00" in _head:
|
|
204
|
+
_sz = path.stat().st_size
|
|
205
|
+
return (
|
|
206
|
+
f"'{source_disp}' is a BINARY file ({_sz} bytes) — not shown, "
|
|
207
|
+
f"because reading it would dump raw bytes into context. It is not "
|
|
208
|
+
f"a text file; open it with the appropriate tool if you need it."
|
|
209
|
+
)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
content = path.read_text(errors="replace")
|
|
213
|
+
lines = content.splitlines()
|
|
214
|
+
default_limit, max_default_chars = _dynamic_read_defaults(ctx)
|
|
215
|
+
offset = args.get("offset", 0)
|
|
216
|
+
explicit_limit = "limit" in args
|
|
217
|
+
limit = args.get("limit", default_limit)
|
|
218
|
+
# A read is "full" (satisfies the read-before-edit guard) only when it
|
|
219
|
+
# starts at the top AND returns every line untruncated. offset>0 or any
|
|
220
|
+
# truncation below flips this to a partial view.
|
|
221
|
+
_full_read = (not offset) and (len(lines) <= offset + limit)
|
|
222
|
+
selected = lines[offset:offset + limit]
|
|
223
|
+
numbered = [f"{i + offset + 1}\t{line}" for i, line in enumerate(selected)]
|
|
224
|
+
result = "\n".join(numbered)
|
|
225
|
+
if len(lines) > offset + limit:
|
|
226
|
+
result += f"\n\n[{len(lines) - offset - limit} more lines — use offset={offset + limit} to continue]"
|
|
227
|
+
if not explicit_limit and len(result) > max_default_chars:
|
|
228
|
+
kept: list[str] = []
|
|
229
|
+
total = 0
|
|
230
|
+
for line in numbered:
|
|
231
|
+
total += len(line) + 1
|
|
232
|
+
if total > max_default_chars:
|
|
233
|
+
break
|
|
234
|
+
kept.append(line)
|
|
235
|
+
result = "\n".join(kept)
|
|
236
|
+
remaining_from_line = offset + len(kept)
|
|
237
|
+
result += (
|
|
238
|
+
f"\n\n[Large file summarized at {max_default_chars} chars. "
|
|
239
|
+
f"File has {len(lines)} lines; continue with "
|
|
240
|
+
f"offset={remaining_from_line}, limit={default_limit}, or request "
|
|
241
|
+
"a focused smaller range around the symbol you need.]"
|
|
242
|
+
)
|
|
243
|
+
_full_read = False # char-truncated: the model has not seen the whole file
|
|
244
|
+
# Record this read for the read-before-edit staleness guard (edit_file /
|
|
245
|
+
# write_file / multi_edit consult it). Best-effort; never fail a read.
|
|
246
|
+
try:
|
|
247
|
+
from . import read_state
|
|
248
|
+
read_state.record_read(ctx.app, path, full=_full_read)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
# Prompt-injection defence: wrap untrusted file content in explicit
|
|
252
|
+
# data/instruction separator markers so the model knows this text
|
|
253
|
+
# is DATA, not commands. Signature detector flags common injection
|
|
254
|
+
# phrases (IGNORE ALL PRIOR INSTRUCTIONS, etc.) with a visible
|
|
255
|
+
# warning before the content. See src/localcode/injection_defense.py.
|
|
256
|
+
from ..injection_defense import wrap_untrusted
|
|
257
|
+
return note + wrap_untrusted(result, source=source_disp)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def is_concurrency_safe(args: dict) -> bool:
|
|
261
|
+
return True
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Per-session read-state registry — the read-before-edit staleness guard.
|
|
2
|
+
|
|
3
|
+
Ports claude-code's `FileEditTool.readFileState` pattern (a map of
|
|
4
|
+
absolute-path -> {content-hash, mtime, full/partial}) and folds in the
|
|
5
|
+
compare-on-write idea from codex/opencode V2 (`writeIfUnchanged`).
|
|
6
|
+
|
|
7
|
+
Why a guard at all
|
|
8
|
+
------------------
|
|
9
|
+
A surgical edit (`edit_file`/`multi_edit`) only makes sense against the
|
|
10
|
+
CURRENT bytes of a file. If the model never read the file, or read it and
|
|
11
|
+
the file has since changed on disk (its own earlier write, a formatter, an
|
|
12
|
+
AV/cloud-sync touch, another process), then `old_string` is being matched
|
|
13
|
+
against text the model has NOT seen — the classic silent-clobber failure.
|
|
14
|
+
claude-code refuses such edits until the file is (re)read.
|
|
15
|
+
|
|
16
|
+
Two subtleties copied from claude-code
|
|
17
|
+
--------------------------------------
|
|
18
|
+
* A PARTIAL read (offset/limit or a truncated large-file read) does NOT
|
|
19
|
+
satisfy the guard — the model hasn't seen the whole file, so its anchor
|
|
20
|
+
may sit in an unseen region.
|
|
21
|
+
* CONTENT-EQUALITY fallback: if the mtime advanced but the bytes are
|
|
22
|
+
byte-identical to what was read, we do NOT false-reject (a formatter or
|
|
23
|
+
AV touch that rewrites the file with the same content bumps mtime).
|
|
24
|
+
|
|
25
|
+
Session scoping
|
|
26
|
+
---------------
|
|
27
|
+
State lives on the app object (`app._edit_read_state`), so it is naturally
|
|
28
|
+
scoped to one agent session and a fresh app (e.g. a unit test that calls
|
|
29
|
+
`edit_file` directly) starts empty. The guard only ENFORCES once the
|
|
30
|
+
session has recorded at least one read (`is_armed`): with no read on record
|
|
31
|
+
there is no freshness baseline, so enforcing would be pure friction. In a
|
|
32
|
+
real session `read_file` fires constantly, arming the guard immediately.
|
|
33
|
+
|
|
34
|
+
After a successful write/edit the tool calls `record_write`, so the model's
|
|
35
|
+
own writes refresh the baseline and never trip the guard on the next round.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import hashlib
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
_ATTR = "_edit_read_state"
|
|
44
|
+
|
|
45
|
+
# Files larger than this are not hashed (staleness still works off mtime;
|
|
46
|
+
# only the content-equality fallback is skipped for very large files).
|
|
47
|
+
_MAX_HASH_BYTES = 4 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _registry(app: Any) -> dict[str, dict]:
|
|
51
|
+
reg = getattr(app, _ATTR, None)
|
|
52
|
+
if not isinstance(reg, dict):
|
|
53
|
+
reg = {}
|
|
54
|
+
try:
|
|
55
|
+
setattr(app, _ATTR, reg)
|
|
56
|
+
except Exception:
|
|
57
|
+
# Slotted/frozen app: fall back to a module-level per-id map so
|
|
58
|
+
# the guard still functions instead of silently disabling.
|
|
59
|
+
reg = _fallback_registry(app)
|
|
60
|
+
return reg
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_FALLBACK: dict[int, dict] = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fallback_registry(app: Any) -> dict[str, dict]:
|
|
67
|
+
return _FALLBACK.setdefault(id(app), {})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _key(path: Path) -> str:
|
|
71
|
+
try:
|
|
72
|
+
return str(path.resolve())
|
|
73
|
+
except Exception:
|
|
74
|
+
return str(path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hash_bytes(data: bytes) -> str:
|
|
78
|
+
return hashlib.sha256(data).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _file_meta(path: Path) -> tuple[float, int, str | None]:
|
|
82
|
+
st = path.stat()
|
|
83
|
+
sha: str | None = None
|
|
84
|
+
if st.st_size <= _MAX_HASH_BYTES:
|
|
85
|
+
try:
|
|
86
|
+
sha = _hash_bytes(path.read_bytes())
|
|
87
|
+
except OSError:
|
|
88
|
+
sha = None
|
|
89
|
+
return st.st_mtime, st.st_size, sha
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def record_read(app: Any, path: Path, *, full: bool) -> None:
|
|
93
|
+
"""Record that `path` was read this session.
|
|
94
|
+
|
|
95
|
+
`full` must be False for a partial read (offset/limit or a truncated
|
|
96
|
+
large-file view) — a partial read must NOT satisfy the guard.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
mtime, size, sha = _file_meta(path)
|
|
100
|
+
except OSError:
|
|
101
|
+
return
|
|
102
|
+
_registry(app)[_key(path)] = {
|
|
103
|
+
"mtime": mtime,
|
|
104
|
+
"size": size,
|
|
105
|
+
"sha": sha,
|
|
106
|
+
"full": bool(full),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_write(app: Any, path: Path, content: str) -> None:
|
|
111
|
+
"""Refresh read-state after a successful write/edit (claude-code write-back)
|
|
112
|
+
so the model's own writes never trip the guard next round."""
|
|
113
|
+
try:
|
|
114
|
+
mtime, size, _ = _file_meta(path)
|
|
115
|
+
except OSError:
|
|
116
|
+
mtime, size = 0.0, len(content.encode("utf-8", "replace"))
|
|
117
|
+
_registry(app)[_key(path)] = {
|
|
118
|
+
"mtime": mtime,
|
|
119
|
+
"size": size,
|
|
120
|
+
"sha": _hash_bytes(content.encode("utf-8", "replace")),
|
|
121
|
+
"full": True,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_armed(app: Any) -> bool:
|
|
126
|
+
"""True once the session has ≥1 read on record. The guard only enforces
|
|
127
|
+
when armed (see module docstring)."""
|
|
128
|
+
reg = getattr(app, _ATTR, None)
|
|
129
|
+
if isinstance(reg, dict) and reg:
|
|
130
|
+
return True
|
|
131
|
+
return bool(_FALLBACK.get(id(app)))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check(app: Any, path: Path) -> str | None:
|
|
135
|
+
"""Return None if editing `path` is allowed, else a reason code:
|
|
136
|
+
|
|
137
|
+
* 'unread' — never read this session
|
|
138
|
+
* 'partial' — only a partial (offset/limit/truncated) read on record
|
|
139
|
+
* 'stale' — file changed on disk since it was read
|
|
140
|
+
|
|
141
|
+
Only meaningful when `is_armed(app)` is True.
|
|
142
|
+
"""
|
|
143
|
+
entry = _registry(app).get(_key(path))
|
|
144
|
+
if entry is None:
|
|
145
|
+
return "unread"
|
|
146
|
+
if not entry.get("full"):
|
|
147
|
+
return "partial"
|
|
148
|
+
try:
|
|
149
|
+
st = path.stat()
|
|
150
|
+
except OSError:
|
|
151
|
+
# File vanished — let the tool's own not-found handling deal with it.
|
|
152
|
+
return None
|
|
153
|
+
# Fresh: mtime has not advanced past the recorded read.
|
|
154
|
+
if st.st_mtime <= entry["mtime"] + 1e-6:
|
|
155
|
+
return None
|
|
156
|
+
# mtime advanced — content-equality fallback (formatter / AV / cloud-sync
|
|
157
|
+
# touch that changes mtime but not the bytes). Only applies to full reads,
|
|
158
|
+
# which are the only ones with a stored hash.
|
|
159
|
+
sha = entry.get("sha")
|
|
160
|
+
if sha is not None:
|
|
161
|
+
try:
|
|
162
|
+
if _hash_bytes(path.read_bytes()) == sha:
|
|
163
|
+
# Same bytes — refresh mtime so we don't re-hash every round.
|
|
164
|
+
entry["mtime"] = st.st_mtime
|
|
165
|
+
entry["size"] = st.st_size
|
|
166
|
+
return None
|
|
167
|
+
except OSError:
|
|
168
|
+
return None
|
|
169
|
+
return "stale"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def guard_edit(app: Any, path: Path, display_path: str) -> str | None:
|
|
173
|
+
"""Read-before-edit guard for surgical edits (edit_file / multi_edit).
|
|
174
|
+
|
|
175
|
+
Returns an actionable error string to hand back to the model, or None to
|
|
176
|
+
allow the edit. No-op unless the session is armed.
|
|
177
|
+
"""
|
|
178
|
+
if not is_armed(app):
|
|
179
|
+
return None
|
|
180
|
+
reason = check(app, path)
|
|
181
|
+
if reason == "unread":
|
|
182
|
+
return (
|
|
183
|
+
f"REJECTED: read {display_path} before editing it. You are editing a "
|
|
184
|
+
f"file you have not read this session, so `old_string` is being "
|
|
185
|
+
f"matched against text you have not seen. Call read_file "
|
|
186
|
+
f"path={display_path!r} first, then edit with an exact anchor."
|
|
187
|
+
)
|
|
188
|
+
if reason == "partial":
|
|
189
|
+
return (
|
|
190
|
+
f"REJECTED: you only read PART of {display_path} (offset/limit or a "
|
|
191
|
+
f"truncated view). Read the whole file — or at least the full "
|
|
192
|
+
f"region around your edit — before editing, so `old_string` "
|
|
193
|
+
f"matches the current text. Call read_file path={display_path!r}."
|
|
194
|
+
)
|
|
195
|
+
if reason == "stale":
|
|
196
|
+
return (
|
|
197
|
+
f"REJECTED: {display_path} changed on disk since you read it (an edit "
|
|
198
|
+
f"elsewhere, a formatter, or another process). Your `old_string` "
|
|
199
|
+
f"may no longer match. Re-read it (read_file path={display_path!r}) "
|
|
200
|
+
f"to get the current text, then edit again."
|
|
201
|
+
)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def guard_overwrite(app: Any, path: Path, display_path: str, existed: bool) -> str | None:
|
|
206
|
+
"""Staleness guard for write_file overwrites.
|
|
207
|
+
|
|
208
|
+
write_file is the create-or-full-rewrite tool, so — unlike edit_file — it
|
|
209
|
+
does NOT require a prior read (a fresh scaffold/rewrite is legitimate). But
|
|
210
|
+
if the file WAS read this session and has since changed on disk, a blind
|
|
211
|
+
overwrite would clobber those changes; refuse it. Returns an error string
|
|
212
|
+
or None.
|
|
213
|
+
"""
|
|
214
|
+
if not existed or not is_armed(app):
|
|
215
|
+
return None
|
|
216
|
+
if check(app, path) == "stale":
|
|
217
|
+
return (
|
|
218
|
+
f"REJECTED: {display_path} changed on disk since you last read it. "
|
|
219
|
+
f"Overwriting now would silently clobber those changes. Re-read it "
|
|
220
|
+
f"(read_file path={display_path!r}) to see the current contents, "
|
|
221
|
+
f"then rewrite intentionally."
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""skill — model-invoked recipe loader (Voyager / agent / minimal-agent pattern)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .base import ToolContext
|
|
5
|
+
|
|
6
|
+
SCHEMA = {
|
|
7
|
+
"type": "function",
|
|
8
|
+
"function": {
|
|
9
|
+
"name": "skill",
|
|
10
|
+
"description": (
|
|
11
|
+
"Load a named recipe's instructions into the conversation. "
|
|
12
|
+
"Use when the user's request matches a skill listed under "
|
|
13
|
+
"'Available skills' in your system prompt. The skill body "
|
|
14
|
+
"returns as the tool result; your next turn should follow it."
|
|
15
|
+
),
|
|
16
|
+
"parameters": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"name": {"type": "string", "description": "Skill name, e.g. 'locate' or 'run-tests'."},
|
|
20
|
+
},
|
|
21
|
+
"required": ["name"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def execute(ctx: ToolContext, args: dict) -> str:
|
|
28
|
+
"""Load a named skill's body and return it as tool-result text. The
|
|
29
|
+
next agent turn will have the instructions in-context and should
|
|
30
|
+
act on them. Mirrors agent's SkillTool inline-context execution —
|
|
31
|
+
no sub-agent fork.
|
|
32
|
+
"""
|
|
33
|
+
from ..skills import load_registry, invoke_skill
|
|
34
|
+
name = (args.get("name") or "").strip()
|
|
35
|
+
if not name:
|
|
36
|
+
return (
|
|
37
|
+
"Error: skill tool requires `name`. Check the 'Available skills' "
|
|
38
|
+
"block in your system prompt for valid names."
|
|
39
|
+
)
|
|
40
|
+
registry = load_registry(ctx.app.repo_root)
|
|
41
|
+
return invoke_skill(registry, name)
|