code-context-control 2.152.0__py3-none-any.whl → 2.153.0__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.
- cli/c3.py +1 -1
- cli/guide/tools.html +8 -2
- cli/guide/workflow.html +1 -1
- cli/mcp_server.py +17 -10
- cli/tools/_edit_report.py +31 -4
- cli/tools/edit.py +358 -108
- cli/tools/edit_verify.py +5 -6
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/METADATA +1 -1
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/RECORD +16 -16
- services/agent_locks.py +30 -0
- services/edit_blobs.py +16 -0
- services/read_stamps.py +27 -0
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
|
|
|
91
91
|
# Config
|
|
92
92
|
CONFIG_DIR = ".c3"
|
|
93
93
|
CONFIG_FILE = ".c3/config.json"
|
|
94
|
-
__version__ = "2.
|
|
94
|
+
__version__ = "2.153.0"
|
|
95
95
|
|
|
96
96
|
# The PreToolUse matcher for native subagent calls (installer and hub migration).
|
|
97
97
|
AGENT_MATCHER = "Agent|Task"
|
cli/guide/tools.html
CHANGED
|
@@ -481,7 +481,13 @@ c3_filter(text=<span class="str">"<webpack build output>"</span>, pattern=
|
|
|
481
481
|
<td class="param-name">edits</td>
|
|
482
482
|
<td class="param-type">array</td>
|
|
483
483
|
<td class="param-default">null</td>
|
|
484
|
-
<td class="param-desc">List of <code>{old_string, new_string}</code> objects for batch edits to the same file</td>
|
|
484
|
+
<td class="param-desc">List of <code>{old_string, new_string}</code> objects for batch edits to the same file. Applied in order, all or none: if one patch cannot be placed, nothing is written</td>
|
|
485
|
+
</tr>
|
|
486
|
+
<tr>
|
|
487
|
+
<td class="param-name">overwrite</td>
|
|
488
|
+
<td class="param-type">bool</td>
|
|
489
|
+
<td class="param-default">false</td>
|
|
490
|
+
<td class="param-desc">Replace all of an existing file with <code>new_string</code>. Refused unless this session has read the file with c3_read and the file has not changed since. Keeps the file's line endings and BOM, stores a pre-image, and can be reverted</td>
|
|
485
491
|
</tr>
|
|
486
492
|
</tbody>
|
|
487
493
|
</table>
|
|
@@ -514,7 +520,7 @@ c3_filter(text=<span class="str">"<webpack build output>"</span>, pattern=
|
|
|
514
520
|
<div class="callout callout-info">
|
|
515
521
|
<span class="callout-icon">💡</span>
|
|
516
522
|
<div class="callout-body">
|
|
517
|
-
<strong>Unicode lookalikes are
|
|
523
|
+
<strong>Unicode lookalikes are reported, not applied.</strong> If <code>old_string</code> uses straight quotes but the file has curly quotes (or vice versa), or the two differ only by ASCII vs. unicode dashes (<code>-</code>/<code>–</code>/<code>—</code>) or non-breaking spaces, c3_edit writes nothing and answers <code>[c3_edit:lookalike]</code> with the file's exact text for that region, so the retry can copy it. Applying such a match would replace the file's characters with the retyped ones.
|
|
518
524
|
</div>
|
|
519
525
|
</div>
|
|
520
526
|
</div>
|
cli/guide/workflow.html
CHANGED
|
@@ -623,7 +623,7 @@ c3_impact(target=<span class="str">'MemoryStore'</span>, mode=<span class="str">
|
|
|
623
623
|
</div>
|
|
624
624
|
<div class="workflow-step-body">
|
|
625
625
|
|
|
626
|
-
<p><code>c3_edit</code> does read + patch + write + edit ledger log in one step. It also validates the old_string match before writing. If the literal match fails but the strings differ only by unicode lookalikes (curly quotes, em/en-dashes, NBSP), c3_edit
|
|
626
|
+
<p><code>c3_edit</code> does read + patch + write + edit ledger log in one step. It also validates the old_string match before writing. If the literal match fails but the strings differ only by unicode lookalikes (curly quotes, em/en-dashes, NBSP), c3_edit writes nothing and returns the file's exact text under <code>[c3_edit:lookalike]</code>.</p>
|
|
627
627
|
|
|
628
628
|
<pre><code>c3_edit(
|
|
629
629
|
file_path=<span class="str">'services/auth.py'</span>,
|
cli/mcp_server.py
CHANGED
|
@@ -543,7 +543,9 @@ def _with_hint(resp, make_hint) -> str:
|
|
|
543
543
|
|
|
544
544
|
def _finalize_response(ctx: Context, tool_name: str, args: dict,
|
|
545
545
|
response: str, summary: str = "",
|
|
546
|
-
response_tokens: int = 0
|
|
546
|
+
response_tokens: int = 0,
|
|
547
|
+
ok: bool | None = None) -> str:
|
|
548
|
+
"""ok: the tool's own verdict on the call; None reads it off the response text."""
|
|
547
549
|
global _last_tool_call_time, _last_badge_count
|
|
548
550
|
|
|
549
551
|
deferred_snapshot = False
|
|
@@ -586,11 +588,14 @@ def _finalize_response(ctx: Context, tool_name: str, args: dict,
|
|
|
586
588
|
svc.session_mgr.log_tool_call(tool_name, args, summary)
|
|
587
589
|
# ok=False lets hook_pretool_enforce's activity scan skip a failed call
|
|
588
590
|
# (ISSUE-3: "Error: File not found" used to count as "c3 was used").
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
591
|
+
if ok is not None:
|
|
592
|
+
call_ok = ok
|
|
593
|
+
else:
|
|
594
|
+
try:
|
|
595
|
+
from cli._hook_utils import response_text_failed as _failed
|
|
596
|
+
call_ok = not _failed(response)
|
|
597
|
+
except Exception:
|
|
598
|
+
call_ok = True
|
|
594
599
|
# Both session ids on every tool_call row (D0b): C3's own id joins the
|
|
595
600
|
# row to session_start / the saved session file, the host id joins it to
|
|
596
601
|
# the hooks' session_open / session_end rows. Either may be absent.
|
|
@@ -903,12 +908,13 @@ async def c3_agent(workflow: str, scope: str = "", context: str = "",
|
|
|
903
908
|
@mcp.tool()
|
|
904
909
|
async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
|
|
905
910
|
summary: str = "", tags: str = "", replace_all: bool = False,
|
|
906
|
-
edits: str | list[dict] = "",
|
|
911
|
+
edits: str | list[dict] = "", overwrite: bool = False,
|
|
907
912
|
ctx: Context = None) -> str:
|
|
908
913
|
"""EDIT — read+patch+write+log in one step. Primary code-change tool; always prefer over native Edit.
|
|
909
914
|
old_string: text to replace. new_string: replacement. summary: ledger description.
|
|
910
|
-
edits: list (or JSON string) of {old_string, new_string, summary?} for multi-hunk batch on one file
|
|
911
|
-
Parallel across files. Create new file: non-existent file_path + old_string='' + new_string=<content>.
|
|
915
|
+
edits: list (or JSON string) of {old_string, new_string, summary?} for multi-hunk batch on one file,
|
|
916
|
+
applied all or none. Parallel across files. Create new file: non-existent file_path + old_string='' + new_string=<content>.
|
|
917
|
+
Rewrite a whole existing file: overwrite=true + new_string=<content>, after a c3_read of it.
|
|
912
918
|
If this call ERRORS OR TIMES OUT, do not retry blind — a failed c3_edit may still have
|
|
913
919
|
written the file. Re-send the same args to c3_edits(action='verify') for a verdict."""
|
|
914
920
|
path_err = validate_file_path(file_path)
|
|
@@ -920,7 +926,8 @@ async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
|
|
|
920
926
|
return _finalize_response(ctx, name, args, resp, summ, **kw)
|
|
921
927
|
|
|
922
928
|
resp = await asyncio.to_thread(handle_edit, file_path, old_string, new_string,
|
|
923
|
-
summary, tags, replace_all, svc, finalize, edits
|
|
929
|
+
summary, tags, replace_all, svc, finalize, edits,
|
|
930
|
+
overwrite)
|
|
924
931
|
return _with_hint(resp, lambda: delegate_hints.after_edit(
|
|
925
932
|
svc, file_path, delegate_hints.edit_chars(new_string, edits)))
|
|
926
933
|
|
cli/tools/_edit_report.py
CHANGED
|
@@ -4,8 +4,9 @@ Two concerns, both read-only:
|
|
|
4
4
|
- ``locate``: a RELATIVE file_path resolves against the project root, which is
|
|
5
5
|
the main checkout. When the repo has other git worktrees, the caller may have
|
|
6
6
|
meant one of them, so the response names the resolved path and any worktree
|
|
7
|
-
holding the same relative path
|
|
8
|
-
in a worktree
|
|
7
|
+
holding the same relative path. It refuses outright when the path exists only
|
|
8
|
+
in a worktree, or when this process last wrote to a worktree that the path
|
|
9
|
+
could equally mean.
|
|
9
10
|
- ``diff_block``: a compact unified diff of the whole file, before → after.
|
|
10
11
|
"""
|
|
11
12
|
import difflib
|
|
@@ -19,6 +20,7 @@ from pathlib import Path
|
|
|
19
20
|
_WORKTREE_TTL_S = 60.0
|
|
20
21
|
_GIT_TIMEOUT_S = 3.0
|
|
21
22
|
_worktree_cache: dict[str, tuple[float, list[Path]]] = {}
|
|
23
|
+
_last_tree: dict[str, Path] = {}
|
|
22
24
|
_cache_lock = threading.Lock()
|
|
23
25
|
|
|
24
26
|
DIFF_MAX_LINES = 24
|
|
@@ -98,13 +100,26 @@ def _other_trees(project_root: Path, roots: list[Path]) -> list[tuple[Path, Path
|
|
|
98
100
|
return [(root, root / prefix) for root in roots if root != own]
|
|
99
101
|
|
|
100
102
|
|
|
103
|
+
def note_write(path: Path, project_path: str) -> None:
|
|
104
|
+
"""Record the worktree a write landed in, for ``locate`` to compare the
|
|
105
|
+
next relative path against."""
|
|
106
|
+
holding = [r for r in worktree_roots(project_path)
|
|
107
|
+
if r == path or r in path.parents]
|
|
108
|
+
if holding:
|
|
109
|
+
with _cache_lock:
|
|
110
|
+
_last_tree[str(Path(project_path).resolve())] = max(
|
|
111
|
+
holding, key=lambda r: len(r.parts))
|
|
112
|
+
|
|
113
|
+
|
|
101
114
|
def locate(file_path: str, path: Path, project_path: str) -> tuple[str, str]:
|
|
102
115
|
"""Check a resolved edit target against the repo's other worktrees.
|
|
103
116
|
|
|
104
117
|
Returns ``(refusal, note)``, at most one non-empty. ``refusal`` is set when a
|
|
105
118
|
relative ``file_path`` does not exist in this checkout but does in another
|
|
106
|
-
worktree
|
|
107
|
-
|
|
119
|
+
worktree, or when the last write went to another worktree where the path
|
|
120
|
+
exists or would be created. ``note`` is extra response text for a relative
|
|
121
|
+
path in a repo with other worktrees. Absolute paths always return
|
|
122
|
+
``("", "")``.
|
|
108
123
|
"""
|
|
109
124
|
if Path(file_path).is_absolute():
|
|
110
125
|
return "", ""
|
|
@@ -117,6 +132,18 @@ def locate(file_path: str, path: Path, project_path: str) -> tuple[str, str]:
|
|
|
117
132
|
except ValueError:
|
|
118
133
|
return "", ""
|
|
119
134
|
hits = [proj / rel for _, proj in others if (proj / rel).exists()]
|
|
135
|
+
with _cache_lock:
|
|
136
|
+
last = _last_tree.get(str(project_root))
|
|
137
|
+
recent = next((proj / rel for root, proj in others if root == last), None)
|
|
138
|
+
if recent is not None and (recent.exists() or not path.exists()):
|
|
139
|
+
there = ("where the same path is" if recent.exists()
|
|
140
|
+
else "where this path would be")
|
|
141
|
+
return (f"[c3_edit:wrong-tree] {file_path} is relative, so it means "
|
|
142
|
+
f"{path} in this checkout, but this session's last edit went "
|
|
143
|
+
f"to another git worktree, {there}:\n"
|
|
144
|
+
f" {recent}\n"
|
|
145
|
+
f" Nothing was written. Pass the absolute path of the one "
|
|
146
|
+
f"you mean."), ""
|
|
120
147
|
if not path.exists() and hits:
|
|
121
148
|
listed = "\n".join(f" {h}" for h in hits)
|
|
122
149
|
return (f"[c3_edit:wrong-tree] {file_path} does not exist in this checkout "
|
cli/tools/edit.py
CHANGED
|
@@ -15,21 +15,108 @@ Parallel safety:
|
|
|
15
15
|
See docs/agent-locks.md §5 (Layer A).
|
|
16
16
|
"""
|
|
17
17
|
import bisect
|
|
18
|
+
import codecs
|
|
18
19
|
import difflib
|
|
19
20
|
import hashlib
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
23
24
|
import threading
|
|
25
|
+
import time
|
|
24
26
|
from contextlib import contextmanager
|
|
25
27
|
from pathlib import Path
|
|
26
28
|
|
|
27
29
|
from cli.tools import _edit_report, _grants
|
|
30
|
+
from cli.tools._helpers import finalize_with_tokens
|
|
28
31
|
from services import access_guard, agent_locks, edit_blobs, read_stamps
|
|
29
32
|
from services import credential_store as _cs
|
|
30
33
|
from services.atomic_json import write_bytes_atomic
|
|
31
34
|
from services.task_store import _FileLock
|
|
32
35
|
|
|
36
|
+
_FAILURES = {
|
|
37
|
+
"missing param": "invalid",
|
|
38
|
+
"bad edits param": "invalid",
|
|
39
|
+
"empty old_string": "invalid",
|
|
40
|
+
"bad overwrite": "invalid",
|
|
41
|
+
"vault-protected": "denied",
|
|
42
|
+
"access-denied": "denied",
|
|
43
|
+
"wrong tree": "wrong_tree",
|
|
44
|
+
"lock held": "locked",
|
|
45
|
+
"lock busy": "locked",
|
|
46
|
+
"stale read": "stale",
|
|
47
|
+
"not found": "not_found",
|
|
48
|
+
"ambiguous": "ambiguous",
|
|
49
|
+
"lookalike": "lookalike",
|
|
50
|
+
"encoding": "encoding",
|
|
51
|
+
"unread": "unread",
|
|
52
|
+
"read error": "io_error",
|
|
53
|
+
"write error": "io_error",
|
|
54
|
+
"create error": "io_error",
|
|
55
|
+
}
|
|
56
|
+
_NOOP = "noop"
|
|
57
|
+
_BATCH_FAILURES = {"miss": "not_found", "ambiguous": "ambiguous",
|
|
58
|
+
"lookalike": "lookalike", "skipped": "invalid"}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Report:
|
|
62
|
+
"""`finalize` for one c3_edit call: records the outcome and wall time of
|
|
63
|
+
whichever exit is taken, and remembers in `wrote` whether it wrote.
|
|
64
|
+
|
|
65
|
+
The outcome is the `outcome` keyword when a call site passes one, else the
|
|
66
|
+
code `_FAILURES` gives the summary slug, else "success". Other keywords
|
|
67
|
+
land in the telemetry detail. `ok` reaches the activity log, which the
|
|
68
|
+
enforcement hook reads to decide whether this call unlocks native Edit.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, finalize, svc):
|
|
72
|
+
self._finalize = finalize
|
|
73
|
+
self._svc = svc
|
|
74
|
+
self._started = time.monotonic()
|
|
75
|
+
self.wrote = False
|
|
76
|
+
|
|
77
|
+
def __call__(self, tool, args, text, summary="", **detail):
|
|
78
|
+
outcome = detail.pop("outcome", "") or _FAILURES.get(summary, "success")
|
|
79
|
+
self.wrote = outcome == "success"
|
|
80
|
+
return finalize_with_tokens(
|
|
81
|
+
self._finalize, self._svc, tool, args, text, summary,
|
|
82
|
+
duration_ms=round((time.monotonic() - self._started) * 1000, 1),
|
|
83
|
+
detail={"outcome": outcome, **detail},
|
|
84
|
+
ok=outcome in ("success", _NOOP))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_in_flight: dict[tuple[str, str], dict] = {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@contextmanager
|
|
91
|
+
def _lease_scope(path: Path, project_path: str, session_id: str, report):
|
|
92
|
+
"""Give back the lease on `path` when this process's calls took it and
|
|
93
|
+
none of them wrote.
|
|
94
|
+
|
|
95
|
+
Calls on one path overlap (each runs on its own thread until
|
|
96
|
+
`_edit_lock`), so they are counted as a group: whether the session held
|
|
97
|
+
the lease is sampled when the group forms, and the lease is judged when
|
|
98
|
+
the last call leaves. A call that fails cannot drop the lease a
|
|
99
|
+
concurrent call is writing under.
|
|
100
|
+
"""
|
|
101
|
+
key = (session_id, str(path))
|
|
102
|
+
with _locks_lock:
|
|
103
|
+
group = _in_flight.get(key)
|
|
104
|
+
if group is None:
|
|
105
|
+
group = _in_flight[key] = {
|
|
106
|
+
"calls": 0,
|
|
107
|
+
"keep": agent_locks.holds(str(path), project_path, session_id)}
|
|
108
|
+
group["calls"] += 1
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
with _locks_lock:
|
|
113
|
+
group["calls"] -= 1
|
|
114
|
+
group["keep"] = group["keep"] or report.wrote
|
|
115
|
+
if group["calls"] == 0:
|
|
116
|
+
del _in_flight[key]
|
|
117
|
+
if not group["keep"]:
|
|
118
|
+
agent_locks.give_back(str(path), project_path, session_id)
|
|
119
|
+
|
|
33
120
|
|
|
34
121
|
def _session_id(svc) -> str:
|
|
35
122
|
"""This agent's lease identity — see `cli.tools._grants.session_id`.
|
|
@@ -107,6 +194,24 @@ def _read_preserving_newlines(path: Path) -> str:
|
|
|
107
194
|
return path.read_bytes().decode("utf-8", errors="surrogateescape")
|
|
108
195
|
|
|
109
196
|
|
|
197
|
+
# UTF-32 first: its little-endian BOM begins with the UTF-16 one.
|
|
198
|
+
_FOREIGN_BOMS = ((codecs.BOM_UTF32_LE, "UTF-32"), (codecs.BOM_UTF32_BE, "UTF-32"),
|
|
199
|
+
(codecs.BOM_UTF16_LE, "UTF-16"), (codecs.BOM_UTF16_BE, "UTF-16"))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _encoding_refusal(raw: bytes, file_label: str) -> str:
|
|
203
|
+
"""A refusal when `raw` opens with a UTF-16 or UTF-32 byte-order mark,
|
|
204
|
+
else "". Read as UTF-8 such a file is NUL-separated characters that no
|
|
205
|
+
old_string matches."""
|
|
206
|
+
name = next((n for bom, n in _FOREIGN_BOMS if raw.startswith(bom)), "")
|
|
207
|
+
if not name:
|
|
208
|
+
return ""
|
|
209
|
+
return (f"[c3_edit:encoding] {file_label} is {name} text. c3_edit and "
|
|
210
|
+
f"c3_read work on UTF-8 only, so nothing was written.\n"
|
|
211
|
+
f" Convert the file to UTF-8 first, or change it with the tool "
|
|
212
|
+
f"that owns it.")
|
|
213
|
+
|
|
214
|
+
|
|
110
215
|
def _write_preserving_newlines(path: Path, content: str) -> None:
|
|
111
216
|
"""Atomically replace `path` with `content`, encoded the way it was read.
|
|
112
217
|
|
|
@@ -137,10 +242,10 @@ def _dominant_eol(text: str, default: str = "\n") -> str:
|
|
|
137
242
|
return max(_EOLS, key=counts.__getitem__)
|
|
138
243
|
|
|
139
244
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
245
|
+
# Typographic characters a model tends to retype as ASCII. An old_string that
|
|
246
|
+
# matches only after this fold is refused, not applied: new_string would
|
|
247
|
+
# overwrite the file's characters inside the span with the retyped ones.
|
|
248
|
+
# Strictly 1:1 so a folded offset is also an offset in the file text.
|
|
144
249
|
_LOOKALIKE_TRANS = str.maketrans({
|
|
145
250
|
"‘": "'", "’": "'", "‚": "'", "‛": "'", # single curly quotes
|
|
146
251
|
"“": '"', "”": '"', "„": '"', "‟": '"', # double curly quotes
|
|
@@ -173,8 +278,7 @@ def _positional_replace(content: str, view: str, crlf_at: list, needle: str,
|
|
|
173
278
|
new: str, replace_all: bool, file_eol: str) -> str:
|
|
174
279
|
"""Replace `needle` found in `view` by splicing `new` into `content`.
|
|
175
280
|
|
|
176
|
-
`view` is `content` with every EOL as ``\\n
|
|
177
|
-
which is 1:1); `crlf_at` holds the sorted view offsets of the ``\\n``
|
|
281
|
+
`view` is `content` with every EOL as ``\\n``; `crlf_at` holds the sorted view offsets of the ``\\n``
|
|
178
282
|
characters that are ``\\r\\n`` in `content`, so view offset v is raw
|
|
179
283
|
offset v + bisect_left(crlf_at, v). `new` uses ``\\n`` and is written with
|
|
180
284
|
the dominant EOL of the span it replaces (`file_eol` when that span has
|
|
@@ -203,15 +307,16 @@ def _apply_replacement(content: str, old: str, new: str, replace_all: bool):
|
|
|
203
307
|
"""Replace `old` with `new` in `content` without disturbing other bytes.
|
|
204
308
|
|
|
205
309
|
Matching ignores EOL style (``\\r\\n``, ``\\r`` and ``\\n`` are equal), so a
|
|
206
|
-
LF old_string matches a CRLF, CR-only or mixed file
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
Returns (new_content, count,
|
|
211
|
-
- (str, count,
|
|
212
|
-
- (None, 0, False)
|
|
213
|
-
|
|
214
|
-
- (None, count,
|
|
310
|
+
LF old_string matches a CRLF, CR-only or mixed file, and reads an
|
|
311
|
+
undecodable byte as the U+FFFD that c3_read showed for it. Only the
|
|
312
|
+
matched spans change.
|
|
313
|
+
|
|
314
|
+
Returns (new_content, count, lookalike):
|
|
315
|
+
- (str, count, False) when at least one match was applied
|
|
316
|
+
- (None, 0, False) when no match is found, or `old` is empty
|
|
317
|
+
- (None, count, False) when count > 1 and replace_all is False
|
|
318
|
+
- (None, count, True) when `old` matches only after the lookalike
|
|
319
|
+
fold; never applied
|
|
215
320
|
"""
|
|
216
321
|
if not old:
|
|
217
322
|
return (None, 0, False)
|
|
@@ -220,21 +325,18 @@ def _apply_replacement(content: str, old: str, new: str, replace_all: bool):
|
|
|
220
325
|
crlf_at = [m.start() - k for k, m in enumerate(re.finditer("\r\n", content))]
|
|
221
326
|
file_eol = _dominant_eol(content)
|
|
222
327
|
|
|
223
|
-
|
|
328
|
+
# c3_read shows an undecodable byte and a real U+FFFD alike, so the two
|
|
329
|
+
# are one character here: an old_string that fits both is ambiguous.
|
|
330
|
+
view = view.translate(_SURROGATE_TRANS)
|
|
331
|
+
old = old.translate(_SURROGATE_TRANS)
|
|
224
332
|
count = view.count(old)
|
|
225
333
|
if count == 0:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
# Neither side contained any lookalike chars — genuinely not found.
|
|
229
|
-
return (None, 0, False)
|
|
230
|
-
view, old, used_fallback = nv, no, True
|
|
231
|
-
count = view.count(old)
|
|
232
|
-
if count == 0:
|
|
233
|
-
return (None, 0, False)
|
|
334
|
+
folded = _norm(view).count(_norm(old))
|
|
335
|
+
return (None, folded, folded > 0)
|
|
234
336
|
if count > 1 and not replace_all:
|
|
235
|
-
return (None, count,
|
|
337
|
+
return (None, count, False)
|
|
236
338
|
return (_positional_replace(content, view, crlf_at, old, new, replace_all,
|
|
237
|
-
file_eol), count,
|
|
339
|
+
file_eol), count, False)
|
|
238
340
|
|
|
239
341
|
|
|
240
342
|
def _closest_region(content: str, old: str,
|
|
@@ -293,35 +395,80 @@ def _closest_region(content: str, old: str,
|
|
|
293
395
|
_REGION_CAP = 4000
|
|
294
396
|
|
|
295
397
|
|
|
296
|
-
def
|
|
297
|
-
"""
|
|
298
|
-
|
|
299
|
-
straight into a retry old_string."""
|
|
300
|
-
if not near:
|
|
301
|
-
return ""
|
|
302
|
-
lo, hi, region, ratio = near
|
|
398
|
+
def _marked_region(lo: int, hi: int, region: str, file_label: str) -> str:
|
|
399
|
+
"""File lines lo..hi between markers, verbatim and unindented so they can
|
|
400
|
+
be copied straight into a retry old_string."""
|
|
303
401
|
region = _display_safe(region)
|
|
304
402
|
if len(region) > _REGION_CAP:
|
|
305
403
|
region = (region[:_REGION_CAP]
|
|
306
404
|
+ f"\n⟦trimmed — run c3_read(file_path='{file_label}', "
|
|
307
405
|
f"lines=[{lo},{hi}]) for the rest⟧")
|
|
406
|
+
return f"⟦L{lo}-L{hi}⟧\n{region}\n⟦end⟧\n"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _not_found_payload(near, file_label: str) -> str:
|
|
410
|
+
"""Render a _closest_region result as an error-message appendix."""
|
|
411
|
+
if not near:
|
|
412
|
+
return ""
|
|
413
|
+
lo, hi, region, ratio = near
|
|
308
414
|
return (f"\n closest match: L{lo}-L{hi} ({ratio:.0%} similar). "
|
|
309
415
|
f"Actual file text between the markers:\n"
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
416
|
+
+ _marked_region(lo, hi, region, file_label)
|
|
417
|
+
+ " Retry with old_string copied exactly from the text above "
|
|
418
|
+
"(markers excluded) — no need to re-read the file.")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _revert_note(project_path, images: dict) -> str:
|
|
422
|
+
"""Response text for a write that c3_edits(action='revert') will refuse:
|
|
423
|
+
no post-image hash on the row, or a pre-image that is not in the store."""
|
|
424
|
+
blob = images.get("blob") or ""
|
|
425
|
+
if not blob.startswith("skipped:"):
|
|
426
|
+
return ""
|
|
427
|
+
pre = images.get("pre_sha256")
|
|
428
|
+
if "post_sha256" in images and (
|
|
429
|
+
pre is None or edit_blobs.has(project_path, pre)):
|
|
430
|
+
return ""
|
|
431
|
+
return (f"\n ⚠ c3_edits cannot revert this edit: the file as it was "
|
|
432
|
+
f"before is not kept ({blob.partition(':')[2]}).")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _lookalike_payload(content: str, old: str, count: int,
|
|
436
|
+
file_label: str) -> str:
|
|
437
|
+
"""The file's own text for the first place `old` matches after the
|
|
438
|
+
lookalike fold, as an error-message appendix."""
|
|
439
|
+
view, old = _eol_norm(content), _eol_norm(old)
|
|
440
|
+
pos = _norm(view).find(_norm(old))
|
|
441
|
+
lo = view.count("\n", 0, pos) + 1
|
|
442
|
+
hi = lo + old.removesuffix("\n").count("\n")
|
|
443
|
+
region = "\n".join(view.split("\n")[lo - 1:hi])
|
|
444
|
+
places = f" ({count} places; the first is shown)" if count > 1 else ""
|
|
445
|
+
return (f"\n It matches only when curly quotes, dashes and non-breaking "
|
|
446
|
+
f"spaces are read as their ASCII lookalikes{places}. "
|
|
447
|
+
f"Nothing was written.\n"
|
|
448
|
+
f" Actual file text between the markers:\n"
|
|
449
|
+
+ _marked_region(lo, hi, region, file_label)
|
|
450
|
+
+ " Retry with old_string copied exactly from the text above "
|
|
451
|
+
"(markers excluded), and keep those characters in new_string "
|
|
452
|
+
"unless you mean to change them.")
|
|
313
453
|
|
|
314
454
|
|
|
315
455
|
def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
316
456
|
summary: str, tags: str, replace_all: bool,
|
|
317
|
-
svc, finalize, edits: str = "") -> str:
|
|
457
|
+
svc, finalize, edits: str = "", overwrite: bool = False) -> str:
|
|
318
458
|
"""Find old_string in file, replace with new_string, write back, log to ledger.
|
|
319
459
|
|
|
320
460
|
edits: optional JSON list of {old_string, new_string, summary?} dicts for
|
|
321
461
|
batch same-file patching in a single read/write cycle.
|
|
462
|
+
overwrite: replace all of an existing file with new_string. The session
|
|
463
|
+
must have read the file, and it must not have changed since.
|
|
322
464
|
"""
|
|
465
|
+
finalize = _Report(finalize, svc)
|
|
323
466
|
if not file_path:
|
|
324
467
|
return finalize("c3_edit", {}, "file_path is required", "missing param")
|
|
468
|
+
if overwrite and (old_string or edits):
|
|
469
|
+
return finalize("c3_edit", {"file": file_path},
|
|
470
|
+
"overwrite replaces the whole file: pass new_string "
|
|
471
|
+
"only, without old_string or edits", "bad overwrite")
|
|
325
472
|
|
|
326
473
|
# Resolve path
|
|
327
474
|
path = Path(file_path)
|
|
@@ -345,6 +492,16 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
345
492
|
if wrong_tree:
|
|
346
493
|
return finalize("c3_edit", {"file": file_path}, wrong_tree, "wrong tree")
|
|
347
494
|
|
|
495
|
+
with _lease_scope(path, svc.project_path, _session_id(svc), finalize):
|
|
496
|
+
return _gated_edit(path, rel, file_path, old_string, new_string,
|
|
497
|
+
summary, tags, replace_all, svc, finalize, edits,
|
|
498
|
+
where, overwrite)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _gated_edit(path: Path, rel: str, file_path: str, old_string: str,
|
|
502
|
+
new_string: str, summary: str, tags: str, replace_all: bool,
|
|
503
|
+
svc, finalize, edits: str, where: str, overwrite: bool) -> str:
|
|
504
|
+
"""The write gates, then the edit under `_edit_lock`."""
|
|
348
505
|
op = "write" if path.exists() else "create"
|
|
349
506
|
refusal = _write_gate(svc, path, rel, file_path, op, "c3_edit", summary,
|
|
350
507
|
finalize)
|
|
@@ -357,6 +514,9 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
357
514
|
# loser's content is silently gone.
|
|
358
515
|
try:
|
|
359
516
|
with _edit_lock(path):
|
|
517
|
+
if overwrite and path.exists():
|
|
518
|
+
return _overwrite_locked(path, rel, file_path, new_string,
|
|
519
|
+
summary, tags, svc, finalize, where)
|
|
360
520
|
return _edit_locked(path, rel, file_path, old_string, new_string,
|
|
361
521
|
summary, tags, replace_all, svc, finalize, edits,
|
|
362
522
|
where)
|
|
@@ -368,6 +528,9 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
368
528
|
f" This is contention, not an error — do not route around it via "
|
|
369
529
|
f"c3_shell or native Write. Retry, or edit a different file.",
|
|
370
530
|
"lock busy")
|
|
531
|
+
finally:
|
|
532
|
+
if finalize.wrote:
|
|
533
|
+
_edit_report.note_write(path, svc.project_path)
|
|
371
534
|
|
|
372
535
|
|
|
373
536
|
def _write_gate(svc, path: Path, rel: str, file_path: str, op: str,
|
|
@@ -398,12 +561,69 @@ def _write_gate(svc, path: Path, rel: str, file_path: str, op: str,
|
|
|
398
561
|
session_id = _session_id(svc)
|
|
399
562
|
holder = agent_locks.check(str(path), svc.project_path, session_id)
|
|
400
563
|
if holder:
|
|
401
|
-
|
|
564
|
+
held_for = round(time.time() - holder.get("acquired_at", time.time()))
|
|
565
|
+
return finalize(tool, {"file": file_path,
|
|
566
|
+
"holder": holder.get("agent_id", ""),
|
|
567
|
+
"held_for_s": held_for},
|
|
402
568
|
agent_locks.refusal(holder, rel), "lock held")
|
|
403
569
|
agent_locks.lease(str(path), svc.project_path, session_id, intent=intent)
|
|
404
570
|
return None
|
|
405
571
|
|
|
406
572
|
|
|
573
|
+
def _overwrite_locked(path: Path, rel: str, file_path: str, new_string: str,
|
|
574
|
+
summary: str, tags: str, svc, finalize, where: str) -> str:
|
|
575
|
+
"""Replace all of an existing file, keeping its line endings and BOM.
|
|
576
|
+
Always called under _edit_lock."""
|
|
577
|
+
args = {"file": file_path, "overwrite": True}
|
|
578
|
+
try:
|
|
579
|
+
pre_image = path.read_bytes()
|
|
580
|
+
except OSError as e:
|
|
581
|
+
return finalize("c3_edit", args, f"Read error: {e}", "read error")
|
|
582
|
+
slug = "unread" if read_stamps.get(path) is None else "stale read"
|
|
583
|
+
guard = read_stamps.check_whole(path, file_path, pre_image)
|
|
584
|
+
if guard.refusal:
|
|
585
|
+
return finalize("c3_edit", args, guard.refusal, slug)
|
|
586
|
+
foreign = _encoding_refusal(pre_image, file_path)
|
|
587
|
+
if foreign:
|
|
588
|
+
return finalize("c3_edit", args, foreign, "encoding")
|
|
589
|
+
|
|
590
|
+
content = pre_image.decode("utf-8", errors="surrogateescape")
|
|
591
|
+
eol = _dominant_eol(content)
|
|
592
|
+
new_content = _eol_norm(new_string)
|
|
593
|
+
if eol != "\n":
|
|
594
|
+
new_content = new_content.replace("\n", eol)
|
|
595
|
+
if content.startswith("") and not new_content.startswith(""):
|
|
596
|
+
new_content = "" + new_content
|
|
597
|
+
if new_content == content:
|
|
598
|
+
return finalize("c3_edit", args,
|
|
599
|
+
f"{rel} unchanged — new_string is the file's current "
|
|
600
|
+
f"text; nothing was written or logged.",
|
|
601
|
+
f"{rel} unchanged", outcome=_NOOP, n_attempted=1,
|
|
602
|
+
n_applied=0)
|
|
603
|
+
|
|
604
|
+
edit_blobs.keep_pre(svc.project_path, path, pre_image)
|
|
605
|
+
try:
|
|
606
|
+
_write_preserving_newlines(path, new_content)
|
|
607
|
+
guard.written()
|
|
608
|
+
except Exception as e:
|
|
609
|
+
return finalize("c3_edit", args, f"Write error: {e}", "write error")
|
|
610
|
+
|
|
611
|
+
images = edit_blobs.record_edit(svc.project_path, path, pre_image)
|
|
612
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
|
613
|
+
n_old = len(content.splitlines())
|
|
614
|
+
n_new = len(new_content.splitlines())
|
|
615
|
+
deferred = _log_to_ledger(
|
|
616
|
+
rel, summary or f"Overwrote {rel} ({n_new}L)", tag_list, svc,
|
|
617
|
+
detail={"old_string": "", "new_string": new_string[:_DETAIL_CAP],
|
|
618
|
+
"overwritten": True, **images})
|
|
619
|
+
short = (f"✓ {rel} [overwritten, -{n_old}+{n_new}L]"
|
|
620
|
+
+ (f" — {summary}" if summary else "")
|
|
621
|
+
+ where + _revert_note(svc.project_path, images) + _display_safe(
|
|
622
|
+
_edit_report.diff_block(content, new_content, svc.project_path)))
|
|
623
|
+
return finalize("c3_edit", args, short + deferred, f"{rel} overwritten",
|
|
624
|
+
n_attempted=1, n_applied=1, blob=images.get("blob"))
|
|
625
|
+
|
|
626
|
+
|
|
407
627
|
def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
408
628
|
new_string: str, summary: str, tags: str, replace_all: bool,
|
|
409
629
|
svc, finalize, edits: str, where: str = "") -> str:
|
|
@@ -444,9 +664,11 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
444
664
|
rel, create_summary, tag_list, svc,
|
|
445
665
|
detail={"old_string": "", "new_string": new_string[:_DETAIL_CAP],
|
|
446
666
|
"created": True, **images})
|
|
447
|
-
short = f"✓ {rel} [created, +{n_new}L]" + (f" — {summary}" if summary else "")
|
|
667
|
+
short = (f"✓ {rel} [created, +{n_new}L]" + (f" — {summary}" if summary else "")
|
|
668
|
+
+ where + _revert_note(svc.project_path, images))
|
|
448
669
|
return finalize("c3_edit", {"file": file_path}, short + deferred,
|
|
449
|
-
f"{rel} created"
|
|
670
|
+
f"{rel} created", n_attempted=1, n_applied=1,
|
|
671
|
+
blob=images.get("blob"))
|
|
450
672
|
|
|
451
673
|
# Parse tag list once
|
|
452
674
|
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
|
@@ -475,11 +697,13 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
475
697
|
except Exception as e:
|
|
476
698
|
return finalize("c3_edit", {"file": file_path},
|
|
477
699
|
f"Read error: {e}", "read error")
|
|
700
|
+
foreign = _encoding_refusal(pre_image, file_path)
|
|
701
|
+
if foreign:
|
|
702
|
+
return finalize("c3_edit", {"file": file_path}, foreign, "encoding")
|
|
478
703
|
original = content
|
|
479
704
|
|
|
480
705
|
results = []
|
|
481
|
-
statuses = [] # parallel to results: 'ok' | 'miss' | 'ambiguous' | 'skipped' | 'noop'
|
|
482
|
-
any_normalized = False
|
|
706
|
+
statuses = [] # parallel to results: 'ok' | 'miss' | 'lookalike' | 'ambiguous' | 'skipped' | 'noop'
|
|
483
707
|
first_miss = ""
|
|
484
708
|
for i, patch in enumerate(edit_list):
|
|
485
709
|
old = patch.get("old_string", "")
|
|
@@ -492,9 +716,22 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
492
716
|
statuses.append("skipped")
|
|
493
717
|
continue
|
|
494
718
|
|
|
495
|
-
new_content, count,
|
|
719
|
+
new_content, count, lookalike = _apply_replacement(content, old, new, r_all)
|
|
720
|
+
if lookalike:
|
|
721
|
+
results.append(f" patch[{i}]: LOOKALIKE ONLY — {old[:80]!r}")
|
|
722
|
+
statuses.append("lookalike")
|
|
723
|
+
if not first_miss:
|
|
724
|
+
on_disk = _apply_replacement(original, old, new, r_all)
|
|
725
|
+
first_miss = (
|
|
726
|
+
_lookalike_payload(original, old, on_disk[1], file_path)
|
|
727
|
+
if on_disk[2] else
|
|
728
|
+
_lookalike_payload(content, old, count, file_path))
|
|
729
|
+
continue
|
|
496
730
|
if new_content is None and count == 0:
|
|
497
|
-
|
|
731
|
+
# Nothing is written on a miss, so line numbers are the
|
|
732
|
+
# file's; an earlier patch's result is the second choice.
|
|
733
|
+
near = (_closest_region(_eol_norm(original), _eol_norm(old))
|
|
734
|
+
or _closest_region(_eol_norm(content), _eol_norm(old)))
|
|
498
735
|
loc = (f" (closest: L{near[0]}-L{near[1]}, {near[3]:.0%} similar)"
|
|
499
736
|
if near else "")
|
|
500
737
|
results.append(f" patch[{i}]: NOT FOUND — {old[:80]!r}{loc}")
|
|
@@ -503,8 +740,7 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
503
740
|
first_miss = _not_found_payload(near, file_path)
|
|
504
741
|
continue
|
|
505
742
|
if new_content is None:
|
|
506
|
-
|
|
507
|
-
results.append(f" patch[{i}]: AMBIGUOUS ({count} matches){tag} — {old[:60]!r}")
|
|
743
|
+
results.append(f" patch[{i}]: AMBIGUOUS ({count} matches) — {old[:60]!r}")
|
|
508
744
|
statuses.append("ambiguous")
|
|
509
745
|
continue
|
|
510
746
|
if new_content == content:
|
|
@@ -515,65 +751,73 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
515
751
|
|
|
516
752
|
content = new_content
|
|
517
753
|
n = count if r_all else 1
|
|
518
|
-
if used_fallback:
|
|
519
|
-
any_normalized = True
|
|
520
754
|
|
|
521
755
|
n_old = old.count("\n") + 1
|
|
522
756
|
n_new = new.count("\n") + 1
|
|
523
757
|
desc = patch_summary or f"{old[:50]!r} → {new[:50]!r}"
|
|
524
758
|
results.append(f" patch[{i}]: -{n_old}L +{n_new}L"
|
|
525
759
|
+ (f" ({n}x)" if n > 1 else "")
|
|
526
|
-
+ (" [norm]" if used_fallback else "")
|
|
527
760
|
+ f" | {desc}")
|
|
528
761
|
statuses.append("ok")
|
|
529
762
|
|
|
530
|
-
#
|
|
531
|
-
#
|
|
532
|
-
|
|
533
|
-
if
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
763
|
+
# All or nothing: the patches that did match are half of a change
|
|
764
|
+
# once one of them cannot be placed.
|
|
765
|
+
total = len(edit_list)
|
|
766
|
+
failed = [r for r, s in zip(results, statuses) if s in _BATCH_FAILURES]
|
|
767
|
+
if failed:
|
|
768
|
+
outcome = next(_BATCH_FAILURES[s] for s in statuses
|
|
769
|
+
if s in _BATCH_FAILURES)
|
|
770
|
+
return finalize(
|
|
771
|
+
"c3_edit", {"file": file_path},
|
|
772
|
+
f"{rel} unchanged — {len(failed)} of {total} patches could not "
|
|
773
|
+
f"be placed, so none were applied.\n" + "\n".join(failed)
|
|
774
|
+
+ first_miss
|
|
775
|
+
+ "\n Resend the whole batch with those patches corrected.",
|
|
776
|
+
f"{rel} batch refused", outcome=outcome, n_attempted=total,
|
|
777
|
+
n_applied=0)
|
|
778
|
+
|
|
779
|
+
unchanged = [r for r, s in zip(results, statuses) if s == "noop"]
|
|
780
|
+
if content == original:
|
|
781
|
+
return finalize(
|
|
782
|
+
"c3_edit", {"file": file_path},
|
|
783
|
+
f"{rel} unchanged — no patch changes the text; nothing was "
|
|
784
|
+
f"written or logged.\n" + "\n".join(unchanged),
|
|
785
|
+
f"{rel} unchanged", outcome=_NOOP, n_attempted=total,
|
|
786
|
+
n_applied=0)
|
|
787
|
+
|
|
788
|
+
edit_blobs.keep_pre(svc.project_path, path, pre_image)
|
|
789
|
+
try:
|
|
790
|
+
_write_preserving_newlines(path, content)
|
|
791
|
+
stale.written()
|
|
792
|
+
except Exception as e:
|
|
793
|
+
return finalize("c3_edit", {"file": file_path},
|
|
794
|
+
f"Write error: {e}", "write error")
|
|
795
|
+
|
|
796
|
+
# One ledger entry for the batch, with each patch's old/new for the diff view.
|
|
797
|
+
batch_detail = {"patches": [
|
|
798
|
+
{
|
|
799
|
+
"old_string": p.get("old_string", "")[:_DETAIL_CAP],
|
|
800
|
+
"new_string": p.get("new_string", "")[:_DETAIL_CAP],
|
|
801
|
+
**({"summary": p["summary"]} if p.get("summary") else {}),
|
|
802
|
+
}
|
|
803
|
+
for p in edit_list if p.get("old_string") is not None
|
|
804
|
+
]}
|
|
805
|
+
batch_detail.update(
|
|
806
|
+
edit_blobs.record_edit(svc.project_path, path, pre_image))
|
|
807
|
+
deferred = _log_to_ledger(
|
|
808
|
+
rel, summary or f"Batch edit: {total} patches",
|
|
809
|
+
tag_list, svc, detail=batch_detail)
|
|
810
|
+
|
|
561
811
|
applied = statuses.count("ok")
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if applied < len(edit_list):
|
|
568
|
-
failed = [r for r, s in zip(results, statuses) if s != "ok"]
|
|
569
|
-
short += "\n" + "\n".join(failed)
|
|
570
|
-
if first_miss:
|
|
571
|
-
short += first_miss
|
|
572
|
-
if changed:
|
|
573
|
-
short += where + _display_safe(
|
|
574
|
-
_edit_report.diff_block(original, content, svc.project_path))
|
|
812
|
+
short = (f"✓ {rel} — {applied}/{total} patches applied"
|
|
813
|
+
+ "".join(f"\n{r}" for r in unchanged)
|
|
814
|
+
+ where + _revert_note(svc.project_path, batch_detail)
|
|
815
|
+
+ _display_safe(
|
|
816
|
+
_edit_report.diff_block(original, content, svc.project_path)))
|
|
575
817
|
return finalize("c3_edit", {"file": file_path}, short + deferred,
|
|
576
|
-
f"{rel} patched ({
|
|
818
|
+
f"{rel} patched ({applied}/{total} patches)",
|
|
819
|
+
n_attempted=total, n_applied=applied,
|
|
820
|
+
blob=batch_detail.get("blob"))
|
|
577
821
|
|
|
578
822
|
# ── Single-edit mode ──────────────────────────────────────────────────────
|
|
579
823
|
if not old_string:
|
|
@@ -587,34 +831,41 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
587
831
|
except Exception as e:
|
|
588
832
|
return finalize("c3_edit", {"file": file_path},
|
|
589
833
|
f"Read error: {e}", "read error")
|
|
834
|
+
foreign = _encoding_refusal(pre_image, file_path)
|
|
835
|
+
if foreign:
|
|
836
|
+
return finalize("c3_edit", {"file": file_path}, foreign, "encoding")
|
|
590
837
|
|
|
591
|
-
new_content, count,
|
|
838
|
+
new_content, count, lookalike = _apply_replacement(
|
|
592
839
|
content, old_string, new_string, replace_all)
|
|
593
840
|
|
|
841
|
+
if lookalike:
|
|
842
|
+
return finalize("c3_edit", {"file": file_path},
|
|
843
|
+
f"[c3_edit:lookalike] old_string is not in {file_path} "
|
|
844
|
+
f"as written."
|
|
845
|
+
+ _lookalike_payload(content, old_string, count, file_path),
|
|
846
|
+
"lookalike")
|
|
594
847
|
if new_content is None and count == 0:
|
|
595
|
-
hint =
|
|
596
|
-
if _norm(old_string) != old_string or _norm(content) != content:
|
|
597
|
-
hint = "\n hint: unicode-lookalike normalization also failed to match."
|
|
598
|
-
hint += _not_found_payload(
|
|
848
|
+
hint = _not_found_payload(
|
|
599
849
|
_closest_region(_eol_norm(content), _eol_norm(old_string)), file_path)
|
|
600
850
|
return finalize("c3_edit", {"file": file_path},
|
|
601
851
|
f"old_string not found in {file_path}\n"
|
|
602
852
|
f" searched for: {old_string[:120]!r}{hint}",
|
|
603
853
|
"not found")
|
|
604
854
|
if new_content is None:
|
|
605
|
-
hint = " (after unicode-lookalike normalization)" if used_fallback else ""
|
|
606
855
|
return finalize("c3_edit", {"file": file_path},
|
|
607
|
-
f"old_string matches {count} locations
|
|
856
|
+
f"old_string matches {count} locations — add more context to make it unique, "
|
|
608
857
|
f"or pass replace_all=true to replace all occurrences.",
|
|
609
858
|
"ambiguous")
|
|
610
859
|
|
|
611
860
|
if new_content == content:
|
|
612
861
|
return finalize("c3_edit", {"file": file_path},
|
|
613
862
|
f"{rel} unchanged — new_string equals old_string; "
|
|
614
|
-
f"nothing was written or logged.", f"{rel} unchanged"
|
|
863
|
+
f"nothing was written or logged.", f"{rel} unchanged",
|
|
864
|
+
outcome=_NOOP, n_attempted=1, n_applied=0)
|
|
615
865
|
|
|
616
866
|
occurrences = count if replace_all else 1
|
|
617
867
|
|
|
868
|
+
edit_blobs.keep_pre(svc.project_path, path, pre_image)
|
|
618
869
|
try:
|
|
619
870
|
_write_preserving_newlines(path, new_content)
|
|
620
871
|
stale.written()
|
|
@@ -629,8 +880,6 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
629
880
|
"old_string": old_string[:_DETAIL_CAP],
|
|
630
881
|
"new_string": new_string[:_DETAIL_CAP],
|
|
631
882
|
}
|
|
632
|
-
if used_fallback:
|
|
633
|
-
single_detail["unicode_normalized"] = True
|
|
634
883
|
single_detail.update(
|
|
635
884
|
edit_blobs.record_edit(svc.project_path, path, pre_image))
|
|
636
885
|
deferred = _log_to_ledger(rel, auto_summary, tag_list, svc,
|
|
@@ -640,12 +889,13 @@ def _edit_locked(path: Path, rel: str, file_path: str, old_string: str,
|
|
|
640
889
|
n_new = new_string.count("\n") + 1
|
|
641
890
|
delta = f"-{n_old}+{n_new}L"
|
|
642
891
|
occ = f" ({occurrences}x)" if occurrences > 1 else ""
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
+
|
|
892
|
+
short = (f"✓ {rel} [{delta}]{occ}" + (f" — {summary}" if summary else "")
|
|
893
|
+
+ where + _revert_note(svc.project_path, single_detail)
|
|
894
|
+
+ _display_safe(
|
|
646
895
|
_edit_report.diff_block(content, new_content, svc.project_path)))
|
|
647
896
|
return finalize("c3_edit", {"file": file_path}, short + deferred,
|
|
648
|
-
f"{rel} patched"
|
|
897
|
+
f"{rel} patched", n_attempted=1, n_applied=1,
|
|
898
|
+
blob=single_detail.get("blob"))
|
|
649
899
|
|
|
650
900
|
|
|
651
901
|
_DETAIL_CAP = 2000 # chars stored per old/new string in the ledger
|
cli/tools/edit_verify.py
CHANGED
|
@@ -244,12 +244,11 @@ def verify(file_path: str, old_string: str, new_string: str, edits: str,
|
|
|
244
244
|
str(patch.get("new_string", "")), ledger, rel, limit)
|
|
245
245
|
verdicts.append(r["verdict"])
|
|
246
246
|
lines.append(f" patch[{i}]: {r['verdict']} — {r['why']}")
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
"
|
|
252
|
-
"some hunks did not match — not a partial write.")
|
|
247
|
+
# Say what a mixed result means, rather than letting the caller infer
|
|
248
|
+
# a torn write that cannot happen.
|
|
249
|
+
lines.append(" note: c3_edit applies a batch whole or not at all, so "
|
|
250
|
+
"a mixed result means the batch was refused or the file "
|
|
251
|
+
"changed afterwards — not a partial write.")
|
|
253
252
|
counts = {v: verdicts.count(v) for v in sorted(set(verdicts))}
|
|
254
253
|
summary = ", ".join(f"{n} {v.lower()}" for v, n in counts.items())
|
|
255
254
|
return "\n".join(lines), f"{rel}: {summary}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.153.0
|
|
4
4
|
Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
|
|
2
2
|
cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
|
|
3
3
|
cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
|
|
4
|
-
cli/c3.py,sha256=
|
|
4
|
+
cli/c3.py,sha256=CPRYdoG-9oXxk71V6ISYoMSjIFmq3FC9li8NxYS5ixY,431810
|
|
5
5
|
cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
|
|
6
6
|
cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
|
|
7
7
|
cli/hook_access_guard.py,sha256=tzK3pQBGehDDZi6fECcYk8nRhy0QkmUtTF2TuJG7nbY,27928
|
|
@@ -26,7 +26,7 @@ cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,625
|
|
|
26
26
|
cli/hub_server.py,sha256=VgDAlVmWgvh57KUbMgL2PT4303fmlr578QLk6ehe2pA,227863
|
|
27
27
|
cli/hub_ui.html,sha256=v41R_JAH4Jwodpy3tbeBUel-80FbNmffBrey-QH5Q64,2963
|
|
28
28
|
cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
|
|
29
|
-
cli/mcp_server.py,sha256=
|
|
29
|
+
cli/mcp_server.py,sha256=VzzsJFckLdtwXYnX_CH-57eObOUP-IN-i5IIb8BWu8Y,74956
|
|
30
30
|
cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
|
|
31
31
|
cli/server.py,sha256=dJOOXYU1zmN-UBuMmHhv8XlnknSyO8xj7mXWSDAGIBY,173188
|
|
32
32
|
cli/ui.html,sha256=uuPwIJNKDQH_fsaR3xNB2AKky9WzKfaXnKr_k3aTtxo,5715
|
|
@@ -47,8 +47,8 @@ cli/guide/masking.html,sha256=BcsTxlnpRD-fdJKBKn7naUfXPfNFnU0lM4mPi4GLOqs,20126
|
|
|
47
47
|
cli/guide/oracle.html,sha256=yRNN9xH8KPIT72C1bl9ZHv8m0ENvaFMvreN-gbtXXoo,33352
|
|
48
48
|
cli/guide/sessions.html,sha256=fBcbX61mvy2T5fX9Lkojb8Gw7n2RZ4Oo95EE_SC5Q6A,31707
|
|
49
49
|
cli/guide/shared.css,sha256=Mmm7W6aYxrkDq2RPOcyXiQFYGR-yB3auwX1uIWi6C74,15967
|
|
50
|
-
cli/guide/tools.html,sha256=
|
|
51
|
-
cli/guide/workflow.html,sha256=
|
|
50
|
+
cli/guide/tools.html,sha256=0XcaKfUZSt7oSHfjnhJcEAT8wtdGLO7w14LXZUnZfcA,134275
|
|
51
|
+
cli/guide/workflow.html,sha256=NTTVmP0Ru8Y4xoL6Zmvk__eUgnpUD3Egvw2uaNMojJY,78600
|
|
52
52
|
cli/hub_ui/app.js,sha256=AWzlFht1Fmn4559KfooKC03CvoG2I6AXbYUEZ2w8ZfU,7362
|
|
53
53
|
cli/hub_ui/state.js,sha256=OrIQ_kx_wUdyiyXlKmMKC8bbKzMPve1ZuFClRXOrDEw,7593
|
|
54
54
|
cli/hub_ui/components/add_project.js,sha256=j5m0mX0gePcEYQW_0vodJkw5v8Vn2lQqPAI0miIGdv4,3182
|
|
@@ -80,7 +80,7 @@ cli/hub_ui/components/task_board.js,sha256=QiWKmj1NkhkloGlfzZj_tWd7Sgz3xSxwVLp8I
|
|
|
80
80
|
cli/hub_ui/components/toasts.js,sha256=caj_x3npcM_wMv9E_xEde88jyfXI-jt8RrDQW_yXz5k,3376
|
|
81
81
|
cli/hub_ui/components/topbar.js,sha256=xg8RqDmJpweISw55NFT07tMiwrFaG9fpDEHHblzkLk4,4817
|
|
82
82
|
cli/tools/__init__.py,sha256=HO6eVKzDm1KPqsKTHY6lfIsnFnv3C7Bd9o3Q_V88idg,127
|
|
83
|
-
cli/tools/_edit_report.py,sha256=
|
|
83
|
+
cli/tools/_edit_report.py,sha256=uZuhMQABN6z9R_FXusoYNNrdYrES3rhbADXmJupwc0c,8392
|
|
84
84
|
cli/tools/_grants.py,sha256=xJwEZWC9r4exq0OuabQy-fFMZFayUYZe0Eub7NS3ikQ,4974
|
|
85
85
|
cli/tools/_helpers.py,sha256=xhF6sSc9wcJjLQmFFipnUgxQusU1PDPcl0DGMGcgK6E,9420
|
|
86
86
|
cli/tools/agent.py,sha256=RmEEh5HhCZLvYu0gSRcwA_NENpd4iIYJW3exBzT8kVs,47983
|
|
@@ -90,8 +90,8 @@ cli/tools/ci.py,sha256=1--Ak18ikHkO7q4HWlDCVac-U969eEFejkd-24T0xuY,20057
|
|
|
90
90
|
cli/tools/compress.py,sha256=PcgxEw7KfofmJsTLfRf3mjWMxyNlqkcSNzMTlT27SW0,12534
|
|
91
91
|
cli/tools/credentials.py,sha256=NczO0KT3LTJlD3ZedXWRAtH_8YTPhya0Txja3ystdzE,20039
|
|
92
92
|
cli/tools/delegate.py,sha256=TrHPXEyufQh0in3Ay7XcOG5wQwCYbnbz9r4naIuhbN8,126990
|
|
93
|
-
cli/tools/edit.py,sha256=
|
|
94
|
-
cli/tools/edit_verify.py,sha256=
|
|
93
|
+
cli/tools/edit.py,sha256=YPcrXfOvaUCiddg9qABFXqEk9kb9TNHMLFEo2Xq-sBQ,44715
|
|
94
|
+
cli/tools/edit_verify.py,sha256=3Y156LikO3Q4v0OSNjKDJUaOB3nRfceFvXCmpSV8amA,11858
|
|
95
95
|
cli/tools/edits.py,sha256=Qkcl7Asa_qyIH2aIn5IYamfKavtfEZF429Bmg8TF0aY,10963
|
|
96
96
|
cli/tools/federate.py,sha256=tE65p06gM6O_yzg08SKeficFCZUH0yOFhCHFUjlTRo0,5126
|
|
97
97
|
cli/tools/filter.py,sha256=mwNabPe7bCvdsIGFsWc4E1HsfiRYx5ZevnxdsitJTAw,12702
|
|
@@ -132,7 +132,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
|
|
|
132
132
|
cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
|
|
133
133
|
cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
|
|
134
134
|
cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
|
|
135
|
-
code_context_control-2.
|
|
135
|
+
code_context_control-2.153.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
|
|
136
136
|
core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
|
|
137
137
|
core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
|
|
138
138
|
core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
|
|
@@ -194,7 +194,7 @@ services/access_telemetry.py,sha256=9pOCLS9LgDtM7Z4cCEyl7osxhj-m3wsPubcXolBKuV4,
|
|
|
194
194
|
services/activity_log.py,sha256=c_jTOsR-d1d5B3u67eg8mRA7LZhtP6H1-6leeGa0Go4,7990
|
|
195
195
|
services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
|
|
196
196
|
services/agent_downshift.py,sha256=hGFg-knqa6NUqmZUijyTDV82Fz88ieh1YjN_mG5tz8U,8444
|
|
197
|
-
services/agent_locks.py,sha256=
|
|
197
|
+
services/agent_locks.py,sha256=1eCOiJ4UkpandhlgHqtO5DpwOjN7bGWYrEEE1puoFc0,23524
|
|
198
198
|
services/agents.py,sha256=q3GgxyzomyjhbixARDQrSM3EpjIhboeKQY5jsQeHQ0g,83609
|
|
199
199
|
services/agents_workflow.py,sha256=ttve887E6SIY2twHx_OxwFBEtLUcuok72_8g3ZkOAkE,502
|
|
200
200
|
services/artifact_defs.py,sha256=4bJAcg2maJCtMhY3ub_IxZg49bVg1mD_B5Z4Bn5NF0o,9805
|
|
@@ -234,7 +234,7 @@ services/doc_index.py,sha256=cIb1jk4GrKK7cLwD6phoc04TvKA7Ee94kKHXxB7tT_k,19223
|
|
|
234
234
|
services/e2e_benchmark.py,sha256=5Z3rE42o08MKVbIX_769V0RX2yAzSVcApCvdAQ9YLAU,134988
|
|
235
235
|
services/e2e_evaluator.py,sha256=FsB4vMzcFUcmicfIwRtA8JZ_XeMpuk2_-86F2iVNEDc,17783
|
|
236
236
|
services/e2e_tasks.py,sha256=Ln5VbGDIcS3NY3aml5ERbgjfjLxEslmRZ8AyaYxpWEo,34524
|
|
237
|
-
services/edit_blobs.py,sha256=
|
|
237
|
+
services/edit_blobs.py,sha256=iPxHo7k_FMq8tsAKmEyb1e7yVlDZmt5_udmTYJPrlNA,6548
|
|
238
238
|
services/edit_ledger.py,sha256=Tj8Q14o19VHQHpofRHdbZKKcVs17v-ctZ3LOeVTqvdQ,29362
|
|
239
239
|
services/embedding_index.py,sha256=wqq7odpGzAxyO-lxr6N1WusyEGInGgEQUjuEw445Bxs,27711
|
|
240
240
|
services/enforcement_policy.py,sha256=znjABRB-8TSl0ELazY2ALzux2YJ5nWf8TvxLRhDsL2w,18905
|
|
@@ -281,7 +281,7 @@ services/project_manager.py,sha256=7NvQau5x0PCVagQsXpuXZ4VtXCkCfy9es_v1oEHmzgs,4
|
|
|
281
281
|
services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
|
|
282
282
|
services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
|
|
283
283
|
services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
|
|
284
|
-
services/read_stamps.py,sha256=
|
|
284
|
+
services/read_stamps.py,sha256=CTAy350FJ079-mDBXFZXU5OXhUdPobGnYelkC28W65Q,10654
|
|
285
285
|
services/repo_map.py,sha256=o-Z3c7ZqIxRKPyqquGafTngdooe1n-yRIcqpmOz5i2M,23208
|
|
286
286
|
services/repo_shape.py,sha256=QF0mXkY-bnBU8Q8mc5iimw2YBhMgZ4IYhoPc09Z8OBQ,4978
|
|
287
287
|
services/reranker.py,sha256=pnyNSzM7gXQ3ZodjY3rGIRCwpD2OIjookYkAJXb_LVE,4694
|
|
@@ -337,8 +337,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
|
|
|
337
337
|
tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
|
|
338
338
|
tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
|
|
339
339
|
tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
|
|
340
|
-
code_context_control-2.
|
|
341
|
-
code_context_control-2.
|
|
342
|
-
code_context_control-2.
|
|
343
|
-
code_context_control-2.
|
|
344
|
-
code_context_control-2.
|
|
340
|
+
code_context_control-2.153.0.dist-info/METADATA,sha256=bdkGNw00IveYo5nIYOYSVzPnK_1f3BwZsQhAWrYdpG4,28902
|
|
341
|
+
code_context_control-2.153.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
342
|
+
code_context_control-2.153.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
|
|
343
|
+
code_context_control-2.153.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
|
|
344
|
+
code_context_control-2.153.0.dist-info/RECORD,,
|
services/agent_locks.py
CHANGED
|
@@ -282,6 +282,18 @@ class LockStore:
|
|
|
282
282
|
return dict(row)
|
|
283
283
|
return None
|
|
284
284
|
|
|
285
|
+
def held_by(self, path, session_id: str) -> bool:
|
|
286
|
+
"""True when ``session_id`` has a live lease on ``path``."""
|
|
287
|
+
try:
|
|
288
|
+
rel = self._rel(path)
|
|
289
|
+
except UnsupportedPathError:
|
|
290
|
+
return False
|
|
291
|
+
now = self._clock()
|
|
292
|
+
return any(row.get("relpath") == rel
|
|
293
|
+
and row.get("expires_at", 0) > now
|
|
294
|
+
and row.get("session_id") == session_id
|
|
295
|
+
for row in self._load()["locks"])
|
|
296
|
+
|
|
285
297
|
# -- mutations ---------------------------------------------------------
|
|
286
298
|
|
|
287
299
|
def acquire(self, paths, *, agent_id: str, session_id: str,
|
|
@@ -507,6 +519,24 @@ def lease(path, project_path=".", session_id: str = "", intent: str = "") -> boo
|
|
|
507
519
|
return False
|
|
508
520
|
|
|
509
521
|
|
|
522
|
+
def holds(path, project_path=".", session_id: str = "") -> bool:
|
|
523
|
+
"""True when this session already has a live lease on ``path``. Never raises."""
|
|
524
|
+
try:
|
|
525
|
+
return store_for(project_path).held_by(path, session_id)
|
|
526
|
+
except Exception:
|
|
527
|
+
return False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def give_back(path, project_path=".", session_id: str = "") -> bool:
|
|
531
|
+
"""Drop this session's lease on ``path``, for a c3_edit call that took one
|
|
532
|
+
and then wrote nothing. Never raises."""
|
|
533
|
+
try:
|
|
534
|
+
res = store_for(project_path).release([path], session_id=session_id)
|
|
535
|
+
return bool(res.get("count"))
|
|
536
|
+
except Exception:
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
510
540
|
def refusal(holder: dict, path) -> str:
|
|
511
541
|
"""The agent-facing refusal. The last line is load-bearing: without an
|
|
512
542
|
explicit 'do not route around this', models reach for c3_shell with a
|
services/edit_blobs.py
CHANGED
|
@@ -88,6 +88,11 @@ def get(project_path, sha: str) -> bytes | None:
|
|
|
88
88
|
return data if sha256(data) == sha else None
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def has(project_path, sha: str) -> bool:
|
|
92
|
+
"""True when a blob for ``sha`` is in the store."""
|
|
93
|
+
return bool(sha) and _blob_path(project_path, sha).exists()
|
|
94
|
+
|
|
95
|
+
|
|
91
96
|
def sweep(project_path) -> int:
|
|
92
97
|
"""Evict oldest blobs until the store is within 90% of its cap.
|
|
93
98
|
|
|
@@ -153,6 +158,17 @@ def _skip_reason(project_path, path, pre: bytes | None,
|
|
|
153
158
|
return ""
|
|
154
159
|
|
|
155
160
|
|
|
161
|
+
def keep_pre(project_path, path, pre: bytes) -> None:
|
|
162
|
+
"""Store the pre-image of a write that is about to happen, when policy
|
|
163
|
+
allows, so a crash after the write still leaves it. Never raises."""
|
|
164
|
+
if _skip_reason(project_path, path, pre, None):
|
|
165
|
+
return
|
|
166
|
+
try:
|
|
167
|
+
put(project_path, pre)
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
156
172
|
def record(project_path, path, pre: bytes | None,
|
|
157
173
|
post: bytes | None) -> dict:
|
|
158
174
|
"""Ledger ``detail`` fields for a write that took ``path`` from ``pre`` to
|
services/read_stamps.py
CHANGED
|
@@ -227,6 +227,33 @@ def _region_block(text: str, lo: int, hi: int, label: str) -> str:
|
|
|
227
227
|
f"⟦L{lo}-L{hi}⟧\n{region}\n⟦end⟧\n")
|
|
228
228
|
|
|
229
229
|
|
|
230
|
+
def check_whole(path, label: str, raw: bytes) -> Guard:
|
|
231
|
+
"""Compare `raw`, the bytes of `path` about to be replaced, to this
|
|
232
|
+
session's stamp.
|
|
233
|
+
|
|
234
|
+
Refuses when the session holds no stamp for the file, or when `raw`
|
|
235
|
+
differs from the stamp at all: every line is in the region being edited.
|
|
236
|
+
"""
|
|
237
|
+
guard = Guard(path)
|
|
238
|
+
stamp = get(path)
|
|
239
|
+
if stamp is None:
|
|
240
|
+
guard.refusal = (
|
|
241
|
+
f"[c3_edit:unread] {label} has not been read in this session, and "
|
|
242
|
+
f"overwrite replaces all of it. Nothing was written.\n"
|
|
243
|
+
f" Read it with c3_read, then resend.")
|
|
244
|
+
return guard
|
|
245
|
+
if hashlib.sha256(raw).hexdigest() == stamp.sha256:
|
|
246
|
+
return guard
|
|
247
|
+
where = (f": {_fmt(changed_ranges(stamp.text, _decode(raw)))}"
|
|
248
|
+
if stamp.text is not None else "")
|
|
249
|
+
guard.refusal = (
|
|
250
|
+
f"[c3_edit:stale] {label} changed outside this session since your "
|
|
251
|
+
f"last read{where}.\n"
|
|
252
|
+
f" overwrite would discard that change. Nothing was written.\n"
|
|
253
|
+
f" Read the file again with c3_read, then resend.")
|
|
254
|
+
return guard
|
|
255
|
+
|
|
256
|
+
|
|
230
257
|
def check(path, project_path, label: str, old_string: str, edits,
|
|
231
258
|
replace_all: bool, norm=None) -> Guard:
|
|
232
259
|
"""Compare `path` to this session's stamp before an edit is applied.
|
|
File without changes
|
{code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.152.0.dist-info → code_context_control-2.153.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|