Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAW_HUB_PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ The audit of 1.5.5 classified the optional scheduled task as cross-session persi

Codex's review of the 1.5.6 branch added: scheduled runs pinned to the consented manifest and flags (`run --scheduled`), DOCX and PDF inspected by `publish-check` (or reported unscannable), legacy credential-derived `accounts.json` redrafted under the recorded preference, placeholder accounts billed as unknown rather than subscription, report and dashboard narrative reflecting how accounts were drafted, `SHA256SUMS` covering optional package files, short host and organisation names still checked, and the scheduler lookup deferred until after consent.

The audit of 1.5.7 (one A.I.G warning, 26 SkillSpector findings) was split into issues #11 to #20 and resolved in 1.5.8: `study_url` validated to HTTPS and rendered through DOM APIs; prompt text capture an explicit opt-in (default off); the generic JSON sniffer refuses broad roots and skips credential-like files; narrowed trigger phrases and a Before You Start disclosure; authorization and minimisation boundaries in the discovery guide and templates; a sensitivity notice in every study package with paths redacted from `ledger.json` unless requested; private permissions for every generated run file; no catch-all attribution rules (unmatched calls are reported as unattributed); subprocess and data text cleaned before printing; one credential-file predicate applied to every archive path including legacy rows; WSL discovery default off; SSH transfers disclosed explicitly.

## Local Readiness Check

Run before publishing:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Part of the CompleteTech LLC agentic services skill library. Where the `agentic-
## OpenClaw / ClawHub Metadata

- Skill key: `ai-usage-ledger`
- Version-ready metadata: `1.5.7`
- Version-ready metadata: `1.5.8`
- Homepage: https://github.com/CompleteTech-LLC/ai-usage-ledger-skill
- README: https://github.com/CompleteTech-LLC/ai-usage-ledger-skill#readme
- Runtime binaries: `python3`
Expand Down
11 changes: 6 additions & 5 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: ai-usage-ledger
description: >-
Compile every locally recorded AI coding-agent model call (Claude Code, Codex CLI/Desktop, GitHub Copilot CLI, Gemini CLI, opencode, OpenClaw, Cline/Roo/Kilo, aider, Kimi Code, Mistral Vibe, Continue, pi, Codebuff and any JSON-logging tool) across the operator's own profile and, when enabled, other drives, user profiles, WSL distros and named SSH hosts into one append-only, de-duplicated ledger stored as SQLite, JSON or CSV; price it at list API rates; split it by subscription account and billing plan; validate it against the tools' own counters; and render a branded light/dark dashboard plus a research-grade study package. Onboards once (branding, theme, storage backend, scheduled refresh, anonymised publication copy; hosts and accounts auto-detected per OS), remembers the preferences, refreshes with one command or on a schedule, renders fifteen ledger documents (statements, memos, briefs, billing evidence) as Markdown, HTML, PDF and DOCX, can anonymise everything for publication, optionally archives the raw log files themselves so they outlive the tools' retention, and answers detailed questions (per day, project, account, model or session, and inside prompt text or the archived logs when those opt-ins are on) through a query layer over the accumulated store. Disclosure: this skill reads agent transcripts and tool databases on this machine and on hosts the operator names, keeps a durable local ledger under ~/.ai-usage-ledger and the chosen working directory, and captures prompt text or credential-derived account claims only when the operator opts in. Use when asked to "find all my AI logs", "how many tokens did I use", "what would this have cost on the API", "compare my subscriptions", "usage by account", "cache savings", "set up my usage ledger", "monthly usage statement", "publish my usage anonymously", or to refresh an existing ledger.
version: 1.5.7
version: 1.5.8
metadata:
openclaw:
skillKey: ai-usage-ledger
Expand Down Expand Up @@ -39,10 +39,11 @@ Relay this table to the operator, in chat or on the terminal, before the first o
| Opt-in: raw-log archive | `archive.raw_logs` (default off). On, every source file the scanners read is copied to `~/.ai-usage-ledger/archive/` (about the size of the tools' log trees, one tenth compressed) so it outlives the tools' retention and `query logs` can search it. |
| Opt-in: other profiles and drives | `detect.all_profiles` (default off). On, every `Users\<name>` / `/home/<name>` / `/Users/<name>` on every mounted drive is scanned; only profiles the operator owns or is authorised to audit, see `references/discovery.md` Boundaries. |
| Opt-in: WSL distros | `detect.wsl` (default off). On, `wsl.exe -l -q` lists the distros and each `/home/<name>` inside them is scanned. |
| Opt-in: SSH hosts | Only hosts the operator names at the prompt or in `config.json` `extra_hosts`; `ssh` and `scp` run fixed commands on those hosts, never on anything discovered. |
| Opt-in: SSH hosts | Only hosts the operator names at the prompt or in `config.json` `extra_hosts`. For each such host the skill uploads `compile_ai_logs.py` with `scp`, runs it there over `ssh`, and downloads the scan output (events, sessions, prompts if captured, inventory) and, when configured, copies of `state_5.sqlite` / `stats-cache.json` and, with the raw-log archive on, the transcript files themselves. Nothing else crosses the network. |
| Opt-in: scheduled refresh | Never registered by onboarding or by a configuration key; only `schedule install`, which prints the full disclosure and needs a typed `yes` (and `yes, I understand` for high-impact schedules) from the operator, not from the agent. |
| Leaving the machine | Nothing leaves through this skill. For anything to be shared, build the anonymised copy (`run --anonymize`, `doc --anonymize`) and run `ledger.py publish-check <dir>` on it first. |
| How to remove everything | `python3 scripts/ledger.py schedule remove` (if a schedule exists), then delete `~/.ai-usage-ledger/` (or `$AI_USAGE_LEDGER_HOME`) and the generated directories under the working directory. `python3 scripts/ledger.py reinit` starts over with a fresh configuration and keeps the previous store beside the new one with a timestamp. |
| Leaving the machine | Apart from the SSH transfers above, nothing leaves through this skill and no service is contacted. For anything to be shared, build the anonymised copy (`run --anonymize`, `doc --anonymize`) and run `ledger.py publish-check <dir>` on it first. |
| Coverage limits | Detection reports the roots it found and the same-volume duplicates it skipped; a directory it could not read or a tool it does not know is not listed. Compare each host's inventory (`files`, `bytes` per root) with a native count when completeness matters (see `references/pitfalls.md` 17). |
| How to remove everything | `python3 scripts/ledger.py schedule remove` (if a schedule exists), then delete `~/.ai-usage-ledger/` (or `$AI_USAGE_LEDGER_HOME`) and the generated directories under the working directory. `python3 scripts/ledger.py reinit` starts over with a fresh configuration and keeps the previous store beside the new one with a timestamp. If `archive.path` was set outside the ledger home, delete that directory too. |
Comment thread
CompleteDotTech marked this conversation as resolved.

## System Boundary

Expand Down Expand Up @@ -278,7 +279,7 @@ Accounts in `query` come from the same `accounts.json` rules the report uses (`a

| Boundary | Requirement |
|---|---|
| Local-only runtime | The scripts make no outbound network calls; SSH and WSL are used only for hosts the operator names in the manifest. |
| Local-only runtime | The scripts contact no service. The only network traffic is `ssh` / `scp` to hosts the operator names in the manifest: the scanner is uploaded, scan outputs (and, when configured, tool counters or archived transcripts) are downloaded. WSL distros are reached through `wsl.exe` or the `\\wsl$` share only when `detect.wsl` is on. |
| Generated pages | The dashboard, study and documents are self-contained: no external fonts, images or stylesheets, and a `Content-Security-Policy` meta tag that forbids network loads. A remote logo or Google Fonts stylesheet is used only when `branding.allow_external_resources` is true. |
| Publishing | Dashboards and study packages are published only when the operator asks (Artifacts, files, or a repository). |
| Data | Scanned transcripts and the compiled ledger never leave the operator's machines through this skill. |
4 changes: 2 additions & 2 deletions assets/examples/example-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Storage backend (sqlite / json / csv) [sqlite]: sqlite
Working directory for scans, compiled tables and reports [~/.ai-usage-ledger/ledger]:
Timezone for time-of-day analysis [UTC]: America/New_York
Also scan other user profiles and other drives on this machine? (y/n) [n]: y
Probe WSL distros on Windows? (y/n) [y]: y
Probe WSL distros on Windows? (y/n) [n]: y
Read credential files (auth.json, .claude.json) to label accounts with their plan? Tokens are never kept. (y/n) [n]: y
Keep e-mail addresses and organisation names in accounts.json? (n = pseudonymous account ids only) (y/n) [n]: n
Also build an anonymised copy for publication on every run? (y/n) [n]: y
Expand Down Expand Up @@ -59,7 +59,7 @@ The saved preferences (`~/.ai-usage-ledger/config.json`, salt omitted):
```json
{
"version": 1,
"initialized_at": "2026-09-10T03:58:13+00:00",
"initialized_at": "2026-09-10T04:08:00+00:00",
"home": "~/.ai-usage-ledger",
"store": {
"kind": "sqlite",
Expand Down
5 changes: 4 additions & 1 deletion references/discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ import json, base64
d = json.load(open(path)); t = d.get("tokens", {}); tok = t.get("id_token") or t.get("access_token")
p = tok.split(".")[1]; p += "=" * (-len(p) % 4); c = json.loads(base64.urlsafe_b64decode(p))
auth = c.get("https://api.openai.com/auth", {}); prof = c.get("https://api.openai.com/profile", {})
print(prof.get("email"), auth.get("chatgpt_plan_type"), auth.get("chatgpt_account_id")[:8], [o.get("title") for o in auth.get("organizations", [])], d.get("last_refresh"))
identifiable = False # set True only when accounts.identifiable is on; otherwise keep the check pseudonymous
print(auth.get("chatgpt_plan_type"), (auth.get("chatgpt_account_id") or "")[:8], d.get("last_refresh"))
if identifiable:
print(prof.get("email"), [o.get("title") for o in auth.get("organizations", [])])
```

Claude: `~/.claude.json` → `oauthAccount.emailAddress`, `organizationType`, `organizationRateLimitTier`, `subscriptionCreatedAt`; `~/.claude/.credentials.json` → `claudeAiOauth.subscriptionType`, `rateLimitTier`.
Expand Down
4 changes: 2 additions & 2 deletions scripts/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def default_config():
"version": CONFIG_VERSION, "initialized_at": None, "home": home_dir(),
"store": {"kind": "sqlite", "path": os.path.join(home_dir(), "ledger.sqlite")},
"workdir": os.path.join(home_dir(), "ledger"), "timezone": local_tz_name(), "theme": "system",
"branding": dict(DEFAULT_BRAND), "detect": {"all_profiles": False, "wsl": True, "on_every_run": True},
"branding": dict(DEFAULT_BRAND), "detect": {"all_profiles": False, "wsl": False, "on_every_run": True},
"pricing_path": os.path.join(home_dir(), "pricing.json"), "accounts_path": os.path.join(home_dir(), "accounts.json"),
"report_config_path": os.path.join(home_dir(), "report_config.json"), "manifest_path": os.path.join(home_dir(), "manifest.json"),
"package_prefix": "AI_Usage_Ledger", "extra_hosts": [],
Expand Down Expand Up @@ -233,7 +233,7 @@ def migrate_config(cfg):
cfg.setdefault("prompts", {"capture": False}) # pre-1.5.8 home: prompt text was never consented to, so it stays off
cfg.setdefault("archive", {"raw_logs": False, "compress": True, "path": os.path.join(home_dir(), "archive")})
cfg.setdefault("schedule", {"frequency": "none", "time": "03:00", "weekday": "mon", "installed_at": None})
cfg.setdefault("detect", {"all_profiles": False, "wsl": True, "on_every_run": True})
cfg.setdefault("detect", {"all_profiles": False, "wsl": False, "on_every_run": True})
Comment thread
CompleteDotTech marked this conversation as resolved.
cfg.setdefault("branding", dict(DEFAULT_BRAND))
cfg.setdefault("extra_hosts", [])
return cfg
Expand Down
20 changes: 19 additions & 1 deletion scripts/ledger_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,24 @@ def list(self, host=None, grep_path=None):
for row in self.db.execute(q + " ORDER BY host, path", args):
if grep_path and grep_path.lower() not in row[1].lower():
continue
if safety.is_credential_file(row[1]): # a row archived before the exclusion existed is never listed, searched or restored
continue
yield {"host": row[0], "path": row[1], "size": row[2], "mtime": datetime.fromtimestamp(row[3]).isoformat(timespec="seconds"), "archived_at": row[4], "versions": row[5]}

def purge_credential_rows(self):
"""Remove index rows (and their stored files) that match the credential predicate; returns how many."""
rows = [(h, p) for h, p in self.db.execute("SELECT host, path FROM files") if safety.is_credential_file(p)]
for h, p in rows:
try:
dest, _ = self.dest_for(h, p)
if os.path.isfile(dest):
os.remove(dest)
except Exception:
pass
self.db.execute("DELETE FROM files WHERE host=? AND path=?", (h, p))
Comment thread
CompleteDotTech marked this conversation as resolved.
self.db.commit()
return len(rows)

def open(self, host, path):
dest, _ = self.dest_for(host, path)
return gzip.open(dest, "rb") if self.compress else open(dest, "rb")
Expand Down Expand Up @@ -414,7 +430,9 @@ def main():
a = ap.parse_args()
ar = Archive(a.archive, compress=not a.no_compress)
if a.cmd == "status":
print(json.dumps(ar.status(), indent=2))
st = ar.status()
st["credential_rows_purged"] = ar.purge_credential_rows()
Comment thread
CompleteDotTech marked this conversation as resolved.
print(json.dumps(st, indent=2))
elif a.cmd == "list":
for f in ar.list(a.host, a.grep_path):
print("%-14s %10d %s v%d %s" % (f["host"], f["size"], f["mtime"], f["versions"], f["path"]))
Expand Down
2 changes: 2 additions & 0 deletions templates/manifest.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"accounts": "accounts.json",
"report_config": "report_config.json",
"package_accounts": false,
"capture_prompts": false,
"package_paths": false,
"study_url": "",
"hosts": [
{
Expand Down
Loading