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.
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/PKG-INFO +1 -1
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/pyproject.toml +1 -1
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/ranking.py +8 -1
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/application/pack_service.py +9 -2
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/benchmark.py +174 -12
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/explain.py +3 -2
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/init.py +12 -2
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/pack.py +21 -1
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/session.py +24 -4
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/watch.py +77 -32
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/config.py +37 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/ignore.py +21 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/scanner.py +26 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/.gitignore +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/LICENSE +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/README.md +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/antigravity.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/base.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/claude.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/codex.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/cursor.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/detect.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/generic.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/adapters/windsurf.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/dependency_graph.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/go_imports.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/java_imports.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/js_ts_imports.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/python_imports.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/rust_imports.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/symbols.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/analysis/tests.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/application/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/cli.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/_shared.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/claude_cmd.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/diff.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/doctor.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/install.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/monitor.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/scan.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/stats.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/status.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/commands/summarize.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/bootstrap.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/cache.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/context_pack.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/diff.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/git.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/git_hooks.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/global_install.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/merkle.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/models.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/redactor.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/snapshot.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/token_estimator.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/core/vscode_tasks.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/data/agentpack.md +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/antigravity.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/claude.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/codex.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/cursor.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/installers/windsurf.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/git_hooks.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/global_install.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/integrations/vscode_tasks.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/compact.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/markdown.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/renderers/receipts.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/session/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/session/state.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/__init__.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/base.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/llm.py +0 -0
- {agentpack_cli-0.1.6 → agentpack_cli-0.1.7}/src/agentpack/summaries/offline.py +0 -0
|
@@ -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] = []
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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.
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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"[[{
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|