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
services/claude_md.py
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLAUDE.md Management Service
|
|
3
|
+
|
|
4
|
+
Provides intelligent CLAUDE.md lifecycle tools:
|
|
5
|
+
- generate: Create CLAUDE.md from live project data + session/memory insights
|
|
6
|
+
- check_staleness: Detect drift between CLAUDE.md and actual project state
|
|
7
|
+
- compact: Reduce bloated CLAUDE.md while preserving critical info
|
|
8
|
+
- get_promotion_candidates: Surface high-value facts/patterns for inclusion
|
|
9
|
+
|
|
10
|
+
All methods are read-only — they return content/reports but never write to disk.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from core import count_tokens
|
|
18
|
+
|
|
19
|
+
# Default truncation limit (Claude Code truncates after 200 lines)
|
|
20
|
+
TRUNCATION_LIMIT = 200
|
|
21
|
+
|
|
22
|
+
C3_COMPACT_WORKFLOW = """\
|
|
23
|
+
## C3 Tools — MANDATORY (enforced by hooks)
|
|
24
|
+
Native tools (Read, Grep, Glob, Edit, Write) are **blocked by PreToolUse hooks** unless a c3_* tool \
|
|
25
|
+
was called first. Do NOT attempt native tools without prior c3_* usage — they will be denied.
|
|
26
|
+
|
|
27
|
+
**Native tools are permitted ONLY when:**
|
|
28
|
+
1. The c3_* tool failed or returned an error
|
|
29
|
+
2. The c3_* tool returned insufficient scope for a targeted follow-up
|
|
30
|
+
When falling back, state which c3_* tool was attempted and why it was insufficient.
|
|
31
|
+
|
|
32
|
+
## Workflow (follow this order — do not skip steps)
|
|
33
|
+
1. **RECALL**: `c3_memory(action='recall')` — before any multi-step or context-dependent task. Large memory stores: use `index` first (compact list), then `fetch` for specific IDs
|
|
34
|
+
2. **SEARCH FIRST**: `c3_search(action='code|files|semantic')` — before ANY file discovery or content search. Never start with Grep/Glob
|
|
35
|
+
3. **MAP before READ**: `c3_compress(mode='map')` then `c3_read(symbols=...|lines=...)` — for ANY file read. Never start with native Read. Use `mode='ast'` for knowledge-graph overview (requires codebase-memory-mcp)
|
|
36
|
+
4. **IMPACT** (shared symbols): `c3_impact(target='symbol')` — blast-radius check before editing any function/class used across files
|
|
37
|
+
5. **EDIT via C3**: `c3_edit(file_path, old_string, new_string, summary)` — for ALL edits. Parallel across files; `edits=[]` batch for same file
|
|
38
|
+
6. **FILTER**: `c3_filter(text=...)` — for terminal output >10 lines or log files
|
|
39
|
+
6.5. **SHELL via C3**: `c3_shell(cmd, cwd='', timeout=60)` — for tests, git, build, scripts. Returns structured `{exit_code, stdout, stderr, duration_ms}`. Auto-filters stdout >30 lines; auto-logs git-mutating commands (commit/add/merge/rebase/reset/restore/checkout) to the edit ledger. Blocks fork bombs and `rm -rf /` or `~`; soft-warns on `--force`, `--no-verify`, `reset --hard`. Native Bash remains the fallback for interactive/TTY commands
|
|
40
|
+
7. **VALIDATE**: `c3_validate(file_path)` — after edits or before reporting done. Runs deep type check (pyright/tsc) automatically if installed
|
|
41
|
+
8. **LOG**: `c3_session(action='log')` for decisions. `c3_session(action='snapshot')` before /clear
|
|
42
|
+
9. **DELEGATE**: `c3_delegate(task, backend='ollama|codex|gemini|claude|auto')` or `c3_agent(workflow=...)` for multi-model pipelines
|
|
43
|
+
|
|
44
|
+
## Plan mode
|
|
45
|
+
In plan mode, all c3_* read tools (search, read, compress, filter, validate, status) work normally — skip edit/delegate steps.
|
|
46
|
+
|
|
47
|
+
## Anti-patterns (DO NOT do these)
|
|
48
|
+
- Starting with native file search/read/grep without a prior c3_* call
|
|
49
|
+
- Using native Edit when c3_edit is available
|
|
50
|
+
- Reading entire files when c3_compress + c3_read would be more surgical
|
|
51
|
+
- Skipping c3_validate after making edits"""
|
|
52
|
+
|
|
53
|
+
# Ultra-compact workflow for nano mode (~250 tokens vs ~800 for full)
|
|
54
|
+
C3_NANO_WORKFLOW = """\
|
|
55
|
+
## C3 Tools — MANDATORY
|
|
56
|
+
Native tools BLOCKED unless c3_* called first. State reason when falling back.
|
|
57
|
+
1. c3_search(action='code|files|semantic') — BEFORE any search/grep/glob
|
|
58
|
+
2. c3_compress(mode='map') then c3_read(symbols=...|lines=...) — BEFORE any file read
|
|
59
|
+
3. c3_edit(file_path, old_string, new_string, summary) — for ALL edits; edits=[{...}] batch
|
|
60
|
+
4. c3_filter(text='...') — output >10 lines
|
|
61
|
+
5. c3_validate(file_path) — after edits
|
|
62
|
+
6. c3_session(action='log'|'snapshot') — decisions / before /clear
|
|
63
|
+
Plan mode: all c3_* read tools work normally — skip edit/delegate steps.
|
|
64
|
+
DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without c3_compress."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ClaudeMdManager:
|
|
68
|
+
"""Manages instructions file generation, analysis, compaction, and insight promotion.
|
|
69
|
+
|
|
70
|
+
Supports multiple IDEs — instructions_file determines the output filename
|
|
71
|
+
(e.g. CLAUDE.md for Claude Code, .github/copilot-instructions.md for VS Code).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, project_path: str, session_mgr, indexer, memory,
|
|
75
|
+
instructions_file: str = "CLAUDE.md", line_limit: int = 200,
|
|
76
|
+
supports_hooks: bool = True, supports_clear: bool = True,
|
|
77
|
+
nano_mode: bool = False):
|
|
78
|
+
self.project_path = Path(project_path)
|
|
79
|
+
self.session_mgr = session_mgr
|
|
80
|
+
self.indexer = indexer
|
|
81
|
+
self.memory = memory
|
|
82
|
+
self.instructions_file = instructions_file
|
|
83
|
+
self.line_limit = line_limit
|
|
84
|
+
self.supports_hooks = supports_hooks
|
|
85
|
+
self.supports_clear = supports_clear
|
|
86
|
+
self._nano_mode = nano_mode
|
|
87
|
+
|
|
88
|
+
# ── Public API (one per MCP tool) ────────────────────────
|
|
89
|
+
|
|
90
|
+
def _build_c3_workflow(self, nano: bool = False) -> str:
|
|
91
|
+
"""Build C3 workflow section.
|
|
92
|
+
|
|
93
|
+
nano=True: ~250 tokens (vs ~800 full). Use for IDEs where instructions space is limited.
|
|
94
|
+
Filters out hooks/snapshot/transcript lines for IDEs that don't support them.
|
|
95
|
+
"""
|
|
96
|
+
if nano:
|
|
97
|
+
workflow = C3_NANO_WORKFLOW
|
|
98
|
+
else:
|
|
99
|
+
workflow = C3_COMPACT_WORKFLOW
|
|
100
|
+
|
|
101
|
+
# Strip features unsupported by this IDE to reduce irrelevant instruction tokens
|
|
102
|
+
if not self.supports_clear:
|
|
103
|
+
# Remove /clear and snapshot/restore references
|
|
104
|
+
lines = workflow.splitlines()
|
|
105
|
+
lines = [l for l in lines if '/clear' not in l and 'snapshot' not in l.lower()]
|
|
106
|
+
workflow = '\n'.join(lines)
|
|
107
|
+
if not self.supports_hooks:
|
|
108
|
+
# Remove hook-specific log lines (hooks are Claude Code / Gemini only)
|
|
109
|
+
lines = workflow.splitlines()
|
|
110
|
+
lines = [l for l in lines if 'PostToolUse' not in l and 'AfterTool' not in l]
|
|
111
|
+
workflow = '\n'.join(lines)
|
|
112
|
+
|
|
113
|
+
return workflow
|
|
114
|
+
|
|
115
|
+
def generate(self, include_sessions: bool = True, mode: str = "compact") -> dict:
|
|
116
|
+
"""Generate token-efficient CLAUDE.md from live project data.
|
|
117
|
+
|
|
118
|
+
mode='compact' (default): full workflow + project tree + key facts (~2,000 tokens)
|
|
119
|
+
mode='nano': minimal mandate only (~250 tokens) — project tree/facts served via c3_memory
|
|
120
|
+
|
|
121
|
+
Optimized for minimal per-turn overhead:
|
|
122
|
+
- Compact C3 tool reference (~7 lines vs ~16)
|
|
123
|
+
- No session history (use c3_memory recall instead)
|
|
124
|
+
- Top 5 learned facts only (rest available via c3_memory)
|
|
125
|
+
- No shortcuts section (low value, costs tokens every turn)
|
|
126
|
+
"""
|
|
127
|
+
if mode == "nano":
|
|
128
|
+
self._nano_mode = True
|
|
129
|
+
|
|
130
|
+
use_nano = getattr(self, '_nano_mode', False)
|
|
131
|
+
|
|
132
|
+
# Nano mode: return minimal mandate only — project tree/facts served via c3_memory on demand
|
|
133
|
+
if use_nano:
|
|
134
|
+
content = self._build_c3_workflow(nano=True)
|
|
135
|
+
metrics = self._count_metrics(content)
|
|
136
|
+
return {
|
|
137
|
+
"content": content,
|
|
138
|
+
"lines": metrics["lines"],
|
|
139
|
+
"tokens": metrics["tokens"],
|
|
140
|
+
"mode": "nano",
|
|
141
|
+
"truncation_warning": None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
parts = []
|
|
145
|
+
|
|
146
|
+
# C3 workflow instructions (compact)
|
|
147
|
+
parts.append(self._build_c3_workflow(nano=False))
|
|
148
|
+
|
|
149
|
+
# Project structure
|
|
150
|
+
parts.append("\n# Project Context\n")
|
|
151
|
+
parts.append(self.session_mgr._scan_project_structure())
|
|
152
|
+
|
|
153
|
+
# Tech stack
|
|
154
|
+
parts.append("\n## Tech Stack\n")
|
|
155
|
+
parts.append(self.session_mgr._detect_tech_stack())
|
|
156
|
+
|
|
157
|
+
# Key files (compact)
|
|
158
|
+
key_files = self._detect_key_files()
|
|
159
|
+
if key_files:
|
|
160
|
+
parts.append("\n## Key Files\n")
|
|
161
|
+
for kf in key_files[:5]:
|
|
162
|
+
parts.append(f"- `{kf['file']}` — {kf['reason']}")
|
|
163
|
+
|
|
164
|
+
# Top learned facts only (rest available via c3_memory recall)
|
|
165
|
+
promoted_facts = [
|
|
166
|
+
f for f in self.memory.facts
|
|
167
|
+
if f.get("relevance_count", 0) >= 3
|
|
168
|
+
]
|
|
169
|
+
if promoted_facts:
|
|
170
|
+
parts.append("\n## Key Facts (use c3_memory for more)\n")
|
|
171
|
+
for f in promoted_facts[:5]:
|
|
172
|
+
parts.append(f"- {f['fact'][:120]}")
|
|
173
|
+
|
|
174
|
+
content = '\n'.join(parts)
|
|
175
|
+
metrics = self._count_metrics(content)
|
|
176
|
+
|
|
177
|
+
# Enforce line budget: progressively prune rather than silently truncate Key Facts
|
|
178
|
+
if self.line_limit and metrics["lines"] > self.line_limit:
|
|
179
|
+
# Pass 1: prune project structure to depth 1
|
|
180
|
+
pruned_parts = []
|
|
181
|
+
for part in parts:
|
|
182
|
+
if part.strip().startswith("```") and "\n" in part:
|
|
183
|
+
part = self._prune_structure_depth(part, max_depth=1)
|
|
184
|
+
pruned_parts.append(part)
|
|
185
|
+
content = '\n'.join(pruned_parts)
|
|
186
|
+
metrics = self._count_metrics(content)
|
|
187
|
+
|
|
188
|
+
if self.line_limit and metrics["lines"] > self.line_limit:
|
|
189
|
+
# Pass 2: drop key facts to 3
|
|
190
|
+
rebuilt = []
|
|
191
|
+
in_facts = False
|
|
192
|
+
facts_shown = 0
|
|
193
|
+
for line in content.splitlines():
|
|
194
|
+
if line.startswith("## Key Facts"):
|
|
195
|
+
in_facts = True
|
|
196
|
+
rebuilt.append(line)
|
|
197
|
+
continue
|
|
198
|
+
if in_facts and line.startswith("- "):
|
|
199
|
+
if facts_shown < 3:
|
|
200
|
+
rebuilt.append(line)
|
|
201
|
+
facts_shown += 1
|
|
202
|
+
continue
|
|
203
|
+
if in_facts and line.startswith("## "):
|
|
204
|
+
in_facts = False
|
|
205
|
+
rebuilt.append(line)
|
|
206
|
+
content = '\n'.join(rebuilt)
|
|
207
|
+
metrics = self._count_metrics(content)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"content": content,
|
|
211
|
+
"lines": metrics["lines"],
|
|
212
|
+
"tokens": metrics["tokens"],
|
|
213
|
+
"truncation_warning": (
|
|
214
|
+
f"Content is {metrics['lines']} lines — exceeds limit of {self.line_limit}. "
|
|
215
|
+
"Run `c3 claudemd compact` to reduce further."
|
|
216
|
+
) if self.line_limit and metrics["lines"] > self.line_limit else None,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def check_staleness(self) -> dict:
|
|
220
|
+
"""Check existing CLAUDE.md for staleness and drift."""
|
|
221
|
+
current = self._read_current()
|
|
222
|
+
if current is None:
|
|
223
|
+
return {
|
|
224
|
+
"status": "missing",
|
|
225
|
+
"issues": [{
|
|
226
|
+
"severity": "error",
|
|
227
|
+
"message": f"No {self.instructions_file} found. Use CLI `c3 claudemd generate` to create one.",
|
|
228
|
+
}],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
issues = []
|
|
232
|
+
sections = self._parse_sections(current)
|
|
233
|
+
metrics = self._count_metrics(current)
|
|
234
|
+
|
|
235
|
+
# Size warning (only if line_limit is set)
|
|
236
|
+
if self.line_limit and metrics["lines"] > self.line_limit:
|
|
237
|
+
issues.append({
|
|
238
|
+
"severity": "warning",
|
|
239
|
+
"message": (
|
|
240
|
+
f"{self.instructions_file} is {metrics['lines']} lines ({metrics['tokens']} tokens). "
|
|
241
|
+
f"Truncation may occur after {self.line_limit} lines. "
|
|
242
|
+
"Use CLI `c3 claudemd compact` to reduce."
|
|
243
|
+
),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
# Structure drift
|
|
247
|
+
structure_issues = self._diff_structure(current)
|
|
248
|
+
issues.extend(structure_issues)
|
|
249
|
+
|
|
250
|
+
# Tech stack drift
|
|
251
|
+
tech_issues = self._diff_tech_stack(current)
|
|
252
|
+
issues.extend(tech_issues)
|
|
253
|
+
|
|
254
|
+
# Session staleness
|
|
255
|
+
session_files = sorted(
|
|
256
|
+
(self.project_path / ".c3" / "sessions").glob("session_*.json"),
|
|
257
|
+
reverse=True,
|
|
258
|
+
) if (self.project_path / ".c3" / "sessions").exists() else []
|
|
259
|
+
|
|
260
|
+
session_section = sections.get("Session History (Compressed)", "")
|
|
261
|
+
if session_files:
|
|
262
|
+
# Count sessions mentioned in CLAUDE.md
|
|
263
|
+
mentioned_ids = set(re.findall(r'Session:\s*(\d{8}_\d{6})', session_section))
|
|
264
|
+
total_sessions = len(session_files)
|
|
265
|
+
unmentioned = total_sessions - len(mentioned_ids)
|
|
266
|
+
if unmentioned > 3:
|
|
267
|
+
issues.append({
|
|
268
|
+
"severity": "info",
|
|
269
|
+
"message": f"{unmentioned} sessions not reflected in CLAUDE.md. Consider regenerating.",
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
if not issues:
|
|
273
|
+
issues.append({
|
|
274
|
+
"severity": "info",
|
|
275
|
+
"message": "CLAUDE.md looks up to date.",
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
"status": "ok" if all(i["severity"] == "info" for i in issues) else "stale",
|
|
280
|
+
"lines": metrics["lines"],
|
|
281
|
+
"tokens": metrics["tokens"],
|
|
282
|
+
"issues": issues,
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def compact(self, target_lines: int = 150) -> dict:
|
|
286
|
+
"""Compact existing CLAUDE.md to fit within target line count."""
|
|
287
|
+
current = self._read_current()
|
|
288
|
+
if current is None:
|
|
289
|
+
return {"error": f"No {self.instructions_file} found on disk. Use CLI `c3 claudemd generate` to preview, then `c3 claudemd save` to persist before compacting."}
|
|
290
|
+
|
|
291
|
+
original_metrics = self._count_metrics(current)
|
|
292
|
+
sections = self._parse_sections(current)
|
|
293
|
+
lines = current.split('\n')
|
|
294
|
+
|
|
295
|
+
# If already under target, no compaction needed
|
|
296
|
+
if original_metrics["lines"] <= target_lines:
|
|
297
|
+
return {
|
|
298
|
+
"content": current,
|
|
299
|
+
"original_lines": original_metrics["lines"],
|
|
300
|
+
"compacted_lines": original_metrics["lines"],
|
|
301
|
+
"original_tokens": original_metrics["tokens"],
|
|
302
|
+
"compacted_tokens": original_metrics["tokens"],
|
|
303
|
+
"actions": ["Already under target — no compaction needed."],
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
actions = []
|
|
307
|
+
|
|
308
|
+
# Step 1: Compress session history — keep last 3, one-line summaries
|
|
309
|
+
if "Session History (Compressed)" in sections:
|
|
310
|
+
session_text = sections["Session History (Compressed)"]
|
|
311
|
+
compressed = self._compress_sessions(session_text, max_sessions=3)
|
|
312
|
+
if len(compressed.split('\n')) < len(session_text.split('\n')):
|
|
313
|
+
sections["Session History (Compressed)"] = compressed
|
|
314
|
+
actions.append("Trimmed session history to last 3 sessions with one-line summaries")
|
|
315
|
+
|
|
316
|
+
# Step 2: Deduplicate — remove exact duplicate lines (excluding blank lines and headers)
|
|
317
|
+
seen_lines = set()
|
|
318
|
+
deduped_sections = {}
|
|
319
|
+
for name, text in sections.items():
|
|
320
|
+
if name in ("User Notes", "C3 — Token-Saving Workflow (MUST FOLLOW)"):
|
|
321
|
+
deduped_sections[name] = text
|
|
322
|
+
continue
|
|
323
|
+
new_lines = []
|
|
324
|
+
for line in text.split('\n'):
|
|
325
|
+
stripped = line.strip()
|
|
326
|
+
if not stripped or stripped.startswith('#'):
|
|
327
|
+
new_lines.append(line)
|
|
328
|
+
elif stripped not in seen_lines:
|
|
329
|
+
seen_lines.add(stripped)
|
|
330
|
+
new_lines.append(line)
|
|
331
|
+
deduped_sections[name] = '\n'.join(new_lines)
|
|
332
|
+
dup_removed = sum(
|
|
333
|
+
len(sections[k].split('\n')) - len(deduped_sections[k].split('\n'))
|
|
334
|
+
for k in sections
|
|
335
|
+
)
|
|
336
|
+
if dup_removed > 0:
|
|
337
|
+
actions.append(f"Removed {dup_removed} duplicate lines")
|
|
338
|
+
sections = deduped_sections
|
|
339
|
+
|
|
340
|
+
# Step 3: Prune structure tree depth if still over target
|
|
341
|
+
content = self._reassemble_sections(sections)
|
|
342
|
+
current_lines = len(content.split('\n'))
|
|
343
|
+
if current_lines > target_lines and "Project Context (Auto-generated by C3)" in sections:
|
|
344
|
+
ctx_section = sections["Project Context (Auto-generated by C3)"]
|
|
345
|
+
pruned = self._prune_structure_depth(ctx_section, max_depth=2)
|
|
346
|
+
if len(pruned.split('\n')) < len(ctx_section.split('\n')):
|
|
347
|
+
sections["Project Context (Auto-generated by C3)"] = pruned
|
|
348
|
+
actions.append("Reduced project structure tree depth")
|
|
349
|
+
|
|
350
|
+
# Reassemble
|
|
351
|
+
content = self._reassemble_sections(sections)
|
|
352
|
+
compacted_metrics = self._count_metrics(content)
|
|
353
|
+
|
|
354
|
+
if not actions:
|
|
355
|
+
actions.append("No compaction opportunities found.")
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"content": content,
|
|
359
|
+
"original_lines": original_metrics["lines"],
|
|
360
|
+
"compacted_lines": compacted_metrics["lines"],
|
|
361
|
+
"original_tokens": original_metrics["tokens"],
|
|
362
|
+
"compacted_tokens": compacted_metrics["tokens"],
|
|
363
|
+
"actions": actions,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
def get_promotion_candidates(self, min_relevance: int = 2) -> dict:
|
|
367
|
+
"""Find facts and patterns worth promoting into CLAUDE.md."""
|
|
368
|
+
current = self._read_current()
|
|
369
|
+
current_text = current or ""
|
|
370
|
+
candidates = {
|
|
371
|
+
"Code Patterns & Conventions": [],
|
|
372
|
+
"Quick Reference Shortcuts": [],
|
|
373
|
+
"Key Files": [],
|
|
374
|
+
"Project Roadmap & Active Plans": [],
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# High-relevance facts
|
|
378
|
+
for fact in self.memory.facts:
|
|
379
|
+
if fact.get("relevance_count", 0) < min_relevance:
|
|
380
|
+
continue
|
|
381
|
+
# Skip if already in CLAUDE.md
|
|
382
|
+
if fact["fact"] in current_text:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
category = fact.get("category", "general")
|
|
386
|
+
target = "Code Patterns & Conventions"
|
|
387
|
+
if category in ("shortcut", "reference", "alias"):
|
|
388
|
+
target = "Quick Reference Shortcuts"
|
|
389
|
+
elif category in ("file", "path", "entry_point"):
|
|
390
|
+
target = "Key Files"
|
|
391
|
+
elif category in ("plan", "roadmap", "todo"):
|
|
392
|
+
target = "Project Roadmap & Active Plans"
|
|
393
|
+
|
|
394
|
+
candidates[target].append({
|
|
395
|
+
"fact": fact["fact"],
|
|
396
|
+
"category": category,
|
|
397
|
+
"relevance_count": fact["relevance_count"],
|
|
398
|
+
"snippet": f"- [{category}] {fact['fact']}",
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
# Recurring decisions and plans from sessions
|
|
402
|
+
session_dir = self.project_path / ".c3" / "sessions"
|
|
403
|
+
if session_dir.exists():
|
|
404
|
+
decision_keywords = {} # keyword -> [session_ids]
|
|
405
|
+
active_plans = [] # List of unique plan strings
|
|
406
|
+
for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
|
|
407
|
+
try:
|
|
408
|
+
with open(sf, encoding='utf-8') as f:
|
|
409
|
+
s = json.load(f)
|
|
410
|
+
sid = s.get("id", "unknown")
|
|
411
|
+
for d in s.get("decisions", []):
|
|
412
|
+
text = d.get("decision", "")
|
|
413
|
+
# Plan detection
|
|
414
|
+
if "PLAN:" in text.upper():
|
|
415
|
+
plan_text = text.split("PLAN:", 1)[1].strip()
|
|
416
|
+
if plan_text and not any(p["fact"] == plan_text for p in active_plans):
|
|
417
|
+
active_plans.append({
|
|
418
|
+
"fact": plan_text,
|
|
419
|
+
"category": "active_plan",
|
|
420
|
+
"relevance_count": 1,
|
|
421
|
+
"snippet": f"- [PLAN] {plan_text}"
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
# Decision keyword extraction (5+ chars)
|
|
425
|
+
words = set(re.findall(r'[a-zA-Z]{5,}', text.lower()))
|
|
426
|
+
for w in words:
|
|
427
|
+
if w not in decision_keywords:
|
|
428
|
+
decision_keywords[w] = []
|
|
429
|
+
if sid not in decision_keywords[w]:
|
|
430
|
+
decision_keywords[w].append(sid)
|
|
431
|
+
except Exception:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# Add unique plans to roadmap
|
|
435
|
+
for p in active_plans:
|
|
436
|
+
if p["fact"] not in current_text:
|
|
437
|
+
candidates["Project Roadmap & Active Plans"].append(p)
|
|
438
|
+
|
|
439
|
+
# Keywords appearing in 2+ sessions
|
|
440
|
+
recurring = {k: v for k, v in decision_keywords.items() if len(v) >= 2}
|
|
441
|
+
for keyword, session_ids in sorted(recurring.items(), key=lambda x: -len(x[1]))[:5]:
|
|
442
|
+
snippet = f"- Recurring decision keyword: \"{keyword}\" (across {len(session_ids)} sessions)"
|
|
443
|
+
if snippet not in current_text:
|
|
444
|
+
candidates["Code Patterns & Conventions"].append({
|
|
445
|
+
"fact": f"Recurring decision keyword: \"{keyword}\"",
|
|
446
|
+
"category": "recurring_decision",
|
|
447
|
+
"relevance_count": len(session_ids),
|
|
448
|
+
"snippet": snippet,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
# Filter out empty groups
|
|
452
|
+
candidates = {k: v for k, v in candidates.items() if v}
|
|
453
|
+
|
|
454
|
+
total = sum(len(v) for v in candidates.values())
|
|
455
|
+
return {
|
|
456
|
+
"total_candidates": total,
|
|
457
|
+
"candidates": candidates,
|
|
458
|
+
"message": (
|
|
459
|
+
f"Found {total} promotion candidates across {len(candidates)} sections."
|
|
460
|
+
if total > 0
|
|
461
|
+
else "No promotion candidates found. Build more session history and facts first."
|
|
462
|
+
),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# ── Shared helpers ───────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
def _read_current(self) -> Optional[str]:
|
|
468
|
+
"""Read existing instructions file from project root."""
|
|
469
|
+
path = self.project_path / self.instructions_file
|
|
470
|
+
if not path.exists():
|
|
471
|
+
return None
|
|
472
|
+
try:
|
|
473
|
+
return path.read_text(encoding="utf-8")
|
|
474
|
+
except Exception:
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
def _parse_sections(self, content: str) -> dict:
|
|
478
|
+
"""Split CLAUDE.md into named sections by # or ## headers."""
|
|
479
|
+
sections = {}
|
|
480
|
+
current_name = "_preamble"
|
|
481
|
+
current_lines = []
|
|
482
|
+
|
|
483
|
+
for line in content.split('\n'):
|
|
484
|
+
header_match = re.match(r'^(#{1,3})\s+(.+)', line)
|
|
485
|
+
if header_match:
|
|
486
|
+
# Save previous section
|
|
487
|
+
if current_lines or current_name != "_preamble":
|
|
488
|
+
sections[current_name] = '\n'.join(current_lines)
|
|
489
|
+
current_name = header_match.group(2).strip()
|
|
490
|
+
current_lines = []
|
|
491
|
+
else:
|
|
492
|
+
current_lines.append(line)
|
|
493
|
+
|
|
494
|
+
# Save last section
|
|
495
|
+
if current_lines or current_name != "_preamble":
|
|
496
|
+
sections[current_name] = '\n'.join(current_lines)
|
|
497
|
+
|
|
498
|
+
return sections
|
|
499
|
+
|
|
500
|
+
def _reassemble_sections(self, sections: dict) -> str:
|
|
501
|
+
"""Reassemble sections into CLAUDE.md content."""
|
|
502
|
+
parts = []
|
|
503
|
+
for name, body in sections.items():
|
|
504
|
+
if name == "_preamble":
|
|
505
|
+
if body.strip():
|
|
506
|
+
parts.append(body)
|
|
507
|
+
else:
|
|
508
|
+
# Determine header level from body context (default ##)
|
|
509
|
+
level = "#"
|
|
510
|
+
if name in ("Project Context (Auto-generated by C3)",
|
|
511
|
+
"Session History (Compressed)", "User Notes"):
|
|
512
|
+
level = "#"
|
|
513
|
+
else:
|
|
514
|
+
level = "##"
|
|
515
|
+
parts.append(f"{level} {name}\n{body}")
|
|
516
|
+
return '\n\n'.join(parts)
|
|
517
|
+
|
|
518
|
+
def _count_metrics(self, content: str) -> dict:
|
|
519
|
+
"""Count lines and tokens."""
|
|
520
|
+
lines = len(content.split('\n'))
|
|
521
|
+
tokens = count_tokens(content)
|
|
522
|
+
return {"lines": lines, "tokens": tokens}
|
|
523
|
+
|
|
524
|
+
# ── Generate helpers ─────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
def _detect_enhanced_patterns(self) -> list:
|
|
527
|
+
"""Detect patterns beyond what SessionManager finds — linting, test frameworks, monorepo."""
|
|
528
|
+
patterns = []
|
|
529
|
+
p = self.project_path
|
|
530
|
+
|
|
531
|
+
# Base patterns from session manager
|
|
532
|
+
base = self.session_mgr._detect_patterns()
|
|
533
|
+
if base and base != "No patterns auto-detected":
|
|
534
|
+
for line in base.split('\n'):
|
|
535
|
+
line = line.strip().lstrip('- ')
|
|
536
|
+
if line:
|
|
537
|
+
patterns.append(line)
|
|
538
|
+
|
|
539
|
+
# Linting / formatting
|
|
540
|
+
linting_indicators = {
|
|
541
|
+
".eslintrc": "ESLint", ".eslintrc.js": "ESLint", ".eslintrc.json": "ESLint",
|
|
542
|
+
".eslintrc.yml": "ESLint", "eslint.config.js": "ESLint (flat config)",
|
|
543
|
+
".prettierrc": "Prettier", ".prettierrc.json": "Prettier",
|
|
544
|
+
"prettier.config.js": "Prettier",
|
|
545
|
+
".flake8": "Flake8", "setup.cfg": "Python config (setup.cfg)",
|
|
546
|
+
"ruff.toml": "Ruff", ".ruff.toml": "Ruff",
|
|
547
|
+
".stylelintrc": "Stylelint",
|
|
548
|
+
"biome.json": "Biome",
|
|
549
|
+
}
|
|
550
|
+
for filename, tool in linting_indicators.items():
|
|
551
|
+
if (p / filename).exists():
|
|
552
|
+
patterns.append(f"Uses {tool}")
|
|
553
|
+
|
|
554
|
+
# Check pyproject.toml for tool configs
|
|
555
|
+
pyproject = p / "pyproject.toml"
|
|
556
|
+
if pyproject.exists():
|
|
557
|
+
try:
|
|
558
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
559
|
+
if "[tool.ruff" in text:
|
|
560
|
+
patterns.append("Uses Ruff (via pyproject.toml)")
|
|
561
|
+
if "[tool.black" in text:
|
|
562
|
+
patterns.append("Uses Black formatter")
|
|
563
|
+
if "[tool.pytest" in text or "[tool.pytest.ini_options" in text:
|
|
564
|
+
patterns.append("Uses pytest")
|
|
565
|
+
if "[tool.mypy" in text:
|
|
566
|
+
patterns.append("Uses mypy type checking")
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
# Test frameworks
|
|
571
|
+
if (p / "jest.config.js").exists() or (p / "jest.config.ts").exists():
|
|
572
|
+
patterns.append("Uses Jest for testing")
|
|
573
|
+
if (p / "vitest.config.ts").exists() or (p / "vitest.config.js").exists():
|
|
574
|
+
patterns.append("Uses Vitest for testing")
|
|
575
|
+
if (p / "pytest.ini").exists() or (p / "conftest.py").exists():
|
|
576
|
+
patterns.append("Uses pytest")
|
|
577
|
+
|
|
578
|
+
# Monorepo indicators
|
|
579
|
+
if (p / "lerna.json").exists():
|
|
580
|
+
patterns.append("Monorepo (Lerna)")
|
|
581
|
+
if (p / "pnpm-workspace.yaml").exists():
|
|
582
|
+
patterns.append("Monorepo (pnpm workspaces)")
|
|
583
|
+
if (p / "turbo.json").exists():
|
|
584
|
+
patterns.append("Monorepo (Turborepo)")
|
|
585
|
+
pkg = p / "package.json"
|
|
586
|
+
if pkg.exists():
|
|
587
|
+
try:
|
|
588
|
+
with open(pkg, encoding='utf-8') as f:
|
|
589
|
+
data = json.load(f)
|
|
590
|
+
if "workspaces" in data:
|
|
591
|
+
patterns.append("Monorepo (npm/yarn workspaces)")
|
|
592
|
+
except Exception:
|
|
593
|
+
pass
|
|
594
|
+
|
|
595
|
+
# Deduplicate
|
|
596
|
+
seen = set()
|
|
597
|
+
unique = []
|
|
598
|
+
for pat in patterns:
|
|
599
|
+
key = pat.lower()
|
|
600
|
+
if key not in seen:
|
|
601
|
+
seen.add(key)
|
|
602
|
+
unique.append(pat)
|
|
603
|
+
|
|
604
|
+
return unique
|
|
605
|
+
|
|
606
|
+
def _detect_key_files(self) -> list:
|
|
607
|
+
"""Identify key files from session history and conventional entry points."""
|
|
608
|
+
key_files = []
|
|
609
|
+
seen = set()
|
|
610
|
+
|
|
611
|
+
# Hot files from session history
|
|
612
|
+
session_dir = self.project_path / ".c3" / "sessions"
|
|
613
|
+
if session_dir.exists():
|
|
614
|
+
file_counts = {}
|
|
615
|
+
for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
|
|
616
|
+
try:
|
|
617
|
+
with open(sf, encoding='utf-8') as f:
|
|
618
|
+
s = json.load(f)
|
|
619
|
+
for ft in s.get("files_touched", []):
|
|
620
|
+
fname = ft.get("file", "")
|
|
621
|
+
if fname:
|
|
622
|
+
file_counts[fname] = file_counts.get(fname, 0) + 1
|
|
623
|
+
except Exception:
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
for fname, count in sorted(file_counts.items(), key=lambda x: -x[1])[:5]:
|
|
627
|
+
if count >= 2 and fname not in seen:
|
|
628
|
+
key_files.append({"file": fname, "reason": f"edited in {count} sessions"})
|
|
629
|
+
seen.add(fname)
|
|
630
|
+
|
|
631
|
+
# Conventional entry points
|
|
632
|
+
entry_points = [
|
|
633
|
+
("main.py", "Python entry point"),
|
|
634
|
+
("app.py", "Application entry point"),
|
|
635
|
+
("index.ts", "TypeScript entry point"),
|
|
636
|
+
("index.js", "JavaScript entry point"),
|
|
637
|
+
("src/index.ts", "Source entry point"),
|
|
638
|
+
("src/index.js", "Source entry point"),
|
|
639
|
+
("src/main.ts", "Source entry point"),
|
|
640
|
+
("src/App.tsx", "React app root"),
|
|
641
|
+
("cli/mcp_server.py", "MCP server entry"),
|
|
642
|
+
]
|
|
643
|
+
for filepath, reason in entry_points:
|
|
644
|
+
if (self.project_path / filepath).exists() and filepath not in seen:
|
|
645
|
+
key_files.append({"file": filepath, "reason": reason})
|
|
646
|
+
seen.add(filepath)
|
|
647
|
+
|
|
648
|
+
return key_files
|
|
649
|
+
|
|
650
|
+
# ── Check helpers ────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
def _diff_structure(self, current_content: str) -> list:
|
|
653
|
+
"""Find dirs mentioned in CLAUDE.md that don't exist, and new dirs not mentioned."""
|
|
654
|
+
issues = []
|
|
655
|
+
|
|
656
|
+
# Extract dir-like references from the code block
|
|
657
|
+
mentioned_dirs = set()
|
|
658
|
+
in_code_block = False
|
|
659
|
+
for line in current_content.split('\n'):
|
|
660
|
+
if line.strip().startswith('```'):
|
|
661
|
+
in_code_block = not in_code_block
|
|
662
|
+
continue
|
|
663
|
+
if in_code_block and line.strip().endswith('/'):
|
|
664
|
+
dirname = line.strip().rstrip('/')
|
|
665
|
+
if dirname:
|
|
666
|
+
mentioned_dirs.add(dirname)
|
|
667
|
+
|
|
668
|
+
# Scan actual top-level dirs
|
|
669
|
+
skip = {'node_modules', '.git', '__pycache__', '.c3', 'venv',
|
|
670
|
+
'env', '.venv', 'dist', 'build', '.next', '.cache', '.claude'}
|
|
671
|
+
actual_dirs = set()
|
|
672
|
+
for item in self.project_path.iterdir():
|
|
673
|
+
if item.is_dir() and item.name not in skip and not item.name.startswith('.'):
|
|
674
|
+
actual_dirs.add(item.name)
|
|
675
|
+
|
|
676
|
+
# Compare (use base names only)
|
|
677
|
+
mentioned_basenames = {d.split('/')[-1] for d in mentioned_dirs if d}
|
|
678
|
+
|
|
679
|
+
missing_in_fs = mentioned_basenames - actual_dirs
|
|
680
|
+
new_in_fs = actual_dirs - mentioned_basenames
|
|
681
|
+
|
|
682
|
+
for d in missing_in_fs:
|
|
683
|
+
# Skip the project root name
|
|
684
|
+
if d == self.project_path.name:
|
|
685
|
+
continue
|
|
686
|
+
issues.append({
|
|
687
|
+
"severity": "warning",
|
|
688
|
+
"message": f"Directory '{d}' mentioned in CLAUDE.md but not found on disk.",
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
for d in new_in_fs:
|
|
692
|
+
issues.append({
|
|
693
|
+
"severity": "info",
|
|
694
|
+
"message": f"New directory '{d}' exists but is not in CLAUDE.md.",
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
return issues
|
|
698
|
+
|
|
699
|
+
def _diff_tech_stack(self, current_content: str) -> list:
|
|
700
|
+
"""Compare tech stack in CLAUDE.md vs detected."""
|
|
701
|
+
issues = []
|
|
702
|
+
detected = self.session_mgr._detect_tech_stack()
|
|
703
|
+
|
|
704
|
+
if detected == "Could not auto-detect":
|
|
705
|
+
return issues
|
|
706
|
+
|
|
707
|
+
detected_set = {t.strip().lower() for t in detected.split(',')}
|
|
708
|
+
|
|
709
|
+
# Find the tech stack line in CLAUDE.md
|
|
710
|
+
sections = self._parse_sections(current_content)
|
|
711
|
+
claimed_text = sections.get("Tech Stack", "")
|
|
712
|
+
claimed_set = set()
|
|
713
|
+
for line in claimed_text.split('\n'):
|
|
714
|
+
line = line.strip().lstrip('- ')
|
|
715
|
+
if line:
|
|
716
|
+
for item in line.split(','):
|
|
717
|
+
item = item.strip().lower()
|
|
718
|
+
if item:
|
|
719
|
+
claimed_set.add(item)
|
|
720
|
+
|
|
721
|
+
new_tech = detected_set - claimed_set
|
|
722
|
+
for tech in new_tech:
|
|
723
|
+
issues.append({
|
|
724
|
+
"severity": "warning",
|
|
725
|
+
"message": f"Detected '{tech}' in project but not listed in CLAUDE.md Tech Stack.",
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
return issues
|
|
729
|
+
|
|
730
|
+
# ── Compact helpers ──────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
def _compress_sessions(self, session_text: str, max_sessions: int = 3) -> str:
|
|
733
|
+
"""Trim session history to last N sessions with one-line summaries."""
|
|
734
|
+
# Split into individual session blocks (## Session: ...)
|
|
735
|
+
blocks = re.split(r'(?=## Session:)', session_text)
|
|
736
|
+
blocks = [b.strip() for b in blocks if b.strip()]
|
|
737
|
+
|
|
738
|
+
if len(blocks) <= max_sessions:
|
|
739
|
+
return session_text
|
|
740
|
+
|
|
741
|
+
# Keep only last max_sessions, compress each to one line
|
|
742
|
+
kept = blocks[:max_sessions]
|
|
743
|
+
compressed_lines = []
|
|
744
|
+
for block in kept:
|
|
745
|
+
lines = block.split('\n')
|
|
746
|
+
header = lines[0] if lines else ""
|
|
747
|
+
# Extract summary if present
|
|
748
|
+
summary = ""
|
|
749
|
+
for line in lines[1:]:
|
|
750
|
+
if line.startswith("**Summary:**"):
|
|
751
|
+
summary = line.replace("**Summary:**", "").strip()
|
|
752
|
+
break
|
|
753
|
+
elif line.startswith("**When:**"):
|
|
754
|
+
date = line.replace("**When:**", "").strip()
|
|
755
|
+
summary = f"({date}) {summary}"
|
|
756
|
+
if summary:
|
|
757
|
+
compressed_lines.append(f"{header}\n**Summary:** {summary}\n")
|
|
758
|
+
else:
|
|
759
|
+
compressed_lines.append(f"{header}\n")
|
|
760
|
+
|
|
761
|
+
return '\n'.join(compressed_lines)
|
|
762
|
+
|
|
763
|
+
def _prune_structure_depth(self, section_text: str, max_depth: int = 2) -> str:
|
|
764
|
+
"""Reduce project structure tree depth."""
|
|
765
|
+
lines = section_text.split('\n')
|
|
766
|
+
pruned = []
|
|
767
|
+
in_code_block = False
|
|
768
|
+
|
|
769
|
+
for line in lines:
|
|
770
|
+
if line.strip().startswith('```'):
|
|
771
|
+
in_code_block = not in_code_block
|
|
772
|
+
pruned.append(line)
|
|
773
|
+
continue
|
|
774
|
+
|
|
775
|
+
if in_code_block:
|
|
776
|
+
# Count indent level (2 spaces per level)
|
|
777
|
+
stripped = line.lstrip()
|
|
778
|
+
indent = len(line) - len(stripped)
|
|
779
|
+
depth = indent // 2
|
|
780
|
+
if depth <= max_depth:
|
|
781
|
+
pruned.append(line)
|
|
782
|
+
else:
|
|
783
|
+
pruned.append(line)
|
|
784
|
+
|
|
785
|
+
return '\n'.join(pruned)
|