codex-usage-tracking 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. codex_usage_tracker/__init__.py +7 -0
  2. codex_usage_tracker/__main__.py +6 -0
  3. codex_usage_tracker/allowance.py +759 -0
  4. codex_usage_tracker/api_payloads.py +90 -0
  5. codex_usage_tracker/cli.py +1326 -0
  6. codex_usage_tracker/context.py +410 -0
  7. codex_usage_tracker/costing.py +176 -0
  8. codex_usage_tracker/dashboard.py +389 -0
  9. codex_usage_tracker/diagnostics.py +624 -0
  10. codex_usage_tracker/formatting.py +225 -0
  11. codex_usage_tracker/json_contracts.py +350 -0
  12. codex_usage_tracker/mcp_server.py +371 -0
  13. codex_usage_tracker/models.py +92 -0
  14. codex_usage_tracker/parser.py +491 -0
  15. codex_usage_tracker/paths.py +18 -0
  16. codex_usage_tracker/plugin_data/__init__.py +1 -0
  17. codex_usage_tracker/plugin_data/assets/icon.svg +8 -0
  18. codex_usage_tracker/plugin_data/dashboard/dashboard.css +954 -0
  19. codex_usage_tracker/plugin_data/dashboard/dashboard.js +1833 -0
  20. codex_usage_tracker/plugin_data/dashboard/dashboard_data.js +155 -0
  21. codex_usage_tracker/plugin_data/dashboard/dashboard_format.js +132 -0
  22. codex_usage_tracker/plugin_data/dashboard/dashboard_state.js +157 -0
  23. codex_usage_tracker/plugin_data/dashboard/dashboard_template.html +141 -0
  24. codex_usage_tracker/plugin_data/docs/assets/dashboard-calls.png +0 -0
  25. codex_usage_tracker/plugin_data/docs/assets/dashboard-details.png +0 -0
  26. codex_usage_tracker/plugin_data/docs/assets/dashboard-insights.png +0 -0
  27. codex_usage_tracker/plugin_data/docs/assets/dashboard-threads.png +0 -0
  28. codex_usage_tracker/plugin_data/docs/dashboard-guide.html +136 -0
  29. codex_usage_tracker/plugin_data/rate_cards/codex-credit-rates.json +69 -0
  30. codex_usage_tracker/plugin_data/skills/codex-usage-api/SKILL.md +62 -0
  31. codex_usage_tracker/plugin_data/skills/codex-usage-tracker/SKILL.md +47 -0
  32. codex_usage_tracker/plugin_installer.py +312 -0
  33. codex_usage_tracker/pricing.py +57 -0
  34. codex_usage_tracker/pricing_config.py +223 -0
  35. codex_usage_tracker/pricing_estimates.py +44 -0
  36. codex_usage_tracker/pricing_openai.py +253 -0
  37. codex_usage_tracker/projects.py +347 -0
  38. codex_usage_tracker/recommendations.py +270 -0
  39. codex_usage_tracker/reports.py +637 -0
  40. codex_usage_tracker/schema.py +71 -0
  41. codex_usage_tracker/server.py +400 -0
  42. codex_usage_tracker/store.py +666 -0
  43. codex_usage_tracker/support.py +147 -0
  44. codex_usage_tracker/threads.py +183 -0
  45. codex_usage_tracking-0.3.0.dist-info/METADATA +278 -0
  46. codex_usage_tracking-0.3.0.dist-info/RECORD +50 -0
  47. codex_usage_tracking-0.3.0.dist-info/WHEEL +5 -0
  48. codex_usage_tracking-0.3.0.dist-info/entry_points.txt +2 -0
  49. codex_usage_tracking-0.3.0.dist-info/licenses/LICENSE +21 -0
  50. codex_usage_tracking-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,491 @@
1
+ """Parse Codex JSONL session logs into aggregate usage records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from collections.abc import Iterable, MutableMapping
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from codex_usage_tracker.models import SessionInfo, UsageEvent
14
+ from codex_usage_tracker.paths import DEFAULT_CODEX_HOME
15
+
16
+ SESSION_ID_RE = re.compile(
17
+ r"rollout-[^-]+-[0-9T:-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$"
18
+ )
19
+
20
+ PARSER_ADAPTER_VERSION = "codex-jsonl-v1"
21
+ PARSER_DIAGNOSTIC_KEYS = (
22
+ "invalid_json",
23
+ "missing_payload",
24
+ "unknown_filename_format",
25
+ "unknown_event_shape",
26
+ "missing_info",
27
+ "missing_last_token_usage",
28
+ "missing_total_token_usage",
29
+ "missing_cumulative_total",
30
+ "duplicate_cumulative_total",
31
+ "invalid_integer",
32
+ "partial_field_count",
33
+ "invalid_model_context_window",
34
+ "skipped_events",
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ParserAdapter:
40
+ """Versioned parser adapter for one Codex log format family."""
41
+
42
+ version: str = PARSER_ADAPTER_VERSION
43
+
44
+ def parse_file(
45
+ self,
46
+ path: Path,
47
+ session_index: dict[str, SessionInfo] | None = None,
48
+ stats: MutableMapping[str, int] | None = None,
49
+ ) -> list[UsageEvent]:
50
+ return _parse_codex_jsonl_v1(path, session_index=session_index, stats=stats)
51
+
52
+
53
+ DEFAULT_PARSER_ADAPTER = ParserAdapter()
54
+
55
+
56
+ def load_session_index(codex_home: Path = DEFAULT_CODEX_HOME) -> dict[str, SessionInfo]:
57
+ """Load Codex thread names without reading transcript content."""
58
+
59
+ index_path = codex_home / "session_index.jsonl"
60
+ sessions: dict[str, SessionInfo] = {}
61
+ if not index_path.exists():
62
+ return sessions
63
+
64
+ with index_path.open("r", encoding="utf-8") as handle:
65
+ for line in handle:
66
+ try:
67
+ payload = json.loads(line)
68
+ except json.JSONDecodeError:
69
+ continue
70
+ session_id = payload.get("id")
71
+ if not isinstance(session_id, str):
72
+ continue
73
+ sessions[session_id] = SessionInfo(
74
+ session_id=session_id,
75
+ thread_name=_optional_str(payload.get("thread_name")),
76
+ updated_at=_optional_str(payload.get("updated_at")),
77
+ )
78
+ return sessions
79
+
80
+
81
+ def find_session_logs(
82
+ codex_home: Path = DEFAULT_CODEX_HOME, include_archived: bool = False
83
+ ) -> list[Path]:
84
+ """Find local Codex JSONL logs."""
85
+
86
+ paths = list((codex_home / "sessions").glob("**/*.jsonl"))
87
+ if include_archived:
88
+ paths.extend((codex_home / "archived_sessions").glob("*.jsonl"))
89
+ return sorted(path for path in paths if path.is_file())
90
+
91
+
92
+ def parse_usage_events(
93
+ paths: Iterable[Path],
94
+ session_index: dict[str, SessionInfo] | None = None,
95
+ stats: MutableMapping[str, int] | None = None,
96
+ ) -> list[UsageEvent]:
97
+ """Parse all provided logs into aggregate usage events."""
98
+
99
+ index = session_index or {}
100
+ events: list[UsageEvent] = []
101
+ for path in paths:
102
+ events.extend(parse_usage_events_from_file(path, index, stats=stats))
103
+ return events
104
+
105
+
106
+ def parse_usage_events_from_file(
107
+ path: Path,
108
+ session_index: dict[str, SessionInfo] | None = None,
109
+ stats: MutableMapping[str, int] | None = None,
110
+ ) -> list[UsageEvent]:
111
+ """Parse one Codex JSONL log without storing raw message content."""
112
+
113
+ return DEFAULT_PARSER_ADAPTER.parse_file(path, session_index=session_index, stats=stats)
114
+
115
+
116
+ def inspect_log(
117
+ path: Path,
118
+ session_index: dict[str, SessionInfo] | None = None,
119
+ ) -> dict[str, object]:
120
+ """Return aggregate-only parser observations for one log without DB writes."""
121
+
122
+ stats = empty_parser_diagnostics()
123
+ events = parse_usage_events_from_file(path, session_index=session_index, stats=stats)
124
+ session_ids = sorted({event.session_id for event in events})
125
+ models = sorted({event.model for event in events if event.model})
126
+ efforts = sorted({event.effort for event in events if event.effort})
127
+ first_event = events[0] if events else None
128
+ last_event = events[-1] if events else None
129
+ return {
130
+ "path": str(path),
131
+ "adapter": DEFAULT_PARSER_ADAPTER.version,
132
+ "file_session_id": _session_id_from_path(path),
133
+ "event_count": len(events),
134
+ "session_ids": session_ids,
135
+ "models": models,
136
+ "efforts": efforts,
137
+ "first_event_timestamp": first_event.event_timestamp if first_event else None,
138
+ "last_event_timestamp": last_event.event_timestamp if last_event else None,
139
+ "diagnostics": compact_parser_diagnostics(stats),
140
+ "events": [
141
+ {
142
+ "record_id": event.record_id,
143
+ "line_number": event.line_number,
144
+ "event_timestamp": event.event_timestamp,
145
+ "session_id": event.session_id,
146
+ "turn_id": event.turn_id,
147
+ "model": event.model,
148
+ "effort": event.effort,
149
+ "input_tokens": event.input_tokens,
150
+ "cached_input_tokens": event.cached_input_tokens,
151
+ "uncached_input_tokens": event.uncached_input_tokens,
152
+ "output_tokens": event.output_tokens,
153
+ "reasoning_output_tokens": event.reasoning_output_tokens,
154
+ "total_tokens": event.total_tokens,
155
+ "cumulative_total_tokens": event.cumulative_total_tokens,
156
+ }
157
+ for event in events
158
+ ],
159
+ }
160
+
161
+
162
+ def empty_parser_diagnostics() -> dict[str, int]:
163
+ """Return all parser diagnostic counters initialized to zero."""
164
+
165
+ return {key: 0 for key in PARSER_DIAGNOSTIC_KEYS}
166
+
167
+
168
+ def compact_parser_diagnostics(stats: MutableMapping[str, int]) -> dict[str, int]:
169
+ """Return non-zero parser diagnostics in stable key order."""
170
+
171
+ return {key: int(stats.get(key, 0)) for key in PARSER_DIAGNOSTIC_KEYS if stats.get(key, 0)}
172
+
173
+
174
+ def _parse_codex_jsonl_v1(
175
+ path: Path,
176
+ session_index: dict[str, SessionInfo] | None = None,
177
+ stats: MutableMapping[str, int] | None = None,
178
+ ) -> list[UsageEvent]:
179
+ """Parse one Codex JSONL v1 log without storing raw message content."""
180
+
181
+ index = session_index or {}
182
+ file_session_id = _session_id_from_path(path)
183
+ if not file_session_id:
184
+ _increment_stat(stats, "unknown_filename_format")
185
+ session_id = file_session_id
186
+ session_info = index.get(session_id) if session_id else None
187
+ current_turn: dict[str, Any] = {}
188
+ session_meta: dict[str, str | None] = {}
189
+ last_cumulative_total = -1
190
+ events: list[UsageEvent] = []
191
+
192
+ with path.open("r", encoding="utf-8") as handle:
193
+ for line_number, line in enumerate(handle, 1):
194
+ try:
195
+ envelope = json.loads(line)
196
+ except json.JSONDecodeError:
197
+ _increment_stat(stats, "invalid_json")
198
+ continue
199
+
200
+ payload = envelope.get("payload")
201
+ if not isinstance(payload, dict):
202
+ _increment_stat(stats, "missing_payload")
203
+ continue
204
+
205
+ entry_type = envelope.get("type")
206
+ timestamp = _optional_str(envelope.get("timestamp")) or ""
207
+
208
+ if entry_type == "session_meta":
209
+ if not session_id:
210
+ session_id = _optional_str(payload.get("id"))
211
+ session_info = index.get(session_id or "")
212
+ session_meta = _session_metadata(payload, index)
213
+ continue
214
+
215
+ if entry_type == "turn_context":
216
+ current_turn = {
217
+ "turn_id": _optional_str(payload.get("turn_id")),
218
+ "turn_timestamp": timestamp,
219
+ "cwd": _optional_str(payload.get("cwd")),
220
+ "model": _optional_str(payload.get("model")),
221
+ "effort": _optional_str(payload.get("effort")),
222
+ "current_date": _optional_str(payload.get("current_date")),
223
+ "timezone": _optional_str(payload.get("timezone")),
224
+ }
225
+ continue
226
+
227
+ if entry_type != "event_msg" or payload.get("type") != "token_count":
228
+ if entry_type == "event_msg":
229
+ _increment_stat(stats, "unknown_event_shape")
230
+ continue
231
+
232
+ info = payload.get("info")
233
+ if not isinstance(info, dict):
234
+ _increment_stat(stats, "missing_info")
235
+ continue
236
+
237
+ total_usage = info.get("total_token_usage")
238
+ last_usage = info.get("last_token_usage")
239
+ if not isinstance(total_usage, dict):
240
+ _increment_stat(stats, "missing_total_token_usage")
241
+ _increment_stat(stats, "skipped_events")
242
+ continue
243
+ if not isinstance(last_usage, dict):
244
+ _increment_stat(stats, "missing_last_token_usage")
245
+ _increment_stat(stats, "skipped_events")
246
+ continue
247
+
248
+ try:
249
+ cumulative_total = _required_usage_int(
250
+ total_usage,
251
+ "total_tokens",
252
+ stats=stats,
253
+ missing_key="missing_cumulative_total",
254
+ )
255
+ except ValueError:
256
+ _increment_stat(stats, "skipped_events")
257
+ continue
258
+ if cumulative_total <= last_cumulative_total:
259
+ _increment_stat(stats, "duplicate_cumulative_total")
260
+ continue
261
+
262
+ effective_session_id = session_id or "unknown"
263
+ session_info = session_info or index.get(effective_session_id)
264
+ try:
265
+ event = _build_event(
266
+ path=path,
267
+ line_number=line_number,
268
+ event_timestamp=timestamp,
269
+ session_id=effective_session_id,
270
+ session_info=session_info,
271
+ session_meta=session_meta,
272
+ current_turn=current_turn,
273
+ model_context_window=_nullable_int(
274
+ info.get("model_context_window"),
275
+ stats=stats,
276
+ invalid_key="invalid_model_context_window",
277
+ ),
278
+ last_usage=last_usage,
279
+ total_usage=total_usage,
280
+ stats=stats,
281
+ )
282
+ except ValueError:
283
+ _increment_stat(stats, "skipped_events")
284
+ continue
285
+ last_cumulative_total = cumulative_total
286
+ events.append(event)
287
+
288
+ return events
289
+
290
+
291
+ def _build_event(
292
+ path: Path,
293
+ line_number: int,
294
+ event_timestamp: str,
295
+ session_id: str,
296
+ session_info: SessionInfo | None,
297
+ session_meta: dict[str, str | None],
298
+ current_turn: dict[str, Any],
299
+ model_context_window: int | None,
300
+ last_usage: dict[str, Any],
301
+ total_usage: dict[str, Any],
302
+ stats: MutableMapping[str, int] | None = None,
303
+ ) -> UsageEvent:
304
+ input_tokens = _required_usage_int(last_usage, "input_tokens", stats=stats)
305
+ cached_input_tokens = _required_usage_int(last_usage, "cached_input_tokens", stats=stats)
306
+ output_tokens = _required_usage_int(last_usage, "output_tokens", stats=stats)
307
+ reasoning_output_tokens = _required_usage_int(
308
+ last_usage, "reasoning_output_tokens", stats=stats
309
+ )
310
+ total_tokens = _required_usage_int(last_usage, "total_tokens", stats=stats)
311
+ cumulative_total_tokens = _required_usage_int(
312
+ total_usage,
313
+ "total_tokens",
314
+ stats=stats,
315
+ missing_key="missing_cumulative_total",
316
+ )
317
+ record_id = _record_id(
318
+ session_id=session_id,
319
+ turn_id=_optional_str(current_turn.get("turn_id")),
320
+ event_timestamp=event_timestamp,
321
+ cumulative_total_tokens=cumulative_total_tokens,
322
+ total_tokens=total_tokens,
323
+ )
324
+ return UsageEvent(
325
+ record_id=record_id,
326
+ session_id=session_id,
327
+ thread_name=session_info.thread_name if session_info else None,
328
+ session_updated_at=session_info.updated_at if session_info else None,
329
+ event_timestamp=event_timestamp,
330
+ source_file=str(path),
331
+ line_number=line_number,
332
+ turn_id=_optional_str(current_turn.get("turn_id")),
333
+ turn_timestamp=_optional_str(current_turn.get("turn_timestamp")),
334
+ cwd=_optional_str(current_turn.get("cwd")),
335
+ model=_optional_str(current_turn.get("model")),
336
+ effort=_optional_str(current_turn.get("effort")),
337
+ current_date=_optional_str(current_turn.get("current_date")),
338
+ timezone=_optional_str(current_turn.get("timezone")),
339
+ thread_source=session_meta.get("thread_source"),
340
+ subagent_type=session_meta.get("subagent_type"),
341
+ agent_role=session_meta.get("agent_role"),
342
+ agent_nickname=session_meta.get("agent_nickname"),
343
+ parent_session_id=session_meta.get("parent_session_id"),
344
+ parent_thread_name=session_meta.get("parent_thread_name"),
345
+ parent_session_updated_at=session_meta.get("parent_session_updated_at"),
346
+ model_context_window=model_context_window,
347
+ input_tokens=input_tokens,
348
+ cached_input_tokens=cached_input_tokens,
349
+ output_tokens=output_tokens,
350
+ reasoning_output_tokens=reasoning_output_tokens,
351
+ total_tokens=total_tokens,
352
+ cumulative_input_tokens=_required_usage_int(total_usage, "input_tokens", stats=stats),
353
+ cumulative_cached_input_tokens=_required_usage_int(
354
+ total_usage, "cached_input_tokens", stats=stats
355
+ ),
356
+ cumulative_output_tokens=_required_usage_int(total_usage, "output_tokens", stats=stats),
357
+ cumulative_reasoning_output_tokens=_required_usage_int(
358
+ total_usage, "reasoning_output_tokens", stats=stats
359
+ ),
360
+ cumulative_total_tokens=cumulative_total_tokens,
361
+ )
362
+
363
+
364
+ def _session_metadata(
365
+ payload: dict[str, Any],
366
+ session_index: dict[str, SessionInfo],
367
+ ) -> dict[str, str | None]:
368
+ source = payload.get("source")
369
+ metadata: dict[str, str | None] = {
370
+ "thread_source": _optional_str(payload.get("thread_source")),
371
+ "subagent_type": None,
372
+ "agent_role": None,
373
+ "agent_nickname": None,
374
+ "parent_session_id": None,
375
+ "parent_thread_name": None,
376
+ "parent_session_updated_at": None,
377
+ }
378
+ if not isinstance(source, dict):
379
+ return metadata
380
+
381
+ subagent = source.get("subagent")
382
+ if not isinstance(subagent, dict):
383
+ return metadata
384
+
385
+ other = _optional_str(subagent.get("other"))
386
+ if other:
387
+ metadata["subagent_type"] = other
388
+ return metadata
389
+
390
+ thread_spawn = subagent.get("thread_spawn")
391
+ if isinstance(thread_spawn, dict):
392
+ metadata["subagent_type"] = "thread_spawn"
393
+ metadata["agent_role"] = _optional_str(thread_spawn.get("agent_role"))
394
+ metadata["agent_nickname"] = _optional_str(thread_spawn.get("agent_nickname"))
395
+ parent_session_id = _optional_str(thread_spawn.get("parent_thread_id"))
396
+ metadata["parent_session_id"] = parent_session_id
397
+ if parent_session_id:
398
+ parent_info = session_index.get(parent_session_id)
399
+ if parent_info:
400
+ metadata["parent_thread_name"] = parent_info.thread_name
401
+ metadata["parent_session_updated_at"] = parent_info.updated_at
402
+ return metadata
403
+
404
+
405
+ def _record_id(
406
+ session_id: str,
407
+ turn_id: str | None,
408
+ event_timestamp: str,
409
+ cumulative_total_tokens: int,
410
+ total_tokens: int,
411
+ ) -> str:
412
+ raw = "|".join(
413
+ [
414
+ session_id,
415
+ turn_id or "",
416
+ event_timestamp,
417
+ str(cumulative_total_tokens),
418
+ str(total_tokens),
419
+ ]
420
+ )
421
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
422
+
423
+
424
+ def _session_id_from_path(path: Path) -> str | None:
425
+ match = SESSION_ID_RE.search(path.name)
426
+ if not match:
427
+ return None
428
+ return match.group(1)
429
+
430
+
431
+ def _optional_str(value: object) -> str | None:
432
+ if isinstance(value, str):
433
+ return value
434
+ return None
435
+
436
+
437
+ def _nullable_int(
438
+ value: object,
439
+ *,
440
+ stats: MutableMapping[str, int] | None = None,
441
+ invalid_key: str = "partial_field_count",
442
+ ) -> int | None:
443
+ if value is None:
444
+ return None
445
+ try:
446
+ return _strict_int(value)
447
+ except ValueError:
448
+ _increment_stat(stats, invalid_key)
449
+ if invalid_key != "partial_field_count":
450
+ _increment_stat(stats, "partial_field_count")
451
+ return None
452
+
453
+
454
+ def _strict_int(value: object) -> int:
455
+ if isinstance(value, bool):
456
+ raise ValueError(f"invalid integer value: {value!r}")
457
+ if isinstance(value, int):
458
+ return value
459
+ if isinstance(value, float):
460
+ raise ValueError(f"invalid integer value: {value!r}")
461
+ if isinstance(value, str) and value.strip():
462
+ try:
463
+ return int(value)
464
+ except ValueError as exc:
465
+ raise ValueError(f"invalid integer value: {value!r}") from exc
466
+ raise ValueError(f"invalid integer value: {value!r}")
467
+
468
+
469
+ def _required_usage_int(
470
+ values: dict[str, Any],
471
+ key: str,
472
+ *,
473
+ stats: MutableMapping[str, int] | None = None,
474
+ missing_key: str = "partial_field_count",
475
+ ) -> int:
476
+ if key not in values or values.get(key) is None:
477
+ _increment_stat(stats, missing_key)
478
+ if missing_key != "partial_field_count":
479
+ _increment_stat(stats, "partial_field_count")
480
+ raise ValueError(f"missing required integer field: {key}")
481
+ try:
482
+ return _strict_int(values.get(key))
483
+ except ValueError:
484
+ _increment_stat(stats, "invalid_integer")
485
+ _increment_stat(stats, "partial_field_count")
486
+ raise
487
+
488
+
489
+ def _increment_stat(stats: MutableMapping[str, int] | None, key: str) -> None:
490
+ if stats is not None:
491
+ stats[key] = stats.get(key, 0) + 1
@@ -0,0 +1,18 @@
1
+ """Shared filesystem defaults for local Codex usage tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ APP_DIR = Path.home() / ".codex-usage-tracker"
8
+ DEFAULT_DB_PATH = APP_DIR / "usage.sqlite3"
9
+ DEFAULT_DASHBOARD_PATH = APP_DIR / "dashboard.html"
10
+ DEFAULT_SUPPORT_BUNDLE_PATH = APP_DIR / "support-bundle.json"
11
+ DEFAULT_PRICING_PATH = APP_DIR / "pricing.json"
12
+ DEFAULT_ALLOWANCE_PATH = APP_DIR / "allowance.json"
13
+ DEFAULT_RATE_CARD_PATH = APP_DIR / "rate-card.json"
14
+ DEFAULT_THRESHOLDS_PATH = APP_DIR / "thresholds.json"
15
+ DEFAULT_PROJECTS_PATH = APP_DIR / "projects.json"
16
+ DEFAULT_CODEX_HOME = Path.home() / ".codex"
17
+ DEFAULT_PLUGIN_LINK = Path.home() / "plugins" / "codex-usage-tracker"
18
+ DEFAULT_MARKETPLACE_PATH = Path.home() / ".agents" / "plugins" / "marketplace.json"
@@ -0,0 +1 @@
1
+ """Package data used to generate the local Codex plugin wrapper."""
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Codex Usage Tracker">
2
+ <rect width="64" height="64" rx="12" fill="#2563EB"/>
3
+ <path d="M14 44h36" stroke="#DBEAFE" stroke-width="4" stroke-linecap="round"/>
4
+ <path d="M18 38V24M30 38V16M42 38V28" stroke="#FFFFFF" stroke-width="6" stroke-linecap="round"/>
5
+ <circle cx="18" cy="24" r="4" fill="#BFDBFE"/>
6
+ <circle cx="30" cy="16" r="4" fill="#BFDBFE"/>
7
+ <circle cx="42" cy="28" r="4" fill="#BFDBFE"/>
8
+ </svg>