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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. 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")