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.
Files changed (88) hide show
  1. agent_knowledge/__init__.py +3 -0
  2. agent_knowledge/__main__.py +3 -0
  3. agent_knowledge/assets/__init__.py +0 -0
  4. agent_knowledge/assets/claude/global.md +44 -0
  5. agent_knowledge/assets/claude/project-template.md +46 -0
  6. agent_knowledge/assets/claude/scripts/install.sh +85 -0
  7. agent_knowledge/assets/commands/doctor.md +21 -0
  8. agent_knowledge/assets/commands/global-knowledge-sync.md +27 -0
  9. agent_knowledge/assets/commands/graphify-sync.md +26 -0
  10. agent_knowledge/assets/commands/knowledge-sync.md +26 -0
  11. agent_knowledge/assets/commands/ship.md +29 -0
  12. agent_knowledge/assets/rules/generate-architecture-doc.mdc +87 -0
  13. agent_knowledge/assets/rules/history-backfill.mdc +67 -0
  14. agent_knowledge/assets/rules/memory-bootstrap.mdc +53 -0
  15. agent_knowledge/assets/rules/memory-writeback.mdc +90 -0
  16. agent_knowledge/assets/rules/shared-memory.mdc +102 -0
  17. agent_knowledge/assets/rules/workflow-orchestration.mdc +93 -0
  18. agent_knowledge/assets/rules-global/action-first.mdc +26 -0
  19. agent_knowledge/assets/rules-global/no-icons-emojis.mdc +16 -0
  20. agent_knowledge/assets/rules-global/no-unsolicited-docs.mdc +20 -0
  21. agent_knowledge/assets/scripts/bootstrap-memory-tree.sh +389 -0
  22. agent_knowledge/assets/scripts/compact-memory.sh +191 -0
  23. agent_knowledge/assets/scripts/doctor.sh +137 -0
  24. agent_knowledge/assets/scripts/global-knowledge-sync.sh +372 -0
  25. agent_knowledge/assets/scripts/graphify-sync.sh +397 -0
  26. agent_knowledge/assets/scripts/import-agent-history.sh +706 -0
  27. agent_knowledge/assets/scripts/install-project-links.sh +258 -0
  28. agent_knowledge/assets/scripts/lib/knowledge-common.sh +875 -0
  29. agent_knowledge/assets/scripts/measure-token-savings.py +540 -0
  30. agent_knowledge/assets/scripts/ship.sh +256 -0
  31. agent_knowledge/assets/scripts/update-knowledge.sh +341 -0
  32. agent_knowledge/assets/scripts/validate-knowledge.sh +265 -0
  33. agent_knowledge/assets/skills/decision-recording/SKILL.md +124 -0
  34. agent_knowledge/assets/skills/history-backfill/SKILL.md +115 -0
  35. agent_knowledge/assets/skills/memory-compaction/SKILL.md +115 -0
  36. agent_knowledge/assets/skills/memory-management/SKILL.md +134 -0
  37. agent_knowledge/assets/skills/project-ontology-bootstrap/SKILL.md +173 -0
  38. agent_knowledge/assets/skills/session-management/SKILL.md +116 -0
  39. agent_knowledge/assets/skills-cursor/create-rule/SKILL.md +164 -0
  40. agent_knowledge/assets/skills-cursor/create-skill/SKILL.md +498 -0
  41. agent_knowledge/assets/skills-cursor/create-subagent/SKILL.md +225 -0
  42. agent_knowledge/assets/skills-cursor/migrate-to-skills/SKILL.md +134 -0
  43. agent_knowledge/assets/skills-cursor/shell/SKILL.md +24 -0
  44. agent_knowledge/assets/skills-cursor/update-cursor-settings/SKILL.md +122 -0
  45. agent_knowledge/assets/templates/dashboards/project-overview.template.md +24 -0
  46. agent_knowledge/assets/templates/dashboards/session-rollup.template.md +23 -0
  47. agent_knowledge/assets/templates/hooks/hooks.json.template +11 -0
  48. agent_knowledge/assets/templates/integrations/claude/CLAUDE.md +7 -0
  49. agent_knowledge/assets/templates/integrations/codex/AGENTS.md +7 -0
  50. agent_knowledge/assets/templates/integrations/cursor/agent-knowledge.mdc +11 -0
  51. agent_knowledge/assets/templates/integrations/cursor/hooks.json +11 -0
  52. agent_knowledge/assets/templates/memory/MEMORY.root.template.md +36 -0
  53. agent_knowledge/assets/templates/memory/branch.template.md +33 -0
  54. agent_knowledge/assets/templates/memory/decision.template.md +33 -0
  55. agent_knowledge/assets/templates/memory/profile.hybrid.yaml +16 -0
  56. agent_knowledge/assets/templates/memory/profile.ml-platform.yaml +18 -0
  57. agent_knowledge/assets/templates/memory/profile.robotics.yaml +19 -0
  58. agent_knowledge/assets/templates/memory/profile.web-app.yaml +16 -0
  59. agent_knowledge/assets/templates/portfolio/.obsidian/README.md +21 -0
  60. agent_knowledge/assets/templates/portfolio/.obsidian/app.json +5 -0
  61. agent_knowledge/assets/templates/portfolio/.obsidian/core-plugins.json +7 -0
  62. agent_knowledge/assets/templates/project/.agent-project.yaml +36 -0
  63. agent_knowledge/assets/templates/project/.agentknowledgeignore +10 -0
  64. agent_knowledge/assets/templates/project/AGENTS.md +87 -0
  65. agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/README.md +23 -0
  66. agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/app.json +5 -0
  67. agent_knowledge/assets/templates/project/agent-knowledge/.obsidian/core-plugins.json +7 -0
  68. agent_knowledge/assets/templates/project/agent-knowledge/Evidence/README.md +34 -0
  69. agent_knowledge/assets/templates/project/agent-knowledge/Evidence/imports/README.md +29 -0
  70. agent_knowledge/assets/templates/project/agent-knowledge/Evidence/raw/README.md +25 -0
  71. agent_knowledge/assets/templates/project/agent-knowledge/Memory/MEMORY.md +37 -0
  72. agent_knowledge/assets/templates/project/agent-knowledge/Memory/decisions/decisions.md +31 -0
  73. agent_knowledge/assets/templates/project/agent-knowledge/Outputs/README.md +24 -0
  74. agent_knowledge/assets/templates/project/agent-knowledge/STATUS.md +43 -0
  75. agent_knowledge/assets/templates/project/agent-knowledge/Sessions/README.md +21 -0
  76. agent_knowledge/assets/templates/project/agent-knowledge/Templates/README.md +19 -0
  77. agent_knowledge/assets/templates/project/gitignore.agent-knowledge +13 -0
  78. agent_knowledge/cli.py +457 -0
  79. agent_knowledge/runtime/__init__.py +0 -0
  80. agent_knowledge/runtime/integrations.py +154 -0
  81. agent_knowledge/runtime/paths.py +46 -0
  82. agent_knowledge/runtime/shell.py +22 -0
  83. agent_knowledge/runtime/sync.py +255 -0
  84. agent_knowledge_cli-0.1.2.dist-info/METADATA +155 -0
  85. agent_knowledge_cli-0.1.2.dist-info/RECORD +88 -0
  86. agent_knowledge_cli-0.1.2.dist-info/WHEEL +4 -0
  87. agent_knowledge_cli-0.1.2.dist-info/entry_points.txt +2 -0
  88. 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())