agent-knowledge-cli 0.1.2__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.
- agent_knowledge/__init__.py +3 -0
- agent_knowledge/__main__.py +3 -0
- agent_knowledge/assets/__init__.py +0 -0
- agent_knowledge/assets/claude/global.md +44 -0
- agent_knowledge/assets/claude/project-template.md +46 -0
- agent_knowledge/assets/claude/scripts/install.sh +85 -0
- agent_knowledge/assets/commands/doctor.md +21 -0
- agent_knowledge/assets/commands/global-knowledge-sync.md +27 -0
- agent_knowledge/assets/commands/graphify-sync.md +26 -0
- agent_knowledge/assets/commands/knowledge-sync.md +26 -0
- agent_knowledge/assets/commands/ship.md +29 -0
- agent_knowledge/assets/rules/generate-architecture-doc.mdc +87 -0
- agent_knowledge/assets/rules/history-backfill.mdc +67 -0
- agent_knowledge/assets/rules/memory-bootstrap.mdc +53 -0
- agent_knowledge/assets/rules/memory-writeback.mdc +90 -0
- agent_knowledge/assets/rules/shared-memory.mdc +102 -0
- agent_knowledge/assets/rules/workflow-orchestration.mdc +93 -0
- agent_knowledge/assets/rules-global/action-first.mdc +26 -0
- agent_knowledge/assets/rules-global/no-icons-emojis.mdc +16 -0
- agent_knowledge/assets/rules-global/no-unsolicited-docs.mdc +20 -0
- agent_knowledge/assets/scripts/bootstrap-memory-tree.sh +389 -0
- agent_knowledge/assets/scripts/compact-memory.sh +191 -0
- agent_knowledge/assets/scripts/doctor.sh +137 -0
- agent_knowledge/assets/scripts/global-knowledge-sync.sh +372 -0
- agent_knowledge/assets/scripts/graphify-sync.sh +397 -0
- agent_knowledge/assets/scripts/import-agent-history.sh +706 -0
- agent_knowledge/assets/scripts/install-project-links.sh +258 -0
- agent_knowledge/assets/scripts/lib/knowledge-common.sh +875 -0
- agent_knowledge/assets/scripts/measure-token-savings.py +540 -0
- agent_knowledge/assets/scripts/ship.sh +256 -0
- agent_knowledge/assets/scripts/update-knowledge.sh +341 -0
- agent_knowledge/assets/scripts/validate-knowledge.sh +265 -0
- agent_knowledge/assets/skills/decision-recording/SKILL.md +124 -0
- agent_knowledge/assets/skills/history-backfill/SKILL.md +115 -0
- agent_knowledge/assets/skills/memory-compaction/SKILL.md +115 -0
- agent_knowledge/assets/skills/memory-management/SKILL.md +134 -0
- agent_knowledge/assets/skills/project-ontology-bootstrap/SKILL.md +173 -0
- agent_knowledge/assets/skills/session-management/SKILL.md +116 -0
- agent_knowledge/assets/skills-cursor/create-rule/SKILL.md +164 -0
- agent_knowledge/assets/skills-cursor/create-skill/SKILL.md +498 -0
- agent_knowledge/assets/skills-cursor/create-subagent/SKILL.md +225 -0
- agent_knowledge/assets/skills-cursor/migrate-to-skills/SKILL.md +134 -0
- agent_knowledge/assets/skills-cursor/shell/SKILL.md +24 -0
- agent_knowledge/assets/skills-cursor/update-cursor-settings/SKILL.md +122 -0
- agent_knowledge/assets/templates/dashboards/project-overview.template.md +24 -0
- agent_knowledge/assets/templates/dashboards/session-rollup.template.md +23 -0
- agent_knowledge/assets/templates/hooks/hooks.json.template +11 -0
- agent_knowledge/assets/templates/integrations/claude/CLAUDE.md +7 -0
- agent_knowledge/assets/templates/integrations/codex/AGENTS.md +7 -0
- agent_knowledge/assets/templates/integrations/cursor/agent-knowledge.mdc +11 -0
- agent_knowledge/assets/templates/integrations/cursor/hooks.json +11 -0
- agent_knowledge/assets/templates/memory/MEMORY.root.template.md +36 -0
- agent_knowledge/assets/templates/memory/branch.template.md +33 -0
- agent_knowledge/assets/templates/memory/decision.template.md +33 -0
- agent_knowledge/assets/templates/memory/profile.hybrid.yaml +16 -0
- agent_knowledge/assets/templates/memory/profile.ml-platform.yaml +18 -0
- agent_knowledge/assets/templates/memory/profile.robotics.yaml +19 -0
- agent_knowledge/assets/templates/memory/profile.web-app.yaml +16 -0
- agent_knowledge/assets/templates/portfolio/.obsidian/README.md +21 -0
- agent_knowledge/assets/templates/portfolio/.obsidian/app.json +5 -0
- agent_knowledge/assets/templates/portfolio/.obsidian/core-plugins.json +7 -0
- agent_knowledge/assets/templates/project/.agent-project.yaml +36 -0
- agent_knowledge/assets/templates/project/.agentknowledgeignore +10 -0
- agent_knowledge/assets/templates/project/AGENTS.md +87 -0
- agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/README.md +23 -0
- agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/app.json +5 -0
- agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/core-plugins.json +7 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Evidence/README.md +34 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Evidence/imports/README.md +29 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Evidence/raw/README.md +25 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Memory/MEMORY.md +37 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Memory/decisions/decisions.md +31 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Outputs/README.md +24 -0
- agent_knowledge/assets/templates/project/agent-knowledge/STATUS.md +43 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Sessions/README.md +21 -0
- agent_knowledge/assets/templates/project/agent-knowledge/Templates/README.md +19 -0
- agent_knowledge/assets/templates/project/gitignore.agent-knowledge +13 -0
- agent_knowledge/cli.py +457 -0
- agent_knowledge/runtime/__init__.py +0 -0
- agent_knowledge/runtime/integrations.py +154 -0
- agent_knowledge/runtime/paths.py +46 -0
- agent_knowledge/runtime/shell.py +22 -0
- agent_knowledge/runtime/sync.py +255 -0
- agent_knowledge_cli-0.1.2.dist-info/METADATA +155 -0
- agent_knowledge_cli-0.1.2.dist-info/RECORD +88 -0
- agent_knowledge_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_knowledge_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_knowledge_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Estimate repo-controlled context savings from scoped project memory.
|
|
4
|
+
|
|
5
|
+
This tool measures only the text files you choose to include. It does not attempt
|
|
6
|
+
to model provider-side hidden tokens, tool-call overhead, or system prompts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
SKIP_DIR_NAMES = {
|
|
22
|
+
".git",
|
|
23
|
+
".next",
|
|
24
|
+
".venv",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
"build",
|
|
27
|
+
"dist",
|
|
28
|
+
"node_modules",
|
|
29
|
+
"vendor",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def now_utc() -> str:
|
|
34
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def json_dump(data: object) -> str:
|
|
38
|
+
return json.dumps(data, indent=2, sort_keys=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_path_list(list_file: str | None) -> list[str]:
|
|
42
|
+
if not list_file:
|
|
43
|
+
return []
|
|
44
|
+
path = Path(list_file).expanduser()
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
raise FileNotFoundError(f"Path list file not found: {path}")
|
|
47
|
+
items: list[str] = []
|
|
48
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
line = raw.strip()
|
|
50
|
+
if not line or line.startswith("#"):
|
|
51
|
+
continue
|
|
52
|
+
items.append(line)
|
|
53
|
+
return items
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unique_paths(paths: Iterable[Path]) -> list[Path]:
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
ordered: list[Path] = []
|
|
59
|
+
for path in paths:
|
|
60
|
+
resolved = str(path)
|
|
61
|
+
if resolved in seen:
|
|
62
|
+
continue
|
|
63
|
+
seen.add(resolved)
|
|
64
|
+
ordered.append(path)
|
|
65
|
+
return ordered
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def expand_inputs(raw_inputs: list[str]) -> tuple[list[Path], list[str]]:
|
|
69
|
+
files: list[Path] = []
|
|
70
|
+
warnings: list[str] = []
|
|
71
|
+
|
|
72
|
+
for raw in raw_inputs:
|
|
73
|
+
path = Path(raw).expanduser()
|
|
74
|
+
if not path.exists():
|
|
75
|
+
warnings.append(f"Missing path skipped: {path}")
|
|
76
|
+
continue
|
|
77
|
+
if path.is_file():
|
|
78
|
+
files.append(path)
|
|
79
|
+
continue
|
|
80
|
+
if path.is_dir():
|
|
81
|
+
for root, dirnames, filenames in os.walk(path):
|
|
82
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIR_NAMES)
|
|
83
|
+
for filename in sorted(filenames):
|
|
84
|
+
file_path = Path(root) / filename
|
|
85
|
+
if file_path.is_file():
|
|
86
|
+
files.append(file_path)
|
|
87
|
+
continue
|
|
88
|
+
warnings.append(f"Unsupported path type skipped: {path}")
|
|
89
|
+
|
|
90
|
+
return unique_paths(sorted(files)), warnings
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_binary_file(path: Path) -> bool:
|
|
94
|
+
try:
|
|
95
|
+
chunk = path.read_bytes()[:8192]
|
|
96
|
+
except OSError:
|
|
97
|
+
return True
|
|
98
|
+
return b"\0" in chunk
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TokenCounter:
|
|
102
|
+
def __init__(self, strategy: str) -> None:
|
|
103
|
+
self.requested_strategy = strategy
|
|
104
|
+
self.strategy = strategy
|
|
105
|
+
self.estimate_only = True
|
|
106
|
+
self.note = "Estimated using a 4-characters-per-token heuristic."
|
|
107
|
+
self._encoder = None
|
|
108
|
+
|
|
109
|
+
if strategy in {"auto", "tiktoken-cl100k"}:
|
|
110
|
+
try:
|
|
111
|
+
import tiktoken # type: ignore
|
|
112
|
+
|
|
113
|
+
self._encoder = tiktoken.get_encoding("cl100k_base")
|
|
114
|
+
self.strategy = "tiktoken-cl100k"
|
|
115
|
+
self.estimate_only = False
|
|
116
|
+
self.note = "Measured with tiktoken cl100k_base. Still excludes provider-side hidden tokens."
|
|
117
|
+
except Exception:
|
|
118
|
+
if strategy == "tiktoken-cl100k":
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
"tiktoken-cl100k was requested, but tiktoken is not available."
|
|
121
|
+
)
|
|
122
|
+
self.strategy = "chars4-estimate"
|
|
123
|
+
self.estimate_only = True
|
|
124
|
+
self.note = (
|
|
125
|
+
"Estimated using a 4-characters-per-token heuristic because tiktoken is unavailable."
|
|
126
|
+
)
|
|
127
|
+
elif strategy != "chars4-estimate":
|
|
128
|
+
raise ValueError(f"Unsupported tokenizer strategy: {strategy}")
|
|
129
|
+
|
|
130
|
+
def count(self, text: str) -> int:
|
|
131
|
+
if self._encoder is not None:
|
|
132
|
+
return len(self._encoder.encode(text))
|
|
133
|
+
return int(math.ceil(len(text) / 4.0))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class Measurement:
|
|
138
|
+
paths: list[str]
|
|
139
|
+
file_count: int
|
|
140
|
+
token_count: int
|
|
141
|
+
byte_count: int
|
|
142
|
+
binary_skipped: list[str]
|
|
143
|
+
missing: list[str]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def relative_or_absolute(path: Path, project_root: Path | None) -> str:
|
|
147
|
+
if project_root is None:
|
|
148
|
+
return str(path)
|
|
149
|
+
candidates = [path.absolute()]
|
|
150
|
+
roots = [project_root.absolute()]
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
candidates.append(path.resolve())
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
try:
|
|
157
|
+
roots.append(project_root.resolve())
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
for candidate in candidates:
|
|
162
|
+
for root in roots:
|
|
163
|
+
try:
|
|
164
|
+
return str(candidate.relative_to(root))
|
|
165
|
+
except Exception:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
return str(path)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def measure_paths(
|
|
172
|
+
counter: TokenCounter, files: list[Path], project_root: Path | None
|
|
173
|
+
) -> Measurement:
|
|
174
|
+
token_count = 0
|
|
175
|
+
byte_count = 0
|
|
176
|
+
binary_skipped: list[str] = []
|
|
177
|
+
missing: list[str] = []
|
|
178
|
+
included_paths: list[str] = []
|
|
179
|
+
|
|
180
|
+
for path in files:
|
|
181
|
+
if not path.exists():
|
|
182
|
+
missing.append(relative_or_absolute(path, project_root))
|
|
183
|
+
continue
|
|
184
|
+
if is_binary_file(path):
|
|
185
|
+
binary_skipped.append(relative_or_absolute(path, project_root))
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
raw_bytes = path.read_bytes()
|
|
190
|
+
text = raw_bytes.decode("utf-8", errors="ignore")
|
|
191
|
+
except OSError:
|
|
192
|
+
missing.append(relative_or_absolute(path, project_root))
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
token_count += counter.count(text)
|
|
196
|
+
byte_count += len(raw_bytes)
|
|
197
|
+
included_paths.append(relative_or_absolute(path, project_root))
|
|
198
|
+
|
|
199
|
+
return Measurement(
|
|
200
|
+
paths=included_paths,
|
|
201
|
+
file_count=len(included_paths),
|
|
202
|
+
token_count=token_count,
|
|
203
|
+
byte_count=byte_count,
|
|
204
|
+
binary_skipped=binary_skipped,
|
|
205
|
+
missing=missing,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def resolve_measurement_dir(project: str | None) -> Path:
|
|
210
|
+
project_root = Path(project or ".").expanduser().absolute()
|
|
211
|
+
knowledge_outputs = project_root / "agent-knowledge" / "Outputs" / "token-measurements"
|
|
212
|
+
if knowledge_outputs.parent.exists():
|
|
213
|
+
return knowledge_outputs
|
|
214
|
+
return project_root / "token-measurements"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def ensure_parent(path: Path) -> None:
|
|
218
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def write_output(path: str | None, payload: dict) -> None:
|
|
222
|
+
if not path:
|
|
223
|
+
return
|
|
224
|
+
dst = Path(path).expanduser()
|
|
225
|
+
ensure_parent(dst)
|
|
226
|
+
dst.write_text(json_dump(payload) + "\n", encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_compare_payload(
|
|
230
|
+
args: argparse.Namespace, counter: TokenCounter
|
|
231
|
+
) -> dict:
|
|
232
|
+
project_root = Path(args.project).expanduser().absolute() if args.project else None
|
|
233
|
+
baseline_inputs = list(args.baseline or []) + load_path_list(args.baseline_list)
|
|
234
|
+
optimized_inputs = list(args.optimized or []) + load_path_list(args.optimized_list)
|
|
235
|
+
|
|
236
|
+
if not baseline_inputs:
|
|
237
|
+
raise ValueError("At least one baseline path is required.")
|
|
238
|
+
if not optimized_inputs:
|
|
239
|
+
raise ValueError("At least one optimized path is required.")
|
|
240
|
+
|
|
241
|
+
baseline_files, baseline_warnings = expand_inputs(baseline_inputs)
|
|
242
|
+
optimized_files, optimized_warnings = expand_inputs(optimized_inputs)
|
|
243
|
+
|
|
244
|
+
baseline = measure_paths(counter, baseline_files, project_root)
|
|
245
|
+
optimized = measure_paths(counter, optimized_files, project_root)
|
|
246
|
+
|
|
247
|
+
absolute_savings = baseline.token_count - optimized.token_count
|
|
248
|
+
percentage_savings = 0.0
|
|
249
|
+
if baseline.token_count > 0:
|
|
250
|
+
percentage_savings = round((absolute_savings / baseline.token_count) * 100.0, 2)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"mode": "static-compare",
|
|
254
|
+
"generated_at": now_utc(),
|
|
255
|
+
"project_root": str(project_root) if project_root else None,
|
|
256
|
+
"tokenizer": {
|
|
257
|
+
"strategy": counter.strategy,
|
|
258
|
+
"estimate_only": counter.estimate_only,
|
|
259
|
+
"note": counter.note,
|
|
260
|
+
},
|
|
261
|
+
"baseline": {
|
|
262
|
+
"file_count": baseline.file_count,
|
|
263
|
+
"token_count": baseline.token_count,
|
|
264
|
+
"byte_count": baseline.byte_count,
|
|
265
|
+
"paths": baseline.paths,
|
|
266
|
+
"binary_skipped": baseline.binary_skipped,
|
|
267
|
+
"missing": baseline.missing,
|
|
268
|
+
},
|
|
269
|
+
"optimized": {
|
|
270
|
+
"file_count": optimized.file_count,
|
|
271
|
+
"token_count": optimized.token_count,
|
|
272
|
+
"byte_count": optimized.byte_count,
|
|
273
|
+
"paths": optimized.paths,
|
|
274
|
+
"binary_skipped": optimized.binary_skipped,
|
|
275
|
+
"missing": optimized.missing,
|
|
276
|
+
},
|
|
277
|
+
"absolute_savings": absolute_savings,
|
|
278
|
+
"percentage_savings": percentage_savings,
|
|
279
|
+
"warnings": baseline_warnings + optimized_warnings,
|
|
280
|
+
"measurement_scope": "Repo-controlled context only. Hidden provider tokens are not included.",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def build_log_entry(args: argparse.Namespace, counter: TokenCounter) -> tuple[dict, Path]:
|
|
285
|
+
project_root = Path(args.project or ".").expanduser().absolute()
|
|
286
|
+
inputs = list(args.context or []) + load_path_list(args.context_list)
|
|
287
|
+
if not inputs:
|
|
288
|
+
raise ValueError("At least one context path is required for log-run.")
|
|
289
|
+
|
|
290
|
+
files, warnings = expand_inputs(inputs)
|
|
291
|
+
measurement = measure_paths(counter, files, project_root)
|
|
292
|
+
log_path = (
|
|
293
|
+
Path(args.log_file).expanduser()
|
|
294
|
+
if args.log_file
|
|
295
|
+
else resolve_measurement_dir(args.project) / "task-run-log.jsonl"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
entry = {
|
|
299
|
+
"logged_at": now_utc(),
|
|
300
|
+
"task": args.task,
|
|
301
|
+
"mode": args.mode,
|
|
302
|
+
"project_root": str(project_root),
|
|
303
|
+
"tokenizer": {
|
|
304
|
+
"strategy": counter.strategy,
|
|
305
|
+
"estimate_only": counter.estimate_only,
|
|
306
|
+
},
|
|
307
|
+
"token_count": measurement.token_count,
|
|
308
|
+
"file_count": measurement.file_count,
|
|
309
|
+
"byte_count": measurement.byte_count,
|
|
310
|
+
"context_paths": measurement.paths,
|
|
311
|
+
"binary_skipped": measurement.binary_skipped,
|
|
312
|
+
"missing": measurement.missing,
|
|
313
|
+
"notes": args.notes or "",
|
|
314
|
+
"warnings": warnings,
|
|
315
|
+
}
|
|
316
|
+
return entry, log_path
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def append_jsonl(path: Path, entry: dict) -> None:
|
|
320
|
+
ensure_parent(path)
|
|
321
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
322
|
+
handle.write(json.dumps(entry, sort_keys=False) + "\n")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def summarize_log_entries(entries: list[dict], task: str | None) -> dict:
|
|
326
|
+
filtered = [entry for entry in entries if not task or entry.get("task") == task]
|
|
327
|
+
grouped: dict[str, dict[str, list[int]]] = {}
|
|
328
|
+
|
|
329
|
+
for entry in filtered:
|
|
330
|
+
task_name = str(entry.get("task", "unknown"))
|
|
331
|
+
mode = str(entry.get("mode", "unknown"))
|
|
332
|
+
grouped.setdefault(task_name, {}).setdefault(mode, []).append(int(entry.get("token_count", 0)))
|
|
333
|
+
|
|
334
|
+
comparisons = []
|
|
335
|
+
for task_name in sorted(grouped):
|
|
336
|
+
broad = grouped[task_name].get("broad", [])
|
|
337
|
+
scoped = grouped[task_name].get("memory-scoped", [])
|
|
338
|
+
broad_avg = round(sum(broad) / len(broad), 2) if broad else None
|
|
339
|
+
scoped_avg = round(sum(scoped) / len(scoped), 2) if scoped else None
|
|
340
|
+
savings = None
|
|
341
|
+
percent = None
|
|
342
|
+
if broad_avg is not None and scoped_avg is not None:
|
|
343
|
+
savings = round(broad_avg - scoped_avg, 2)
|
|
344
|
+
percent = round((savings / broad_avg) * 100.0, 2) if broad_avg else 0.0
|
|
345
|
+
comparisons.append(
|
|
346
|
+
{
|
|
347
|
+
"task": task_name,
|
|
348
|
+
"broad_runs": len(broad),
|
|
349
|
+
"memory_scoped_runs": len(scoped),
|
|
350
|
+
"broad_avg_tokens": broad_avg,
|
|
351
|
+
"memory_scoped_avg_tokens": scoped_avg,
|
|
352
|
+
"avg_savings": savings,
|
|
353
|
+
"avg_percentage_savings": percent,
|
|
354
|
+
}
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
"mode": "summarize-log",
|
|
359
|
+
"generated_at": now_utc(),
|
|
360
|
+
"task_filter": task,
|
|
361
|
+
"comparisons": comparisons,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def load_jsonl(path: Path) -> list[dict]:
|
|
366
|
+
entries: list[dict] = []
|
|
367
|
+
if not path.is_file():
|
|
368
|
+
raise FileNotFoundError(f"Log file not found: {path}")
|
|
369
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
370
|
+
line = raw.strip()
|
|
371
|
+
if not line:
|
|
372
|
+
continue
|
|
373
|
+
entries.append(json.loads(line))
|
|
374
|
+
return entries
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def print_human_compare(payload: dict) -> None:
|
|
378
|
+
print("Static context comparison")
|
|
379
|
+
print(f" tokenizer: {payload['tokenizer']['strategy']}")
|
|
380
|
+
print(f" baseline tokens: {payload['baseline']['token_count']}")
|
|
381
|
+
print(f" optimized tokens: {payload['optimized']['token_count']}")
|
|
382
|
+
print(f" absolute savings: {payload['absolute_savings']}")
|
|
383
|
+
print(f" percentage savings: {payload['percentage_savings']}%")
|
|
384
|
+
print(f" note: {payload['tokenizer']['note']}")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def print_human_log(entry: dict, log_path: Path) -> None:
|
|
388
|
+
print("Task-run token log entry")
|
|
389
|
+
print(f" task: {entry['task']}")
|
|
390
|
+
print(f" mode: {entry['mode']}")
|
|
391
|
+
print(f" token count: {entry['token_count']}")
|
|
392
|
+
print(f" file count: {entry['file_count']}")
|
|
393
|
+
print(f" log file: {log_path}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def print_human_summary(payload: dict, log_path: Path) -> None:
|
|
397
|
+
print("Task-run log summary")
|
|
398
|
+
print(f" log file: {log_path}")
|
|
399
|
+
if not payload["comparisons"]:
|
|
400
|
+
print(" no comparable entries found")
|
|
401
|
+
return
|
|
402
|
+
for item in payload["comparisons"]:
|
|
403
|
+
print(
|
|
404
|
+
" {task}: broad={broad} memory-scoped={scoped} savings={savings} ({percent}%)".format(
|
|
405
|
+
task=item["task"],
|
|
406
|
+
broad=item["broad_avg_tokens"],
|
|
407
|
+
scoped=item["memory_scoped_avg_tokens"],
|
|
408
|
+
savings=item["avg_savings"],
|
|
409
|
+
percent=item["avg_percentage_savings"],
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
415
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
416
|
+
common.add_argument("--json", action="store_true", help="Print JSON instead of human-readable text.")
|
|
417
|
+
|
|
418
|
+
parser = argparse.ArgumentParser(
|
|
419
|
+
description="Measure repo-controlled context savings from scoped project memory."
|
|
420
|
+
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"--json",
|
|
423
|
+
dest="json_global",
|
|
424
|
+
action="store_true",
|
|
425
|
+
help="Print JSON instead of human-readable text.",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
429
|
+
|
|
430
|
+
compare = subparsers.add_parser(
|
|
431
|
+
"compare",
|
|
432
|
+
parents=[common],
|
|
433
|
+
help="Compare a broad baseline context set against an optimized memory-scoped set.",
|
|
434
|
+
)
|
|
435
|
+
compare.add_argument("--project", default=".", help="Project repo root used for relative output paths.")
|
|
436
|
+
compare.add_argument("--baseline", nargs="*", help="Baseline context paths.")
|
|
437
|
+
compare.add_argument("--baseline-list", help="Text file containing one baseline path per line.")
|
|
438
|
+
compare.add_argument("--optimized", nargs="*", help="Optimized context paths.")
|
|
439
|
+
compare.add_argument("--optimized-list", help="Text file containing one optimized path per line.")
|
|
440
|
+
compare.add_argument(
|
|
441
|
+
"--tokenizer",
|
|
442
|
+
default="auto",
|
|
443
|
+
choices=["auto", "tiktoken-cl100k", "chars4-estimate"],
|
|
444
|
+
help="Tokenizer strategy. auto prefers tiktoken cl100k_base, otherwise falls back to a chars/4 estimate.",
|
|
445
|
+
)
|
|
446
|
+
compare.add_argument("--report-file", help="Optional JSON report path.")
|
|
447
|
+
|
|
448
|
+
log_run = subparsers.add_parser(
|
|
449
|
+
"log-run",
|
|
450
|
+
parents=[common],
|
|
451
|
+
help="Append a file-based token estimate entry for a task run.",
|
|
452
|
+
)
|
|
453
|
+
log_run.add_argument("--project", default=".", help="Project repo root.")
|
|
454
|
+
log_run.add_argument("--task", required=True, help="Task label used later for comparison.")
|
|
455
|
+
log_run.add_argument(
|
|
456
|
+
"--mode",
|
|
457
|
+
required=True,
|
|
458
|
+
choices=["broad", "memory-scoped"],
|
|
459
|
+
help="Whether the task used broad or memory-scoped context.",
|
|
460
|
+
)
|
|
461
|
+
log_run.add_argument("--context", nargs="*", help="Context file or directory paths.")
|
|
462
|
+
log_run.add_argument("--context-list", help="Text file containing one context path per line.")
|
|
463
|
+
log_run.add_argument(
|
|
464
|
+
"--tokenizer",
|
|
465
|
+
default="auto",
|
|
466
|
+
choices=["auto", "tiktoken-cl100k", "chars4-estimate"],
|
|
467
|
+
help="Tokenizer strategy.",
|
|
468
|
+
)
|
|
469
|
+
log_run.add_argument("--notes", help="Optional note saved with the log entry.")
|
|
470
|
+
log_run.add_argument(
|
|
471
|
+
"--log-file",
|
|
472
|
+
help="Optional JSONL log path. Defaults to agent-knowledge/Outputs/token-measurements/task-run-log.jsonl when available.",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
summarize = subparsers.add_parser(
|
|
476
|
+
"summarize-log",
|
|
477
|
+
parents=[common],
|
|
478
|
+
help="Summarize logged broad vs memory-scoped runs by task.",
|
|
479
|
+
)
|
|
480
|
+
summarize.add_argument("--project", default=".", help="Project repo root.")
|
|
481
|
+
summarize.add_argument("--task", help="Optional task filter.")
|
|
482
|
+
summarize.add_argument(
|
|
483
|
+
"--log-file",
|
|
484
|
+
help="Optional JSONL log path. Defaults to agent-knowledge/Outputs/token-measurements/task-run-log.jsonl when available.",
|
|
485
|
+
)
|
|
486
|
+
summarize.add_argument("--report-file", help="Optional JSON report path.")
|
|
487
|
+
|
|
488
|
+
return parser
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def main() -> int:
|
|
492
|
+
parser = build_parser()
|
|
493
|
+
args = parser.parse_args()
|
|
494
|
+
args.json = bool(getattr(args, "json", False) or getattr(args, "json_global", False))
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
if args.command == "compare":
|
|
498
|
+
counter = TokenCounter(args.tokenizer)
|
|
499
|
+
payload = build_compare_payload(args, counter)
|
|
500
|
+
write_output(args.report_file, payload)
|
|
501
|
+
if args.json:
|
|
502
|
+
print(json_dump(payload))
|
|
503
|
+
else:
|
|
504
|
+
print_human_compare(payload)
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
if args.command == "log-run":
|
|
508
|
+
counter = TokenCounter(args.tokenizer)
|
|
509
|
+
entry, log_path = build_log_entry(args, counter)
|
|
510
|
+
append_jsonl(log_path, entry)
|
|
511
|
+
if args.json:
|
|
512
|
+
print(json_dump({"mode": "log-run", "log_file": str(log_path), "entry": entry}))
|
|
513
|
+
else:
|
|
514
|
+
print_human_log(entry, log_path)
|
|
515
|
+
return 0
|
|
516
|
+
|
|
517
|
+
if args.command == "summarize-log":
|
|
518
|
+
log_path = (
|
|
519
|
+
Path(args.log_file).expanduser()
|
|
520
|
+
if args.log_file
|
|
521
|
+
else resolve_measurement_dir(args.project) / "task-run-log.jsonl"
|
|
522
|
+
)
|
|
523
|
+
payload = summarize_log_entries(load_jsonl(log_path), args.task)
|
|
524
|
+
payload["log_file"] = str(log_path)
|
|
525
|
+
write_output(args.report_file, payload)
|
|
526
|
+
if args.json:
|
|
527
|
+
print(json_dump(payload))
|
|
528
|
+
else:
|
|
529
|
+
print_human_summary(payload, log_path)
|
|
530
|
+
return 0
|
|
531
|
+
|
|
532
|
+
parser.error("Unknown command.")
|
|
533
|
+
return 2
|
|
534
|
+
except Exception as exc: # pragma: no cover - CLI error path
|
|
535
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
536
|
+
return 1
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
if __name__ == "__main__":
|
|
540
|
+
raise SystemExit(main())
|