codeledger 0.1.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 (58) hide show
  1. codeledger/__init__.py +4 -0
  2. codeledger/__main__.py +6 -0
  3. codeledger/classifier/__init__.py +21 -0
  4. codeledger/classifier/deferred.py +143 -0
  5. codeledger/classifier/rules.py +116 -0
  6. codeledger/classifier/session.py +60 -0
  7. codeledger/classifier/slm.py +19 -0
  8. codeledger/cli.py +510 -0
  9. codeledger/compressor/__init__.py +15 -0
  10. codeledger/compressor/scope_engine.py +73 -0
  11. codeledger/compressor/token_compressor.py +138 -0
  12. codeledger/config/__init__.py +19 -0
  13. codeledger/config/loader.py +141 -0
  14. codeledger/config/presets/cli_tool.yaml +62 -0
  15. codeledger/config/presets/data_pipeline.yaml +67 -0
  16. codeledger/config/presets/fullstack.yaml +80 -0
  17. codeledger/config/presets/minimal.yaml +48 -0
  18. codeledger/config/presets/ml_research.yaml +73 -0
  19. codeledger/config/presets/python_api.yaml +86 -0
  20. codeledger/config/presets/react_frontend.yaml +70 -0
  21. codeledger/config/schema.py +212 -0
  22. codeledger/generator/__init__.py +15 -0
  23. codeledger/generator/api_client.py +104 -0
  24. codeledger/generator/local_client.py +72 -0
  25. codeledger/generator/model_router.py +61 -0
  26. codeledger/generator/prompt_builder.py +125 -0
  27. codeledger/merge/__init__.py +15 -0
  28. codeledger/merge/deduplicator.py +100 -0
  29. codeledger/merge/extractor.py +131 -0
  30. codeledger/merge/merge_engine.py +127 -0
  31. codeledger/models/__init__.py +3 -0
  32. codeledger/models/inference.py +0 -0
  33. codeledger/parser/__init__.py +39 -0
  34. codeledger/parser/base.py +136 -0
  35. codeledger/parser/fallback.py +122 -0
  36. codeledger/parser/java_parser.py +10 -0
  37. codeledger/parser/js_parser.py +10 -0
  38. codeledger/parser/python_parser.py +239 -0
  39. codeledger/postprocess/__init__.py +23 -0
  40. codeledger/postprocess/file_manager.py +183 -0
  41. codeledger/postprocess/formatter.py +114 -0
  42. codeledger/postprocess/validator.py +130 -0
  43. codeledger/scanner/__init__.py +30 -0
  44. codeledger/scanner/change_dag.py +228 -0
  45. codeledger/scanner/dependency.py +124 -0
  46. codeledger/scanner/file_scanner.py +207 -0
  47. codeledger/scanner/snapshot.py +278 -0
  48. codeledger/templates/doc_template.md.j2 +23 -0
  49. codeledger/templates/merge_template.md.j2 +17 -0
  50. codeledger/templates/prompt_templates/merge.txt +35 -0
  51. codeledger/templates/prompt_templates/micro.txt +20 -0
  52. codeledger/templates/prompt_templates/refactor.txt +30 -0
  53. codeledger/templates/prompt_templates/standard.txt +19 -0
  54. codeledger-0.1.0.dist-info/METADATA +296 -0
  55. codeledger-0.1.0.dist-info/RECORD +58 -0
  56. codeledger-0.1.0.dist-info/WHEEL +4 -0
  57. codeledger-0.1.0.dist-info/entry_points.txt +2 -0
  58. codeledger-0.1.0.dist-info/licenses/LICENSE +21 -0
codeledger/cli.py ADDED
@@ -0,0 +1,510 @@
1
+ """CLI — Typer-based command-line interface for CodeLedger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ import codeledger
14
+ from codeledger.config.loader import (
15
+ init_project,
16
+ list_presets,
17
+ load_config,
18
+ )
19
+
20
+ app = typer.Typer(
21
+ name="codeledger",
22
+ help="Auto-generated code comprehension for AI-assisted development.",
23
+ no_args_is_help=True,
24
+ )
25
+ console = Console()
26
+
27
+
28
+ def _resolve_root(project_dir: Optional[Path]) -> Path:
29
+ """Resolve the project root directory."""
30
+ if project_dir is None:
31
+ return Path.cwd()
32
+ return project_dir.resolve()
33
+
34
+
35
+ # ── init ──
36
+
37
+
38
+ @app.command()
39
+ def init(
40
+ project_dir: Optional[Path] = typer.Argument(
41
+ None, help="Project root directory (defaults to current directory)."
42
+ ),
43
+ preset: Optional[str] = typer.Option(
44
+ None, "--preset", "-p", help="Configuration preset to use."
45
+ ),
46
+ name: Optional[str] = typer.Option(
47
+ None, "--name", "-n", help="Project name."
48
+ ),
49
+ language: Optional[str] = typer.Option(
50
+ None, "--lang", "-l", help="Primary language (python, javascript, etc.)."
51
+ ),
52
+ list_all_presets: bool = typer.Option(
53
+ False, "--list-presets", help="List available presets and exit."
54
+ ),
55
+ ) -> None:
56
+ """Initialize CodeLedger for a project."""
57
+ if list_all_presets:
58
+ presets = list_presets()
59
+ console.print("[bold]Available presets:[/bold]")
60
+ for p in presets:
61
+ console.print(f" - {p}")
62
+ raise typer.Exit()
63
+
64
+ root = _resolve_root(project_dir)
65
+
66
+ try:
67
+ config = init_project(
68
+ project_root=root,
69
+ preset=preset,
70
+ project_name=name,
71
+ language=language,
72
+ )
73
+ console.print(
74
+ Panel(
75
+ f"[green]Initialized CodeLedger[/green] for [bold]{config.project.name}[/bold]\n"
76
+ f"Config: {root / '.codeledger' / 'config.yaml'}\n"
77
+ f"Preset: {preset or 'default'}",
78
+ title="codeledger init",
79
+ )
80
+ )
81
+ except FileExistsError as e:
82
+ console.print(f"[red]Error:[/red] {e}")
83
+ raise typer.Exit(code=1)
84
+ except ValueError as e:
85
+ console.print(f"[red]Error:[/red] {e}")
86
+ raise typer.Exit(code=1)
87
+
88
+
89
+ # ── generate ──
90
+
91
+
92
+ @app.command()
93
+ def generate(
94
+ project_dir: Optional[Path] = typer.Argument(
95
+ None, help="Project root directory."
96
+ ),
97
+ force: bool = typer.Option(
98
+ False, "--force", "-f", help="Force generation even if no changes detected."
99
+ ),
100
+ dry_run: bool = typer.Option(
101
+ False, "--dry-run", help="Show what would be generated without calling the model."
102
+ ),
103
+ ) -> None:
104
+ """Generate documentation based on current project state."""
105
+ root = _resolve_root(project_dir)
106
+
107
+ try:
108
+ config = load_config(root)
109
+ except FileNotFoundError as e:
110
+ console.print(f"[red]Error:[/red] {e}")
111
+ raise typer.Exit(code=1)
112
+
113
+ with console.status("[bold blue]Scanning project...[/bold blue]"):
114
+ # 1. Scan files
115
+ from codeledger.scanner import (
116
+ ProjectDAG,
117
+ compare_snapshots,
118
+ create_snapshot,
119
+ load_latest_snapshot,
120
+ save_snapshot,
121
+ scan_project,
122
+ )
123
+
124
+ manifest = scan_project(
125
+ root,
126
+ include_patterns=config.focus.include_patterns,
127
+ exclude_patterns=config.focus.exclude_patterns,
128
+ )
129
+ console.print(
130
+ f" Scanned [bold]{manifest.total_files}[/bold] files "
131
+ f"({manifest.total_lines} lines)"
132
+ )
133
+
134
+ # 2. Snapshot comparison
135
+ old_snapshot = load_latest_snapshot(root)
136
+ new_snapshot = create_snapshot(manifest)
137
+
138
+ if old_snapshot:
139
+ diff = compare_snapshots(old_snapshot, new_snapshot)
140
+ if diff.is_empty and not force:
141
+ console.print("[yellow]No changes detected.[/yellow] Use --force to generate anyway.")
142
+ raise typer.Exit()
143
+ console.print(
144
+ f" Changes: [green]+{diff.files_created}[/green] created, "
145
+ f"[yellow]~{diff.files_modified}[/yellow] modified, "
146
+ f"[red]-{diff.files_deleted}[/red] deleted"
147
+ )
148
+ else:
149
+ diff = None
150
+ console.print(" [dim]First scan — no previous snapshot[/dim]")
151
+
152
+ # 3. Build Change DAG
153
+ dag = ProjectDAG()
154
+ dag.build(manifest, root)
155
+ if diff:
156
+ subgraph = dag.extract_subgraph(diff)
157
+ else:
158
+ subgraph = None
159
+
160
+ # 4. Classify session
161
+ from codeledger.classifier import SessionType, classify_session
162
+
163
+ if subgraph and not subgraph.is_empty:
164
+ metrics = subgraph.metrics()
165
+ classification = classify_session(metrics)
166
+ else:
167
+ # First run or forced — treat as STANDARD
168
+ from codeledger.classifier.session import SessionClassification
169
+
170
+ classification = SessionClassification(
171
+ session_type=SessionType.STANDARD,
172
+ confidence=1.0,
173
+ input_token_budget=config.model.max_input_tokens,
174
+ output_token_budget=config.model.max_output_tokens,
175
+ reason="Initial scan or forced generation",
176
+ )
177
+
178
+ console.print(
179
+ f" Session: [bold]{classification.session_type.value}[/bold] "
180
+ f"(confidence: {classification.confidence:.0%})"
181
+ )
182
+
183
+ # 5. Handle deferred sessions
184
+ if classification.should_defer and not force:
185
+ from codeledger.classifier.deferred import load_pending, save_pending
186
+
187
+ pending = load_pending(root)
188
+ if subgraph and not subgraph.is_empty:
189
+ pending.add_session(
190
+ changed_paths=diff.changed_paths() if diff else [],
191
+ metrics=subgraph.metrics(),
192
+ summary=f"Deferred: {classification.session_type.value}",
193
+ )
194
+ save_pending(root, pending)
195
+ console.print("[dim]Trivial changes deferred. Use --force to generate anyway.[/dim]")
196
+ # Save snapshot even though we deferred
197
+ save_snapshot(root, new_snapshot)
198
+ raise typer.Exit()
199
+
200
+ if dry_run:
201
+ console.print(Panel(
202
+ f"Session type: {classification.session_type.value}\n"
203
+ f"Input budget: {classification.input_token_budget} tokens\n"
204
+ f"Output budget: {classification.output_token_budget} tokens\n"
205
+ f"Files to analyze: {manifest.total_files}\n"
206
+ f"Model: {config.model.model_name}",
207
+ title="Dry Run",
208
+ ))
209
+ raise typer.Exit()
210
+
211
+ # 6. Parse and compress
212
+ with console.status("[bold blue]Analyzing code...[/bold blue]"):
213
+ from codeledger.parser import parse_file
214
+ from codeledger.compressor import compress_project, trim_to_budget
215
+
216
+ parsed_files = []
217
+ for fi in manifest.code_files():
218
+ try:
219
+ parsed = parse_file(fi.absolute_path, fi.language or "")
220
+ parsed_files.append(parsed)
221
+ except Exception:
222
+ pass # Skip unparseable files
223
+
224
+ compressed = compress_project(parsed_files)
225
+ trimmed_files, trimmed_sections = trim_to_budget(
226
+ compressed,
227
+ config.template_sections,
228
+ classification.input_token_budget,
229
+ )
230
+
231
+ # 7. Build prompt and generate
232
+ from codeledger.generator import build_generation_prompt, generate as gen_call
233
+
234
+ system_prompt, user_prompt = build_generation_prompt(
235
+ compressed_payload=trimmed_files,
236
+ sections=trimmed_sections,
237
+ session_type=classification.session_type,
238
+ project_name=config.project.name,
239
+ focus_highlights=config.focus.highlight or None,
240
+ )
241
+
242
+ with console.status(f"[bold blue]Generating with {config.model.model_name}...[/bold blue]"):
243
+ response = gen_call(
244
+ system_prompt=system_prompt,
245
+ user_prompt=user_prompt,
246
+ config=config.model,
247
+ )
248
+
249
+ console.print(
250
+ f" Generated {response.output_tokens} tokens in {response.latency_ms / 1000:.1f}s"
251
+ )
252
+
253
+ # 8. Validate
254
+ from codeledger.postprocess import ValidationResult, validate_output
255
+
256
+ known_paths = {fi.path for fi in manifest.files}
257
+ section_names = [s.name for s in config.template_sections]
258
+ validation = validate_output(response.content, section_names, known_paths)
259
+
260
+ if validation.has_warnings:
261
+ for w in validation.warnings:
262
+ icon = "[red]✗[/red]" if w.severity == "error" else "[yellow]![/yellow]"
263
+ console.print(f" {icon} {w.message}")
264
+
265
+ # 9. Format and save
266
+ from codeledger.postprocess import format_doc, load_manifest, save_doc
267
+
268
+ doc_manifest = load_manifest(root)
269
+ doc_id = doc_manifest.next_doc_id()
270
+
271
+ formatted = format_doc(
272
+ content=response.content,
273
+ doc_id=doc_id,
274
+ project_name=config.project.name,
275
+ model_name=config.model.model_name,
276
+ session_type=classification.session_type.value,
277
+ files_analyzed=len(parsed_files),
278
+ input_tokens=response.input_tokens,
279
+ output_tokens=response.output_tokens,
280
+ )
281
+
282
+ doc_path = save_doc(
283
+ project_root=root,
284
+ doc_id=doc_id,
285
+ content=formatted,
286
+ session_type=classification.session_type.value,
287
+ model=config.model.model_name,
288
+ files_analyzed=len(parsed_files),
289
+ manifest=doc_manifest,
290
+ )
291
+
292
+ # 10. Save new snapshot
293
+ new_snapshot.doc_id = doc_id
294
+ save_snapshot(root, new_snapshot)
295
+
296
+ console.print(Panel(
297
+ f"[green]Documentation generated:[/green] {doc_path.relative_to(root)}\n"
298
+ f"Doc ID: [bold]{doc_id}[/bold]",
299
+ title="codeledger generate",
300
+ ))
301
+
302
+
303
+ # ── merge ──
304
+
305
+
306
+ @app.command()
307
+ def merge(
308
+ project_dir: Optional[Path] = typer.Argument(
309
+ None, help="Project root directory."
310
+ ),
311
+ local_only: bool = typer.Option(
312
+ False, "--local", help="Merge locally without calling an LLM."
313
+ ),
314
+ output: Optional[Path] = typer.Option(
315
+ None, "--output", "-o", help="Output file path for merged doc."
316
+ ),
317
+ ) -> None:
318
+ """Merge all generated docs into a single conceptualized document."""
319
+ root = _resolve_root(project_dir)
320
+
321
+ try:
322
+ config = load_config(root)
323
+ except FileNotFoundError as e:
324
+ console.print(f"[red]Error:[/red] {e}")
325
+ raise typer.Exit(code=1)
326
+
327
+ from codeledger.merge import merge_local as ml, merge_with_llm
328
+
329
+ with console.status("[bold blue]Merging documentation...[/bold blue]"):
330
+ if local_only:
331
+ content = ml(root)
332
+ else:
333
+ content = merge_with_llm(root, config)
334
+
335
+ # Write output
336
+ if output:
337
+ out_path = output.resolve()
338
+ else:
339
+ out_path = root / ".codeledger" / "DOCUMENTATION.md"
340
+
341
+ out_path.parent.mkdir(parents=True, exist_ok=True)
342
+ out_path.write_text(content, encoding="utf-8")
343
+
344
+ console.print(Panel(
345
+ f"[green]Merged documentation written to:[/green] {out_path}",
346
+ title="codeledger merge",
347
+ ))
348
+
349
+
350
+ # ── status ──
351
+
352
+
353
+ @app.command()
354
+ def status(
355
+ project_dir: Optional[Path] = typer.Argument(
356
+ None, help="Project root directory."
357
+ ),
358
+ ) -> None:
359
+ """Show the current CodeLedger status for a project."""
360
+ root = _resolve_root(project_dir)
361
+
362
+ try:
363
+ config = load_config(root)
364
+ except FileNotFoundError as e:
365
+ console.print(f"[red]Error:[/red] {e}")
366
+ raise typer.Exit(code=1)
367
+
368
+ from codeledger.postprocess.file_manager import load_manifest
369
+
370
+ manifest = load_manifest(root)
371
+
372
+ table = Table(title=f"CodeLedger Status — {config.project.name}")
373
+ table.add_column("Property", style="bold")
374
+ table.add_column("Value")
375
+
376
+ table.add_row("Project", config.project.name)
377
+ table.add_row("Language", config.project.language)
378
+ table.add_row("Model", config.model.model_name)
379
+ table.add_row("Docs generated", str(manifest.total_docs))
380
+ table.add_row("Last doc ID", manifest.last_doc_id or "—")
381
+ table.add_row("Merge state", manifest.merge_state)
382
+ table.add_row("Cadence (N)", str(config.cadence.n_value))
383
+ table.add_row("Trigger", config.cadence.trigger.value)
384
+
385
+ console.print(table)
386
+
387
+ if manifest.docs:
388
+ doc_table = Table(title="Generated Documents")
389
+ doc_table.add_column("ID", style="cyan")
390
+ doc_table.add_column("Session")
391
+ doc_table.add_column("Model")
392
+ doc_table.add_column("Files")
393
+ doc_table.add_column("Timestamp")
394
+
395
+ for doc in manifest.docs:
396
+ doc_table.add_row(
397
+ doc.doc_id,
398
+ doc.session_type,
399
+ doc.model,
400
+ str(doc.files_analyzed),
401
+ doc.timestamp[:19],
402
+ )
403
+ console.print(doc_table)
404
+
405
+
406
+ # ── diff ──
407
+
408
+
409
+ @app.command()
410
+ def diff(
411
+ project_dir: Optional[Path] = typer.Argument(
412
+ None, help="Project root directory."
413
+ ),
414
+ ) -> None:
415
+ """Show changes since the last snapshot."""
416
+ root = _resolve_root(project_dir)
417
+
418
+ try:
419
+ config = load_config(root)
420
+ except FileNotFoundError as e:
421
+ console.print(f"[red]Error:[/red] {e}")
422
+ raise typer.Exit(code=1)
423
+
424
+ from codeledger.scanner import (
425
+ compare_snapshots,
426
+ create_snapshot,
427
+ load_latest_snapshot,
428
+ scan_project,
429
+ )
430
+
431
+ manifest = scan_project(root, config.focus)
432
+ old_snapshot = load_latest_snapshot(root)
433
+
434
+ if not old_snapshot:
435
+ console.print("[yellow]No previous snapshot found.[/yellow] Run 'codeledger generate' first.")
436
+ raise typer.Exit()
437
+
438
+ new_snapshot = create_snapshot(manifest)
439
+ snap_diff = compare_snapshots(old_snapshot, new_snapshot)
440
+
441
+ if snap_diff.is_empty:
442
+ console.print("[green]No changes detected since last snapshot.[/green]")
443
+ raise typer.Exit()
444
+
445
+ table = Table(title="Changes Since Last Snapshot")
446
+ table.add_column("Status", width=10)
447
+ table.add_column("File")
448
+ table.add_column("Lines Δ", justify="right")
449
+
450
+ for change in snap_diff.changes:
451
+ if change.change_type == "created":
452
+ status_str = "[green]+ created[/green]"
453
+ elif change.change_type == "modified":
454
+ status_str = "[yellow]~ modified[/yellow]"
455
+ else:
456
+ status_str = "[red]- deleted[/red]"
457
+
458
+ delta = change.lines_delta
459
+ delta_str = f"+{delta}" if delta > 0 else str(delta)
460
+
461
+ table.add_row(status_str, change.path, delta_str)
462
+
463
+ console.print(table)
464
+ console.print(
465
+ f"\nTotal: [green]+{snap_diff.files_created}[/green] "
466
+ f"[yellow]~{snap_diff.files_modified}[/yellow] "
467
+ f"[red]-{snap_diff.files_deleted}[/red]"
468
+ )
469
+
470
+
471
+ # ── version ──
472
+
473
+
474
+ @app.command()
475
+ def version() -> None:
476
+ """Show the CodeLedger version."""
477
+ console.print(f"codeledger {codeledger.__version__}")
478
+
479
+
480
+ # ── explain ──
481
+
482
+
483
+ @app.command()
484
+ def explain(
485
+ doc_id: str = typer.Argument(..., help="Document ID to explain (e.g., pd_001)."),
486
+ project_dir: Optional[Path] = typer.Option(
487
+ None, "--project", "-p", help="Project root directory."
488
+ ),
489
+ ) -> None:
490
+ """Display a generated document by its ID."""
491
+ root = _resolve_root(project_dir)
492
+
493
+ from codeledger.postprocess.file_manager import load_manifest
494
+
495
+ manifest = load_manifest(root)
496
+ record = manifest.get_doc(doc_id)
497
+
498
+ if not record:
499
+ console.print(f"[red]Error:[/red] Document '{doc_id}' not found.")
500
+ raise typer.Exit(code=1)
501
+
502
+ doc_path = root / record.path
503
+ if not doc_path.exists():
504
+ console.print(f"[red]Error:[/red] Document file missing: {record.path}")
505
+ raise typer.Exit(code=1)
506
+
507
+ content = doc_path.read_text(encoding="utf-8")
508
+ from rich.markdown import Markdown
509
+
510
+ console.print(Markdown(content))
@@ -0,0 +1,15 @@
1
+ """Compressor package — token compression and scope management."""
2
+
3
+ from codeledger.compressor.token_compressor import (
4
+ compress_file,
5
+ compress_project,
6
+ estimate_tokens,
7
+ )
8
+ from codeledger.compressor.scope_engine import trim_to_budget
9
+
10
+ __all__ = [
11
+ "compress_file",
12
+ "compress_project",
13
+ "estimate_tokens",
14
+ "trim_to_budget",
15
+ ]
@@ -0,0 +1,73 @@
1
+ """Scope engine — trims payload and template sections to fit within token budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codeledger.compressor.token_compressor import estimate_tokens
6
+ from codeledger.config.schema import TemplateSectionConfig
7
+
8
+
9
+ def trim_to_budget(
10
+ compressed_files: list[dict],
11
+ sections: list[TemplateSectionConfig],
12
+ input_token_budget: int,
13
+ ) -> tuple[list[dict], list[TemplateSectionConfig]]:
14
+ """Trim payload and sections to fit within the input token budget.
15
+
16
+ Strategy (applied in order):
17
+ 1. Drop affected-only files (keep changed files)
18
+ 2. Drop priority 3 sections
19
+ 3. Drop priority 2 sections
20
+ 4. Truncate individual file descriptions (remove docstrings, globals)
21
+ 5. Drop files with fewest changes
22
+
23
+ Returns:
24
+ Tuple of (trimmed_files, trimmed_sections).
25
+ """
26
+ current_tokens = estimate_tokens(compressed_files)
27
+
28
+ # Estimate section overhead (template text per section)
29
+ section_overhead_per = 50 # ~50 tokens per section in prompt template
30
+ section_tokens = len(sections) * section_overhead_per
31
+ total = current_tokens + section_tokens
32
+
33
+ if total <= input_token_budget:
34
+ return compressed_files, sections
35
+
36
+ # Step 1: Drop priority 3 sections
37
+ trimmed_sections = [s for s in sections if s.priority <= 2]
38
+ section_tokens = len(trimmed_sections) * section_overhead_per
39
+ total = current_tokens + section_tokens
40
+
41
+ if total <= input_token_budget:
42
+ return compressed_files, trimmed_sections
43
+
44
+ # Step 2: Drop priority 2 sections
45
+ trimmed_sections = [s for s in trimmed_sections if s.priority <= 1]
46
+ section_tokens = len(trimmed_sections) * section_overhead_per
47
+ total = current_tokens + section_tokens
48
+
49
+ if total <= input_token_budget:
50
+ return compressed_files, trimmed_sections
51
+
52
+ # Step 3: Strip verbose fields from files
53
+ for f in compressed_files:
54
+ f.pop("module_doc", None)
55
+ f.pop("globals", None)
56
+ for cls in f.get("classes", []):
57
+ cls.pop("doc", None)
58
+ for func in f.get("functions", []):
59
+ func.pop("doc", None)
60
+
61
+ current_tokens = estimate_tokens(compressed_files)
62
+ total = current_tokens + section_tokens
63
+
64
+ if total <= input_token_budget:
65
+ return compressed_files, trimmed_sections
66
+
67
+ # Step 4: Drop files from the end (least important) until within budget
68
+ while compressed_files and total > input_token_budget:
69
+ compressed_files.pop()
70
+ current_tokens = estimate_tokens(compressed_files)
71
+ total = current_tokens + section_tokens
72
+
73
+ return compressed_files, trimmed_sections