code-context-control 2.28.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/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
cli/tools/edit.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""c3_edit — in-place file patch: read + replace + write + ledger log in one step.
|
|
2
|
+
|
|
3
|
+
Bypasses the native Edit tool's requirement for a prior native Read call,
|
|
4
|
+
so c3_read → c3_edit works without an intermediate redundant native read.
|
|
5
|
+
|
|
6
|
+
Parallel safety:
|
|
7
|
+
- Different files: safe to call concurrently (no shared state).
|
|
8
|
+
- Same file: serialized via per-file threading.Lock (_file_locks).
|
|
9
|
+
- Same file, multiple hunks: use the `edits` batch parameter — one read/write cycle.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Per-file locks — keyed by resolved absolute path string.
|
|
16
|
+
_file_locks: dict[str, threading.Lock] = {}
|
|
17
|
+
_locks_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_file_lock(path: Path) -> threading.Lock:
|
|
21
|
+
key = str(path)
|
|
22
|
+
with _locks_lock:
|
|
23
|
+
if key not in _file_locks:
|
|
24
|
+
_file_locks[key] = threading.Lock()
|
|
25
|
+
return _file_locks[key]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Unicode lookalike substitutions used as a fallback when the literal
|
|
29
|
+
# old_string is not found. Strictly 1:1 (same-length) substitutions so
|
|
30
|
+
# positions are preserved — we locate the match on the normalized string
|
|
31
|
+
# and splice the replacement into the original content at the same offsets.
|
|
32
|
+
_LOOKALIKE_TRANS = str.maketrans({
|
|
33
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", # single curly quotes
|
|
34
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', # double curly quotes
|
|
35
|
+
"′": "'", "″": '"', # prime / double prime
|
|
36
|
+
"‐": "-", "‑": "-", "‒": "-", "–": "-", # hyphen / dashes
|
|
37
|
+
"—": "-", "―": "-", "−": "-",
|
|
38
|
+
" ": " ", " ": " ", " ": " ", # non-breaking / figure / narrow-nbsp
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _norm(s: str) -> str:
|
|
43
|
+
return s.translate(_LOOKALIKE_TRANS) if s else s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _positional_replace(content: str, norm_content: str, norm_old: str,
|
|
47
|
+
new: str, replace_all: bool) -> str:
|
|
48
|
+
"""Replace occurrences of `norm_old` in `norm_content` with `new`, splicing
|
|
49
|
+
into the matching offsets of the original `content`. Safe because
|
|
50
|
+
_LOOKALIKE_TRANS is 1:1 (character lengths are preserved)."""
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
i = 0
|
|
53
|
+
n = len(norm_old)
|
|
54
|
+
while True:
|
|
55
|
+
pos = norm_content.find(norm_old, i)
|
|
56
|
+
if pos < 0:
|
|
57
|
+
parts.append(content[i:])
|
|
58
|
+
break
|
|
59
|
+
parts.append(content[i:pos])
|
|
60
|
+
parts.append(new)
|
|
61
|
+
i = pos + n
|
|
62
|
+
if not replace_all:
|
|
63
|
+
parts.append(content[i:])
|
|
64
|
+
break
|
|
65
|
+
return "".join(parts)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _apply_replacement(content: str, old: str, new: str, replace_all: bool):
|
|
69
|
+
"""Try direct replace; on zero matches, retry with unicode-lookalike
|
|
70
|
+
normalization (curly quotes, unicode dashes, NBSP).
|
|
71
|
+
|
|
72
|
+
Returns (new_content, count, used_fallback):
|
|
73
|
+
- (str, count, bool) when at least one match was applied
|
|
74
|
+
- (None, 0, False) when no match is found (even after fallback)
|
|
75
|
+
- (None, count, bool) when count > 1 and replace_all is False
|
|
76
|
+
"""
|
|
77
|
+
count = content.count(old)
|
|
78
|
+
if count >= 1:
|
|
79
|
+
if count > 1 and not replace_all:
|
|
80
|
+
return (None, count, False)
|
|
81
|
+
return (content.replace(old, new, -1 if replace_all else 1), count, False)
|
|
82
|
+
|
|
83
|
+
# Fallback: normalize unicode lookalikes on both sides.
|
|
84
|
+
nc = _norm(content)
|
|
85
|
+
no = _norm(old)
|
|
86
|
+
if nc == content and no == old:
|
|
87
|
+
# Neither side contained any lookalike chars — genuinely not found.
|
|
88
|
+
return (None, 0, False)
|
|
89
|
+
count = nc.count(no)
|
|
90
|
+
if count == 0:
|
|
91
|
+
return (None, 0, False)
|
|
92
|
+
if count > 1 and not replace_all:
|
|
93
|
+
return (None, count, True)
|
|
94
|
+
return (_positional_replace(content, nc, no, new, replace_all), count, True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
98
|
+
summary: str, tags: str, replace_all: bool,
|
|
99
|
+
svc, finalize, edits: str = "") -> str:
|
|
100
|
+
"""Find old_string in file, replace with new_string, write back, log to ledger.
|
|
101
|
+
|
|
102
|
+
edits: optional JSON list of {old_string, new_string, summary?} dicts for
|
|
103
|
+
batch same-file patching in a single read/write cycle.
|
|
104
|
+
"""
|
|
105
|
+
if not file_path:
|
|
106
|
+
return finalize("c3_edit", {}, "file_path is required", "missing param")
|
|
107
|
+
|
|
108
|
+
# Resolve path
|
|
109
|
+
path = Path(file_path)
|
|
110
|
+
if not path.is_absolute():
|
|
111
|
+
path = Path(svc.project_path) / path
|
|
112
|
+
path = path.resolve()
|
|
113
|
+
|
|
114
|
+
# Relative path for ledger + display (computed even for new files)
|
|
115
|
+
try:
|
|
116
|
+
rel = str(path.relative_to(Path(svc.project_path).resolve())).replace("\\", "/")
|
|
117
|
+
except ValueError:
|
|
118
|
+
rel = file_path
|
|
119
|
+
|
|
120
|
+
# ── Create mode ───────────────────────────────────────────────────────────
|
|
121
|
+
# File doesn't exist + single-edit mode + empty old_string → create file.
|
|
122
|
+
# Batch mode always requires an existing file.
|
|
123
|
+
if not path.exists():
|
|
124
|
+
if edits:
|
|
125
|
+
return finalize("c3_edit", {"file": file_path},
|
|
126
|
+
f"File not found: {file_path} (batch edits require an existing file)",
|
|
127
|
+
"not found")
|
|
128
|
+
if old_string:
|
|
129
|
+
return finalize("c3_edit", {"file": file_path},
|
|
130
|
+
f"File not found: {file_path}", "not found")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
path.write_text(new_string, encoding="utf-8")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
return finalize("c3_edit", {"file": file_path},
|
|
137
|
+
f"Create error: {e}", "create error")
|
|
138
|
+
|
|
139
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
|
140
|
+
n_new = new_string.count("\n") + 1 if new_string else 0
|
|
141
|
+
create_summary = summary or f"Created {rel} ({n_new}L)"
|
|
142
|
+
_log_to_ledger(rel, create_summary, tag_list, svc,
|
|
143
|
+
detail={"old_string": "", "new_string": new_string[:_DETAIL_CAP], "created": True})
|
|
144
|
+
short = f"✓ {rel} [created, +{n_new}L]" + (f" — {summary}" if summary else "")
|
|
145
|
+
return finalize("c3_edit", {"file": file_path}, short, f"{rel} created")
|
|
146
|
+
|
|
147
|
+
# Parse tag list once
|
|
148
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
|
149
|
+
|
|
150
|
+
file_lock = _get_file_lock(path)
|
|
151
|
+
|
|
152
|
+
# ── Batch mode ────────────────────────────────────────────────────────────
|
|
153
|
+
if edits:
|
|
154
|
+
try:
|
|
155
|
+
edit_list = json.loads(edits) if isinstance(edits, str) else edits
|
|
156
|
+
except json.JSONDecodeError as exc:
|
|
157
|
+
return finalize("c3_edit", {"file": file_path},
|
|
158
|
+
f"edits must be a valid JSON list: {exc}", "bad edits param")
|
|
159
|
+
|
|
160
|
+
if not isinstance(edit_list, list) or not edit_list:
|
|
161
|
+
return finalize("c3_edit", {"file": file_path},
|
|
162
|
+
"edits must be a non-empty JSON list", "bad edits param")
|
|
163
|
+
|
|
164
|
+
with file_lock:
|
|
165
|
+
try:
|
|
166
|
+
content = path.read_text(encoding="utf-8")
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return finalize("c3_edit", {"file": file_path},
|
|
169
|
+
f"Read error: {e}", "read error")
|
|
170
|
+
|
|
171
|
+
results = []
|
|
172
|
+
any_normalized = False
|
|
173
|
+
for i, patch in enumerate(edit_list):
|
|
174
|
+
old = patch.get("old_string", "")
|
|
175
|
+
new = patch.get("new_string", "")
|
|
176
|
+
patch_summary = patch.get("summary", "")
|
|
177
|
+
r_all = patch.get("replace_all", False)
|
|
178
|
+
|
|
179
|
+
if not old:
|
|
180
|
+
results.append(f" patch[{i}]: skipped — empty old_string")
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
new_content, count, used_fallback = _apply_replacement(content, old, new, r_all)
|
|
184
|
+
if new_content is None and count == 0:
|
|
185
|
+
results.append(f" patch[{i}]: NOT FOUND — {old[:80]!r}")
|
|
186
|
+
continue
|
|
187
|
+
if new_content is None:
|
|
188
|
+
tag = " (unicode-normalized)" if used_fallback else ""
|
|
189
|
+
results.append(f" patch[{i}]: AMBIGUOUS ({count} matches){tag} — {old[:60]!r}")
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
content = new_content
|
|
193
|
+
n = count if r_all else 1
|
|
194
|
+
if used_fallback:
|
|
195
|
+
any_normalized = True
|
|
196
|
+
|
|
197
|
+
n_old = old.count("\n") + 1
|
|
198
|
+
n_new = new.count("\n") + 1
|
|
199
|
+
desc = patch_summary or f"{old[:50]!r} → {new[:50]!r}"
|
|
200
|
+
results.append(f" patch[{i}]: -{n_old}L +{n_new}L"
|
|
201
|
+
+ (f" ({n}x)" if n > 1 else "")
|
|
202
|
+
+ (" [norm]" if used_fallback else "")
|
|
203
|
+
+ f" | {desc}")
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
path.write_text(content, encoding="utf-8")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
return finalize("c3_edit", {"file": file_path},
|
|
209
|
+
f"Write error: {e}", "write error")
|
|
210
|
+
|
|
211
|
+
# Log batch to ledger as one entry (store each patch's old/new for diff view)
|
|
212
|
+
batch_detail = {"patches": [
|
|
213
|
+
{
|
|
214
|
+
"old_string": p.get("old_string", "")[:_DETAIL_CAP],
|
|
215
|
+
"new_string": p.get("new_string", "")[:_DETAIL_CAP],
|
|
216
|
+
**({"summary": p["summary"]} if p.get("summary") else {}),
|
|
217
|
+
}
|
|
218
|
+
for p in edit_list if p.get("old_string") is not None
|
|
219
|
+
]}
|
|
220
|
+
_log_to_ledger(rel, summary or f"Batch edit: {len(edit_list)} patches", tag_list, svc, detail=batch_detail)
|
|
221
|
+
|
|
222
|
+
applied = sum(1 for r in results if "NOT FOUND" not in r and "AMBIGUOUS" not in r and "skipped" not in r)
|
|
223
|
+
norm_tag = " [unicode-normalized]" if any_normalized else ""
|
|
224
|
+
short = f"✓ {rel} — {applied}/{len(edit_list)} patches applied{norm_tag}"
|
|
225
|
+
if applied < len(edit_list):
|
|
226
|
+
failed = [r for r in results if "NOT FOUND" in r or "AMBIGUOUS" in r or "skipped" in r]
|
|
227
|
+
short += "\n" + "\n".join(failed)
|
|
228
|
+
return finalize("c3_edit", {"file": file_path}, short,
|
|
229
|
+
f"{rel} patched ({len(edit_list)} patches)")
|
|
230
|
+
|
|
231
|
+
# ── Single-edit mode ──────────────────────────────────────────────────────
|
|
232
|
+
if old_string is None:
|
|
233
|
+
return finalize("c3_edit", {"file": file_path}, "old_string is required", "missing param")
|
|
234
|
+
|
|
235
|
+
with file_lock:
|
|
236
|
+
try:
|
|
237
|
+
content = path.read_text(encoding="utf-8")
|
|
238
|
+
except Exception as e:
|
|
239
|
+
return finalize("c3_edit", {"file": file_path},
|
|
240
|
+
f"Read error: {e}", "read error")
|
|
241
|
+
|
|
242
|
+
new_content, count, used_fallback = _apply_replacement(
|
|
243
|
+
content, old_string, new_string, replace_all)
|
|
244
|
+
|
|
245
|
+
if new_content is None and count == 0:
|
|
246
|
+
hint = ""
|
|
247
|
+
if _norm(old_string) != old_string or _norm(content) != content:
|
|
248
|
+
hint = "\n hint: unicode-lookalike normalization also failed to match."
|
|
249
|
+
return finalize("c3_edit", {"file": file_path},
|
|
250
|
+
f"old_string not found in {file_path}\n"
|
|
251
|
+
f" searched for: {old_string[:120]!r}{hint}",
|
|
252
|
+
"not found")
|
|
253
|
+
if new_content is None:
|
|
254
|
+
hint = " (after unicode-lookalike normalization)" if used_fallback else ""
|
|
255
|
+
return finalize("c3_edit", {"file": file_path},
|
|
256
|
+
f"old_string matches {count} locations{hint} — add more context to make it unique, "
|
|
257
|
+
f"or pass replace_all=true to replace all occurrences.",
|
|
258
|
+
"ambiguous")
|
|
259
|
+
|
|
260
|
+
occurrences = count if replace_all else 1
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
path.write_text(new_content, encoding="utf-8")
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return finalize("c3_edit", {"file": file_path},
|
|
266
|
+
f"Write error: {e}", "write error")
|
|
267
|
+
|
|
268
|
+
auto_summary = (summary or
|
|
269
|
+
f"Replaced: {old_string[:60]!r} → {new_string[:60]!r}"
|
|
270
|
+
+ (f" ({occurrences}x)" if occurrences > 1 else ""))
|
|
271
|
+
single_detail = {
|
|
272
|
+
"old_string": old_string[:_DETAIL_CAP],
|
|
273
|
+
"new_string": new_string[:_DETAIL_CAP],
|
|
274
|
+
}
|
|
275
|
+
if used_fallback:
|
|
276
|
+
single_detail["unicode_normalized"] = True
|
|
277
|
+
_log_to_ledger(rel, auto_summary, tag_list, svc, detail=single_detail)
|
|
278
|
+
|
|
279
|
+
n_old = old_string.count("\n") + 1
|
|
280
|
+
n_new = new_string.count("\n") + 1
|
|
281
|
+
delta = f"-{n_old}+{n_new}L"
|
|
282
|
+
occ = f" ({occurrences}x)" if occurrences > 1 else ""
|
|
283
|
+
norm_tag = " [unicode-normalized]" if used_fallback else ""
|
|
284
|
+
short = f"✓ {rel} [{delta}]{occ}{norm_tag}" + (f" — {summary}" if summary else "")
|
|
285
|
+
return finalize("c3_edit", {"file": file_path}, short, f"{rel} patched")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_DETAIL_CAP = 2000 # chars stored per old/new string in the ledger
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _log_to_ledger(rel: str, summary: str, tag_list, svc, detail: dict = None) -> None:
|
|
292
|
+
"""Log an edit to the ledger, activity log, and session manager. Never raises."""
|
|
293
|
+
if not svc.edit_ledger:
|
|
294
|
+
return
|
|
295
|
+
try:
|
|
296
|
+
entry = svc.edit_ledger.log_edit(
|
|
297
|
+
file=rel,
|
|
298
|
+
change_type="modified",
|
|
299
|
+
summary=summary,
|
|
300
|
+
tags=tag_list,
|
|
301
|
+
detail=detail,
|
|
302
|
+
)
|
|
303
|
+
if svc.activity_log:
|
|
304
|
+
svc.activity_log.log("file_change", {
|
|
305
|
+
"file": rel,
|
|
306
|
+
"change_type": "modified",
|
|
307
|
+
"summary": summary,
|
|
308
|
+
"edit_id": entry.get("id", ""),
|
|
309
|
+
})
|
|
310
|
+
if svc.session_mgr and hasattr(svc.session_mgr, "log_file_change"):
|
|
311
|
+
svc.session_mgr.log_file_change(rel, "modified")
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
cli/tools/edits.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""c3_edits — AI-tracked edit ledger: log, query, and version file changes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def handle_edits(action: str, file: str, change_type: str, summary: str,
|
|
5
|
+
lines_changed: str, tags: str, limit: int, since: str,
|
|
6
|
+
edit_id: str, tag: str, svc, finalize) -> str:
|
|
7
|
+
"""Route c3_edits actions."""
|
|
8
|
+
ledger = svc.edit_ledger
|
|
9
|
+
if ledger is None:
|
|
10
|
+
return finalize("c3_edits", {"action": action}, "Edit ledger not available", "ledger disabled")
|
|
11
|
+
|
|
12
|
+
if action == "log":
|
|
13
|
+
if not file:
|
|
14
|
+
return finalize("c3_edits", {"action": "log"}, "file is required", "missing file")
|
|
15
|
+
# Parse lines_changed: "120,145" → [120, 145]
|
|
16
|
+
lc = None
|
|
17
|
+
if lines_changed:
|
|
18
|
+
try:
|
|
19
|
+
lc = [int(x.strip()) for x in lines_changed.split(",") if x.strip()]
|
|
20
|
+
except ValueError:
|
|
21
|
+
lc = None
|
|
22
|
+
# Parse tags: "tag1,tag2" → ["tag1", "tag2"]
|
|
23
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
|
|
24
|
+
session_id = ""
|
|
25
|
+
if svc.session_mgr and hasattr(svc.session_mgr, "current_session"):
|
|
26
|
+
cs = svc.session_mgr.current_session
|
|
27
|
+
if cs:
|
|
28
|
+
session_id = cs.get("id", "")
|
|
29
|
+
|
|
30
|
+
entry = ledger.log_edit(
|
|
31
|
+
file=file,
|
|
32
|
+
change_type=change_type or "modified",
|
|
33
|
+
summary=summary or "Edit logged",
|
|
34
|
+
lines_changed=lc,
|
|
35
|
+
tags=tag_list,
|
|
36
|
+
session_id=session_id,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Cross-log to activity_log
|
|
40
|
+
if svc.activity_log:
|
|
41
|
+
svc.activity_log.log("file_change", {
|
|
42
|
+
"file": entry["file"],
|
|
43
|
+
"change_type": entry["change_type"],
|
|
44
|
+
"summary": entry["summary"],
|
|
45
|
+
"edit_id": entry["id"],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Cross-log to session_mgr
|
|
49
|
+
if svc.session_mgr and hasattr(svc.session_mgr, "log_file_change"):
|
|
50
|
+
svc.session_mgr.log_file_change(entry["file"], entry["change_type"])
|
|
51
|
+
|
|
52
|
+
body = (f"[edit:{entry['id']}] {entry['file']} {entry['version']}\n"
|
|
53
|
+
f" type: {entry['change_type']}\n"
|
|
54
|
+
f" summary: {entry['summary']}")
|
|
55
|
+
if entry.get("diff_summary"):
|
|
56
|
+
body += f"\n diff: {entry['diff_summary']}"
|
|
57
|
+
if entry.get("git", {}).get("commit"):
|
|
58
|
+
body += f"\n git: {entry['git']['commit']} ({entry['git']['subject']})"
|
|
59
|
+
return finalize("c3_edits", {"action": "log", "file": file}, body,
|
|
60
|
+
f"{entry['file']} → {entry['version']}")
|
|
61
|
+
|
|
62
|
+
elif action == "history":
|
|
63
|
+
entries = ledger.get_history(
|
|
64
|
+
file=file or None,
|
|
65
|
+
limit=limit or 50,
|
|
66
|
+
since=since or None,
|
|
67
|
+
)
|
|
68
|
+
if not entries:
|
|
69
|
+
return finalize("c3_edits", {"action": "history"}, "No edits found", "0 edits")
|
|
70
|
+
lines = [f"[edits:history] {len(entries)} entries" + (f" for {file}" if file else "")]
|
|
71
|
+
for e in entries:
|
|
72
|
+
ln = f" {e['timestamp'][:19]} | {e['file']} {e['version']} | {e['change_type']} | {e['summary']}"
|
|
73
|
+
if e.get("tags"):
|
|
74
|
+
ln += f" [{','.join(e['tags'])}]"
|
|
75
|
+
lines.append(ln)
|
|
76
|
+
return finalize("c3_edits", {"action": "history", "file": file},
|
|
77
|
+
"\n".join(lines), f"{len(entries)} edits")
|
|
78
|
+
|
|
79
|
+
elif action == "versions":
|
|
80
|
+
if not file:
|
|
81
|
+
return finalize("c3_edits", {"action": "versions"}, "file is required", "missing file")
|
|
82
|
+
versions = ledger.get_file_versions(file)
|
|
83
|
+
if not versions:
|
|
84
|
+
return finalize("c3_edits", {"action": "versions", "file": file},
|
|
85
|
+
f"No versions found for {file}", "0 versions")
|
|
86
|
+
lines = [f"[edits:versions] {file} — {len(versions)} versions"]
|
|
87
|
+
for v in versions:
|
|
88
|
+
ln = f" {v['version']} | {v['timestamp'][:19]} | {v['change_type']} | {v['summary']}"
|
|
89
|
+
lines.append(ln)
|
|
90
|
+
current = versions[-1]["version"] if versions else "v0"
|
|
91
|
+
return finalize("c3_edits", {"action": "versions", "file": file},
|
|
92
|
+
"\n".join(lines), f"{file} current: {current}")
|
|
93
|
+
|
|
94
|
+
elif action == "stats":
|
|
95
|
+
stats = ledger.get_stats()
|
|
96
|
+
lines = [
|
|
97
|
+
f"[edits:stats] {stats['total']} total edits across {stats['files']} files",
|
|
98
|
+
f" by type: {stats['by_type']}",
|
|
99
|
+
]
|
|
100
|
+
if stats.get("most_edited"):
|
|
101
|
+
lines.append(" most edited:")
|
|
102
|
+
for m in stats["most_edited"][:5]:
|
|
103
|
+
lines.append(f" {m['file']}: {m['count']} edits")
|
|
104
|
+
return finalize("c3_edits", {"action": "stats"},
|
|
105
|
+
"\n".join(lines), f"{stats['total']} edits, {stats['files']} files")
|
|
106
|
+
|
|
107
|
+
elif action == "tag":
|
|
108
|
+
if not edit_id or not tag:
|
|
109
|
+
return finalize("c3_edits", {"action": "tag"},
|
|
110
|
+
"edit_id and tag are required", "missing params")
|
|
111
|
+
ok = ledger.tag_edit(edit_id, tag)
|
|
112
|
+
msg = f"Tagged {edit_id} with '{tag}'" if ok else f"Edit {edit_id} not found"
|
|
113
|
+
return finalize("c3_edits", {"action": "tag"}, msg, msg)
|
|
114
|
+
|
|
115
|
+
else:
|
|
116
|
+
return finalize("c3_edits", {"action": action},
|
|
117
|
+
f"Unknown action: {action}. Use: log, history, versions, stats, tag",
|
|
118
|
+
"unknown action")
|