agentpack-cli 0.1.6__tar.gz → 0.1.7__tar.gz

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 (83) hide show
  1. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/PKG-INFO +1 -1
  2. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/pyproject.toml +1 -1
  3. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/ranking.py +8 -1
  4. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/application/pack_service.py +9 -2
  5. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/benchmark.py +174 -12
  6. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/explain.py +3 -2
  7. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/init.py +12 -2
  8. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/pack.py +21 -1
  9. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/session.py +24 -4
  10. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/watch.py +77 -32
  11. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/config.py +37 -0
  12. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/ignore.py +21 -0
  13. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/scanner.py +26 -0
  14. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/.gitignore +0 -0
  15. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/LICENSE +0 -0
  16. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/README.md +0 -0
  17. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/__init__.py +0 -0
  18. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/__init__.py +0 -0
  19. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/antigravity.py +0 -0
  20. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/base.py +0 -0
  21. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/claude.py +0 -0
  22. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/codex.py +0 -0
  23. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/cursor.py +0 -0
  24. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/detect.py +0 -0
  25. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/generic.py +0 -0
  26. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/windsurf.py +0 -0
  27. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/__init__.py +0 -0
  28. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/dependency_graph.py +0 -0
  29. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/go_imports.py +0 -0
  30. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/java_imports.py +0 -0
  31. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/js_ts_imports.py +0 -0
  32. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/python_imports.py +0 -0
  33. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/rust_imports.py +0 -0
  34. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/symbols.py +0 -0
  35. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/tests.py +0 -0
  36. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/application/__init__.py +0 -0
  37. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/cli.py +0 -0
  38. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/__init__.py +0 -0
  39. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/_shared.py +0 -0
  40. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/claude_cmd.py +0 -0
  41. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/diff.py +0 -0
  42. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/doctor.py +0 -0
  43. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/install.py +0 -0
  44. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/monitor.py +0 -0
  45. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/scan.py +0 -0
  46. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/stats.py +0 -0
  47. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/status.py +0 -0
  48. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/summarize.py +0 -0
  49. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/__init__.py +0 -0
  50. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/bootstrap.py +0 -0
  51. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/cache.py +0 -0
  52. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/context_pack.py +0 -0
  53. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/diff.py +0 -0
  54. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/git.py +0 -0
  55. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/git_hooks.py +0 -0
  56. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/global_install.py +0 -0
  57. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/merkle.py +0 -0
  58. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/models.py +0 -0
  59. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/redactor.py +0 -0
  60. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/snapshot.py +0 -0
  61. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/token_estimator.py +0 -0
  62. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/vscode_tasks.py +0 -0
  63. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/data/agentpack.md +0 -0
  64. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/__init__.py +0 -0
  65. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/antigravity.py +0 -0
  66. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/claude.py +0 -0
  67. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/codex.py +0 -0
  68. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/cursor.py +0 -0
  69. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/windsurf.py +0 -0
  70. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/__init__.py +0 -0
  71. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/git_hooks.py +0 -0
  72. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/global_install.py +0 -0
  73. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/vscode_tasks.py +0 -0
  74. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/__init__.py +0 -0
  75. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/compact.py +0 -0
  76. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/markdown.py +0 -0
  77. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/receipts.py +0 -0
  78. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/session/__init__.py +0 -0
  79. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/session/state.py +0 -0
  80. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/__init__.py +0 -0
  81. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/base.py +0 -0
  82. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/llm.py +0 -0
  83. {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpack-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Token-aware context packing for AI coding agents — Claude, Cursor, Windsurf, and Codex
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentpack-cli"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "Token-aware context packing for AI coding agents — Claude, Cursor, Windsurf, and Codex"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -282,6 +282,7 @@ def score_files(
282
282
  include_tests: bool = True,
283
283
  include_configs: bool = True,
284
284
  weights: ScoringWeights | None = None,
285
+ summaries: dict | None = None,
285
286
  ) -> list[tuple[FileInfo, float, list[str]]]:
286
287
  from agentpack.core.models import DependencyGraph as _DG
287
288
  if not isinstance(dep_graph, _DG):
@@ -312,7 +313,13 @@ def score_files(
312
313
  reasons.append("filename keyword match")
313
314
 
314
315
  node = dep_graph.get(fi.path)
315
- sym_names: list[str] = [] # symbols aren't stored on DependencyNode; scoring uses path/content only
316
+ sym_names: list[str] = []
317
+ if summaries and fi.path in summaries:
318
+ raw_syms = summaries[fi.path].get("symbols", [])
319
+ sym_names = [
320
+ (s["name"] if isinstance(s, dict) else s.name)
321
+ for s in raw_syms
322
+ ]
316
323
  if _symbol_matches_keywords(sym_names, keywords):
317
324
  score += w.symbol_keyword
318
325
  reasons.append("symbol keyword match")
@@ -128,6 +128,7 @@ class FileRanker:
128
128
  dep_graph: DependencyGraph,
129
129
  task: str,
130
130
  cfg: Any,
131
+ summaries: dict | None = None,
131
132
  ) -> RankResult:
132
133
  keywords = extract_keywords(task)
133
134
  keywords = enrich_keywords_from_files(keywords, changes.all_changed, packable)
@@ -147,6 +148,7 @@ class FileRanker:
147
148
  include_tests=cfg.context.include_tests,
148
149
  include_configs=cfg.context.include_configs,
149
150
  weights=cfg.scoring,
151
+ summaries=summaries,
150
152
  )
151
153
  return RankResult(keywords=keywords, scored=scored)
152
154
 
@@ -163,7 +165,12 @@ class PackPlanner:
163
165
 
164
166
  t0 = time.perf_counter()
165
167
  previous_snap = load_snapshot(root)
166
- scan_result = scan(root, ignore_spec, cfg.context.max_file_tokens, previous_snapshot=previous_snap)
168
+ scan_result = scan(
169
+ root, ignore_spec, cfg.context.max_file_tokens,
170
+ previous_snapshot=previous_snap,
171
+ include_globs=cfg.project.include_globs or None,
172
+ exclude_globs=cfg.project.exclude_globs or None,
173
+ )
167
174
  phase_times["scan"] = time.perf_counter() - t0
168
175
 
169
176
  packable = scan_result.packable
@@ -182,7 +189,7 @@ class PackPlanner:
182
189
  phase_times["changes"] = time.perf_counter() - t0
183
190
 
184
191
  t0 = time.perf_counter()
185
- rank_result = FileRanker().rank(packable, changes, dep_graph, request.task, cfg)
192
+ rank_result = FileRanker().rank(packable, changes, dep_graph, request.task, cfg, summaries=summaries)
186
193
  phase_times["rank"] = time.perf_counter() - t0
187
194
 
188
195
  t0 = time.perf_counter()
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import random
3
5
  import time
4
6
  from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
5
8
  from pathlib import Path
6
9
  from typing import Optional
7
10
 
@@ -24,13 +27,21 @@ class BenchmarkCase:
24
27
  class CaseResult:
25
28
  case: BenchmarkCase
26
29
  packed_tokens: int
27
- raw_tokens: int
28
- saving_pct: float
30
+ raw_tokens: int # all files (incl. ignored)
31
+ after_ignore_tokens: int # packable files only — honest baseline
32
+ saving_pct: float # vs raw
33
+ saving_pct_honest: float # vs after_ignore
29
34
  selected_paths: list[str]
30
- changed_covered: int # # changed files that were selected
31
- changed_total: int # total changed files detected
35
+ selected_tokens: dict[str, int] # path token count for noise calc
36
+ changed_covered: int
37
+ changed_total: int
32
38
  total_s: float
33
39
  phase_times: dict[str, float]
40
+ rank_at_k: int | None = None # min rank to see all expected_files; None if no expected
41
+ noise_pct: float | None = None # tokens on non-expected / packed; None if no expected
42
+ random_precision: float | None = None
43
+ random_recall: float | None = None
44
+ random_f1: float | None = None
34
45
 
35
46
 
36
47
  def _load_cases(path: Path) -> list[BenchmarkCase]:
@@ -74,9 +85,64 @@ def _scaffold_cases(root: Path) -> Path:
74
85
  return out
75
86
 
76
87
 
88
+ def _load_history_cases(root: Path, n: int) -> list[BenchmarkCase]:
89
+ """Sample last N unique tasks from metrics.jsonl."""
90
+ metrics_path = root / ".agentpack" / "metrics.jsonl"
91
+ if not metrics_path.exists():
92
+ return []
93
+ seen: list[str] = []
94
+ seen_set: set[str] = set()
95
+ for line in reversed(metrics_path.read_text().splitlines()):
96
+ line = line.strip()
97
+ if not line:
98
+ continue
99
+ try:
100
+ rec = json.loads(line)
101
+ task = rec.get("task", "").strip()
102
+ mode = rec.get("mode", "balanced")
103
+ if task and task not in seen_set:
104
+ seen_set.add(task)
105
+ seen.append((task, mode))
106
+ if len(seen) >= n:
107
+ break
108
+ except json.JSONDecodeError:
109
+ pass
110
+ return [BenchmarkCase(task=t, mode=m) for t, m in seen]
111
+
112
+
113
+ def _random_baseline(
114
+ packable_paths: list[str],
115
+ packable_tokens: dict[str, int],
116
+ expected_files: list[str],
117
+ budget: int,
118
+ ) -> tuple[list[str], float, float, float]:
119
+ """Random file selection at same budget. Returns (selected, precision, recall, f1)."""
120
+ shuffled = list(packable_paths)
121
+ random.shuffle(shuffled)
122
+ selected: list[str] = []
123
+ used = 0
124
+ for p in shuffled:
125
+ tok = packable_tokens.get(p, 50)
126
+ if used + tok <= budget:
127
+ selected.append(p)
128
+ used += tok
129
+
130
+ expected = set(expected_files)
131
+ sel_set = set(selected)
132
+ if not expected or not sel_set:
133
+ return selected, 0.0, 0.0, 0.0
134
+ tp = len(sel_set & expected)
135
+ p = tp / len(sel_set)
136
+ r = tp / len(expected)
137
+ f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
138
+ return selected, p, r, f1
139
+
140
+
77
141
  def _run_case(root: Path, case: BenchmarkCase) -> CaseResult:
78
142
  from agentpack.application.pack_service import PackPlanner, PackRequest, _sf_tokens
79
- from agentpack.core.token_estimator import estimate_tokens
143
+ from agentpack.core.config import load_config
144
+
145
+ cfg = load_config(root)
80
146
 
81
147
  request = PackRequest(
82
148
  root=root,
@@ -95,29 +161,63 @@ def _run_case(root: Path, case: BenchmarkCase) -> CaseResult:
95
161
 
96
162
  packed_tokens = sum(_sf_tokens(sf) for sf in plan.selected)
97
163
  raw_tokens = sum(f.estimated_tokens for f in plan.scan_result.all_files)
164
+ after_ignore_tokens = sum(f.estimated_tokens for f in plan.scan_result.packable)
98
165
  saving_pct = (1 - packed_tokens / raw_tokens) * 100 if raw_tokens > 0 else 0.0
166
+ saving_pct_honest = (1 - packed_tokens / after_ignore_tokens) * 100 if after_ignore_tokens > 0 else 0.0
99
167
 
100
168
  selected_paths = [sf.path for sf in plan.selected]
101
169
  selected_set = set(selected_paths)
170
+ selected_tokens = {sf.path: _sf_tokens(sf) for sf in plan.selected}
102
171
 
103
172
  changed_covered = len(plan.all_changed & selected_set)
104
173
  changed_total = len(plan.all_changed)
105
174
 
175
+ # Rank@K: min rank in scored list to cover all expected files
176
+ rank_at_k: int | None = None
177
+ noise_pct: float | None = None
178
+ rand_p = rand_r = rand_f1 = None
179
+
180
+ if case.expected_files:
181
+ expected_set = set(case.expected_files)
182
+ scored_paths = [fi.path for fi, _score, _reasons in plan.scored]
183
+ found: set[str] = set()
184
+ for k, path in enumerate(scored_paths, 1):
185
+ if path in expected_set:
186
+ found.add(path)
187
+ if found >= expected_set:
188
+ rank_at_k = k
189
+ break
190
+
191
+ expected_tokens = sum(selected_tokens.get(p, 0) for p in selected_set & expected_set)
192
+ noise_pct = (1 - expected_tokens / packed_tokens) * 100 if packed_tokens > 0 else 0.0
193
+
194
+ packable_paths = [f.path for f in plan.scan_result.packable]
195
+ packable_token_map = {f.path: f.estimated_tokens for f in plan.scan_result.packable}
196
+ budget = cfg.context.default_budget
197
+ _, rand_p, rand_r, rand_f1 = _random_baseline(packable_paths, packable_token_map, case.expected_files, budget)
198
+
106
199
  return CaseResult(
107
200
  case=case,
108
201
  packed_tokens=packed_tokens,
109
202
  raw_tokens=raw_tokens,
203
+ after_ignore_tokens=after_ignore_tokens,
110
204
  saving_pct=saving_pct,
205
+ saving_pct_honest=saving_pct_honest,
111
206
  selected_paths=selected_paths,
207
+ selected_tokens=selected_tokens,
112
208
  changed_covered=changed_covered,
113
209
  changed_total=changed_total,
114
210
  total_s=total_s,
115
211
  phase_times=plan.phase_times,
212
+ rank_at_k=rank_at_k,
213
+ noise_pct=noise_pct,
214
+ random_precision=rand_p,
215
+ random_recall=rand_r,
216
+ random_f1=rand_f1,
116
217
  )
117
218
 
118
219
 
119
220
  def _precision_recall(result: CaseResult) -> tuple[float, float, float]:
120
- """Returns (precision, recall, f1). Requires expected_files on the case."""
121
221
  expected = set(result.case.expected_files)
122
222
  if not expected:
123
223
  return 0.0, 0.0, 0.0
@@ -129,6 +229,37 @@ def _precision_recall(result: CaseResult) -> tuple[float, float, float]:
129
229
  return p, r, f1
130
230
 
131
231
 
232
+ def _persist_result(root: Path, result: CaseResult) -> None:
233
+ out = root / ".agentpack" / "benchmark_results.jsonl"
234
+ p, r, f1 = _precision_recall(result) if result.case.expected_files else (None, None, None)
235
+ record = {
236
+ "ts": datetime.now(timezone.utc).isoformat(),
237
+ "task": result.case.task,
238
+ "mode": result.case.mode,
239
+ "packed_tokens": result.packed_tokens,
240
+ "raw_tokens": result.raw_tokens,
241
+ "after_ignore_tokens": result.after_ignore_tokens,
242
+ "saving_pct": round(result.saving_pct, 1),
243
+ "saving_pct_honest": round(result.saving_pct_honest, 1),
244
+ "files_selected": len(result.selected_paths),
245
+ "changed_covered": result.changed_covered,
246
+ "changed_total": result.changed_total,
247
+ "total_s": round(result.total_s, 3),
248
+ "phases": {k: round(v, 3) for k, v in result.phase_times.items()},
249
+ "precision": round(p, 3) if p is not None else None,
250
+ "recall": round(r, 3) if r is not None else None,
251
+ "f1": round(f1, 3) if f1 is not None else None,
252
+ "rank_at_k": result.rank_at_k,
253
+ "noise_pct": round(result.noise_pct, 1) if result.noise_pct is not None else None,
254
+ "random_f1": round(result.random_f1, 3) if result.random_f1 is not None else None,
255
+ }
256
+ try:
257
+ with out.open("a") as fh:
258
+ fh.write(json.dumps(record) + "\n")
259
+ except Exception:
260
+ pass
261
+
262
+
132
263
  def _print_case_detail(result: CaseResult) -> None:
133
264
  has_gt = bool(result.case.expected_files)
134
265
  p, r, f1 = _precision_recall(result) if has_gt else (0.0, 0.0, 0.0)
@@ -139,8 +270,10 @@ def _print_case_detail(result: CaseResult) -> None:
139
270
  tbl.add_column(style="dim")
140
271
  tbl.add_column(justify="right", style="bold")
141
272
  tbl.add_row("packed tokens", f"{result.packed_tokens:,}")
142
- tbl.add_row("raw tokens", f"{result.raw_tokens:,}")
143
- tbl.add_row("saving", f"[green]{result.saving_pct:.1f}%[/]")
273
+ tbl.add_row("raw tokens (all files)", f"{result.raw_tokens:,}")
274
+ tbl.add_row("after ignore tokens", f"{result.after_ignore_tokens:,}")
275
+ tbl.add_row("saving vs raw", f"[green]{result.saving_pct:.1f}%[/]")
276
+ tbl.add_row("saving vs after-ignore", f"[cyan]{result.saving_pct_honest:.1f}%[/]")
144
277
  tbl.add_row("files selected", str(len(result.selected_paths)))
145
278
  if result.changed_total > 0:
146
279
  cov_pct = result.changed_covered / result.changed_total * 100
@@ -162,6 +295,19 @@ def _print_case_detail(result: CaseResult) -> None:
162
295
  f"recall [bold]{r:.1%}[/] "
163
296
  f"F1 [bold]{f1:.1%}[/]"
164
297
  )
298
+ if result.rank_at_k is not None:
299
+ console.print(f" rank@K (all expected covered at rank) [bold]{result.rank_at_k}[/]")
300
+ else:
301
+ console.print(" rank@K [dim]expected files not all found in scored list[/]")
302
+ if result.noise_pct is not None:
303
+ console.print(f" noise (tokens on non-expected files) [bold]{result.noise_pct:.1f}%[/]")
304
+ if result.random_f1 is not None:
305
+ lift = f1 - result.random_f1
306
+ color = "green" if lift >= 0 else "red"
307
+ console.print(
308
+ f" random baseline F1 [dim]{result.random_f1:.1%}[/] "
309
+ f"ranker lift [{color}]{lift:+.1%}[/{color}]"
310
+ )
165
311
  expected_set = set(result.case.expected_files)
166
312
  selected_set = set(result.selected_paths)
167
313
  hits = expected_set & selected_set
@@ -181,13 +327,17 @@ def _print_summary_table(results: list[CaseResult]) -> None:
181
327
  tbl.add_column("task", max_width=40)
182
328
  tbl.add_column("mode", width=9)
183
329
  tbl.add_column("tokens", justify="right")
184
- tbl.add_column("saving", justify="right")
330
+ tbl.add_column("vs raw", justify="right")
331
+ tbl.add_column("vs ignore", justify="right")
185
332
  tbl.add_column("files", justify="right")
186
333
  tbl.add_column("time", justify="right")
187
334
  if has_gt:
188
335
  tbl.add_column("P", justify="right")
189
336
  tbl.add_column("R", justify="right")
190
337
  tbl.add_column("F1", justify="right")
338
+ tbl.add_column("rand F1", justify="right")
339
+ tbl.add_column("rank@K", justify="right")
340
+ tbl.add_column("noise%", justify="right")
191
341
 
192
342
  for r in results:
193
343
  p, rec, f1 = _precision_recall(r) if r.case.expected_files else (0.0, 0.0, 0.0)
@@ -196,6 +346,7 @@ def _print_summary_table(results: list[CaseResult]) -> None:
196
346
  r.case.mode,
197
347
  f"{r.packed_tokens:,}",
198
348
  f"{r.saving_pct:.1f}%",
349
+ f"{r.saving_pct_honest:.1f}%",
199
350
  str(len(r.selected_paths)),
200
351
  f"{r.total_s:.2f}s",
201
352
  ]
@@ -204,6 +355,9 @@ def _print_summary_table(results: list[CaseResult]) -> None:
204
355
  f"{p:.1%}" if r.case.expected_files else "—",
205
356
  f"{rec:.1%}" if r.case.expected_files else "—",
206
357
  f"{f1:.1%}" if r.case.expected_files else "—",
358
+ f"{r.random_f1:.1%}" if r.random_f1 is not None else "—",
359
+ str(r.rank_at_k) if r.rank_at_k is not None else "—",
360
+ f"{r.noise_pct:.0f}%" if r.noise_pct is not None else "—",
207
361
  ]
208
362
  tbl.add_row(*row)
209
363
 
@@ -212,13 +366,13 @@ def _print_summary_table(results: list[CaseResult]) -> None:
212
366
 
213
367
 
214
368
  def _print_compare_table(task: str, results: list[CaseResult]) -> None:
215
- """Side-by-side mode comparison for a single task."""
216
369
  console.print(f"\n[bold]Mode comparison:[/] [cyan]{task}[/]\n")
217
370
 
218
371
  tbl = Table(box=box.SIMPLE, show_header=True, padding=(0, 2))
219
372
  tbl.add_column("mode", width=10)
220
373
  tbl.add_column("tokens", justify="right")
221
- tbl.add_column("saving", justify="right")
374
+ tbl.add_column("vs raw", justify="right")
375
+ tbl.add_column("vs ignore", justify="right")
222
376
  tbl.add_column("files", justify="right")
223
377
  tbl.add_column("time", justify="right")
224
378
 
@@ -227,6 +381,7 @@ def _print_compare_table(task: str, results: list[CaseResult]) -> None:
227
381
  r.case.mode,
228
382
  f"{r.packed_tokens:,}",
229
383
  f"{r.saving_pct:.1f}%",
384
+ f"{r.saving_pct_honest:.1f}%",
230
385
  str(len(r.selected_paths)),
231
386
  f"{r.total_s:.2f}s",
232
387
  )
@@ -241,6 +396,7 @@ def register(app: typer.Typer) -> None:
241
396
  cases: str = typer.Option("", "--cases", help="Path to TOML cases file (default: .agentpack/benchmark.toml)."),
242
397
  compare: bool = typer.Option(False, "--compare", is_flag=True, help="Compare minimal/balanced/deep for each task."),
243
398
  init: bool = typer.Option(False, "--init", is_flag=True, help="Scaffold a benchmark.toml and exit."),
399
+ from_history: int = typer.Option(0, "--from-history", help="Sample last N unique tasks from metrics.jsonl history."),
244
400
  ) -> None:
245
401
  """Benchmark file selection quality and token efficiency across tasks."""
246
402
  root = _root()
@@ -252,7 +408,12 @@ def register(app: typer.Typer) -> None:
252
408
  return
253
409
 
254
410
  # Build case list
255
- if task:
411
+ if from_history > 0:
412
+ bench_cases = _load_history_cases(root, from_history)
413
+ if not bench_cases:
414
+ console.print("[yellow]No task history found in metrics.jsonl. Run agentpack pack first.[/]")
415
+ raise typer.Exit(1)
416
+ elif task:
256
417
  resolved = _resolve_task(task) if task == "auto" else task
257
418
  bench_cases = [BenchmarkCase(task=resolved, mode=mode)]
258
419
  else:
@@ -282,6 +443,7 @@ def register(app: typer.Typer) -> None:
282
443
  with console.status(f"[dim]{label}[/]"):
283
444
  try:
284
445
  r = _run_case(root, c)
446
+ _persist_result(root, r)
285
447
  results.append(r)
286
448
  except Exception as e:
287
449
  console.print(f"[red]Error on case '{c.task}': {e}[/]")
@@ -204,11 +204,12 @@ def register(app: typer.Typer) -> None:
204
204
  console.print("[bold]Top selected files (ranked):[/]")
205
205
  for i, sf in enumerate(selected, 1):
206
206
  score_val, reasons = score_map.get(sf.path, (sf.score, sf.reasons))
207
- reason_str = reasons[0] if reasons else ""
207
+ reason_str = ", ".join(reasons) if reasons else ""
208
+ mode_color = "green" if sf.include_mode == "full" else "yellow" if sf.include_mode == "symbols" else "dim"
208
209
  console.print(
209
210
  f" [bold]{i}.[/] {sf.path:<50} "
210
211
  f"[dim]score={score_val:.0f}[/] "
211
- f"[[{'green' if sf.include_mode == 'full' else 'yellow' if sf.include_mode == 'symbols' else 'dim'}]{sf.include_mode}[/]] "
212
+ f"[[{mode_color}]{sf.include_mode}[/]] "
212
213
  f"[dim]{reason_str}[/]"
213
214
  )
214
215
 
@@ -5,7 +5,7 @@ from typing import Optional
5
5
 
6
6
  import typer
7
7
 
8
- from agentpack.core.config import DEFAULT_CONFIG, save_config
8
+ from agentpack.core.config import DEFAULT_CONFIG, CONFIG_TEMPLATE, save_config
9
9
  from agentpack.core.ignore import DEFAULT_AGENTIGNORE
10
10
  from agentpack.commands._shared import console, _root
11
11
 
@@ -63,7 +63,17 @@ def register(app: typer.Typer) -> None:
63
63
  if budget > 0:
64
64
  cfg.context.default_budget = budget
65
65
 
66
- save_config(cfg, root)
66
+ config_toml = CONFIG_TEMPLATE.replace(
67
+ 'default_mode = "balanced"',
68
+ f'default_mode = "{cfg.context.default_mode}"',
69
+ )
70
+ if budget > 0:
71
+ config_toml = config_toml.replace(
72
+ "default_budget = 25000",
73
+ f"default_budget = {cfg.context.default_budget}",
74
+ )
75
+ config_path_file.parent.mkdir(parents=True, exist_ok=True)
76
+ config_path_file.write_text(config_toml)
67
77
  console.print(f"[green]Created[/] .agentpack/config.toml [dim](mode: {cfg.context.default_mode}, budget: {cfg.context.default_budget:,})[/]")
68
78
  else:
69
79
  console.print("[dim]Skipped[/] .agentpack/config.toml (exists)")
@@ -11,6 +11,7 @@ from rich.table import Table
11
11
  from rich import box
12
12
 
13
13
  from agentpack.core import git
14
+ from agentpack.core.ignore import SENSITIVE_PATTERNS
14
15
  from agentpack.application.pack_service import PackRequest, PackService, PackResult
15
16
  from agentpack.commands._shared import console, _root
16
17
 
@@ -101,7 +102,7 @@ def _print_pack_summary(result: PackResult) -> None:
101
102
  files_tbl.add_row(
102
103
  f"{sf.path}{changed_marker}",
103
104
  f"[{style}]{sf.include_mode}[/]",
104
- sf.reasons[0] if sf.reasons else "",
105
+ ", ".join(sf.reasons) if sf.reasons else "",
105
106
  )
106
107
  if len(selected) > 20:
107
108
  files_tbl.add_row(f"[dim]... {len(selected) - 20} more[/]", "", "")
@@ -128,6 +129,25 @@ def _print_pack_summary(result: PackResult) -> None:
128
129
  console.print(f"\n[bold]Changed files[/] ({len(changed_files)}):")
129
130
  console.print(changed_lines)
130
131
 
132
+ redaction_warnings = result.pack.redaction_warnings
133
+ if redaction_warnings:
134
+ console.print(f"\n[bold yellow]⚠ Secrets redacted ({len(redaction_warnings)}):[/]")
135
+ for w in redaction_warnings[:10]:
136
+ console.print(f" [yellow]{w}[/]")
137
+ if len(redaction_warnings) > 10:
138
+ console.print(f" [dim]... {len(redaction_warnings) - 10} more[/]")
139
+
140
+ sensitive_excluded = [
141
+ fi.path for fi in result.scan_result.ignored
142
+ if SENSITIVE_PATTERNS.match_file(fi.path)
143
+ ]
144
+ if sensitive_excluded:
145
+ console.print(f"\n[bold green]✓ Sensitive files excluded ({len(sensitive_excluded)}):[/]")
146
+ for p in sensitive_excluded[:10]:
147
+ console.print(f" [dim]{p}[/]")
148
+ if len(sensitive_excluded) > 10:
149
+ console.print(f" [dim]... {len(sensitive_excluded) - 10} more[/]")
150
+
131
151
  console.print(f"\n[bold]Next step:[/]")
132
152
  console.print(f" [bold white]claude < {out_path}[/]")
133
153
  console.print()
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ import os
4
5
  from pathlib import Path
5
6
  from typing import Optional
6
7
 
@@ -174,17 +175,16 @@ def _run_refresh(
174
175
  summary_provider="offline",
175
176
  ))
176
177
 
177
- # Write readable context
178
+ # Write context files atomically — avoids partial reads if interrupted mid-write
178
179
  from agentpack.renderers.markdown import render_generic
179
180
  context_text = render_generic(result.pack)
180
181
  context_path = root / CONTEXT_FILE
181
182
  context_path.parent.mkdir(parents=True, exist_ok=True)
182
- context_path.write_text(context_text, encoding="utf-8")
183
+ _atomic_write(context_path, context_text)
183
184
 
184
- # Write compact context
185
185
  compact_text = render_compact(result.pack)
186
186
  compact_path = root / COMPACT_FILE
187
- compact_path.write_text(compact_text, encoding="utf-8")
187
+ _atomic_write(compact_path, compact_text)
188
188
 
189
189
  return {
190
190
  "files": len(result.pack.selected_files),
@@ -196,6 +196,26 @@ def _run_refresh(
196
196
  return None
197
197
 
198
198
 
199
+ def _atomic_write(path: Path, text: str) -> None:
200
+ """Write to a temp file in the same dir, then rename — atomic on POSIX."""
201
+ import tempfile
202
+ dir_ = path.parent
203
+ try:
204
+ fd, tmp = tempfile.mkstemp(dir=dir_, prefix=".tmp_")
205
+ try:
206
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
207
+ fh.write(text)
208
+ os.replace(tmp, path)
209
+ except Exception:
210
+ try:
211
+ os.unlink(tmp)
212
+ except OSError:
213
+ pass
214
+ raise
215
+ except OSError:
216
+ path.write_text(text, encoding="utf-8")
217
+
218
+
199
219
  def _now_iso() -> str:
200
220
  from datetime import datetime, timezone
201
221
  return datetime.now(timezone.utc).isoformat()
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import time
4
5
  from datetime import datetime
5
6
  from pathlib import Path
@@ -10,8 +11,15 @@ from agentpack.commands._shared import console, _root
10
11
  from agentpack.session.state import TASK_FILE, load_session, save_session, log_activity
11
12
 
12
13
 
13
- _IGNORE_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", ".next", "__pycache__"}
14
+ _IGNORE_DIRS = {
15
+ ".git", "node_modules", ".venv", "venv", "dist", "build", ".next",
16
+ "__pycache__", ".yarn", ".mypy_cache", ".ruff_cache", ".pytest_cache",
17
+ ".tox", ".eggs", "*.egg-info",
18
+ }
14
19
  _IGNORE_NAMES = {"context.md", "context.compact.md"}
20
+ _IGNORE_PREFIXES = (".agentpack/context",)
21
+
22
+ _MAX_POLL_FILES = 50_000
15
23
 
16
24
 
17
25
  def register(app: typer.Typer) -> None:
@@ -58,12 +66,19 @@ def _should_ignore(path: str) -> bool:
58
66
  if part in _IGNORE_DIRS:
59
67
  return True
60
68
  name = Path(path).name
61
- return name in _IGNORE_NAMES
69
+ if name in _IGNORE_NAMES:
70
+ return True
71
+ norm = path.replace("\\", "/")
72
+ return any(norm.startswith(p) for p in _IGNORE_PREFIXES)
62
73
 
63
74
 
64
75
  def _run_refresh(root: Path, agent: str, mode: str, budget: int) -> None:
65
76
  from agentpack.commands.session import _run_refresh as do_refresh, _file_hash, _now_iso
66
- result = do_refresh(root, agent, mode, budget)
77
+ try:
78
+ result = do_refresh(root, agent, mode, budget)
79
+ except Exception as e:
80
+ console.print(f"[dim][{_ts()}][/] [red]refresh error: {e}[/]")
81
+ return
67
82
  if result:
68
83
  ts = _ts()
69
84
  console.print(
@@ -102,21 +117,37 @@ def _watch_with_watchdog(
102
117
  def on_any_event(self, event): # type: ignore[override]
103
118
  if event.is_directory:
104
119
  return
105
- path = str(event.src_path)
120
+ try:
121
+ path = str(Path(event.src_path).relative_to(root))
122
+ except ValueError:
123
+ return
106
124
  if _should_ignore(path):
107
125
  return
108
- # Task file change → show message
109
126
  if path.endswith(TASK_FILE):
110
127
  console.print(f"[dim][{_ts()}][/] task changed")
111
128
  _pending[0] = True
112
129
 
113
130
  observer = Observer()
114
- observer.schedule(Handler(), str(root), recursive=True)
115
- observer.start()
131
+ try:
132
+ observer.schedule(Handler(), str(root), recursive=True)
133
+ observer.start()
134
+ except Exception as e:
135
+ console.print(f"[red]Failed to start file watcher: {e}[/]")
136
+ console.print("[dim]Falling back to polling.[/]")
137
+ _watch_polling(root, agent, mode, budget, debounce, state)
138
+ return
116
139
 
117
140
  try:
118
141
  while True:
119
142
  time.sleep(0.5)
143
+ if not observer.is_alive():
144
+ console.print(f"[dim][{_ts()}][/] [yellow]watcher thread died — restarting...[/]")
145
+ try:
146
+ observer.stop()
147
+ except Exception:
148
+ pass
149
+ _watch_polling(root, agent, mode, budget, debounce, state)
150
+ return
120
151
  current_state = load_session(root)
121
152
  if current_state is not None and not current_state.active:
122
153
  console.print("\n[dim]Session stopped — watch exiting.[/]")
@@ -127,14 +158,45 @@ def _watch_with_watchdog(
127
158
  if now - _last_refresh[0] >= debounce:
128
159
  _pending[0] = False
129
160
  _last_refresh[0] = now
130
- try:
131
- _run_refresh(root, agent, mode, budget)
132
- except Exception as e:
133
- console.print(f"[red]refresh error: {e}[/]")
161
+ _run_refresh(root, agent, mode, budget)
134
162
  except KeyboardInterrupt:
135
163
  observer.stop()
136
164
  console.print("\n[dim]Watch stopped.[/]")
137
- observer.join()
165
+ finally:
166
+ observer.join(timeout=3)
167
+
168
+
169
+ def _collect_mtimes(root: Path) -> dict[str, float]:
170
+ """Walk repo files without following symlinks; cap at _MAX_POLL_FILES."""
171
+ mtimes: dict[str, float] = {}
172
+ try:
173
+ for entry in _walk_no_symlinks(root):
174
+ rel = str(Path(entry).relative_to(root))
175
+ if _should_ignore(rel):
176
+ continue
177
+ try:
178
+ mtimes[rel] = os.stat(entry).st_mtime
179
+ except OSError:
180
+ pass
181
+ if len(mtimes) >= _MAX_POLL_FILES:
182
+ break
183
+ except OSError:
184
+ pass
185
+ return mtimes
186
+
187
+
188
+ def _walk_no_symlinks(root: Path):
189
+ """os.walk without following symlinks — avoids infinite loops in symlink forests."""
190
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=lambda e: None):
191
+ # Prune ignored dirs in-place so os.walk won't descend into them
192
+ dirnames[:] = [
193
+ d for d in dirnames
194
+ if d not in _IGNORE_DIRS and not os.path.islink(os.path.join(dirpath, d))
195
+ ]
196
+ for fname in filenames:
197
+ fpath = os.path.join(dirpath, fname)
198
+ if not os.path.islink(fpath):
199
+ yield fpath
138
200
 
139
201
 
140
202
  def _watch_polling(
@@ -148,21 +210,7 @@ def _watch_polling(
148
210
  """Polling fallback: walk repo files and compare mtimes."""
149
211
  _POLL_INTERVAL = 1.5
150
212
 
151
- def _collect_mtimes() -> dict[str, float]:
152
- mtimes: dict[str, float] = {}
153
- for p in root.rglob("*"):
154
- if not p.is_file():
155
- continue
156
- rel = str(p.relative_to(root))
157
- if _should_ignore(rel):
158
- continue
159
- try:
160
- mtimes[rel] = p.stat().st_mtime
161
- except OSError:
162
- pass
163
- return mtimes
164
-
165
- prev = _collect_mtimes()
213
+ prev = _collect_mtimes(root)
166
214
  _run_refresh(root, agent, mode, budget)
167
215
  _last_refresh = time.monotonic()
168
216
 
@@ -173,7 +221,7 @@ def _watch_polling(
173
221
  if current_state is not None and not current_state.active:
174
222
  console.print("\n[dim]Session stopped — watch exiting.[/]")
175
223
  break
176
- curr = _collect_mtimes()
224
+ curr = _collect_mtimes(root)
177
225
  changed = {p for p, m in curr.items() if prev.get(p) != m}
178
226
  changed |= set(prev) - set(curr)
179
227
  if changed:
@@ -184,10 +232,7 @@ def _watch_polling(
184
232
  if now - _last_refresh >= debounce:
185
233
  _last_refresh = now
186
234
  prev = curr
187
- try:
188
- _run_refresh(root, agent, mode, budget)
189
- except Exception as e:
190
- console.print(f"[red]refresh error: {e}[/]")
235
+ _run_refresh(root, agent, mode, budget)
191
236
  else:
192
237
  prev = curr
193
238
  except KeyboardInterrupt:
@@ -14,6 +14,8 @@ from pydantic import BaseModel, Field
14
14
  class ProjectConfig(BaseModel):
15
15
  root: str = "."
16
16
  ignore_file: str = ".agentignore"
17
+ include_globs: list[str] = Field(default_factory=list)
18
+ exclude_globs: list[str] = Field(default_factory=list)
17
19
 
18
20
 
19
21
  class ContextConfig(BaseModel):
@@ -74,6 +76,41 @@ class Config(BaseModel):
74
76
 
75
77
  DEFAULT_CONFIG = Config()
76
78
 
79
+ CONFIG_TEMPLATE = """\
80
+ [project]
81
+ # Restrict packing to these glob patterns (empty = all files).
82
+ # Example: include_globs = ["app/**", "packages/core/**"]
83
+ include_globs = []
84
+ # Always exclude these patterns on top of .agentignore.
85
+ # Example: exclude_globs = ["migrations/**", "generated/**", "snapshots/**"]
86
+ exclude_globs = []
87
+
88
+ [context]
89
+ default_budget = 25000 # token budget per pack
90
+ default_mode = "balanced" # minimal | balanced | deep
91
+ max_file_tokens = 4000 # files larger than this are summarised, not inlined
92
+ include_tests = true
93
+ include_configs = true
94
+ include_receipts = true
95
+
96
+ [scoring]
97
+ # Scoring weights — higher wins budget allocation.
98
+ # Tune these to make agentpack favour your team's file layout.
99
+ modified = 100
100
+ staged = 90
101
+ filename_keyword = 80
102
+ symbol_keyword = 70
103
+ content_keyword_per_hit = 10
104
+ content_keyword_max = 60
105
+ direct_dep = 50
106
+ reverse_dep = 40
107
+ related_test = 35
108
+ config_file = 25
109
+ recently_modified = 20
110
+ large_unrelated_penalty = -50
111
+ ignored_penalty = -100
112
+ """
113
+
77
114
 
78
115
  def config_path(root: Path) -> Path:
79
116
  return root / ".agentpack" / "config.toml"
@@ -36,6 +36,18 @@ generated/
36
36
  .env.*
37
37
  *.pem
38
38
  *.key
39
+ id_rsa
40
+ id_dsa
41
+ id_ecdsa
42
+ id_ed25519
43
+ *.p12
44
+ *.pfx
45
+ *.jks
46
+ .npmrc
47
+ .pypirc
48
+ .netrc
49
+ *.tfvars
50
+ terraform.tfvars
39
51
 
40
52
  # lock files
41
53
  package-lock.json
@@ -57,6 +69,15 @@ Gemfile.lock
57
69
  """
58
70
 
59
71
 
72
+ SENSITIVE_PATTERNS = pathspec.PathSpec.from_lines("gitignore", [
73
+ ".env", ".env.*", "*.pem", "*.key",
74
+ "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
75
+ "*.p12", "*.pfx", "*.jks",
76
+ ".npmrc", ".pypirc", ".netrc",
77
+ "*.tfvars", "terraform.tfvars",
78
+ ])
79
+
80
+
60
81
  def load_spec(ignore_path: Path) -> pathspec.PathSpec:
61
82
  if ignore_path.exists():
62
83
  lines = ignore_path.read_text().splitlines()
@@ -75,17 +75,29 @@ def _is_binary(path: Path) -> bool:
75
75
  return True
76
76
 
77
77
 
78
+ def _build_glob_specs(
79
+ include_globs: list[str],
80
+ exclude_globs: list[str],
81
+ ) -> tuple[pathspec.PathSpec | None, pathspec.PathSpec | None]:
82
+ inc = pathspec.PathSpec.from_lines("gitignore", include_globs) if include_globs else None
83
+ exc = pathspec.PathSpec.from_lines("gitignore", exclude_globs) if exclude_globs else None
84
+ return inc, exc
85
+
86
+
78
87
  def scan(
79
88
  root: Path,
80
89
  ignore_spec: pathspec.PathSpec,
81
90
  max_file_tokens: int = 4000,
82
91
  previous_snapshot: dict | None = None,
92
+ include_globs: list[str] | None = None,
93
+ exclude_globs: list[str] | None = None,
83
94
  ) -> ScanResult:
84
95
  packable: list[FileInfo] = []
85
96
  ignored: list[FileInfo] = []
86
97
  binary: list[FileInfo] = []
87
98
 
88
99
  prev_files: dict[str, dict] = (previous_snapshot or {}).get("files", {})
100
+ inc_spec, exc_spec = _build_glob_specs(include_globs or [], exclude_globs or [])
89
101
 
90
102
  for abs_path in root.rglob("*"):
91
103
  if not abs_path.is_file():
@@ -99,6 +111,20 @@ def scan(
99
111
 
100
112
  rel_str = str(rel)
101
113
 
114
+ if inc_spec is not None and not inc_spec.match_file(rel_str):
115
+ ignored.append(FileInfo(
116
+ path=rel_str, abs_path=abs_path,
117
+ size_bytes=abs_path.stat().st_size, estimated_tokens=0, ignored=True,
118
+ ))
119
+ continue
120
+
121
+ if exc_spec is not None and exc_spec.match_file(rel_str):
122
+ ignored.append(FileInfo(
123
+ path=rel_str, abs_path=abs_path,
124
+ size_bytes=abs_path.stat().st_size, estimated_tokens=0, ignored=True,
125
+ ))
126
+ continue
127
+
102
128
  if is_ignored(ignore_spec, rel_str):
103
129
  ignored.append(
104
130
  FileInfo(
File without changes
File without changes
File without changes