Compare commits

...
Author SHA1 Message Date
time 268b0ee626 card #160: kept .github/time/test_time.py (generated)
heartbeat / tick (pull_request_review) Canceled after 0s
ci / test (pull_request) Successful in 36s
2026-10-19 15:10:47 +00:00
time 16d7a8993c card #160: kept .github/time/test_time.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' s = open("test_time.py").read() a='''for w_, home_ in ((wh79, "ca
2026-10-19 15:10:39 +00:00
time f78613c5c7 card #160: kept .github/time/test_time.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' s = open("test_time.py").read() a='''"plan": {"pricing": []}}], T
2026-10-19 15:10:32 +00:00
time 7c2a2aa45f card #160: kept .github/time/test_time.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' s = open("test_time.py").read() a = s.index("# a renamed venture
2026-10-19 15:09:58 +00:00
time c466dfbb28 card #160: kept .github/time/venture.py (generated)
time/worker-run cd .github/time; python3 - <<'EOF' s = open("test_time.py").read() def sub(a, b): global s a
2026-10-19 15:09:13 +00:00
time 5aefa807be ledger: 47 calls, $0.93
time/worker-run git branch -a; git ls-tree -r --name-only origin/gh-pages 2>&1 | grep -i havre | head; ls; ls ventur
2026-10-19 14:13:57 +00:00
time 815649f59f ledger: 15 calls, $0.24
time/worker-run cd .github/time; cat > /tmp/p.py <<'EOF' import re p = ".github/time/time.py" s = open(p).read() #
2026-10-19 13:57:46 +00:00
teahubxyzandClaude Opus 5.5 bc1da90c33 Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)
ci / test (push) Successful in 37s
sync / sync (push) Successful in 4s
Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)

An open Depends on #N, or the seat's blocked note, keeps a card out of the picks and after the movable cards in the promotion order. astra.yml passes TIME_WORKER and TIME_PACE.

Closes #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 13:55:03 +00:00
time 8fdb2f9469 ledger: 1 calls, $0.18 2026-10-19 13:50:29 +00:00
teahubxyzandClaude Opus 5.5 5a0ffcfee6 router: live
ci / test (push) Successful in 37s
sync / sync (push) Successful in 4s
Two dry rankings (2026-10-19 01:02 and 13:14 UTC) put the right cards on top (#135, #175), dropped blocked #84 once it was known stale, and kept L cards out. The two gaps they showed are fixed: money left is the worker's pool (#181, closes #175) and repeat cards aren't written (#181); the stall that kept promotion from happening at all is fixed (#183, closes #135).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 13:48:28 +00:00
teahubxyzandClaude Opus 5.5 32943339e1 promote_next counts only moving cards: one waiting on a person holds no ready slot (#183)
sync / sync (push) Canceled after 0s
ci / test (push) Successful in 36s
promote_next counts only moving cards: one waiting on a person holds no ready slot (#183)

Closes #135.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 13:47:55 +00:00
teahubxyzandClaude Opus 5.5 d235afb7f3 Housekeeping: a null page is an empty one (#182)
ci / test (push) Successful in 37s
sync / sync (push) Successful in 4s
Housekeeping: a null page is an empty one (#182)

Gitea answers an issue nobody reacted to with null; the signal step failed on it today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 13:47:24 +00:00
teahubxyzandClaude Opus 5.5 805e20d695 Router: money left is the worker pool's allowance; no repeat cards (#181)
ci / test (push) Successful in 37s
sync / sync (push) Successful in 4s
Router: money left is the worker pool's allowance; no repeat cards (#181)

money_left(): the allowance left in the pool that takes TIME_WORKER's route, else the daily cap's remainder, capped by the month's. The seat sees cards closed in the last 14 days; a new card repeating an open or lately closed one is not written.

Closes #175.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 13:44:30 +00:00
time 2efe67c9a9 ledger: 1 calls, $0.16 2026-10-19 13:14:08 +00:00
teahubxyzandClaude Opus 5.5 745231844f sync: secret names Gitea accepts (SYNC_GITHUB_KEY, SYNC_GITHUB_TOKEN) (#179)
ci / test (push) Successful in 36s
sync / sync (push) Successful in 4s
sync: secret names Gitea accepts (SYNC_GITHUB_KEY, SYNC_GITHUB_TOKEN) (#179)

Gitea refuses secret names starting GITHUB_ or GITEA_, so the GitHub copy's deploy key is in SYNC_GITHUB_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 04:03:20 +00:00
teahubxyzandClaude Opus 5.5 e34994bbc6 Sync: the GitHub copy over SSH with a deploy key (host keys pinned) (#178)
ci / test (push) Successful in 35s
sync / sync (push) Successful in 2s
Sync: the GitHub copy over SSH with a deploy key, host keys pinned (#178)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 03:33:59 +00:00
teahubxyzandClaude Opus 5.5 ab3c5f5200 Sync: a repo's copies on other forges follow its home (GitHub first, for cloud sessions) (#177)
ci / test (push) Successful in 36s
sync / sync (push) Successful in 3s
Sync: a repo's copies on other forges follow its home (#177)

sync.py + time/sync.json + the sync workflow. One home per repo, one writer per branch: main goes out to every copy
fast-forward only (a diverged copy is a needs-human issue, never overwritten, unless forced once); a copy's claude/*
branches come home as PRs for the council. First copy: github.com/teahubxyz/time, skipped until GITHUB_MIRROR_TOKEN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 03:18:50 +00:00
time 896fd3c872 ledger: 1 calls, $0.00 2026-10-19 03:07:58 +00:00
time 17c2fa0377 ledger: 35 calls, $1.72 2026-10-19 02:21:30 +00:00
teahubxyzandClaude Opus 5.5 b7bd03c6fd pools: pace Claude spend to the Anthropic credit ($161.49 left on 2026-10-19, lapses 2026-11-08)
ci / test (push) Successful in 36s
time/worker-run cd .github/time; grep -n "stuck_h =\|auto_max =\|TIME_AUTO_RETRY" time.py | head | cut -c1-220; sed
The owner's credit: $200 granted 2026-10-13, $162.22 left on the 18th by the console, expiring 2026-11-08. The ledger
books $38.51 of Claude spend since the grant, so $161.49 as of the 19th. Paced over the 20 days left that is about
$8/day for the anthropic route; the $100 monthly cap stays hard and binds first in October.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 01:10:37 +00:00
teahubxyzandClaude Opus 5.5 ec043b2985 Adaptive budget: pace spend to each capacity pool (re-cut of #167 onto main) (#174)
ci / test (push) Successful in 36s
Adaptive budget: pace spend to each capacity pool (#174, re-cut of #167)

The worker's #82 change (sonnet via Time): budget_check(route) paces each call to its capacity pool in
time/pools.json (expiring credit spread over the days left, refilling windows, or TIME_DAILY_USD without a pool);
the monthly cap stays hard. pools.json is read through the API once per run, since every workflow checks out only
.github/time. Ships empty: nothing changes until it is filled in. Closes #82.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 01:10:09 +00:00
time 463a6fecbb ledger: 1 calls, $0.00 2026-10-19 01:07:54 +00:00
time 0fdce2c58f ledger: 1 calls, $0.18 2026-10-19 01:02:01 +00:00
teahubxyzandClaude Opus 5.5 b8d52252f4 Router v1: the factory ranks its own next work (dry to start) (#169)
ci / test (push) Successful in 34s
Router v1: the factory ranks its own next work, dry to start (#169)

Astra's seat judges every candidate (kind, value, size); router.py ranks them with the owner's weights per kind
(time/router.json), a restless boost and card age. dry posts the table; live queues from the top within the day's
money and the heartbeat promotes queued cards in ranked order. Astra reads the last 14 days of its log. Closes #168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-19 01:00:17 +00:00
time b8041955ab ledger: 19 calls, $0.55 2026-10-19 00:10:39 +00:00
teahubxyz 3d1865ecb9 Signups count by the venture's name, not only its slug (#159)
ci / test (push) Successful in 35s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-19 00:10:37 +00:00
time ef00a80f7f ledger: 1 calls, $0.00 2026-10-19 00:07:41 +00:00
teahubxyzandClaude Opus 5.5 97e3e1f35f venture: the lead (opus) researches and picks the day's venture alone; fable is out (#164)
ci / test (push) Successful in 35s
venture: the lead (opus) researches and picks the day's venture alone; fable is out (#164)

One seat, TIME_VENTURE_LEAD (opus), runs the day's venture: a research round over the top trending repos (with the
start of their READMEs) and the studio's track record, TIME_VENTURE_FIELD candidates with a verdict each, one pick;
no pitches, no vote. It writes the launch plan alone and judges the build, and hands the launch drafts to its helper
(TIME_VENTURE_HELPER, sonnet). The chair and judge default to opus. Batches keep the board; TIME_VENTURE_LEAD=board
brings it back for the daily venture. Closes #165.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 22:11:50 +00:00
teahubxyzandClaude Opus 5.5 c268e75234 venture: one venture a day, built for quality (#162)
ci / test (push) Successful in 34s
venture: one venture a day, built for quality (#162)

The day's card starts only once every other venture card is launched or held; the board picks one from a field of
TIME_VENTURE_FIELD (6) pitches; opus builds; the judge scores the product 1-10 and a page under TIME_VENTURE_BAR (8)
gets polish rounds (never the last of TIME_VENTURE_FIXES=3), then launches with its score. An open batch card with
no Time beats no longer starts a batch while batches are off. Closes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 22:04:49 +00:00
teahubxyzandClaude Opus 5.5 e6f04f614a venture.yml: install Chromium for the browser pass (#157)
ci / test (push) Successful in 35s
venture.yml: install Chromium for the browser pass (#157)

The first Claude ventures launched without the browser pass (no Chrome on the runner): no headless checks, no
contact sheet for the portfolio. One step installs Chromium in the job image; venture.py's chrome() finds it.
Merged on the owner's word, CI green (run 1014).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 19:46:19 +00:00
time 4512c59c8f ledger: 10 calls, $0.75 2026-10-18 18:32:22 +00:00
time 3f81c2cfa9 ledger: 37 calls, $3.43 2026-10-18 18:17:46 +00:00
time facd11d390 ledger: 2 calls, $0.01 2026-10-18 17:52:37 +00:00
teahubxyz f329066a51 Worker: a cut-off tool call is an error the worker reads, not a crash (#155)
ci / test (push) Successful in 35s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 17:52:36 +00:00
time a7c92b6497 ledger: 27 calls, $0.72 2026-10-18 17:52:02 +00:00
time 80f21b58ea ledger: 45 calls, $3.17
time/worker-run cat > /tmp/patch.py <<'EOF' import re p = ".github/time/time.py" s = open(p).read() def sub(old, new
2026-10-18 17:46:45 +00:00
teahubxyzandClaude Opus 5.5 8fe83ac05a venture.yml: every 6 hours, Claude seats (#141)
ci / test (push) Successful in 35s
venture.yml: every 6 hours, Claude seats (#141)

Owner's call: the venture loop runs every 6 hours instead of every 20 minutes, on the Claude seats.
The builder is vars.TIME_VENTURE_WORKER || claude:sonnet, decoupled from the heartbeat's worker.
Merged on the owner's word after the council approved the engine half (#140).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 17:25:59 +00:00
teahubxyz ebedc7a5bc Venture loop on Teahub with Claude seats (#140)
ci / test (push) Successful in 35s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 17:24:35 +00:00
teahubxyz 8ab719e2f2 Ponytail on prod: seat workers carry its rules (#145)
ci / test (push) Successful in 36s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 17:24:33 +00:00
time 4b521f7a86 ledger: 2 calls, $0.01 2026-10-18 17:24:27 +00:00
time 8c0d93db9d ledger: 1 calls, $0.00 2026-10-18 17:18:58 +00:00
time 9abd17f979 ledger: 1 calls, $0.01 2026-10-18 17:16:19 +00:00
time 5fac881ec5 ledger: 1 calls, $0.01 2026-10-18 16:59:59 +00:00
teahubxyz 385b1a48d0 A label filter is checked on every row: housekeeping listed all 59 open issues as parked (#138)
ci / test (push) Successful in 35s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 16:22:20 +00:00
time 429595a543 ledger: 1 calls, $0.00 2026-10-18 16:22:14 +00:00
teahubxyzandClaude Opus 5.5 0543771a02 Workflows batch: heartbeat knobs, housekeeping.yml, its test in CI (#127)
ci / test (push) Successful in 35s
Workflows batch: heartbeat knobs, housekeeping.yml, its test in CI (#127)

heartbeat.yml passes TIME_CAP_SLACK, TIME_ANTHROPIC_API (the caching kill switch), TIME_REVIEW_TIEBREAK and TIME_REVIEW_QUORUM; housekeeping.yml runs the daily sweep at 13:17 UTC; ci.yml runs test_housekeeping.py. Owner-approved merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 16:10:58 +00:00
time 501192977d ledger: 1 calls, $0.00 2026-10-18 08:07:52 +00:00
time 61e0712f63 ledger: 25 calls, $0.85 2026-10-18 07:25:26 +00:00
time 4c91878f93 Time retries its own parked cards (error / stuck) (#132)
time/worker-run cd .github/time; sed -n 1120,1190p time.py; grep -n "andon" venture.py | head; grep -n "TIME_MODE" t
ci / test (push) Successful in 35s
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-18 07:17:32 +00:00
time 0cf097c1c3 ledger: 51 calls, $1.40 2026-10-18 07:16:17 +00:00
time e6bee7ccfe ledger: 4 calls, $0.01
time/worker-run python3 - <<'EOF' p = "/tmp/p/tests_new.py" s = open(p).read() s = s.replace(' and w.posted == w.pos
2026-10-18 06:08:18 +00:00
time cbd9d7536b ledger: 62 calls, $1.72 2026-10-18 05:15:05 +00:00
teahubxyzandClaude Fable 5.1 bed8743c94 checks(): a status from one of Time's own workflows is not a check (#124)
ci / test (push) Successful in 35s
checks(): a status from one of Time's own workflows is not a check (#124)

A seat's review wakes heartbeat.yml during a heartbeat; the concurrency group cancels the run and Gitea records it as a failure on the PR head. Those statuses neither block nor earn a merge; CI decides. Owner-approved merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 05:08:14 +00:00
time a6f4542203 ledger: 1 calls, $0.00 2026-10-18 04:59:28 +00:00
time b61b185201 Council verdict: TIME_REVIEW takes several seats, votes count per seat, TIME_REVIEW_TIEBREAK on a tie (#122)
ci / test (push) Successful in 34s
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-18 04:59:25 +00:00
teahubxyz 86b1207c4d Worker: build where the card says (Base: <branch>), finish what it starts (the cap gates launches), oldest card first (#121)
ci / test (push) Successful in 34s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 04:59:23 +00:00
teahubxyz bcdc71383e Claude calls go to Anthropic's own Messages API with prompt caching; the ledger prices cached tokens (#120)
ci / test (push) Successful in 34s
Co-authored-by: teahubxyz <boring+teahub@agentmail.to>
2026-10-18 04:59:20 +00:00
time 2bfaf6f672 ledger: 3 calls, $0.01 2026-10-18 04:50:46 +00:00
time b8bd19eaf8 ledger: 23 calls, $3.30 2026-10-18 04:47:35 +00:00
time 7a471e39ca Cards adopt an open PR that already closes them (review and land it instead of dispatching a worker) (#110)
time/worker-run cat > /tmp/edit.py <<'PYEOF' import re, json p = ".github/time/time.py" s = open(p).read() def sub(
ci / test (push) Successful in 35s
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-18 04:39:49 +00:00
time 31ae884fe1 ledger: 8 calls, $0.14 2026-10-18 04:27:32 +00:00
time 1747f07521 Seats review as themselves: TIME_SEAT_<SEAT> token makes the verdict a real Gitea review (#116)
ci / test (push) Successful in 35s
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-18 04:27:14 +00:00
time f4058061e0 Daily housekeeping: prune merged branches, close spent signals and empty milestones, list the stale (#115)
ci / test (push) Successful in 34s
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-18 04:27:12 +00:00
time 183e09332d ledger: 2 calls, $0.00 2026-10-18 04:16:22 +00:00
time 97e863b9fb ledger: 31 calls, $3.10 2026-10-18 04:14:42 +00:00
time 999abbf590 ledger: 95 calls, $5.01
time/worker-run cat > /tmp/p.py <<'EOF' import re p = ".github/time/time.py" s = open(p).read() old = ''' gh(
2026-10-18 02:10:37 +00:00
teahubxyzandClaude Fable 5.1 4f8eda2483 labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)
ci / test (push) Successful in 34s
time/worker-run curl -sS -m 15 https://docs.whop.com/.well-known/skills/index.json | head -c 1500; echo; curl -sS -m
labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)

Opened 2026-10-14 by a Claude Code session under the owner's login, which is why it read as the operator's PR for three days. Lab-only and reversible: Clef-flash draws first, the LLM checks in parallel, the chart is redrawn only on disagreement; API callers keep the LLM default. The lab's tests pass on the branch (run 652, "aicharts: all good"); its deploy step cannot run from prod, which holds no Cloudflare secrets, so nothing is deployed by this merge.

Squash-merged by the CEO session on the owner's explicit instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 00:12:54 +00:00
teahubxyzandClaude Fable 5.1 b539fff1b2 heartbeat: per-seat tokens for haiku, sonnet, opus and fable
ci / test (push) Successful in 35s
One Gitea user per seat (model line); the reviewer's verdict becomes a real review by that seat's own user once #76 lands. TIME_SEAT_CLAUDE stays until then as the umbrella.

On the owner's explicit instruction (CEO session).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 22:54:56 +00:00
time 0d893d7fc5 ledger: 40 calls, $2.52 2026-10-17 20:24:01 +00:00
teahubxyzandClaude Fable 5.1 5c8254cc50 TIME_PROTECTED governs every protected path except the workflow directories (#81)
ci / test (push) Successful in 34s
TIME_PROTECTED governs every protected path except the workflow directories (#81)

touches_protected() hard-coded all of .github/ and .gitea/ on top of TIME_PROTECTED, so the engine under .github/time/ could never land without a human. Only the workflow directories stay hard-coded; DEFAULT_PROTECTED is unchanged. With this repo's TIME_PROTECTED narrowed, card PRs under .github/time/ with a trusted Approve and green checks merge via the time bot.

CI green on 3b757fa. Squash-merged by the CEO session on the owner's explicit instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 20:20:12 +00:00
teahubxyzandClaude Fable 5.1 b53016007a heartbeat: 55-minute job, and the claude seat's token for reviews
ci / test (push) Successful in 34s
The job timeout moves from 20 to 55 minutes: workers can overlap inside one run (#75), and at 20 every in-flight worker died together. TIME_SEAT_CLAUDE reaches the heartbeat so the reviewer can post as the `claude` user once #76 lands; until then it is unused.

Both on the owner's explicit instruction (CEO session).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 20:16:32 +00:00
timeandClaude Fable 5.1 449f89fc38 Reviewer rule wording; no duplicate "Closes #N" in PR bodies (#74)
ci / test (push) Successful in 35s
Reviewer rule wording; no duplicate "Closes #N" in PR bodies (#74)

Reviewer Approve (haiku) on the diff, CI green on 16b369dd. Engine-only change for card #58; squash-merged by the CEO session on the owner's explicit instruction.

Closes #58

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-17 20:13:25 +00:00
time 5e2d211609 ledger: 10 calls, $0.13 2026-10-17 18:28:11 +00:00
time fb1df02f64 ledger: 16 calls, $1.65
time/worker-run cd .github/time; python3 - <<'E' s=open('time.py').read() s=s.replace(''' body = f"{a['su
2026-10-17 18:23:44 +00:00
timeandClaude Fable 5.1 0e582aa70b Objections go back to the worker, with the review attached (#59)
ci / test (push) Successful in 35s
time/worker-run cd .github/time && sed -n 80,150p test_time.py; sed -n 455,480p test_time.py; grep -n "claude-sonnet
Objections go back to the worker, with the review attached (#59)

Revise loop for `/time retry`: an open PR's reviewer comments go to the worker on the same branch, open_pr updates the open PR, the card returns to `dispatched`, and verdicts older than the last revision no longer count. Check 55 covers it.

Reviewed: Approve by Time's reviewer (haiku); the two confirmation points answered by the steward. Merged by the CEO session on the owner's explicit clearance after eleven steward sign-offs were blocked by a session permission guard.

Closes #53

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-17 17:46:39 +00:00
time d786ec6500 ledger: 1 calls, $0.12 2026-10-17 13:13:44 +00:00
time 498285e136 ledger: 1 calls, $0.12 2026-10-16 13:13:33 +00:00
teahubxyzandClaude Opus 5.5 e705ca00ac refresh: rebuild a gh-pages venture in place, from its own tools/build.sh (#67)
ci / test (push) Successful in 34s
refresh: rebuild a gh-pages venture in place, from its own tools/build.sh (#67)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:18:37 +00:00
time aaeb9417ca ledger: 40 calls, $2.27 2026-10-16 00:11:40 +00:00
teahubxyzandClaude Opus 5.5 721212ba7e seed: land a venture built elsewhere on yolopreneur.lol (#64)
ci / test (push) Successful in 35s
time/worker-run git fetch origin gh-pages 2>&1|tail -1; git show FETCH_HEAD:ventures/dollhouse/app.js | sed -n 140,3
seed: land a venture built elsewhere on yolopreneur.lol (#64)

Actions -> seed -> Run workflow with a slug, the URL(s) of a .tgz whose top folder is the slug, and its sha256
(plus a name and a one-liner for a new venture). seed.py checks the bundle, copies it over ventures/<slug>/ on
gh-pages in one commit, adds or refreshes the venture's line in ventures/ventures.json, and the workflow asks
Teahub pages to pull. Nothing in the bundle runs. test_seed.py runs in CI. Merged at the owner's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9wtGiGEeq1H5DA3T88PRe
2026-10-15 23:56:46 +00:00
time 08bba21dfa ledger: 69 calls, $4.96 2026-10-15 21:25:52 +00:00
time 7d76ab4338 ledger: 1 calls, $0.11
time/worker-run grep -n "^# Create app\|^# Create App\|^# Update App\|^# Update app" /tmp/full.txt; sed -n 22107,221
2026-10-15 13:13:32 +00:00
time b02e73c0e6 ledger: 1 calls, $0.00 2026-10-14 22:08:12 +00:00
time d30be0208d ledger: 20 calls, $2.26 2026-10-14 21:46:53 +00:00
time 1da01fcd68 Review: show the reviewer the PR check statuses (#56)
ci / test (push) Successful in 35s
time/worker-run cat > /tmp/patch.py <<'PYEOF' p = ".github/time/time.py" s = open(p).read() def sub(old, new): g
venture / tick (push) Successful in 3s
Closes #52

Merged by the steward (Claude, for the operator). Review answered on the PR.

Co-authored-by: Time <time@users.noreply.teahub.xyz>
Co-committed-by: Time <time@users.noreply.teahub.xyz>
2026-10-14 21:39:13 +00:00
time 0caaad4a1a ledger: 1 calls, $0.00 2026-10-14 21:23:12 +00:00
time 495494b0e7 ledger: 1 calls, $0.05 2026-10-14 21:22:16 +00:00
time 56b74116e7 ledger: 10 calls, $0.22 2026-10-14 21:18:52 +00:00
teahubxyzandClaude Opus 5.5 97b7667970 astra: queue picks instead of labelling them ready, so they run one at a time behind the existing queue
ci / test (push) Successful in 35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fTCFiw6ohysUDcWoPsTys
2026-10-14 21:18:36 +00:00
time 195c65cb55 Tests: test_time.py clears the job TIME_* environment before importing time.py (#54)
ci / test (push) Successful in 35s
time/worker-run cd .github/time; python3 - <<'EOF' s=open('time.py').read() s=s.replace('''def review(card: dict, pr
Closes #51

Merged by Claude for the operator (the delegated merger for engine PRs). Reviewer objection answered on the PR: CI was green; the reviewer cannot see checks yet (#52).

Co-authored-by: Time <time@users.noreply.teahub.xyz>
Co-committed-by: Time <time@users.noreply.teahub.xyz>
2026-10-14 21:15:49 +00:00
time f19429bccb ledger: 1 calls, $0.00 2026-10-14 21:08:05 +00:00
time 7e9ae24beb ledger: 4 calls, $0.20 2026-10-14 21:03:54 +00:00
teahubxyzandClaude Opus 5.5 c2055e85eb time: every write but comments and statuses goes out as the time bot
ci / test (push) Successful in 34s
time/worker-run python3 - <<'EOF' p=".github/time/test_time.py" s=open(p).read() old='import concurrent.futures as c
Gitea 1.24 also refuses the Actions identity label edits (removing a label is a 500), which crashed
promote_next and stalled the queue. gh() now uses TIME_TOKEN for every write except comments (Time's
beats are gitea-actions' comments) and commit statuses; the time user never counts as trusted and
mention replies ignore it; astra.yml gets TIME_TOKEN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fTCFiw6ohysUDcWoPsTys
2026-10-14 14:02:51 -07:00
teahubxyz b13ddff34f Merge pull request 'Ledger README: explain time/ledger/<YYYY-MM>.jsonl' (#50) from time/card-49 into main
venture / tick (push) Successful in 3s
Reviewed-on: #50
2026-10-14 20:53:04 +00:00
teahubxyzandClaude Opus 5.5 f718a14295 ledger README: the cap is checked before every model call; say exactly who opens the andon
ci / test (pull_request) Successful in 35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fTCFiw6ohysUDcWoPsTys
2026-10-14 20:30:18 +00:00
time e46ec071c1 ledger: 1 calls, $0.00 2026-10-14 20:26:12 +00:00
time 10d0466ad9 ledger: 10 calls, $0.13 2026-10-14 20:24:31 +00:00
teahubxyzandClaude Opus 5.5 f3a841a762 heartbeat: put each secret's comment back on its own line
ci / test (push) Successful in 36s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fTCFiw6ohysUDcWoPsTys
2026-10-14 20:22:05 +00:00
teahubxyz bbc62970cb time: PRs open and merge as the time bot (TIME_TOKEN); Gitea denies both to the Actions identity
ci / test (push) Successful in 35s
venture / tick (push) Successful in 3s
2026-10-14 13:11:41 -07:00
time 8a14773aa0 ledger: 7 calls, $0.10 2026-10-14 19:55:41 +00:00
time 223a4a0617 Add ledger README
time/worker-run env | grep -E '^TIME_(WORKER|MODE|PLANNER|REVIEW)=' ; env -u TIME_WORKER -u TIME_MODE -u TIME_PLANNE
ci / test (pull_request) Successful in 35s
2026-10-14 19:55:31 +00:00
teahubxyz e12728c5ff hands: list_files resolves the branch to a sha (trees route rejects refs with a slash)
ci / test (push) Successful in 34s
2026-10-14 12:54:22 -07:00
time a694c10428 ledger: 1 calls, $0.00 2026-10-14 19:52:35 +00:00
teahubxyz 0eb9234a7e time: every write is a git push (Gitea refuses the Actions token's API writes); ledger appends; one commit per kept batch
ci / test (push) Successful in 35s
time/worker-run sed -n 262,335p .github/time/time.py
2026-10-14 12:51:52 -07:00
teahubxyz 444b01abca astra: daily planning workflow (off until TIME_ASTRA=on)
ci / test (push) Successful in 36s
2026-10-14 19:49:42 +00:00
teahubxyz 4086657dc8 astra: the daily planner (one call a day; queues cards, never spends beyond its call; guardrails in code)
ci / test (push) Has been cancelled
2026-10-14 19:49:35 +00:00
teahubxyz aecc4ee90d ci: don't retest on ledger commits
ci / test (push) Successful in 34s
2026-10-14 19:46:44 +00:00
teahubxyz c92313b62d doctor: the Cursor key is optional when a seat is the worker
ci / test (push) Successful in 34s
venture / tick (push) Successful in 2s
2026-10-14 12:38:27 -07:00
teahubxyz dc2a3e8a05 doctor: ANTHROPIC_API_KEY + spend caps
ci / test (push) Successful in 35s
2026-10-14 19:34:32 +00:00
teahubxyz a871722024 tests: provider routing, ledger booking, Budget, capped beat + andon, claude:sonnet worker
ci / test (push) Has been cancelled
2026-10-14 12:34:10 -07:00
teahubxyz 6bf0b0411b README: Claude seats and the spend governor
ci / test (push) Successful in 36s
2026-10-14 19:32:32 +00:00
teahubxyz 7c2b6a00a3 venture: ANTHROPIC_API_KEY + spend caps
ci / test (push) Has been cancelled
2026-10-14 19:32:11 +00:00
teahubxyz d05679b11a heartbeat: ANTHROPIC_API_KEY, TIME_DAILY_USD, TIME_MONTHLY_USD
ci / test (push) Has been cancelled
2026-10-14 19:31:54 +00:00
teahubxyz ee0302f763 mention: ANTHROPIC_API_KEY + spend caps
ci / test (push) Has been cancelled
2026-10-14 19:31:41 +00:00
teahubxyz 387fe44e15 time: Claude seats via the Claude API's OpenAI-compatible layer; spend ledger + dollar caps (governor)
ci / test (push) Successful in 37s
2026-10-14 19:25:42 +00:00
teahubxyz 9ffbb793bf seats: Claude seats (haiku, sonnet, opus, fable) with provider and prices
ci / test (push) Successful in 35s
2026-10-14 19:23:25 +00:00
teahubxyz bd6fcd7ed7 time: trust = repo owner, TIME_TRUSTED, or collaborator (the Actions token can't read permissions)
ci / test (push) Successful in 34s
heartbeat / tick (push) Successful in 3s
venture / tick (push) Successful in 3s
2026-10-13 23:58:48 -07:00
teahubxyz 8c874e8115 time: log why a permission lookup failed
ci / test (push) Successful in 34s
2026-10-13 23:57:12 -07:00
teahubxyz c360639456 Port: Time runs on Gitea (teahub.xyz) (#47)
ci / test (push) Successful in 35s
2026-10-14 06:55:19 +00:00
teahubxyzandclaude 8c6ad054b9 security: a real CI check before auto-merge; .github/ always protected (#46)
- checks(): the worker's own time/worker-run status can block or delay a merge but never counts as green
- dispatch_ci(): with no other check on the head, Time runs TIME_CI_WORKFLOW (ci.yml, workflow_dispatch) on the PR branch and waits; beat ci-dispatched, once
- ci.yml accepts workflow_dispatch
- touches_protected(): .github/ is always protected, whatever TIME_PROTECTED says
- labs-aicharts.yml: deploy URL via env, not ${{ }} in the script
- tests: worker-status-only no longer merges; dispatch once; real context merges


Claude-Session: https://claude.ai/code/session_01CYHXFvWMCs1GUmRmpySqQ6

Co-authored-by: claude <noreply@anthropic.com>
2026-10-13 21:33:35 -07:00
teahubxyzandclaude 17e51a6b08 security: harden Time for a public repo (#45)
- beats_of/verdict/retry: trust only github-actions[bot] and owner/member/collaborator authors (forged state and stranger retries on a public repo)
- touches_protected: paginate pull files and include previous_filename
- browser_check: drop .git after clone, serve only the venture folder, Chrome with no network but localhost
- Hands.run: execute in a docker container when a daemon exists (TIME_RUN_IMAGE), host shell fallback
- tests: logins on fake comments, stranger retry/forged beat cases, paginated files fake


Claude-Session: https://claude.ai/code/session_01CYHXFvWMCs1GUmRmpySqQ6

Co-authored-by: claude <noreply@anthropic.com>
2026-10-13 21:24:42 -07:00
teahubxyzandClaude e52e116815 labs/aicharts: show decision confidences, per-request floor, bench user agent (#41)
Live: Clef answered both probes right in ~0.5 s but at 0.21-0.25 confidence, so
the 0.5 floor sent them to the LLM. Expose every confidence and the weakest
question; a request may set its own floor (only changes cost); bench.py takes
clef@0 for calibration and sends a user agent Cloudflare accepts.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 15:31:05 +00:00
teahubxyzandClaude 2d0f997030 labs/aicharts: Jev / Clef decision brains (choice answers -> spec), LLM as the door (#40)
decide.js asks ~10 choice questions in one call (mark, x, measure, agg, series,
order, top, one filter per text column) and assembles the spec; the validator
still checks it. Weakest needed answer < FLOOR, a failed check or a model error
hands the question to the LLM path, and the response says why. Page: brain
picker, latency/confidence line, 'or as <mark>' from the mark probabilities.
bench.py scores brains on 12 fixed questions.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 15:25:17 +00:00
teahubxyzandClaude 3086cb52ff labs/aicharts: fast non-thinking model for specs (#39)
Live: gemma-4 took 70s+ and returned empty content on harder prompts (budget spent
thinking). Llama 3.3 70B fp8-fast answers the spec directly; still overridable.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 14:01:33 +00:00
teahubxyzandClaude 9e065e416d labs/aicharts: plain prompt first, JSON mode only as fallback (#38)
Live, the json_schema decoder filled every step with the same key soup (order, step)
and dropped agg/by. The prompt's grammar + example steers better.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 13:53:14 +00:00
teahubxyzandClaude 165d499644 labs/aicharts: fix model spelling drift, show the model's answer on failure (#37)
First live run: Workers AI answered specs the validator refused (agg spelling).
normalizeSpec fixes case and common aliases only; on a 422 the response
carries model_said (shown, never run). Smoke now fails on a bad spec.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 13:47:49 +00:00
teahubxyzandClaude 8eff78170b labs/aicharts: live at aicharts.<venture domain>, like ventures (#36)
The account has no workers.dev subdomain; use TIME_VENTURE_DOMAIN when set.
Deploy step fails on its own error (pipefail); smoke waits up to 3 min for the cert.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 13:42:56 +00:00
teahubxyzandClaude ba4174326a labs: AICharts demo Worker (NL -> validated chart spec, D1, workers.dev) (#35)
Library (aicharts.js): spec validator, fixed ops (filter/group/sort/bin/limit),
Chart.js config, attach() for existing charts, <ai-chart> element.
Worker: sample orders in D1, schema/sample/aggregates to Workers AI with
structured output + one repair round, saved_charts, per-IP and daily caps.
deploy.py: workers.dev only, no DNS. For teahub matcha/teahub#93.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 13:35:18 +00:00
teahubxyzandClaude 88f9d509ed venture: escalate the second fix round to kimi code, not grok 4.7 (#34)
grok 4.7 fix rounds (28-54 steps, three at once) were most of a quick $20 of Venice
spend in the first live batch. Kimi's code model is a different model from round one's
Qwen Coder, so escalation still gets a fresh attempt, at a fraction of the cost.
TIME_VENTURE_ESCALATE still overrides.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 12:13:57 +00:00
teahubxyzandClaude c6451370a7 venture: children start the tick they're spawned; briefs claim only what the code does (#33)
Two findings from the first live batch (#26):
- its children waited a tick: GitHub doesn't list a card made a moment ago. spawn()
  now hands its cards to the same tick's card loop.
- every fix round was a page claiming what its code didn't do: promised drops, and
  'builds on' the trending repos the brief asked it to name. The brief now says the
  repos are the wave it rides, forbids promised features, and has the worker re-read
  the page against app.js; fix rounds remove a failing claim instead of building.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 02:57:42 +00:00
teahubxyzandClaude a933c17fdb venture: a new batch card runs as a batch, not a single venture (#25)
GitHub returns labels as objects, and a card made a moment ago isn't listed yet, so
the batch card a tick had just made fell through to the single-venture path (live:
#24). Labels are now read by name, and the tests' fake GitHub returns label objects
and holds a new batch card back from listings for a tick, which reproduces #24.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 01:47:34 +00:00
teahubxyzandClaude fbe1b0c9a2 venture: parallel builds, up to TIME_VENTURE_HEAVY worker runs at once (#23)
A tick runs its top cards in one wave, handing out worker runs in priority order (at
most TIME_VENTURE_HEAVY, default 3; one per card per tick). Made safe for threads: the
folder lock is a per-build Hands subclass instead of a patch on the shared class,
ventures.json is read fresh and written under a lock, and a contents PUT that loses
the race for the branch head (409) is retried. TIME_VENTURE_HEAVY=1 is the old
one-at-a-time.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 01:40:32 +00:00
teahubxyzandClaude bc6f232a56 venture batches: the board's top k as one milestone, launched together (multi-ceremony) (#22)
One batch card scouts once, the seats pitch a field, and the board ranks it; the top k
(each wanted by two seats) become venture cards in the batch's milestone at 'picked'.
Children run the ordinary loop side by side, wait at 'ready' until every sibling is
settled or the hold runs out, then launch together. No child left behind: one batch
retry for a held child, then it is counted dead in the cohort retro, which closes the
card and the milestone. Fix rounds now yield the single worker run per tick to cards
still due their first build, so a failing page can't starve its siblings.


Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-13 01:20:49 +00:00
Claude c54cbf2596 venture backends: accounts with passkeys
Each venture's Worker can now sign people up and in with passkeys (WebAuthn):
no passwords, no email, no OAuth app, nothing to configure. The relying party
is the venture's own host and the session a __Host- cookie (HttpOnly, Secure,
SameSite=Lax, 30 days), stored hashed in the venture's D1 database. ES256 and
RS256 keys; challenges are single-use and expire in five minutes; origin,
relying party, user presence and the signature counter are checked; auth
endpoints refuse cross-origin calls. Items saved while signed in are recorded
as that person's, and /api/me/items lists them.

time.js gains Time.accounts, user, signUp, signIn, signOut and mine; builds
are told to use them for what the paid tier promises and never to put sign-in
in front of the free product. test_auth.mjs runs the whole ceremony against a
real SQLite and a software authenticator, including replay, phishing origin,
another site's passkey, a cloned counter, a tampered signature and a duplicate
registration; CI pins node 22 so it runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
2026-10-12 18:46:56 +00:00
Claude cb687873d3 venture backends: Gemma 4 by default; read either answer shape from Workers AI
The first default model had been retired on Workers AI (5028 "deprecated"),
found by calling the live backend. Default to @cf/google/gemma-4-26b-a4b-it,
and accept both the older { response } and the OpenAI-style choices answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
2026-10-12 11:05:52 -07:00
Claude 134a03ce41 venture backends on Cloudflare: a domain, a Worker, a database and AI per venture
With CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID, each venture gets its own
Worker (time-<slug>) with its own D1 database and Workers AI. The Worker serves
the venture's GitHub Pages folder under <slug>.<TIME_VENTURE_DOMAIN> (or
workers.dev) and adds a small API: share links stored in D1, and a
rate-limited small model. The page reaches it through time.js, which Time
writes next to index.html and the build may not edit; every call degrades to
null, so products still work on Pages alone and in the headless checks.

The backend is deployed when a venture is picked, so the plan, the signal
issue and the page all carry the venture's own domain. A Cloudflare failure
never holds a venture: it launches on Pages and the chores retry. The chores
also backfill older ventures and redeploy every venture when worker.js or its
settings change (a revision hash), a couple per tick; update.yml carries the
new files. The doctor checks the token. Offline tests cover the provisioner,
the loop with and without Cloudflare, the backfill and redeploys, and the
worker itself (node, against a fake D1, AI and Pages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
2026-10-12 10:50:13 -07:00
teahubxyzandClaude Opus 5.5 e1dc4f2790 venture: a card made a moment ago starts in the same run
The first "Start a new venture now" run made its card and ended without
touching it: GitHub hadn't listed the new issue yet, so nothing looked
due. The tick now adds the card it just made to what it considers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 21:20:22 -07:00
teahubxyzandClaude Opus 5.5 32fa6ee49d venture: start a new venture whenever you like
The schedule starts one venture card a day, and Run workflow only
advanced the cards already open. Run workflow now has a "Start a new
venture now" checkbox (gh workflow run venture.yml -f start=true) that
opens a fresh card even when today's has run; a second card on the same
day is titled "Venture <day> (2)". The schedule is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 21:18:11 -07:00
teahubxyzandClaude Opus 5.5 7a821c5cf2 venture judge: static page content counts as delivered
The honesty review only saw the JavaScript and the README, so a feature
that is plain HTML (Docscope's reference table of the OOXML parts it
handles, linked to the ECMA spec) looked unimplemented and blocked the
launch. The judge now also reads the page's own text, scripts and styles
stripped, and static content there counts as delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 21:09:27 -07:00
teahubxyzandClaude Opus 5.5 6d0c600de0 venture checks: painted products are alive; layout problems name their culprits; the CTA asks for early access
Three things the last two andons showed:

- Doodleloop draws on a canvas, so the scripts-on/off comparison saw no
  new elements or text and called a working product dead. The harness
  now also counts canvases with pixels on them and images or videos a
  script made.
- Fix rounds got "horizontal scrolling at 390px" and "the product starts
  285px down" and guessed. The probe now names what is too wide (the
  outermost element past the edge, and its container) and how tall the
  header is, so the worker knows what to change.
- The plan gave Doodleloop's signup button the label "Export GIF", the
  product's own action. A primary CTA that doesn't ask for early access
  (join, waitlist, notify, ...) falls back to the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 21:02:25 -07:00
teahubxyzandClaude Opus 5.5 e8843b7e62 ventures index: the Time site's blueprint theme
The studio index at /ventures/ now wears the front page's blueprint: the same
tokens, grid paper, title block with the clock mark and nav, condensed
headlines and corner-marked sheets. Each launched venture shows its laptop
screenshot, cropped from the launch contact sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 18:06:13 -07:00
teahubxyzandClaude Opus 5.5 51811c2ba0 venture: the launch post says header, not hero
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 17:57:42 -07:00
teahubxyzandClaude Opus 5.5 99511c6c08 venture: builds stay in their folder; the browser check catches a covered product
A fix round for .mdeck wrote its page into the Pages branch's root
index.html, over the studio's front page. The Pages branch carries the
studio's site and every venture, so a venture build's hands now write
only under ventures/<slug>/: other writes are refused with a note the
model can act on, and run `keep` paths outside the folder aren't kept.

The same build squeezed the editor into a body fixed to the screen's
height, so the sections below slid over the product, and every check
passed. The browser probe now samples the visible parts of #try and asks
the page what sits on top of each one; a product mostly covered by
something outside it fails, naming the culprit. The brief says to size
the first screen with min-height, never by fixing html/body height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 17:43:08 -07:00
teahubxyzandClaude Opus 5.5 398af5a9ac venture: the board sees the studio's ventures; repeats are set aside
The week's trending list barely moves from day to day, so the seats
kept pitching the same idea: today's card picked DeckPreview, which is
.mdeck again. The pitch and vote prompts now list the studio's ventures
and ask for something new, and pitches whose slug, name or title match
a venture's slug, name or an earlier slug (aliases) are set aside before
the vote, deterministically. A picked slug that matches an alias gets
the card suffix, so it never overwrites an old address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 17:30:37 -07:00
teahubxyzandClaude Opus 5.5 c97198ea15 venture: delight over developer utilities; the sample is the pitch
The board's taste, from the owner: a neat surprise anyone gets within a
minute ranks above one more developer utility. The playbook, the pitch
prompt and the vote say so. When a product makes something people read
(a deck, a card), the build brief makes its preloaded sample the
product's own pitch, claiming only what the page does. LAUNCH.md calls
the call to action the header's button, not the hero's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 17:25:52 -07:00
Claude 23d6383060 Worker: runs can keep the files they generate
grok's Docscope round generated a real sample .docx and the finished page
with scripts inside run, then tried to cp them into place: each run starts
from a fresh checkout, so none of it reached the branch and the round
burned its steps. The run tool now takes keep: [paths] and commits exactly
those files afterwards (inside the checkout, not workflows, up to 2 MB,
at most ten), and the rules say plainly that everything else a run does is
thrown away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:59:41 -07:00
Claude f64a48300d Ventures: the product is the page
Taste test on the relaunched MDecks: the notplanned launch-page template
and its rotation of print styles turned the working tool into a section
under a big hero, and every page looked like the same magazine. Now:
- the brief puts the tool in the first screen under a slim header (name,
  a few words, the one signup button); how it works, why, pricing and
  limits sit quietly below;
- the look follows the product (a slide tool should feel like a slide
  tool), dark or light; the design rotation is gone;
- headless Chrome fails a page whose product starts below the top third
  of the first screen, at phone and laptop size;
- an earlier version can be kept on purpose (prior_note), so MDecks can
  be rebuilt from the first design people liked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:55:28 -07:00
Claude 35860f4bf8 Worker: a command that never exits is stopped and reported, not a crash
Docscope's fix round ran 'python -m http.server' to test its page; the
600s timeout raised and took the whole round down. The run tool now stops
a command after TIME_RUN_TIMEOUT (default 180s) and tells the model to use
one-shot commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:46:13 -07:00
Claude 3bf9fbe94d Ventures: an honesty review of the product code, after the cheap gates
Docscope's next fix round dodged the phrase check: 'This would be the real
implementation using DecompressionStream. For now, we'll create a realistic
representation...' and still returned canned parts. Phrase matching can be
talked around, so the inference tier now reads the code: a seat
(TIME_VENTURE_JUDGE, default grok) answers whether the output depends on
the user's input and lists features the page claims for the free product
that the code doesn't implement (paid tiers and stated limits don't count).
It runs only after the deterministic and browser gates pass, and its
absence (outage, no verdict) never blocks a launch.

Checked against the live pages: it passes MDecks and catches Docscope
('parseOfficeFile always returns createRealisticParsedParts(file), which
hardcodes identical XML regardless of input').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:43:19 -07:00
Claude 67f7b4d2d9 Ventures: a failed fix round escalates to a stronger worker (TIME_VENTURE_ESCALATE, default grok 4.7)
The cost cascade, applied to fixes: the cheap worker gets the first round;
when its page still fails the checks, the next round goes to a stronger
model instead of the same one again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:32:40 -07:00
Claude 16cd9a6306 Venture plans: keep prices short (no parentheticals in the pricing card)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:31:31 -07:00
Claude c287a2acf6 Ventures: catch output the code admits is simulated
Docscope's fix round added 19KB of script that renders on load and passed
the scripts-on/off check, but the code says 'Simulate processing (in a real
implementation, this would parse the file)': a dropped file gets a canned
leak report. For a privacy tool that is worse than no tool.
- the checks flag the phrases generated code uses for canned output;
- the brief and fix rounds say the sample must be real input through the
  same code path as a user's input, and point at browser built-ins
  (DecompressionStream for zip, DOMParser for XML).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:30:20 -07:00
Claude 4fc82f0f9a Venture brief: build the product first (app.js), then the page around it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:23:31 -07:00
Claude 0f74a7d7c4 Ventures must ship a working product; launch drafts are checked against the build
Docscope launched as a facade: a well-shaped page with no script at all.
The checks verified structure, never that the product works. Now:
- deterministic: the page and its local scripts must carry real code;
- headless Chrome loads a scripts-off twin next to the live page and
  compares #try: if the product renders nothing of its own on first load
  (a preloaded sample run through the real code), the page fails;
- fix rounds get the product description, so 'no working product' is
  fixable in a round;
- at launch the chair rewrites the launch drafts against the README and
  the page as built, with no invented people, stories or results (a draft
  had made up a leaked-client-file anecdote); LAUNCH.md gets the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:21:21 -07:00
Claude 0c569ca5c2 Docs: Venice-first setup, and the venture loop in the setup skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:14:48 -07:00
Claude 79e38fa894 Ventures wait out provider outages; the worker falls back to another model
- A venture stage that fails because Venice stays overloaded now posts one
  'waiting for capacity' note and retries every tick without spending one
  of its three strikes (MDecks and Docscope were one 429 from being closed).
- The worker hands over to TIME_WORKER_FALLBACK (default grok build) when its
  model stays overloaded after the backoff retries.
- Fix: venture builds passed the worker spec with its venice: prefix, so the
  seat lookup missed and every venture was built by kimi's code model
  instead of the configured worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 16:04:58 -07:00
Claude 91b11019db Ride out Venice overloads: retry 429, 5xx and dropped connections with backoff
One overloaded reply in the middle of a worker run used to throw away the
whole build (the MDecks relaunch hit exactly that). Seats and the worker
now retry up to three times (15s, 60s, 135s) before giving up; a 4xx like
a bad key still fails at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 15:58:39 -07:00
Claude cdfe4c7199 Venture loop: launch products, not pages
Ported from the notplanned venture template:
- launch round: every board seat writes a launch plan (positioning, first
  users, pricing, channels, 7-day success and kill lines); one seat
  synthesizes them into LAUNCH.md with where each idea came from
- launch page brief: one primary call to action in the hero, the working
  product preloaded with a sample, why-this-exists with real star counts,
  pricing (free plus at most two honest early-access tiers), honest limits,
  a design direction that rotates and never repeats the last six
- signups through a GitHub issue form prefilled with venture and plan;
  each signup is thanked on its own issue
- checks: deterministic gates, then headless Chrome measuring at exactly
  390x844 and 1280x800 (horizontal scroll, button below the fold, JS
  errors); two fix rounds, then andon with needs-human and /time retry
- a contact-sheet screenshot of phone and desktop on the card
- 7-day decision on distinct people: v2 card, kill, or one more week then
  park; a next version counts only signups after its own launch
- scheduling: a card that is measuring never blocks the others; one new
  card a day; a tick runs light stages back to back and one worker run
- the studio index at /ventures/ is generated from ventures.json

Security: the worker's shell no longer sees tokens or keys, its checkout
keeps no token in .git/config, and workflows don't persist credentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 15:48:21 -07:00
claude 66c427eba4 venture: with several open cards, work the furthest-along one first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 14:47:22 -07:00
claude 5a185facd3 Venture loop: ventures, not tools
Pitches carry a name, who it's for, a next version and a CTA. The board may
rank nothing and needs two seats behind a winner. Every venture gets a
`signal` issue (👍 = I want the next version) and a Want-more section on the
page pointing at it. After ship: a launch stage drafts posts for the chair
(never sent), and a measure stage 24h later queues an iterate card or parks
the venture. Seats that reply in prose get re-asked once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 14:37:23 -07:00
github-actions[bot] a4970e3e2b Add Status section linking to CHANGELOG.md (#10)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-11 21:22:31 +00:00
claude bae0bdc0ef venture: statuses permission for the worker's test run; dedupe board rankings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 14:14:30 -07:00
claude fd23310271 venture: parse JSON out of fenced or chatty seat replies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpT7eWzeLhRwuYmsSQnbRb
2026-10-11 14:09:52 -07:00
46 changed files with 10436 additions and 358 deletions

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
---
name: housekeeping
description: The daily sweep of this repo: prunes merged branches, closes spent signal issues and empty milestones, and lists what is stale. Use when asked what housekeeping did, why a branch or issue was closed, how to run the sweep by hand, or what to do with the stale list.
---
# Housekeeping
Deterministic: `.github/time/housekeeping.py` decides, never a model. Every step is idempotent, so a second run finds nothing to do.
## Trigger
- `housekeeping.yml`: daily, a jittered minute after the Astra tick (13:13 UTC), and `workflow_dispatch`. It runs
`python3 .github/time/housekeeping.py` with the heartbeat's env, as the `time` user.
- `TIME_MODE=live` makes it act. Anything else (the default) is a dry run: it prints `would delete ...` / `would close ...`
in the run log and changes and comments nothing.
- By hand: Actions → housekeeping → Run workflow, or locally with `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, `TIME_TOKEN` set.
## What the sweep does
1. **Branches.** Deletes every branch whose PR merged into the default branch (`time/card-*`, `ceo/*`, `labs/*`, anything
with a merged PR; merges made through the MCP keep their head branch, Time's own merges delete it). Never the default
branch, `gh-pages`, a protected branch, a branch with an open PR, or a branch that has commits after its merge.
2. **Signal issues.** Closes `signal` issues ("want the next version?") older than `TIME_VENTURE_MEASURE_DAYS` (default 7)
whose venture has no signups: no 👍 from a person and no early-access issue for its slug. One closing line names the count.
Issues with signups are left to the venture loop.
3. **Milestones.** Closes milestones with no open issues and at least one closed one. An empty, never-used milestone stays.
4. **Stale list.** No action, one comment on the Astra log (#57): `needs-human` untouched for over 3 days, open PRs idle
7 days or more, cards parked after their automatic retries, `error` cards older than 2 days. An empty list posts no comment.
Each run ends with one line on the Astra log: branches deleted, issues closed, milestones closed, stale count. A step that
fails is logged and the others still run; the job exits 1.
## What the steward does with the stale list
Housekeeping never acts on it. Read it on #57 once a day and for each entry either:
- **`needs-human` card or PR:** decide it (merge, close, or comment `/time retry` to send it back to the worker) and remove the label.
- **Idle PR:** merge it, push it, or close it. A PR nobody wants closes; its branch goes at the next sweep.
- **Parked card:** read why it stopped, fix the card (smaller, clearer `DONE:`) or close it.
- **`error` card:** comment `/time retry`, or close it if the error is not worth another try.
## The workflow
The workflow file is the CEO's merge. It is the heartbeat's job with this step in place of `time.py heartbeat`:
```yaml
name: housekeeping
on:
schedule:
- cron: "17 13 * * *" # a few minutes after the Astra tick (13:13)
workflow_dispatch:
concurrency:
group: housekeeping
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false
- run: python3 .github/time/housekeeping.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branch and issue writes go out as the `time` user
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }}
```
Tests: `python3 .github/time/test_housekeeping.py` (offline fake forge).
+18 -1
View File
@@ -38,7 +38,7 @@ Files: `.github/workflows/{heartbeat,mention,doctor,update}.yml`, `.github/time/
- `gh variable set TIME_MODE --body dry-run`
- `gh variable set TIME_DAILY_CAP --body 5` — max agent launches per day
- `TIME_WORKER`: `cursor` (default) or `venice:<seat> [model] [effort]`, e.g. `venice:kimi code`. A Venice seat needs only `VENICE_API_KEY`: it reads and writes the repo on branch `time/card-N` through the GitHub API, runs tests on the runner, and opens the PR itself. Workflow files are refused to it.
- optional `TIME_CURSOR_MODEL` (Cursor worker model id), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6), `TIME_WORKER_STEPS` (default 40 tool calls per card)
- optional `TIME_CURSOR_MODEL` (Cursor worker model id), `TIME_REVIEW`, `TIME_STUCK_HOURS` (default 6), `TIME_WORKER_STEPS` (default 40 tool calls per card), `TIME_WORKER_FALLBACK` (default `grok build`: takes over when the worker's model stays overloaded; `off` to disable), `TIME_WORKER_MAX_TOKENS` (default 16000)
4. Cursor (the worker, only when `TIME_WORKER` is `cursor`):
- The human installs Cursor's GitHub app on this repo (Cursor dashboard → Integrations → GitHub). Cloud agents can't clone or open PRs without it. You can't check this from `gh`; ask them to confirm.
- The human creates an API key (Cursor dashboard → API Keys) and stores it: `gh secret set CURSOR_API_KEY` (they paste the value at the prompt).
@@ -110,3 +110,20 @@ Only when the human says so: `gh variable set TIME_MODE --body live`. The daily
- Runner minutes: private repos have a monthly free allowance, each job rounds up to a minute; keep the heartbeat hourly unless asked. Cursor agent runs bill to the human's Cursor plan.
- Changed `time.py`? Run `python3 .github/time/test_time.py` before committing.
- Status: `gh issue list -l ready`, `gh run list -L 10`, and the latest Time comment on each card.
## Ventures (optional)
`gh variable set TIME_VENTURE --body on` starts the venture loop (`venture.yml`, every 20 minutes): one venture card a day, from the repos trending on GitHub to a launch page on GitHub Pages. Seats pitch, the board votes, board seats write launch plans that one seat synthesizes into `LAUNCH.md`, the worker builds the product as the page on `gh-pages` (the tool fills the first screen), deterministic checks and headless Chrome gate it, and seven days of signups decide (v2, kill, or one more week). It needs `VENICE_API_KEY`; the loop creates the `gh-pages` branch, the Pages site and the `signup` label if it can.
- Start one now, whatever the day: `gh workflow run venture.yml -f start=true` (or Actions → venture → Run workflow → "Start a new venture now"). A second card on the same day is titled `Venture <day> (2)`.
- Signups come in through the issue form `.github/ISSUE_TEMPLATE/early-access.yml` (label `signup`); the loop thanks each one on its issue. A signup is a distinct GitHub account (not the owner, not a bot) that opened an early-access issue or 👍'd the venture's signal issue.
- Settings: `TIME_VENTURE_PITCH`, `TIME_VENTURE_BOARD` (comma-separated seats), `TIME_VENTURE_CHAIR` (synthesizes the launch plans), `TIME_VENTURE_MEASURE_DAYS` (default 7).
- A page that still fails its checks after two fix rounds gets `needs-human`: fix it on `gh-pages` and comment `/time retry` on the card. A Venice outage only makes a stage wait; it never closes a card.
- The studio index is generated at `/ventures/` from `ventures/ventures.json`; launch posts are drafts on the card, never sent.
### Backends on Cloudflare (optional)
With a Cloudflare API token, each venture also gets a backend: its own Worker (`time-<slug>`, code in `.github/time/cloudflare/worker.js`), its own D1 database and Workers AI, serving the venture's Pages folder under its own domain. The page reaches it through `time.js` (written next to `index.html`; the build never edits it): `Time.save` / `Time.load` for share links, `Time.ai` for a small model, and accounts with passkeys (`Time.signUp`, `Time.signIn`, `Time.user`, `Time.mine` for what someone saved). Accounts work only on the venture's own domain: the passkey belongs to that host and the session is a `__Host-` cookie. Every call degrades to null, so a product still works on Pages alone and in the headless checks.
- Token: Cloudflare → My Profile → API Tokens → Create Token (custom) with Account · Workers Scripts · Edit, Account · D1 · Edit, Account · Workers AI · Read, and, for a domain, Zone · Workers Routes · Edit and Zone · DNS · Edit on that zone. Then `gh secret set CLOUDFLARE_API_TOKEN` and `gh secret set CLOUDFLARE_ACCOUNT_ID` (the account id is in the dashboard URL).
- Domain: `gh variable set TIME_VENTURE_DOMAIN --body example.com` (a zone on that account) puts each venture at `<slug>.example.com`. Without it, ventures live at `time-<slug>.<account>.workers.dev`.
- Optional: `TIME_VENTURE_AI_MODEL` (default `@cf/google/gemma-4-26b-a4b-it`).
- The doctor checks the token. Existing ventures get backends a couple per tick, and a new `worker.js` (from Time's update) redeploys every venture the same way. A Cloudflare failure never holds a venture: it launches on Pages and the next tick tries again.
+29
View File
@@ -0,0 +1,29 @@
name: Early access
description: Join the early-access list for one of the studio's ventures. No email, nothing charged.
title: "Early access: "
labels: ["signup"]
body:
- type: markdown
attributes:
value: |
Thanks for raising your hand. This is a **public** issue on Teahub: no email is collected and nothing is charged.
The studio replies here, and what you write below shapes the next version.
- type: input
id: venture
attributes:
label: Venture
description: The venture's name, as on the page you came from (the title already carries it, so you can leave this empty).
placeholder: e.g. mdecks
validations:
required: false
- type: input
id: plan
attributes:
label: Plan
description: The tier you would want. Paid tiers are early-access ideas; nothing is charged.
placeholder: e.g. Free, or a founding tier
- type: textarea
id: use
attributes:
label: What would you use it for?
description: One or two sentences about your use. This decides what gets built next.
+342
View File
@@ -0,0 +1,342 @@
"""Astra: the factory's daily planner. One model call a day that reads the ledger and the board and decides what
to work on next. It queues; it never spends on its own beyond its one call.
python3 .github/time/astra.py tick # daily (astra.yml): read, decide, queue up to a few cards
Luna is Time's deterministic tick (no model); Sol is the hourly heartbeat that executes cards with a seat; Astra is the
slow outer loop that orients them. Astra labels its picks `queued`, never `ready`: the heartbeat's promote_next moves
one queued card at a time to `ready` (oldest first, `Depends on #N` respected), so Astra's work lines up behind
whatever is already queued instead of running beside it on the same files. Guardrails are code, not prompt: at most
TIME_ASTRA_MAX_READY cards queued and TIME_ASTRA_MAX_NEW cards created per run; an existing issue is only queued if it
has a `DONE:` line and none of `queued`/`ready`/`needs-human`/`andon`; an open `andon` issue means Astra writes its note
and queues nothing; the spend cap is checked by the engine like any other call. Everything Astra does is a comment on
one issue, "Astra log", so a human can read the day's reasoning in one place and object there.
The router (time/router.json, `mode`: off | dry | live; router.py): instead of picking cards in prose, the seat judges
every candidate (its kind, its value 1-5, its size) and code ranks them with the owner's weights per kind, a restless
boost for kinds not picked lately, and the card's age. The log shows the ranked table. `dry` writes it and queues
nothing; `live` queues from the top while the day's money left covers each card's estimated cost (the allowance left
in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left),
creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed
card), and the heartbeat promotes queued cards in the ranked order.
`off`, or no router.json, is the planner as before.
"""
from __future__ import annotations
import base64
import datetime as dt
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
_rspec = importlib.util.spec_from_file_location("router", HERE / "router.py")
router = importlib.util.module_from_spec(_rspec)
_rspec.loader.exec_module(router)
ROUTER_FILE = "time/router.json"
SPINE = os.environ.get("TIME_SPINE") or "teahubxyz/teahub" # where AGENTS.md and docs/decisions.md live
LOG_TITLE = "Astra log"
SKIP = {"queued", "ready", "needs-human", "andon", "astra", # already moving, a human's, or Astra's own log
"venture", "venture-batch", "signal", "signup"} # the venture loop's own: it runs them itself
RULES = """You are Astra, the daily planner of a small, fully AI-run software factory (Teahub). Once a day you read the
state below and decide what the factory should work on next. You do not do the work: you queue cards, and a worker seat
builds queued cards one at a time; you only choose and write cards.
Rules:
- Prefer finishing over starting. Unblock open PRs and half-done cards before opening new work. Cards already labelled
`queued` or `ready` are already lined up; don't queue them again.
- A card is an issue with a clear list of changes and one `DONE:` line (what must be true for it to be finished, e.g.
"DONE: PR open, tests green"). Small, one PR each, a worker can finish in one session.
- Never touch workflows, secrets, variables, or anything under docs/decisions.md; those are a human's. Never queue
anything that posts outside the repo.
- Respect the budget: the day's cap is small. At most {max_ready} cards may be queued and {max_new} created.
- If nothing is worth doing, say so; an empty day is fine.
Answer with JSON only, no prose around it:
{{"note": "<3-6 sentences: what the factory did yesterday, what matters today, and why you chose these>",
"queue": [<existing open issue numbers to queue now; they must already have a DONE: line>],
"new": [{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>", "queue": true}}]}}"""
ROUTER = """You are Astra, the daily planner of a small, fully AI-run software factory (Teahub). Today you judge the
candidates below; code ranks them with the owner's weights and queues from the top, so don't choose: judge each one.
For every candidate give:
- kind: venture (a new venture, or a venture's next version), feature (new functionality), refinement (making what
exists better: fixes, polish, a live venture's page or skills) or optimization (cost, speed, reliability)
- value: 1-5, what finishing it is worth to the factory now (5: it unblocks the factory, or the owner asked for it and
it matters today; 1: nice to have, stale, or a duplicate of another card)
- size: S (well under one worker session), M (one session), L (more than one: it must be split first)
- why: one short sentence
- blocked: only when it can't move today, what it waits on (a PR that can't merge, a human, something outside the
repo); leave it out otherwise. A blocked card is never queued, however it scores. A card whose `Depends on #N` card
is still open is already held back by code.
You may also write up to {max_new} new cards for work the board is missing; they are candidates from tomorrow on.
Never write one that posts outside the repo or touches workflows, secrets or variables, and never one for work that is
already a candidate or is in the list of cards closed lately: that work is done or under way.
Answer with JSON only, no prose around it:
{{"note": "<3-6 sentences: what happened yesterday and what matters today>",
"assess": [{{"key": "<the candidate's key, e.g. #82>", "kind": "feature", "value": 3, "size": "M", "why": "..."}}],
"new": [{{"title": "<card title>", "body": "<what to change, as a list, ending with a DONE: line>"}}]}}"""
def env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name) or default)
except ValueError:
return default
def spine_file(path: str) -> str:
try:
import base64
return base64.b64decode(tm.gh("GET", f"/repos/{SPINE}/contents/{path}")["content"]).decode(errors="replace")
except Exception as e: # noqa: BLE001 the spine is context, not a dependency
return f"(could not read {SPINE}/{path}: {str(e)[:80]})"
def board() -> dict:
"""What Astra reads: open issues and PRs, yesterday's closed issues, andons, spend."""
open_issues = tm.issues("state=open")
closed = tm.gh("GET", f"/repos/{tm.REPO}/issues?type=issues&state=closed&limit=20")
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=20")
today, day_cap, month, month_cap = tm.budget()
trim = lambda i: {"n": i["number"], "title": i["title"], "labels": [l["name"] for l in i.get("labels") or []],
"has_done": bool(re.search(r"^\s*DONE:", i.get("body") or "", re.M | re.I)),
"body": (i.get("body") or "")[:600], "updated": i.get("updated_at")}
return {"issues": [trim(i) for i in open_issues if i["title"] != LOG_TITLE],
"closed_recently": [{"n": i["number"], "title": i["title"], "closed": i.get("closed_at")} for i in closed[:20]],
"prs": [{"n": p["number"], "title": p["title"], "head": p["head"]["ref"], "mergeable": p.get("mergeable")} for p in prs],
"andons": [i["number"] for i in open_issues if any(l["name"] == "andon" for l in i.get("labels") or [])],
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap,
"pools": {p["name"]: {"spent": round(tm.pool_spent(p), 2), "allowance": round(tm.allowance(p), 2)} for p in tm.pools()}}}
def log_issue() -> int:
for i in tm.issues("state=open"):
if i["title"] == LOG_TITLE:
return i["number"]
return tm.new_issue(LOG_TITLE, "Astra, the daily planner, writes one comment here per day: what it read, what it "
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
def decide(state: dict, context: str, seat: str) -> dict:
user = f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nBoard:\n{json.dumps(state, indent=1)}"
raw = tm.think(seat, None, None, RULES.format(max_ready=env_int("TIME_ASTRA_MAX_READY", 2), max_new=env_int("TIME_ASTRA_MAX_NEW", 2)), user)
m = re.search(r"\{.*\}", raw, re.S)
try:
out = json.loads(m.group(0)) if m else {}
except ValueError:
out = {}
picks = out.get("queue") if out.get("queue") is not None else out.get("ready") # older answers said "ready"
return {"note": str(out.get("note") or raw[:800]), "queue": [int(n) for n in picks or [] if str(n).isdigit()],
"new": [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]}
def router_config() -> dict:
"""time/router.json on the default branch; missing or unreadable, the router is off and Astra plans as before."""
try:
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{ROUTER_FILE}")
return router.config(base64.b64decode(f.get("content") or "").decode(errors="replace"))
except Exception as e: # noqa: BLE001
if "-> 404" not in str(e):
print(f"router config unreadable: {str(e)[:120]}")
return router.config(None)
def recent(log: int, days: int = 14) -> list[dict]:
"""The log's comments of the last `days` days, oldest first (Gitea pages from the oldest, so a plain limit=20
read the thread's first twenty comments forever)."""
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
out: list[dict] = []
for page in range(1, 5):
batch = tm.gh("GET", f"/repos/{tm.REPO}/issues/{log}/comments?since={since}&limit=50&page={page}") or []
out += batch
if len(batch) < 50:
break
return out
def parse(raw: str) -> dict:
"""The first JSON object in the seat's reply."""
dec = json.JSONDecoder()
for m in re.finditer(r"\{", raw or ""):
try:
out, _ = dec.raw_decode(raw, m.start())
except ValueError:
continue
if isinstance(out, dict):
return out
return {}
def money_left() -> tuple[float, str]:
"""What the router may queue against today, and where it comes from (#175): the allowance left in the pool that takes
the worker's route (as budget_check sees it), or the daily cap's remainder without one; either way no more than
what's left of the monthly cap."""
today, day_cap, month, month_cap = tm.budget()
spec = (os.environ.get("TIME_WORKER") or "cursor").strip()
p = tm.pool_for(tm.provider_of(tm.worker_seat_model(spec)[1])) if tm.seat_worker(spec) else None
left, source = (tm.allowance(p) - tm.pool_spent(p), f"pool {p['name']}") if p else (day_cap - today, "daily cap")
return max(0.0, min(left, month_cap - month)), source
def closed_lately(days: int = 14) -> list[dict]:
"""Cards closed (done, or dropped) in the last `days` days, newest first: the seat sees them so it doesn't write a
card for work already finished (it proposed the housekeeping label fix two days running after #137 had landed)."""
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
try:
return [i for i in tm.issues(f"state=closed&since={since}") if i.get("title") != LOG_TITLE]
except Exception as e: # noqa: BLE001 context, not a dependency
print(f"closed cards unreadable: {str(e)[:120]}")
return []
def _norm(title: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
def repeat_of(title: str, known: list[dict]) -> int | None:
"""The open or lately closed card a proposed title repeats, if any: the same words, or the whole of one title (four
words or more) inside the other. Not a similarity score: sibling cards share most of their words ("Tool: havre_check",
"Tool: havre_new") and are not repeats. Paraphrases are the seat's to avoid, with the closed list in front of it."""
t = _norm(title)
for i in known:
k = _norm(i.get("title", ""))
short, long_ = sorted((t, k), key=len)
if t and k and (t == k or (len(short.split()) >= 4 and short in long_)):
return i.get("number")
return None
def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict], max_ready: int, max_new: int) -> int:
"""The seat judges every candidate; router.py ranks them; live queues from the top, dry only shows the ranking."""
open_issues = [i for i in tm.issues("state=open") if i.get("title") != LOG_TITLE]
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)]
listing = "\n\n".join(f"### {c['key']}: {c['title']}\nlabels: {', '.join(c['labels']) or '-'}; opened "
f"{c['created'][:10] or '-'}; cards waiting on it: {c['unblocks']}\n{c['body'][:400]}" for c in cands)
today, day_cap, month, month_cap = tm.budget()
left, source = money_left()
closed = closed_lately()
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)"
user = (f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nSpend: ${today:.2f} of "
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month; ${left:.2f} left to queue against ({source})."
f"\n\nCards closed in the last 14 days (done or dropped; don't write these again):\n{done}"
f"\n\nCandidates ({len(cands)}):\n\n{listing}")
try:
out = parse(tm.think(seat, None, None, ROUTER.format(max_new=max_new), user))
except tm.Budget as e:
tm.comment(log, f"🔭 Astra: {e}. No ranking today.", {"beat": "astra", "router": cfg["mode"], "queued": 0, "capped": True})
return 0
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), dt.datetime.now(dt.timezone.utc).date())
picks = router.pick(ranked, cfg, left, max_ready)
new, repeats = [], []
for c in [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]:
n = repeat_of(str(c["title"]), open_issues + closed)
(repeats.append((c, n)) if n else new.append(c))
new = new[:max_new]
live, created = cfg["mode"] == "live", []
if live:
for r in picks:
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
for c in new:
body = str(c.get("body") or "")
if not router.DONE.search(body):
body += "\n\nDONE: PR open that does the above, tests green."
created.append(tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])["number"])
unjudged = sum(1 for r in ranked if not r["judged"])
w = cfg["weights"]
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to "
f"queue against ({source}). Weights: "
+ ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day.")
lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"]), ""]
if unjudged:
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
if live:
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.")
if created:
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
else:
lines.append("Dry run: nothing queued or created. Live, it would queue: "
+ (", ".join(f"#{r['n']}" for r in picks) or "nothing") + ".")
if new:
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
if repeats:
lines.append("Not written, repeats of existing cards: "
+ "; ".join(f"{router._txt(c['title'], 80)} (#{n})" for c, n in repeats) + ".")
meta = {"beat": "astra", "router": cfg["mode"], "queued": len(picks) if live else 0, "order": router.order(ranked),
"picked": [{"n": r["n"], "kind": r["kind"]} for r in picks] if live else [],
"would": [r["n"] for r in picks] if not live else [], "new": created}
tm.comment(log, "\n".join(lines), meta)
print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}")
return 0
def tick() -> int:
seat = (os.environ.get("TIME_ASTRA_SEAT") or "opus").strip()
max_ready, max_new = env_int("TIME_ASTRA_MAX_READY", 2), env_int("TIME_ASTRA_MAX_NEW", 2)
state = board()
log = log_issue()
if state["andons"]:
tm.comment(log, f"🔭 Astra: an andon is open (#{', #'.join(map(str, state['andons']))}), so I'm queuing nothing today. "
"Close it once it's handled and I'll plan tomorrow.", {"beat": "astra", "queued": 0, "andon": True})
return 0
context = spine_file("AGENTS.md")[:6000] + "\n\n" + "\n".join(spine_file("docs/decisions.md").splitlines()[-25:])
# the log thread is part of the context: humans object there
thread = recent(log)
human = [c["body"][:500] for c in thread if not tm.is_time(c)][-5:]
if human:
context += "\n\nRecent human replies on the Astra log (follow these):\n- " + "\n- ".join(human)
cfg = router_config()
if cfg["mode"] != "off":
return router_tick(cfg, seat, log, context, thread, max_ready, max_new)
try:
plan = decide(state, context, seat)
except tm.Budget as e:
tm.comment(log, f"🔭 Astra: {e}. No plan today.", {"beat": "astra", "queued": 0, "capped": True})
return 0
by_n = {i["n"]: i for i in state["issues"]}
queued, created, skipped = [], [], []
for n in plan["queue"][:max_ready]:
i = by_n.get(n)
if not i or not i["has_done"] or SKIP & set(i["labels"]):
skipped.append(n)
continue
tm.label(n, "queued")
queued.append(n)
for c in plan["new"][:max_new]:
body = str(c.get("body") or "")
if not re.search(r"^\s*DONE:", body, re.M | re.I):
body += "\n\nDONE: PR open that does the above, tests green."
issue = tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])
created.append(issue["number"])
if c.get("queue", c.get("ready", True)) and len(queued) + len(created) <= max_ready:
tm.label(issue["number"], "queued") # the heartbeat promotes it when nothing else is in flight
per = ", ".join(f"{k} ${v['spent']:.2f} of ${v['allowance']:.2f}" for k, v in state["spend"]["pools"].items()) \
or f"${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f}"
lines = [f"🔭 Astra ({seat}), spend today, {per}:", "", plan["note"], ""]
if queued:
lines.append("Queued: " + ", ".join(f"#{n}" for n in queued))
if created:
lines.append("New cards: " + ", ".join(f"#{n}" for n in created))
if skipped:
lines.append("Skipped (no DONE line, already moving, or a human's): " + ", ".join(f"#{n}" for n in skipped))
if not (queued or created):
lines.append("Queued nothing today.")
tm.comment(log, "\n".join(lines), {"beat": "astra", "queued": len(queued) + len(created), "queue": queued, "new": created})
print(f"astra: queued={queued} new={created} skipped={skipped}")
return 0
if __name__ == "__main__":
sys.exit(tick() if sys.argv[1:2] == ["tick"] else __doc__)
+177
View File
@@ -0,0 +1,177 @@
"""Cloudflare backing for ventures: each venture gets a domain, a Worker, a D1 database and Workers AI.
python3 .github/time/cloudflare.py check # what the token can do (doctor)
python3 .github/time/cloudflare.py deploy <slug> <pages-folder-url>
Off unless CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set. The page stays on GitHub Pages; the Worker
(cloudflare/worker.js, one script `time-<slug>` per venture) serves it under the venture's own domain and adds the API
the page's time.js client calls: share links stored in D1, and a rate-limited small model. With TIME_VENTURE_DOMAIN set
(a zone on the same Cloudflare account) a venture lives at <slug>.<domain>; without it, at the script's workers.dev URL.
Everything here is idempotent: deploying again updates the script in place and reuses the database and the domain.
The deployed revision (REV) is a hash of the worker code and its settings, so the venture loop can redeploy every
venture when Time's update brings a new worker, and leave them alone otherwise.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path
API = "https://api.cloudflare.com/client/v4"
HERE = Path(__file__).resolve().parent
WORKER = HERE / "cloudflare" / "worker.js"
CLIENT = HERE / "cloudflare" / "time.js"
COMPAT = "2026-09-01"
AI_MODEL = "@cf/google/gemma-4-26b-a4b-it"
def env(name: str, default: str = "") -> str:
return (os.environ.get(name) or default).strip()
def enabled() -> bool:
return bool(env("CLOUDFLARE_API_TOKEN") and env("CLOUDFLARE_ACCOUNT_ID"))
def account() -> str:
return f"/accounts/{env('CLOUDFLARE_ACCOUNT_ID')}"
def cf(method: str, path: str, body=None, *, data: bytes | None = None, ctype: str = "application/json"):
"""One Cloudflare API call; returns `result`. Errors name the call and Cloudflare's own messages, never the token."""
raw = data if data is not None else (json.dumps(body).encode() if body is not None else None)
req = urllib.request.Request(API + path, method=method, data=raw,
headers={"Authorization": f"Bearer {env('CLOUDFLARE_API_TOKEN')}", "Content-Type": ctype})
try:
with urllib.request.urlopen(req, timeout=60) as r:
out = json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
text = e.read().decode(errors="replace")
try:
msgs = "; ".join(f"{x.get('code')} {x.get('message')}" for x in json.loads(text).get("errors", []))
except ValueError:
msgs = text[:200]
raise RuntimeError(f"cloudflare {method} {path.split('?')[0].replace(env('CLOUDFLARE_ACCOUNT_ID'), '<account>')} "
f"-> {e.code}: {msgs}") from None
return out.get("result")
def script_name(slug: str) -> str:
return re.sub(r"[^a-z0-9-]", "-", f"time-{slug}".lower())[:63].strip("-")
def host(slug: str) -> str | None:
zone = env("TIME_VENTURE_DOMAIN").lower().strip(".")
return f"{slug}.{zone}" if zone else None
def settings(slug: str, origin: str, pages_origin: str) -> dict:
return {"SLUG": slug, "ORIGIN": origin.rstrip("/"), "PAGES_ORIGIN": pages_origin,
"AI_MODEL": env("TIME_VENTURE_AI_MODEL", AI_MODEL), "AI_DAILY": env("TIME_VENTURE_AI_DAILY", "200"),
"SAVE_DAILY": env("TIME_VENTURE_SAVE_DAILY", "2000")}
def rev(vars_: dict) -> str:
"""The revision a deploy would make: worker code + settings + domain. Equal revs mean nothing to do."""
h = hashlib.sha256(WORKER.read_bytes())
h.update(json.dumps({**vars_, "host": host(vars_["SLUG"]), "compat": COMPAT}, sort_keys=True).encode())
return h.hexdigest()[:12]
def database(slug: str) -> str:
"""The venture's own D1 database (created once). Returns its id."""
name = script_name(slug)
for d in cf("GET", f"{account()}/d1/database?name={name}") or []:
if d.get("name") == name:
return d["uuid"]
return cf("POST", f"{account()}/d1/database", {"name": name})["uuid"]
def multipart(parts: list[tuple[str, str, bytes]]) -> tuple[bytes, str]:
b = uuid.uuid4().hex
out = b"".join(f'--{b}\r\nContent-Disposition: form-data; name="{n}"; filename="{n}"\r\nContent-Type: {t}\r\n\r\n'.encode()
+ data + b"\r\n" for n, t, data in parts) + f"--{b}--\r\n".encode()
return out, f"multipart/form-data; boundary={b}"
def upload(name: str, db: str, vars_: dict) -> None:
meta = {"main_module": "worker.js", "compatibility_date": COMPAT,
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
data, ctype = multipart([("metadata", "application/json", json.dumps(meta).encode()),
("worker.js", "application/javascript+module", WORKER.read_bytes())])
cf("PUT", f"{account()}/workers/scripts/{name}", data=data, ctype=ctype)
def attach(name: str, hostname: str) -> str:
"""Point <slug>.<domain> at the script as a Workers custom domain (Cloudflare adds the DNS record and the cert)."""
zone = hostname.split(".", 1)[1]
zones = cf("GET", f"/zones?name={zone}") or []
if not zones:
raise RuntimeError(f"TIME_VENTURE_DOMAIN {zone} is not a zone this Cloudflare token can see")
cf("PUT", f"{account()}/workers/domains",
{"hostname": hostname, "service": name, "zone_id": zones[0]["id"], "environment": "production"})
return f"https://{hostname}/"
def workers_dev(name: str) -> str:
cf("POST", f"{account()}/workers/scripts/{name}/subdomain", {"enabled": True, "previews_enabled": False})
sub = (cf("GET", f"{account()}/workers/subdomain") or {}).get("subdomain")
if not sub:
raise RuntimeError("this Cloudflare account has no workers.dev subdomain yet: set TIME_VENTURE_DOMAIN instead")
return f"https://{name}.{sub}.workers.dev/"
def pages_origin_of(url: str) -> str:
m = re.match(r"(https?://[^/]+)", url)
return m.group(1) if m else ""
def deploy(slug: str, origin: str) -> dict:
"""Database, script and address for one venture. Returns {url, worker, db, rev, api}."""
vars_ = settings(slug, origin, pages_origin_of(origin))
vars_["REV"] = r = rev(vars_)
name = script_name(slug)
db = database(slug)
upload(name, db, vars_)
hostname = host(slug)
url = attach(name, hostname) if hostname else workers_dev(name)
return {"url": url, "worker": name, "db": db, "rev": r, "api": url + "api/"}
def client(api: str) -> str:
"""The time.js a venture page loads, pointed at its backend."""
return CLIENT.read_text().replace("__TIME_API__", api)
def check() -> tuple[bool, str]:
"""Can this token do what a venture needs? For the doctor: (ok, detail), with nothing secret in the detail."""
if not enabled():
return False, "not set (optional): CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID give ventures domains, a database and AI"
try:
cf("GET", f"{account()}/workers/scripts")
cf("GET", f"{account()}/d1/database?per_page=1")
zone = env("TIME_VENTURE_DOMAIN")
if zone and not cf("GET", f"/zones?name={zone}"):
return False, f"works, but TIME_VENTURE_DOMAIN {zone} is not a zone this token can see"
return True, f"works: Workers and D1; ventures live at {'<slug>.' + zone if zone else 'workers.dev'}"
except Exception as e: # noqa: BLE001
return False, f"set but failing: {str(e)[:160]}"
if __name__ == "__main__":
if sys.argv[1:2] == ["check"]:
ok, detail = check()
print(detail)
sys.exit(0 if ok else 1)
if sys.argv[1:2] == ["deploy"] and len(sys.argv) == 4:
print(json.dumps(deploy(sys.argv[2], sys.argv[3]), indent=1))
sys.exit(0)
sys.exit(__doc__)
+187
View File
@@ -0,0 +1,187 @@
// Offline test for passkey accounts in worker.js: a real SQLite standing in for D1 (node:sqlite) and a software
// authenticator with real P-256 and RSA keys. Run: node .github/time/cloudflare/test_auth.mjs
// Skips (and says so) when this node has no node:sqlite.
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
let DatabaseSync;
try {
process.removeAllListeners("warning");
({ DatabaseSync } = await import("node:sqlite"));
} catch {
console.log("AUTH SKIPPED: no node:sqlite");
process.exit(0);
}
const here = dirname(fileURLToPath(import.meta.url));
const worker = (await import(join(here, "worker.js"))).default;
// D1's surface over SQLite: prepare/bind/run/first/all, and batch as one transaction.
class D1 {
constructor() { this.db = new DatabaseSync(":memory:"); }
prepare(sql) {
const db = this.db;
const make = (args) => ({
bind: (...a) => make(a),
exec: () => ({ results: db.prepare(sql).all(...args) }),
run: async () => ({ results: db.prepare(sql).all(...args) }),
first: async () => db.prepare(sql).all(...args)[0] ?? null,
all: async () => ({ results: db.prepare(sql).all(...args) }),
});
return make([]);
}
async batch(stmts) {
this.db.exec("BEGIN");
try {
const out = stmts.map((s) => s.exec());
this.db.exec("COMMIT");
return out;
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
}
}
const HOST = "mdecks.yolo.lol", ORIGIN = `https://${HOST}`;
const env = { DB: new D1(), SLUG: "mdecks", REV: "r", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io" };
const enc = new TextEncoder();
const b64u = (buf) => Buffer.from(buf).toString("base64url");
const unb64u = (s) => new Uint8Array(Buffer.from(s, "base64url"));
const sha256 = async (d) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof d === "string" ? enc.encode(d) : d));
let ip = 0;
const call = (path, { body, cookie, origin, host = HOST } = {}) => worker.fetch(new Request(`https://${host}${path}`, {
method: body === undefined ? "GET" : "POST", body: body === undefined ? undefined : JSON.stringify(body),
headers: { "content-type": "application/json", "cf-connecting-ip": `10.0.0.${++ip % 250}`, ...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}) } }), env);
const cookieOf = (r) => (r.headers.get("set-cookie") || "").split(";")[0];
// The software authenticator: what a phone or a laptop's passkey would hand the page.
function authData(rpHash, flags, counter) {
const a = new Uint8Array(37);
a.set(rpHash);
a[32] = flags;
new DataView(a.buffer).setUint32(33, counter);
return a;
}
function rawToDer(raw) {
const int = (b) => {
let v = [...b];
while (v.length > 1 && v[0] === 0 && !(v[1] & 0x80)) v.shift();
if (v[0] & 0x80) v = [0, ...v];
return [0x02, v.length, ...v];
};
const body = [...int(raw.slice(0, 32)), ...int(raw.slice(32))];
return new Uint8Array([0x30, body.length, ...body]);
}
class Authenticator {
static async make(kind = "ES256") {
const a = new Authenticator();
a.kind = kind;
a.alg = kind === "ES256" ? -7 : -257;
a.keys = kind === "ES256"
? await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])
: await crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256" }, true, ["sign", "verify"]);
a.id = b64u(crypto.getRandomValues(new Uint8Array(16)));
a.counter = 0;
return a;
}
async create(options, { origin = ORIGIN } = {}) {
const clientData = enc.encode(JSON.stringify({ type: "webauthn.create", challenge: options.publicKey.challenge, origin }));
return { challengeId: options.challengeId, id: this.id, alg: this.alg, clientDataJSON: b64u(clientData),
authenticatorData: b64u(authData(await sha256(options.publicKey.rp.id), 0x45, this.counter)),
publicKey: b64u(await crypto.subtle.exportKey("spki", this.keys.publicKey)) };
}
async get(options, { origin = ORIGIN, counter = ++this.counter, rpId = options.publicKey.rpId } = {}) {
const clientData = enc.encode(JSON.stringify({ type: "webauthn.get", challenge: options.publicKey.challenge, origin }));
const ad = authData(await sha256(rpId), 0x05, counter);
const signed = new Uint8Array([...ad, ...(await sha256(clientData))]);
const algo = this.kind === "ES256" ? { name: "ECDSA", hash: "SHA-256" } : { name: "RSASSA-PKCS1-v1_5" };
let sig = new Uint8Array(await crypto.subtle.sign(algo, this.keys.privateKey, signed));
if (this.kind === "ES256") sig = rawToDer(sig);
return { challengeId: options.challengeId, id: this.id, clientDataJSON: b64u(clientData), authenticatorData: b64u(ad),
signature: b64u(sig) };
}
}
// sign up: options for this host, then the passkey's answer makes the account and a session
const key = await Authenticator.make();
let r = await call("/api/auth/register/start", { body: { name: "Ada" } });
let opts = await r.json();
assert.equal(opts.publicKey.rp.id, HOST);
assert.equal(opts.publicKey.user.name, "Ada");
assert.deepEqual(opts.publicKey.pubKeyCredParams.map((p) => p.alg), [-7, -257]);
r = await call("/api/auth/register/finish", { body: await key.create(opts) });
assert.equal(r.status, 200, await r.clone().text());
const ada = (await r.json()).user;
assert.equal(ada.name, "Ada");
let cookie = cookieOf(r);
assert.match(r.headers.get("set-cookie"), /^__Host-time=[A-Za-z0-9_-]+; Path=\/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000$/);
// the session knows who it is, and keeps what they save
assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada);
r = await call("/api/items", { body: { deck: "# mine" }, cookie });
const mine = (await r.json()).id;
await call("/api/items", { body: { deck: "# someone else's" } });
r = await call("/api/me/items", { cookie });
assert.deepEqual((await r.json()).items.map((i) => i.id), [mine]);
assert.equal((await call("/api/me/items")).status, 401);
// sign out, then sign back in with the same passkey (discoverable: no username asked)
r = await call("/api/auth/logout", { body: {}, cookie });
assert.match(r.headers.get("set-cookie"), /Max-Age=0/);
assert.equal((await call("/api/auth/me", { cookie })).status, 401);
opts = await (await call("/api/auth/login/start", { body: {} })).json();
assert.equal(opts.publicKey.rpId, HOST);
const answer = await key.get(opts);
r = await call("/api/auth/login/finish", { body: answer });
assert.equal(r.status, 200, await r.clone().text());
cookie = cookieOf(r);
assert.deepEqual((await (await call("/api/auth/me", { cookie })).json()).user, ada);
assert.deepEqual((await (await call("/api/me/items", { cookie })).json()).items.map((i) => i.id), [mine]);
// what must fail
const fails = async (path, body, why) => {
const res = await call(path, { body });
assert.equal(res.status, 400, `${why}: ${res.status}`);
return (await res.json()).error;
};
assert.match(await fails("/api/auth/login/finish", answer, "replayed answer"), /expired/);
let o = await (await call("/api/auth/login/start", { body: {} })).json();
assert.match(await fails("/api/auth/login/finish", await key.get(o, { origin: "https://evil.example" }), "phishing origin"), /origin/);
o = await (await call("/api/auth/login/start", { body: {} })).json();
assert.match(await fails("/api/auth/login/finish", await key.get(o, { rpId: "evil.example" }), "other site's passkey"), /relying party/);
o = await (await call("/api/auth/login/start", { body: {} })).json();
assert.match(await fails("/api/auth/login/finish", await key.get(o, { counter: 1 }), "counter went back"), /cloned/);
o = await (await call("/api/auth/login/start", { body: {} })).json();
const forged = await key.get(o);
const bad = unb64u(forged.signature);
bad[bad.length - 1] ^= 1;
assert.match(await fails("/api/auth/login/finish", { ...forged, signature: b64u(bad) }, "tampered signature"), /signature/);
o = await (await call("/api/auth/login/start", { body: {} })).json();
const stranger = await Authenticator.make();
assert.match(await fails("/api/auth/login/finish", await stranger.get(o), "unregistered passkey"), /unknown passkey/);
o = await (await call("/api/auth/register/start", { body: { name: "Again" } })).json();
assert.match(await fails("/api/auth/register/finish", await key.create(o), "same passkey twice"), /already registered/);
o = await (await call("/api/auth/register/start", { body: { name: "Eve" } })).json();
assert.match(await fails("/api/auth/register/finish", await stranger.create(o, { origin: "https://o.github.io" }), "from Pages"), /origin/);
assert.equal((await call("/api/auth/login/start", { body: {}, origin: "https://o.github.io" })).status, 403);
assert.equal((await call("/api/auth/me", { cookie: "__Host-time=forged" })).status, 401);
// a failed sign-up left no half-made account behind
assert.equal(env.DB.db.prepare("SELECT COUNT(*) AS n FROM users").all()[0].n, 1);
// RSA passkeys (RS256, some Windows Hello setups) work too
const rsa = await Authenticator.make("RS256");
o = await (await call("/api/auth/register/start", { body: { name: "Grace" } })).json();
r = await call("/api/auth/register/finish", { body: await rsa.create(o) });
assert.equal(r.status, 200, await r.clone().text());
o = await (await call("/api/auth/login/start", { body: {} })).json();
r = await call("/api/auth/login/finish", { body: await rsa.get(o) });
assert.equal((await r.json()).user.name, "Grace");
// the other endpoints still work on a database that has accounts
r = await call("/api/health");
assert.equal((await r.json()).ok, true);
console.log("AUTH OK");
+113
View File
@@ -0,0 +1,113 @@
// Offline test for worker.js: a fake D1 (just the statements the worker uses), fake Workers AI and a fake Pages origin.
// Run: node .github/time/cloudflare/test_worker.mjs (test_time.py runs it when node is around)
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const worker = (await import(join(here, "worker.js"))).default;
class FakeD1 {
constructor() { this.items = new Map(); this.quota = new Map(); this.created = 0; }
prepare(sql) {
const db = this;
const stmt = {
args: [],
bind(...a) { return { ...stmt, args: a }; },
async run() { return this.exec(); },
async first() { return (await this.exec()).results[0] ?? null; },
async exec() {
const a = this.args;
if (sql.startsWith("CREATE TABLE")) { db.created++; return { results: [] }; }
if (sql.startsWith("INSERT INTO quota")) {
const n = (db.quota.get(a[0]) || 0) + 1; db.quota.set(a[0], n); return { results: [{ n }] };
}
if (sql.startsWith("INSERT INTO items")) { db.items.set(a[0], a[1]); return { results: [] }; }
if (sql.startsWith("SELECT data FROM items")) { return { results: db.items.has(a[0]) ? [{ data: db.items.get(a[0]) }] : [] }; }
throw new Error("unexpected sql " + sql);
},
};
return stmt;
}
async batch(stmts) { return Promise.all(stmts.map((s) => s.exec())); }
}
const prompts = [];
const env = {
DB: new FakeD1(), SLUG: "mdecks", REV: "abc123", ORIGIN: "https://o.github.io/r/ventures/mdecks/", PAGES_ORIGIN: "https://o.github.io",
AI_MODEL: "@cf/test/model", AI_DAILY: "3", SAVE_DAILY: "100",
AI: { async run(model, input) { prompts.push({ model, input }); return { response: "a crisp title" }; } },
};
const pages = [];
globalThis.fetch = async (url, init) => {
pages.push({ url, init });
if (url.endsWith("/ventures/mdecks/shots")) return new Response(null, { status: 301, headers: { location: "https://o.github.io/r/ventures/mdecks/shots/" } });
return new Response(`<html>${url}</html>`, { status: 200, headers: { "content-type": "text/html" } });
};
const call = (path, init = {}, ip = "1.1.1.1") =>
worker.fetch(new Request("https://mdecks.yolo.lol" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
const post = (path, body, ip, headers = {}) =>
call(path, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json", ...headers } }, ip);
// health, without touching the database
let r = await call("/api/health");
assert.deepEqual(await r.json(), { ok: true, slug: "mdecks", rev: "abc123", ai: true });
assert.equal(env.DB.created, 0);
// save and load a creation; the link comes back to this domain
r = await post("/api/items", { slides: ["# Hi"] });
assert.equal(r.status, 201);
const saved = await r.json();
assert.match(saved.id, /^[a-f0-9]{12}$/);
assert.equal(saved.link, `https://mdecks.yolo.lol/?s=${saved.id}`);
assert.equal(env.DB.created, 7); // every table, created once
r = await call(`/api/items/${saved.id}`);
assert.deepEqual(await r.json(), { slides: ["# Hi"] });
assert.equal((await call("/api/items/doesnotexist1")).status, 404);
assert.equal((await post("/api/items", "not json")).status, 400);
assert.equal((await post("/api/items", JSON.stringify({ x: "y".repeat(70000) }))).status, 413);
// AI through Workers AI with the configured model; per-person and daily limits
r = await post("/api/ai", { prompt: "title for my deck", system: "be brief" }, "2.2.2.2");
assert.deepEqual(await r.json(), { text: "a crisp title" });
assert.equal(prompts[0].model, "@cf/test/model");
assert.deepEqual(prompts[0].input.messages.map((m) => m.role), ["system", "user"]);
assert.equal((await post("/api/ai", { prompt: "" }, "2.2.2.2")).status, 400);
assert.equal((await post("/api/ai", { prompt: "two" }, "3.3.3.3")).status, 200);
assert.equal((await post("/api/ai", { prompt: "three" }, "4.4.4.4")).status, 200);
assert.equal((await post("/api/ai", { prompt: "over the day" }, "5.5.5.5")).status, 429);
assert.equal(prompts.length, 3);
// newer models answer in the OpenAI shape
const openai = { ...env, DB: new FakeD1(), AI: { async run() { return { choices: [{ message: { content: "from choices" } }] }; } } };
r = await worker.fetch(new Request("https://mdecks.yolo.lol/api/ai", { method: "POST", body: JSON.stringify({ prompt: "hi" }),
headers: { "cf-connecting-ip": "9.9.9.9" } }), openai);
assert.deepEqual(await r.json(), { text: "from choices" });
// CORS: this domain and the Pages origin, nobody else
r = await call("/api/health", { headers: { origin: "https://o.github.io" } });
assert.equal(r.headers.get("access-control-allow-origin"), "https://o.github.io");
r = await call("/api/health", { headers: { origin: "https://evil.example" } });
assert.equal(r.headers.get("access-control-allow-origin"), null);
assert.equal((await call("/api/nope")).status, 404);
// a broken database answers 500 with JSON, not an exception
const broken = { ...env, DB: { prepare() { throw new Error("D1 down"); }, batch() { return Promise.reject(new Error("D1 down")); } } };
r = await worker.fetch(new Request("https://x.yolo.lol/api/items", { method: "POST", body: "{}" }), broken);
assert.equal(r.status, 500);
assert.match((await r.json()).detail, /D1 down/);
// the page: Pages under this domain, query kept, redirects kept on this host
r = await call("/?s=" + saved.id);
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/?s=" + saved.id);
assert.equal(r.headers.get("x-time-venture"), "mdecks");
assert.match(await r.text(), /ventures\/mdecks\//);
r = await call("/app.js");
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/app.js");
r = await call("");
assert.equal(pages.at(-1).url, "https://o.github.io/r/ventures/mdecks/");
r = await call("/shots");
assert.equal(r.status, 301);
assert.equal(r.headers.get("location"), "/shots/");
console.log("WORKER OK");
+94
View File
@@ -0,0 +1,94 @@
/* Time's backend client for a venture page. Written by Time next to index.html; the build never edits it.
Every call resolves to null (or false) when the backend can't help (no network, over the daily limit, or the page is
being checked locally), so a product uses these only for extras and works fully without them.
Time.save(obj) -> a share link (string) or null; when someone is signed in it is also theirs
Time.load() -> the object a share link (?s=...) carried, or null
Time.ai(prompt, {system}) -> text from a small model, or null
Time.ready -> a promise of true when the backend answers
Accounts, with passkeys (Face ID, Touch ID, Windows Hello, a phone): no passwords, no email.
Time.accounts -> true when this page can sign people in (its own domain, a browser with passkeys)
Time.user() -> { id, name } of whoever is signed in, or null
Time.signUp(name) -> makes an account with a new passkey and signs in: { id, name } or null
Time.signIn() -> signs in with a passkey made here before: { id, name } or null
Time.signOut() -> true
Time.mine() -> [{ id, link, created }] the signed-in person saved, or null */
(function () {
var API = "__TIME_API__";
var local = /^(localhost|127\.|\[::1\]|0\.0\.0\.0)/.test(location.hostname) || location.protocol === "file:";
var home = API.replace(/\/api\/?$/, "");
var accounts = !local && location.origin === home.replace(/\/$/, "") && !!window.PublicKeyCredential;
function call(path, init) {
if (local) return Promise.resolve(null);
return fetch(API + path, init).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function post(path, body) {
return call(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
}
function bytes(s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(s + "===".slice((s.length + 3) % 4)), function (c) { return c.charCodeAt(0); });
}
function text(buf) {
var b = new Uint8Array(buf), s = "";
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function user(r) { return r && r.user ? r.user : null; }
window.Time = {
api: API,
accounts: accounts,
ready: call("health").then(function (r) { return !!(r && r.ok); }),
save: function (obj) {
return post("items", obj).then(function (r) { return r && r.link ? r.link : null; });
},
load: function () {
var id = new URLSearchParams(location.search).get("s");
return id && /^[a-z0-9]{6,32}$/.test(id) ? call("items/" + id) : Promise.resolve(null);
},
ai: function (prompt, opts) {
return post("ai", { prompt: String(prompt || ""), system: opts && opts.system })
.then(function (r) { return r && typeof r.text === "string" ? r.text : null; });
},
user: function () {
return accounts ? call("auth/me").then(user) : Promise.resolve(null);
},
signUp: function (name) {
if (!accounts) return Promise.resolve(null);
return post("auth/register/start", { name: name }).then(function (o) {
if (!o) return null;
var pk = o.publicKey;
pk.challenge = bytes(pk.challenge);
pk.user.id = bytes(pk.user.id);
return navigator.credentials.create({ publicKey: pk }).then(function (c) {
var r = c.response;
return post("auth/register/finish", {
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
authenticatorData: text(r.getAuthenticatorData()), publicKey: text(r.getPublicKey()), alg: r.getPublicKeyAlgorithm()
}).then(user);
});
}).catch(function () { return null; });
},
signIn: function () {
if (!accounts) return Promise.resolve(null);
return post("auth/login/start").then(function (o) {
if (!o) return null;
o.publicKey.challenge = bytes(o.publicKey.challenge);
return navigator.credentials.get({ publicKey: o.publicKey }).then(function (c) {
var r = c.response;
return post("auth/login/finish", {
challengeId: o.challengeId, id: text(c.rawId), clientDataJSON: text(r.clientDataJSON),
authenticatorData: text(r.authenticatorData), signature: text(r.signature)
}).then(user);
});
}).catch(function () { return null; });
},
signOut: function () {
return accounts ? post("auth/logout").then(function () { return true; }) : Promise.resolve(true);
},
mine: function () {
return accounts ? call("me/items").then(function (r) { return r && r.items ? r.items : null; }) : Promise.resolve(null);
}
};
})();
+308
View File
@@ -0,0 +1,308 @@
// A venture's backend on Cloudflare: its own domain in front of its GitHub Pages folder, plus a small API.
// Time deploys one copy per venture (script time-<slug>) with its own D1 database; see .github/time/cloudflare.py.
//
// GET /api/health -> { ok, slug, rev, ai }
// POST /api/items -> { id, link } save a JSON creation (<= 64 KB), for share links
// GET /api/items/:id -> the saved JSON
// POST /api/ai -> { text } { prompt, system? } through Workers AI, rate limited
// accounts, with passkeys (WebAuthn): no passwords, no email, nothing to configure
// POST /api/auth/register/start { name } -> { challengeId, publicKey } options for navigator.credentials.create
// POST /api/auth/register/finish -> { user } sets the session cookie
// POST /api/auth/login/start -> { challengeId, publicKey } options for navigator.credentials.get
// POST /api/auth/login/finish -> { user } sets the session cookie
// GET /api/auth/me -> { user } or 401 POST /api/auth/logout
// GET /api/me/items -> { items } what the signed-in person saved (POST /api/items records the owner)
// anything else -> the venture's page on GitHub Pages (ORIGIN), served under this domain
//
// Accounts live on the venture's own domain: the passkey's relying party is this host, and the session is a
// __Host- cookie (HttpOnly, Secure, SameSite=Lax). On GitHub Pages or in the checks there is no session, by design.
//
// Bindings: DB (D1), AI (Workers AI). Vars: SLUG, ORIGIN, PAGES_ORIGIN, REV, AI_MODEL, AI_DAILY, SAVE_DAILY.
const SCHEMA = [
"CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT NOT NULL, created TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS creds (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, spki TEXT NOT NULL, alg INTEGER NOT NULL, " +
"counter INTEGER NOT NULL, created TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS challenges (id TEXT PRIMARY KEY, challenge TEXT NOT NULL, kind TEXT NOT NULL, user_id TEXT, " +
"name TEXT, expires INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS owned (item_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, created TEXT NOT NULL)",
];
const COOKIE = "__Host-time";
const SESSION_DAYS = 30;
const CHALLENGE_MS = 5 * 60 * 1000;
const MAX_ITEM = 65536;
const MAX_PROMPT = 4000;
let ready = null;
const json = (body, status = 200, headers = {}) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } });
function cors(req, env) {
const origin = req.headers.get("origin");
if (!origin) return {};
const self = new URL(req.url).origin;
const ok = origin === self || (env.PAGES_ORIGIN && origin === env.PAGES_ORIGIN);
return ok ? { "access-control-allow-origin": origin, "access-control-allow-methods": "GET, POST",
"access-control-allow-headers": "content-type", "vary": "origin" } : {};
}
async function setup(env) {
ready ??= env.DB.batch(SCHEMA.map((s) => env.DB.prepare(s))).catch((e) => { ready = null; throw e; });
return ready;
}
async function who(req, env) {
const ip = req.headers.get("cf-connecting-ip") || "?";
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${env.SLUG}:${ip}`));
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
async function spend(env, kind, person, perPerson, total) {
const day = new Date().toISOString().slice(0, 10);
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
return mine.results[0].n <= perPerson && all.results[0].n <= total;
}
// ---------- passkeys (WebAuthn) ----------
const enc = new TextEncoder();
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64u = (s) => Uint8Array.from(atob(String(s).replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const random = (n) => crypto.getRandomValues(new Uint8Array(n));
const sha256 = async (data) => new Uint8Array(await crypto.subtle.digest("SHA-256", typeof data === "string" ? enc.encode(data) : data));
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
const ALGS = {
"-7": { importAs: { name: "ECDSA", namedCurve: "P-256" }, verifyAs: { name: "ECDSA", hash: "SHA-256" }, der: true },
"-257": { importAs: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verifyAs: { name: "RSASSA-PKCS1-v1_5" }, der: false },
};
// Authenticators sign ES256 as DER (SEQUENCE of two INTEGERs); WebCrypto wants r || s, 32 bytes each.
function derToRaw(der) {
let i = 2;
if (der[1] & 0x80) i += der[1] & 0x7f;
const part = () => {
if (der[i++] !== 0x02) throw new Error("bad signature");
const len = der[i++];
let v = der.slice(i, i + len);
i += len;
while (v.length > 32 && v[0] === 0) v = v.slice(1);
const out = new Uint8Array(32);
out.set(v, 32 - v.length);
return out;
};
const r = part(), s = part();
const raw = new Uint8Array(64);
raw.set(r);
raw.set(s, 32);
return raw;
}
async function challenge(env, kind, extra = {}) {
const id = b64u(random(12)), value = b64u(random(32)), now = Date.now();
await env.DB.batch([
env.DB.prepare("DELETE FROM challenges WHERE expires < ?").bind(now),
env.DB.prepare("INSERT INTO challenges (id, challenge, kind, user_id, name, expires) VALUES (?, ?, ?, ?, ?, ?)")
.bind(id, value, kind, extra.user ?? null, extra.name ?? null, now + CHALLENGE_MS),
]);
return { id, value };
}
// One use only: the row is gone whether or not the answer checks out.
async function takeChallenge(env, id, kind) {
const row = await env.DB.prepare("DELETE FROM challenges WHERE id = ? RETURNING challenge, kind, user_id, name, expires")
.bind(String(id || "")).first();
if (!row || row.kind !== kind || row.expires < Date.now()) throw new AuthError("that sign-in attempt expired; try again");
return row;
}
class AuthError extends Error {}
// What the browser and the authenticator both vouched for: the ceremony, our challenge, our origin, our host.
async function checkCeremony(url, type, expected, clientDataB64, authDataB64) {
const clientRaw = unb64u(clientDataB64);
const client = JSON.parse(new TextDecoder().decode(clientRaw));
if (client.type !== type) throw new AuthError("wrong ceremony");
if (client.challenge !== expected) throw new AuthError("challenge mismatch");
if (client.origin !== url.origin) throw new AuthError("wrong origin");
const auth = unb64u(authDataB64);
if (auth.length < 37 || !same(auth.slice(0, 32), await sha256(url.hostname))) throw new AuthError("wrong relying party");
const flags = auth[32];
if (!(flags & 0x01)) throw new AuthError("no user presence");
const counter = new DataView(auth.buffer, auth.byteOffset + 33, 4).getUint32(0);
return { clientRaw, auth, flags, counter };
}
async function startSession(env, url, userId, h) {
const token = b64u(random(32));
const expires = Date.now() + SESSION_DAYS * 86400e3;
await env.DB.prepare("INSERT INTO sessions (hash, user_id, expires) VALUES (?, ?, ?)").bind(b64u(await sha256(token)), userId, expires).run();
const user = await env.DB.prepare("SELECT id, name FROM users WHERE id = ?").bind(userId).first();
return json({ user }, 200, { ...h, "set-cookie": `${COOKIE}=${token}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}` });
}
function cookieToken(req) {
const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]+)`));
return m ? m[1] : null;
}
async function currentUser(req, env) {
const token = cookieToken(req);
if (!token) return null;
return env.DB.prepare("SELECT users.id, users.name FROM sessions JOIN users ON users.id = sessions.user_id " +
"WHERE sessions.hash = ? AND sessions.expires > ?").bind(b64u(await sha256(token)), Date.now()).first();
}
async function auth(req, env, url, path, h) {
// Accounts belong to this domain: refuse cross-origin calls outright.
const origin = req.headers.get("origin");
if (origin && origin !== url.origin) return json({ error: "sign in on the venture's own domain" }, 403, h);
if (path === "/api/auth/me") {
const user = await currentUser(req, env);
return user ? json({ user }, 200, h) : json({ error: "not signed in" }, 401, h);
}
if (path === "/api/auth/logout" && req.method === "POST") {
const token = cookieToken(req);
if (token) await env.DB.prepare("DELETE FROM sessions WHERE hash = ?").bind(b64u(await sha256(token))).run();
return json({ ok: true }, 200, { ...h, "set-cookie": `${COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0` });
}
if (req.method !== "POST") return json({ error: "no such endpoint" }, 404, h);
let body;
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); }
try {
if (path === "/api/auth/register/start") {
if (!(await spend(env, "register", await who(req, env), 20, 2000))) return json({ error: "over today's limit" }, 429, h);
const name = String(body.name || "").trim().slice(0, 40) || "Me";
const user = b64u(random(16));
const c = await challenge(env, "register", { user, name });
return json({ challengeId: c.id, publicKey: {
challenge: c.value, rp: { name: env.SLUG, id: url.hostname }, user: { id: user, name, displayName: name },
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, attestation: "none", timeout: 60000,
} }, 200, h);
}
if (path === "/api/auth/register/finish") {
const c = await takeChallenge(env, body.challengeId, "register");
const { flags, counter } = await checkCeremony(url, "webauthn.create", c.challenge, body.clientDataJSON, body.authenticatorData);
if (!(flags & 0x40)) throw new AuthError("no credential in the answer");
const alg = ALGS[String(body.alg)];
if (!alg || !body.publicKey || !/^[A-Za-z0-9_-]{16,1400}$/.test(String(body.id))) throw new AuthError("unsupported passkey");
await crypto.subtle.importKey("spki", unb64u(body.publicKey), alg.importAs, false, ["verify"]); // throws if it isn't a key
const now = new Date().toISOString();
try {
await env.DB.batch([
env.DB.prepare("INSERT INTO users (id, name, created) VALUES (?, ?, ?)").bind(c.user_id, c.name, now),
env.DB.prepare("INSERT INTO creds (id, user_id, spki, alg, counter, created) VALUES (?, ?, ?, ?, ?, ?)")
.bind(body.id, c.user_id, body.publicKey, Number(body.alg), counter, now),
]);
} catch { throw new AuthError("that passkey is already registered here"); }
return startSession(env, url, c.user_id, h);
}
if (path === "/api/auth/login/start") {
if (!(await spend(env, "login", await who(req, env), 60, 5000))) return json({ error: "over today's limit" }, 429, h);
const c = await challenge(env, "login");
return json({ challengeId: c.id, publicKey: { challenge: c.value, rpId: url.hostname, userVerification: "preferred",
timeout: 60000 } }, 200, h);
}
if (path === "/api/auth/login/finish") {
const c = await takeChallenge(env, body.challengeId, "login");
const { clientRaw, auth: authData, counter } = await checkCeremony(url, "webauthn.get", c.challenge, body.clientDataJSON,
body.authenticatorData);
const cred = await env.DB.prepare("SELECT user_id, spki, alg, counter FROM creds WHERE id = ?").bind(String(body.id || "")).first();
if (!cred) throw new AuthError("unknown passkey for this site");
const alg = ALGS[String(cred.alg)];
const key = await crypto.subtle.importKey("spki", unb64u(cred.spki), alg.importAs, false, ["verify"]);
const signed = new Uint8Array(authData.length + 32);
signed.set(authData);
signed.set(await sha256(clientRaw), authData.length);
let sig = unb64u(body.signature);
if (alg.der) sig = derToRaw(sig);
if (!(await crypto.subtle.verify(alg.verifyAs, key, sig, signed))) throw new AuthError("signature does not check out");
if ((counter || cred.counter) && counter <= cred.counter) throw new AuthError("this passkey looks cloned");
await env.DB.prepare("UPDATE creds SET counter = ? WHERE id = ?").bind(counter, body.id).run();
return startSession(env, url, cred.user_id, h);
}
} catch (e) {
if (e instanceof AuthError) return json({ error: e.message }, 400, h);
throw e;
}
return json({ error: "no such endpoint" }, 404, h);
}
async function api(req, env, url, h) {
const path = url.pathname.replace(/\/+$/, "");
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: h });
if (path === "/api/health") return json({ ok: true, slug: env.SLUG, rev: env.REV, ai: !!env.AI }, 200, h);
await setup(env);
if (path.startsWith("/api/auth/")) return auth(req, env, url, path, h);
if (path === "/api/me/items") {
const user = await currentUser(req, env);
if (!user) return json({ error: "not signed in" }, 401, h);
const rows = await env.DB.prepare("SELECT item_id, created FROM owned WHERE user_id = ? ORDER BY created DESC LIMIT 50")
.bind(user.id).all();
return json({ items: rows.results.map((r) => ({ id: r.item_id, link: `${url.origin}/?s=${r.item_id}`, created: r.created })) }, 200, h);
}
if (path === "/api/items" && req.method === "POST") {
const text = await req.text();
if (text.length > MAX_ITEM) return json({ error: "too big (64 KB max)" }, 413, h);
try { JSON.parse(text); } catch { return json({ error: "send JSON" }, 400, h); }
if (!(await spend(env, "save", await who(req, env), 50, Number(env.SAVE_DAILY || 2000))))
return json({ error: "over today's limit" }, 429, h);
const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
const now = new Date().toISOString();
const user = await currentUser(req, env);
await env.DB.batch([env.DB.prepare("INSERT INTO items (id, data, created) VALUES (?, ?, ?)").bind(id, text, now),
...(user ? [env.DB.prepare("INSERT INTO owned (item_id, user_id, created) VALUES (?, ?, ?)").bind(id, user.id, now)] : [])]);
return json({ id, link: `${url.origin}/?s=${id}` }, 201, h);
}
const item = path.match(/^\/api\/items\/([a-z0-9]{6,32})$/);
if (item && req.method === "GET") {
const row = await env.DB.prepare("SELECT data FROM items WHERE id = ?").bind(item[1]).first();
return row ? new Response(row.data, { headers: { "content-type": "application/json; charset=utf-8", ...h } })
: json({ error: "not found" }, 404, h);
}
if (path === "/api/ai" && req.method === "POST") {
if (!env.AI) return json({ error: "no AI on this venture" }, 501, h);
let body;
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400, h); }
const prompt = String(body.prompt || "").slice(0, MAX_PROMPT);
if (!prompt) return json({ error: "prompt is empty" }, 400, h);
if (!(await spend(env, "ai", await who(req, env), 20, Number(env.AI_DAILY || 200))))
return json({ error: "over today's limit" }, 429, h);
const messages = [...(body.system ? [{ role: "system", content: String(body.system).slice(0, MAX_PROMPT) }] : []),
{ role: "user", content: prompt }];
const out = await env.AI.run(env.AI_MODEL, { messages, max_tokens: 512 });
// older models answer { response }, newer ones the OpenAI shape { choices: [{ message: { content } }] }
const text = out.response ?? out.choices?.[0]?.message?.content ?? "";
return json({ text: typeof text === "string" ? text : JSON.stringify(text) }, 200, h);
}
return json({ error: "no such endpoint" }, 404, h);
}
// The page itself: the venture's folder on GitHub Pages, under this domain. Pages redirects folder URLs without a
// trailing slash to its own host; those are rewritten to stay here.
async function page(req, env, url) {
const origin = env.ORIGIN.replace(/\/+$/, "");
const up = await fetch(origin + url.pathname + url.search, { redirect: "manual", cf: { cacheTtl: 60, cacheEverything: true } });
const headers = new Headers(up.headers);
const loc = headers.get("location");
if (loc && loc.startsWith(origin)) headers.set("location", loc.slice(origin.length) || "/");
headers.set("x-time-venture", env.SLUG);
return new Response(up.body, { status: up.status, headers });
}
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
const h = cors(req, env);
try { return await api(req, env, url, h); }
catch (e) { return json({ error: "backend error", detail: String(e).slice(0, 200) }, 500, h); }
}
return page(req, env, url);
},
};
+271
View File
@@ -0,0 +1,271 @@
"""Housekeeping: the daily sweep. Deterministic on purpose: no model decides what to delete or close. stdlib only.
python3 .github/time/housekeeping.py # daily (housekeeping.yml), or by hand
Four steps, each idempotent (a second run finds nothing to do) and each logged:
1. branches delete every branch whose PR merged into the default branch. Never the default branch, `gh-pages`, a
protected branch, a branch with an open PR, or a branch that has commits newer than its merge.
2. signals close `signal` issues whose venture's measuring window (TIME_VENTURE_MEASURE_DAYS, default 7) has passed
with no signups (no 👍 on the issue, no early-access issue for the venture), with one closing line.
3. milestones close milestones with no open issues and at least one closed one.
4. stale list, never act: `needs-human` untouched for 3 days, open PRs idle 7 days, cards parked after their
automatic retries, cards whose last beat is `error` for 2 days. One comment on the Astra log; none if empty.
Then one summary line on the Astra log. Only TIME_MODE=live changes anything; anything else (the default) prints what it
would do and writes nothing, comments included. A step that fails is logged and the others still run (exit code 1).
"""
from __future__ import annotations
import datetime as dt
import importlib.util
import os
import re
import sys
import urllib.parse
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
NEVER = {"gh-pages"} # besides the default branch and protected ones
LOG_TITLE = "Astra log"
LOG_FALLBACK = 57 # the Astra log's number here, if it cannot be found by title
NEEDS_HUMAN_DAYS, PR_IDLE_DAYS, ERROR_DAYS = 3, 7, 2
def log(msg: str) -> None:
print(msg, flush=True)
def repo() -> str:
return f"/repos/{tm.REPO}"
def paged(path: str, pages: int = 40) -> list[dict]:
sep = "&" if "?" in path else "?"
out: list[dict] = []
for page in range(1, pages + 1):
batch = tm.gh("GET", f"{path}{sep}limit=50&page={page}") or [] # Gitea answers an issue with no reactions with null
out += batch
if len(batch) < 50:
break
return out
def parse(ts: str | None) -> dt.datetime | None:
if not ts:
return None
try:
d = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
return d if d.tzinfo else d.replace(tzinfo=dt.timezone.utc)
def age_days(ts: str | None, now: dt.datetime) -> float:
d = parse(ts)
return (now - d).total_seconds() / 86400 if d else 0.0
def labelled(name: str, query: str = "state=open") -> list[dict]:
"""Issues (and PRs) that carry `name`. Each row is checked too: Gitea ignores a filter on a label the repo does not
have and returns everything, which on the first live run listed every open issue as a parked card."""
return [i for i in paged(f"{repo()}/issues?{query}&labels={urllib.parse.quote(name)}") if tm.has_label(i, name)]
def same_repo(pr: dict) -> bool:
return ((pr.get("head") or {}).get("repo") or {}).get("full_name", tm.REPO) == tm.REPO # a fork's branch is not ours
# ---------- 1. branches ----------
def step_branches(live: bool) -> int:
default = tm.default_branch()
open_heads = {p["head"]["ref"] for p in paged(f"{repo()}/pulls?state=open") if same_repo(p)}
merged: dict[str, dt.datetime | None] = {} # head ref -> when its latest merged PR merged
for p in paged(f"{repo()}/pulls?state=closed&sort=recentupdate"):
if (p.get("merged") or p.get("merged_at")) and (p.get("base") or {}).get("ref") == default and same_repo(p):
at = parse(p.get("merged_at"))
ref = p["head"]["ref"]
merged[ref] = max([d for d in (merged.get(ref), at) if d], default=None)
deleted = 0
for b in paged(f"{repo()}/branches"):
name = b["name"]
if name == default or name in NEVER or b.get("protected") or name not in merged:
continue
if name in open_heads:
log(f"branches: keep {name} (open PR)")
continue
tip = parse((b.get("commit") or {}).get("timestamp"))
if tip and merged[name] and tip > merged[name]:
log(f"branches: keep {name} (commits after its merge)")
continue
if not live:
log(f"branches: would delete {name}")
continue
try:
tm.gh("DELETE", f"{repo()}/branches/{urllib.parse.quote(name, safe='')}")
except RuntimeError as e:
if "-> 404" not in str(e): # already gone is fine
raise
continue
log(f"branches: deleted {name}")
deleted += 1
return deleted
# ---------- 2. signal issues ----------
def person(user: dict | None) -> str | None:
login = ((user or {}).get("login") or "").lower()
if not login or (user or {}).get("type") == "Bot" or login in (tm.REPO.split("/")[0].lower(), tm.TIME_USER.lower(), tm.TIME_BOT.lower()):
return None
return login
def signup_people() -> dict[str, set[str]]:
"""venture slug -> distinct people who opened an early-access issue for it."""
out: dict[str, set[str]] = {}
for i in labelled("signup", "state=all&type=issues"):
m = re.search(r"^###\s*Venture\s*$\n+(.*?)(?=^###\s|\Z)", i.get("body") or "", re.M | re.S)
who = person(i.get("user"))
if m and who and m.group(1).strip():
out.setdefault(m.group(1).strip().lower(), set()).add(who)
return out
def step_signals(live: bool, now: dt.datetime) -> int:
days = int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7))
due = [i for i in labelled("signal", "state=open&type=issues")
if not i.get("pull_request") and age_days(i.get("created_at"), now) >= days]
if not due:
return 0
signups = signup_people()
closed = 0
for i in due:
n = i["number"]
m = re.search(r"[?&]venture=([\w.-]+)", i.get("body") or "")
if not m:
log(f"signals: keep #{n} (cannot tell which venture it belongs to)")
continue
people = set(signups.get(m.group(1).lower(), set()))
people |= {w for r in paged(f"{repo()}/issues/{n}/reactions") if r.get("content") in ("+1", "👍")
and (w := person(r.get("user")))}
if people:
log(f"signals: keep #{n} ({len(people)} signup(s))")
continue
if not live:
log(f"signals: would close #{n} (0 signups after {days} days)")
continue
tm.gh("PATCH", f"{repo()}/issues/{n}", {"state": "closed"})
tm.comment(n, f"Closed by housekeeping: 0 signups in the {days}-day measuring window.", {"housekeeping": "signal"})
log(f"signals: closed #{n}")
closed += 1
return closed
# ---------- 3. milestones ----------
def step_milestones(live: bool) -> int:
closed = 0
for m in paged(f"{repo()}/milestones?state=open"):
if int(m.get("open_issues") or 0) or not int(m.get("closed_issues") or 0):
continue
if not live:
log(f"milestones: would close {m.get('title')!r}")
continue
tm.gh("PATCH", f"{repo()}/milestones/{m['id']}", {"state": "closed"})
log(f"milestones: closed {m.get('title')!r}")
closed += 1
return closed
# ---------- 4. the stale list ----------
def stale(now: dt.datetime) -> dict[str, list[tuple[int, float, str]]]:
"""category -> [(number, age in days, title)]. Read-only."""
out: dict[str, list[tuple[int, float, str]]] = {}
def add(cat: str, number: int, age: float, title: str) -> None:
out.setdefault(cat, []).append((number, age, title))
for i in labelled("needs-human"): # issues and PRs alike
age = age_days(i.get("updated_at") or i.get("created_at"), now)
if age > NEEDS_HUMAN_DAYS:
add(f"`needs-human`, untouched for over {NEEDS_HUMAN_DAYS} days", i["number"], age, i.get("title", ""))
for p in paged(f"{repo()}/pulls?state=open"):
age = age_days(p.get("updated_at") or p.get("created_at"), now)
if age >= PR_IDLE_DAYS:
add(f"open PRs idle {PR_IDLE_DAYS} days or more", p["number"], age, p.get("title", ""))
for i in labelled("parked", "state=open&type=issues"):
add("parked cards", i["number"], age_days(i.get("updated_at") or i.get("created_at"), now), i.get("title", ""))
for card in labelled("ready", "state=open&type=issues"): # last beat: error for a while, or parked
beats = [b for b in tm.beats_of(paged(f"{repo()}/issues/{card['number']}/comments")) if b["beat"] not in ("blocked", "capped")]
last = beats[-1] if beats else None
if not last:
continue
age = age_days(last["at"], now)
if last["beat"] == "parked":
add("parked cards", card["number"], age, card.get("title", ""))
elif last["beat"] == "error" and age > ERROR_DAYS:
add(f"`error` cards older than {ERROR_DAYS} days", card["number"], age, card.get("title", ""))
for cat, rows in out.items(): # a card can be listed under two names (parked label and beat): once per category
out[cat] = list({r[0]: r for r in rows}.values())
return out
def stale_text(found: dict[str, list[tuple[int, float, str]]]) -> str:
lines = ["🧹 Housekeeping: the stale list (nothing was changed; for the steward)", ""]
for cat, rows in found.items():
lines.append(f"- {cat}: " + "; ".join(f"#{n} {t[:60]} ({a:.0f}d)" for n, a, t in sorted(rows)))
return "\n".join(lines)
def log_issue() -> int:
for i in labelled("astra", "state=open&type=issues"):
if i.get("title") == LOG_TITLE:
return i["number"]
return LOG_FALLBACK
# ---------- the run ----------
def main() -> int:
live = (os.environ.get("TIME_MODE") or "dry-run").strip() == "live"
now = dt.datetime.now(dt.timezone.utc)
log(f"housekeeping {now:%Y-%m-%dT%H:%MZ} mode={'live' if live else 'dry-run'}")
failed = 0
done = {"branches": 0, "signals": 0, "milestones": 0}
found: dict = {}
for name, step in (("branches", lambda: step_branches(live)), ("signals", lambda: step_signals(live, now)),
("milestones", lambda: step_milestones(live))):
try:
done[name] = step()
except Exception as e: # noqa: BLE001 one step must not stop the others
failed += 1
log(f"{name}: failed: {type(e).__name__}: {str(e)[:200]}")
try:
found = stale(now)
except Exception as e: # noqa: BLE001
failed += 1
log(f"stale: failed: {type(e).__name__}: {str(e)[:200]}")
count = len({r[0] for rows in found.values() for r in rows})
if found:
log(stale_text(found))
line = (f"🧹 Housekeeping: {done['branches']} branch(es) deleted, {done['signals']} signal issue(s) closed, "
f"{done['milestones']} milestone(s) closed, {count} stale." + (f" {failed} step(s) failed; see the run log." if failed else ""))
log(line)
if live:
try:
target = log_issue()
if found:
tm.comment(target, stale_text(found), {"housekeeping": "stale", "count": count})
tm.comment(target, line, {"housekeeping": "run", **done, "stale": count})
except Exception as e: # noqa: BLE001
failed += 1
log(f"log: failed: {type(e).__name__}: {str(e)[:200]}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+261
View File
@@ -0,0 +1,261 @@
// AICharts: natural-language charts for the Chart.js you already have, without sending your rows to a model.
//
// The model sees a dataset's schema, a few sample rows and aggregates, never the data itself. It answers with a small
// JSON chart spec. The spec is validated, then a fixed set of operations (filter, group, sort, bin, limit) computes the
// chart's data in the browser. No model-written code runs anywhere.
//
// attach(chart, { data, schema, endpoint }) -> { ask(prompt) } drive an existing Chart.js chart
// <ai-chart endpoint="..." dataset="..."> a framework-free element: prompt box, chart, save
// validateSpec(spec, schema) -> { ok, errors, fields }
// applySpec(rows, spec) -> rows the fixed operations
// chartConfig(rows, spec) -> a Chart.js config
//
// A spec:
// { "title": "...", "mark": "bar" | "line" | "pie" | "doughnut" | "scatter",
// "transform": [ { "op": "filter", "field": "region", "in": ["West", "East"] },
// { "op": "group", "by": ["month"], "agg": "sum", "field": "amount", "as": "value" },
// { "op": "sort", "field": "month", "order": "asc" }, { "op": "limit", "n": 12 } ],
// "x": "month", "y": "value", "series": "region" (optional) }
export const MARKS = ["bar", "line", "pie", "doughnut", "scatter"];
export const AGGS = ["sum", "avg", "count", "min", "max"];
export const OPS = ["filter", "group", "sort", "bin", "limit"];
const MAX_STEPS = 8;
const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,40}$/;
// schema: { columns: [{ name, type: "string" | "number" | "month" | "date" }] }
export function validateSpec(spec, schema) {
const errors = [];
const err = (m) => { errors.push(m); return { ok: false, errors }; };
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return err("the spec must be a JSON object");
if (!MARKS.includes(spec.mark)) err(`mark must be one of ${MARKS.join(", ")}`);
const types = new Map((schema?.columns || []).map((c) => [c.name, c.type]));
const steps = spec.transform ?? [];
if (!Array.isArray(steps)) return err("transform must be a list of steps");
if (steps.length > MAX_STEPS) err(`at most ${MAX_STEPS} transform steps`);
const has = (f) => types.has(f);
steps.forEach((s, i) => {
const at = `step ${i + 1}`;
if (!s || typeof s !== "object" || !OPS.includes(s.op)) { err(`${at}: op must be one of ${OPS.join(", ")}`); return; }
if (s.op === "filter") {
if (!has(s.field)) { err(`${at}: unknown field ${JSON.stringify(s.field)}`); return; }
const keys = ["eq", "in", "gte", "lte"].filter((k) => k in s);
if (!keys.length) err(`${at}: a filter needs eq, in, gte or lte`);
if ("in" in s && (!Array.isArray(s.in) || s.in.length > 50)) err(`${at}: in must be a list of at most 50 values`);
} else if (s.op === "group") {
const by = s.by ?? [];
if (!Array.isArray(by) || by.length > 2 || !by.every(has)) { err(`${at}: by must list up to 2 known fields`); return; }
if (!AGGS.includes(s.agg)) { err(`${at}: agg must be one of ${AGGS.join(", ")}`); return; }
if (s.agg !== "count" && types.get(s.field) !== "number") { err(`${at}: ${s.agg} needs a number field`); return; }
const as = s.as ?? "value";
if (!NAME.test(as)) { err(`${at}: bad output name ${JSON.stringify(as)}`); return; }
const next = new Map(by.map((f) => [f, types.get(f)]));
next.set(as, "number");
types.clear();
next.forEach((t, f) => types.set(f, t));
} else if (s.op === "sort") {
if (!has(s.field)) err(`${at}: unknown field ${JSON.stringify(s.field)}`);
if (s.order && !["asc", "desc"].includes(s.order)) err(`${at}: order must be asc or desc`);
} else if (s.op === "bin") {
if (types.get(s.field) !== "number") { err(`${at}: bin needs a number field`); return; }
if (!(Number(s.step) > 0)) err(`${at}: step must be a positive number`);
const as = s.as ?? `${s.field}_bin`;
if (!NAME.test(as)) { err(`${at}: bad output name`); return; }
types.set(as, "number");
} else if (s.op === "limit") {
if (!Number.isInteger(s.n) || s.n < 1 || s.n > 500) err(`${at}: n must be a whole number from 1 to 500`);
}
});
for (const k of ["x", "y"]) if (!has(spec[k])) err(`${k} must be a field the transform leaves (got ${JSON.stringify(spec[k])})`);
if (has(spec.y) && types.get(spec.y) !== "number") err("y must be a number field");
if (spec.series != null && !has(spec.series)) err(`series must be a field the transform leaves`);
if (spec.mark === "scatter" && types.get(spec.x) !== "number") err("a scatter needs a number on x");
if (spec.title != null && typeof spec.title !== "string") err("title must be text");
return { ok: errors.length === 0, errors, fields: Object.fromEntries(types) };
}
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
export function applySpec(rows, spec) {
let out = rows.map((r) => ({ ...r }));
for (const s of spec.transform ?? []) {
if (s.op === "filter") {
out = out.filter((r) => {
const v = r[s.field];
if ("eq" in s && v != s.eq) return false; // loose on purpose: "2026" matches 2026
if ("in" in s && !s.in.some((x) => x == v)) return false;
if ("gte" in s && !(v >= s.gte)) return false;
if ("lte" in s && !(v <= s.lte)) return false;
return true;
});
} else if (s.op === "group") {
const by = s.by ?? [], as = s.as ?? "value", groups = new Map();
for (const r of out) {
const key = JSON.stringify(by.map((f) => r[f]));
if (!groups.has(key)) groups.set(key, { row: Object.fromEntries(by.map((f) => [f, r[f]])), vals: [] });
groups.get(key).vals.push(s.agg === "count" ? 1 : Number(r[s.field]));
}
out = [...groups.values()].map(({ row, vals }) => {
const sum = vals.reduce((a, b) => a + b, 0);
const v = { sum, count: vals.length, avg: sum / (vals.length || 1), min: Math.min(...vals), max: Math.max(...vals) }[s.agg];
return { ...row, [as]: Math.round(v * 100) / 100 };
});
} else if (s.op === "sort") {
const dir = s.order === "desc" ? -1 : 1;
out = [...out].sort((a, b) => dir * cmp(a[s.field], b[s.field]));
} else if (s.op === "bin") {
const as = s.as ?? `${s.field}_bin`, step = Number(s.step);
out = out.map((r) => ({ ...r, [as]: Math.floor(Number(r[s.field]) / step) * step }));
} else if (s.op === "limit") {
out = out.slice(0, s.n);
}
}
return out;
}
const PALETTE = ["#2f6fdf", "#e0752d", "#2f9e6e", "#c2417a", "#7a5bd6", "#9a8a2a", "#3aa0b8", "#b8573a"];
export function chartConfig(rows, spec) {
const title = spec.title ? { display: true, text: spec.title } : { display: false };
if (spec.mark === "scatter") {
return { type: "scatter", data: { datasets: [{ label: spec.y, data: rows.map((r) => ({ x: Number(r[spec.x]), y: Number(r[spec.y]) })),
backgroundColor: PALETTE[0] }] }, options: { plugins: { title }, animation: false } };
}
const labels = [...new Set(rows.map((r) => r[spec.x]))];
let datasets;
if (spec.series && !["pie", "doughnut"].includes(spec.mark)) {
const names = [...new Set(rows.map((r) => r[spec.series]))];
datasets = names.map((name, i) => ({
label: String(name),
data: labels.map((l) => rows.find((r) => r[spec.x] === l && r[spec.series] === name)?.[spec.y] ?? null),
backgroundColor: PALETTE[i % PALETTE.length], borderColor: PALETTE[i % PALETTE.length],
}));
} else {
const round = ["pie", "doughnut"].includes(spec.mark);
datasets = [{ label: spec.y, data: labels.map((l) => rows.find((r) => r[spec.x] === l)?.[spec.y] ?? null),
backgroundColor: round ? labels.map((_, i) => PALETTE[i % PALETTE.length]) : PALETTE[0], borderColor: PALETTE[0] }];
}
return { type: spec.mark, data: { labels: labels.map(String), datasets },
options: { plugins: { title, legend: { display: datasets.length > 1 || ["pie", "doughnut"].includes(spec.mark) } }, animation: false } };
}
async function fetchSpec(endpoint, body) {
const r = await fetch(endpoint.replace(/\/$/, "") + "/api/spec", { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify(body) });
const out = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(out.error || `the chart service answered ${r.status}`);
return out; // { spec, brain?, model?, ms?, confidence?, alternatives?, handoff? }
}
// Drive an existing Chart.js chart. `data` stays in the browser; only `schema` (plus a sample and aggregates the server
// computes for its own datasets) shapes the prompt.
export function attach(chart, { data, schema, endpoint, dataset }) {
let last;
return {
async ask(prompt, prior = last, brain) {
const { spec } = await fetchSpec(endpoint, { dataset, prompt, prior, ...(brain ? { brain } : {}) });
const v = validateSpec(spec, schema);
if (!v.ok) throw new Error("the spec failed validation: " + v.errors.join("; "));
const cfg = chartConfig(applySpec(data, spec), spec);
chart.config.type = cfg.type;
chart.config.data = cfg.data;
chart.config.options = { ...chart.config.options, ...cfg.options };
chart.update();
last = spec;
return spec;
},
};
}
const Base = globalThis.HTMLElement ?? class {};
// <ai-chart endpoint="https://..." dataset="orders" brain="fast"></ai-chart>
// brain: fast (decision model first, LLM checks) | llm | jev | clef (server default if unset)
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base {
connectedCallback() {
if (this._ready) return;
this._ready = true;
this.innerHTML = `<form class="aic-ask"><input name="q" placeholder="Ask for a chart, e.g. sales by region" aria-label="Chart question"
autocomplete="off"><button>Chart it</button></form><div class="aic-msg" role="status"></div>
<div class="aic-canvas"><canvas></canvas></div><button class="aic-save" hidden>Save chart</button>`;
this._msg = this.querySelector(".aic-msg");
this.querySelector("form").addEventListener("submit", (e) => { e.preventDefault(); this.ask(e.target.q.value); });
this.querySelector(".aic-save").addEventListener("click", () => this.save());
}
get endpoint() { return this.getAttribute("endpoint") || location.origin; }
get dataset() { return this.getAttribute("dataset") || "orders"; }
async load() {
if (this._data) return;
const base = this.endpoint.replace(/\/$/, "");
const [schema, data] = await Promise.all([fetch(`${base}/api/schema?dataset=${this.dataset}`).then((r) => r.json()),
fetch(`${base}/api/data?dataset=${this.dataset}`).then((r) => r.json())]);
this._schema = schema.schema;
this._data = data.rows;
}
async ask(prompt) {
if (!prompt?.trim()) return;
this._msg.textContent = "Thinking…";
try {
await this.load();
const brain = this.getAttribute("brain");
const body = { dataset: this.dataset, prompt, prior: this._spec };
if (brain === "fast") return await this.askFast(body);
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, ...(brain ? { brain } : {}) });
await this.render(spec, prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} catch (e) {
this._msg.textContent = String(e.message || e);
}
}
// brain="fast": a decision model draws first (under a second), an LLM answers in the background, and the chart is
// redrawn only if the LLM's chart differs. Whichever comes back alone still draws.
async askFast(body) {
const ask = ++this._asks || (this._asks = 1);
const current = () => ask === this._asks;
const same = (a, b) => JSON.stringify({ ...a, title: 0 }) === JSON.stringify({ ...b, title: 0 });
const llm = fetchSpec(this.endpoint, { ...body, brain: "llm" }).catch((e) => ({ error: e }));
let drawn = null;
try {
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, brain: "clef", floor: 0 });
if (!current()) return;
await this.render(spec, body.prompt);
drawn = spec;
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: "fast" } } }));
} catch { /* the LLM will answer */ }
const out = await llm;
if (!current() || out.error) { if (!drawn && out.error) throw out.error; return; }
const { spec, ...info } = out;
const changed = !drawn || !same(drawn, spec);
if (changed) await this.render(spec, body.prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: drawn ? (changed ? "corrected" : "confirmed") : "llm" } } }));
}
async render(spec, prompt) {
await this.load();
const v = validateSpec(spec, this._schema);
if (!v.ok) throw new Error("That chart didn't pass the checks: " + v.errors.join("; "));
const cfg = chartConfig(applySpec(this._data, spec), spec);
this._chart?.destroy();
this._chart = new globalThis.Chart(this.querySelector("canvas"), cfg);
this._spec = spec;
this._prompt = prompt;
this._msg.textContent = spec.title || "";
this.querySelector(".aic-save").hidden = false;
}
async save() {
const detail = { spec: this._spec, prompt: this._prompt, dataset: this.dataset };
this.dispatchEvent(new CustomEvent("save", { detail }));
if (this.onSave) {
try { await this.onSave(detail); this._msg.textContent = "Saved."; } catch (e) { this._msg.textContent = String(e.message || e); }
}
}
}
if (globalThis.customElements && !customElements.get("ai-chart")) customElements.define("ai-chart", AiChart);
+105
View File
@@ -0,0 +1,105 @@
"""Compare AICharts brains on one fixed question set against the live demo.
python3 bench.py https://aicharts.yolopreneur.lol [brain ...] (default brains: llm jev clef)
python3 bench.py <url> clef@0 Clef with the fallback off (calibration)
Each question lists what a right chart must have; a spec scores 1 when it has all of it. Reports, per brain: right
answers, median and p90 latency (as the browser sees it), and how often a decision brain handed off to the LLM.
Uses the public API like a visitor, so it counts against the demo's daily AI limit (12 calls per brain).
"""
import json
import statistics
import sys
import time
import urllib.error
import urllib.request
Q = [
("total revenue by region", {"x": "region", "agg": ("sum", "amount")}),
("monthly revenue, web vs store", {"mark": {"line"}, "x": "month", "series": "channel", "agg": ("sum", "amount")}),
("share of units by product", {"mark": {"pie", "doughnut"}, "x": "product", "agg": ("sum", "units")}),
("top 3 products in the West by revenue", {"x": "product", "only": {"region": "West"}, "agg": ("sum", "amount"), "limit": 3}),
("average order amount by channel", {"x": "channel", "agg": ("avg", "amount")}),
("how did matcha units trend month by month?", {"mark": {"line"}, "x": "month", "only": {"product": "Matcha"}, "agg": ("sum", "units")}),
("how many orders per region", {"x": "region", "agg": ("count", None)}),
("biggest single order amount for each product", {"x": "product", "agg": ("max", "amount")}),
("store revenue by region", {"x": "region", "only": {"channel": "store"}, "agg": ("sum", "amount")}),
("teapot units by month in the North", {"x": "month", "only": {"product": "Teapot", "region": "North"}, "agg": ("sum", "units")}),
("which region sells the most hojicha?", {"x": "region", "only": {"product": "Hojicha"}, "sorted_desc": True}),
("revenue by product, split by channel", {"x": {"product", "channel"}, "series": {"product", "channel"}, "agg": ("sum", "amount")}),
]
def facts(spec):
f = {"mark": spec.get("mark"), "x": spec.get("x"), "series": spec.get("series"), "only": {}, "limit": None, "sorted_desc": False}
for s in spec.get("transform") or []:
if s.get("op") == "filter":
if "eq" in s:
f["only"][s["field"]] = s["eq"]
elif isinstance(s.get("in"), list) and len(s["in"]) == 1:
f["only"][s["field"]] = s["in"][0]
elif s.get("op") == "group":
f["agg"] = (s.get("agg"), None if s.get("agg") == "count" else s.get("field"))
elif s.get("op") == "limit":
f["limit"] = s.get("n")
elif s.get("op") == "sort" and s.get("order") == "desc" and s.get("field") not in ("month", "date", f["x"]):
f["sorted_desc"] = True
return f
def misses(spec, want):
f, out = facts(spec), []
for k, v in want.items():
got = f.get(k)
if k == "only":
if {a: str(b).lower() for a, b in got.items()} != {a: b.lower() for a, b in v.items()}:
out.append(f"only {got} != {v}")
elif isinstance(v, set):
if got not in v:
out.append(f"{k} {got} not in {sorted(v)}")
elif k == "agg":
if got is None or got[0] != v[0] or (v[1] and got[1] != v[1]):
out.append(f"agg {got} != {v}")
elif got != v:
out.append(f"{k} {got} != {v}")
if "x" in want and "series" in want and f["x"] == f["series"]:
out.append("x == series")
return out
def ask(url, prompt, brain):
name, _, floor = brain.partition("@") # "clef@0" = Clef with no fallback: its own answers, for calibration
body = json.dumps({"dataset": "orders", "prompt": prompt, "brain": name, **({"floor": float(floor)} if floor else {})}).encode()
req = urllib.request.Request(url.rstrip("/") + "/api/spec", data=body, # Cloudflare turns away Python's default agent
headers={"content-type": "application/json", "user-agent": "aicharts-bench/1"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=90) as r:
out = json.loads(r.read())
except urllib.error.HTTPError as e:
out = json.loads(e.read() or b"{}")
return out, int((time.time() - t0) * 1000)
def main():
url, brains = sys.argv[1], sys.argv[2:] or ["llm", "jev", "clef"]
report = {}
for brain in brains:
rows = []
for prompt, want in Q:
out, ms = ask(url, prompt, brain)
m = misses(out["spec"], want) if out.get("spec") else [out.get("error", "no spec")]
rows.append({"q": prompt, "ok": not m, "ms": ms, "via": out.get("brain"), "handoff": (out.get("handoff") or {}).get("reason"),
"confidence": out.get("confidence"), "confidences": out.get("confidences"), "misses": m})
print(f"[{brain}] {'ok ' if not m else 'MISS'} {ms:>6} ms via {out.get('brain')} conf {out.get('confidence')}: {prompt}"
+ (f" -- {'; '.join(m)}" if m else "")
+ (f" (handoff: {rows[-1]['handoff']})" if rows[-1]["handoff"] else ""), flush=True)
lat = sorted(r["ms"] for r in rows)
report[brain] = {"right": sum(r["ok"] for r in rows), "of": len(rows), "median_ms": int(statistics.median(lat)),
"p90_ms": lat[int(0.9 * (len(lat) - 1))], "handoffs": sum(1 for r in rows if r["handoff"]), "rows": rows}
print(json.dumps({b: {k: v for k, v in r.items() if k != "rows"} for b, r in report.items()}, indent=1))
json.dump(report, open("bench.json", "w"), indent=1)
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
// A decision-model brain for AICharts: Jev (TypeSafe) or Clef (Cloudflare) answers a handful of choice questions in
// one pass, and this module assembles the chart spec from the answers. The model never writes JSON, so a spec can't
// be malformed; the validator still checks it, and weak confidence hands the question to the LLM path.
//
// questions(info) -> System One questions for one dataset (info = describe(rows, columns))
// assemble(answers, info) -> { spec, confidence, weakest, alternatives }
// decide(env, model, info, prompt, prior) -> the above plus { model, ms } (throws if the model call fails)
const MARK_MENU = {
bar: "Comparing amounts across categories or a handful of periods.",
line: "A value over time (months, dates) or another ordered sequence; the trend matters.",
pie: "Shares of one whole across a few categories (6 or fewer).",
doughnut: "Shares of one whole, shown as a ring; same use as a pie.",
scatter: "Two numeric measures plotted against each other across many points.",
};
const AGG_MENU = {
sum: "The total (revenue, units sold): add the values up.",
avg: "The typical or average value per order.",
count: "How many orders or rows, regardless of their values.",
min: "The smallest value.",
max: "The largest value.",
};
const ORDER_MENU = {
natural: "No particular order asked for (time stays in time order).",
value_desc: "Biggest first: top, best, most, highest, ranking.",
value_asc: "Smallest first: bottom, worst, least, lowest.",
};
const TOP_MENU = { all: "Show every group.", 3: "Only the top or bottom 3.", 5: "Only the top or bottom 5.", 10: "Only the top or bottom 10." };
function shuffled(menu) {
const e = Object.entries(menu);
for (let i = e.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [e[i], e[j]] = [e[j], e[i]]; }
return Object.fromEntries(e);
}
const choice = (instructions, menu) => ({ type: "choice", instructions, criteria: shuffled(menu) });
const describeCol = (c, agg) => {
const a = agg[c.name] || {};
if (a.values) return `${c.type} column; values: ${a.values.join(", ")}`;
if ("min" in a) return `${c.type} column from ${a.min} to ${a.max}`;
return `${c.type} column`;
};
export function questions(info) {
const cols = info.columns.filter((c) => c.name !== "id");
const text = cols.filter((c) => c.type === "string");
const nums = cols.filter((c) => c.type === "number");
const axis = Object.fromEntries(cols.map((c) => [c.name, describeCol(c, info.aggregates)]));
const measure = { ...Object.fromEntries(nums.map((c) => [c.name, describeCol(c, info.aggregates)])), rows: "The number of orders (no column)." };
const q = {
mark: choice("Which chart type answers the request best?", MARK_MENU),
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
measure: choice("Which quantity does the chart measure?", measure),
agg: choice("How should that quantity be combined within each group?", AGG_MENU),
series: choice("Does the request compare two or more values of a column side by side (\"web vs store\", \"split by channel\", \"each region over time\")? If so, pick that column; it becomes one coloured line or bar per value. Otherwise none.",
{ none: "No comparison asked for: a single series.",
...Object.fromEntries(text.map((c) => [c.name, `Compare ${c.name}s (${(info.aggregates[c.name]?.values || []).join(" vs ")}): one series per ${c.name}.`])) }),
order: choice("How should the groups be ordered?", ORDER_MENU),
top: choice("Does the request limit how many groups to show?", TOP_MENU),
};
for (const c of text) {
const vals = info.aggregates[c.name]?.values;
if (vals?.length && vals.length <= 30)
q[`only.${c.name}`] = choice(`Does the request name exactly one ${c.name} to look at (for example "${vals[0]} sales" or "in the ${vals[0]}")? Pick that value; pick any if it names none, or names several to compare.`,
{ any: `The request names no single ${c.name}.`,
...Object.fromEntries(vals.map((v) => [v, `The request mentions "${v}": keep only rows where ${c.name} is ${v}.`])) });
}
return q;
}
export function assemble(answers, info) {
const pick = (k) => answers[k]?.choice;
const conf = Object.fromEntries(Object.entries(answers).map(([k, v]) => [k, Number(v?.confidence ?? 0)]));
const types = Object.fromEntries(info.columns.map((c) => [c.name, c.type]));
const mark = pick("mark"), x = pick("x"), measure = pick("measure");
let agg = pick("agg");
let series = pick("series");
if (series === "none" || series === x || ["pie", "doughnut", "scatter"].includes(mark)) series = undefined;
const gating = ["mark", "x", "measure", "order", "top"];
if (measure === "rows") agg = "count"; else gating.push("agg");
if (series) gating.push("series");
const transform = [];
for (const k of Object.keys(answers).filter((k) => k.startsWith("only."))) {
const v = pick(k);
if (v && v !== "any") { transform.push({ op: "filter", field: k.slice(5), eq: v }); gating.push(k); }
}
let spec;
if (mark === "scatter") {
// a scatter plots rows, not groups: x and the measure are both raw number columns
spec = { mark, x, y: measure === "rows" ? "amount" : measure, transform };
} else {
transform.push({ op: "group", by: series ? [x, series] : [x], agg, ...(agg === "count" ? {} : { field: measure }), as: "value" });
const order = pick("order");
if (order === "value_desc" || order === "value_asc") transform.push({ op: "sort", field: "value", order: order === "value_desc" ? "desc" : "asc" });
else if (["month", "date", "number"].includes(types[x])) transform.push({ op: "sort", field: x, order: "asc" });
const top = pick("top");
if (top && top !== "all") transform.push({ op: "limit", n: Number(top) });
spec = { mark, x, y: "value", ...(series ? { series } : {}), transform };
}
const what = measure === "rows" ? "Orders" : `${{ sum: "Total", avg: "Average", count: "Count of", min: "Lowest", max: "Highest" }[agg]} ${measure}`;
const only = transform.filter((s) => s.op === "filter").map((s) => s.eq);
spec.title = mark === "scatter" ? `${spec.y} vs ${x}` : `${what} by ${x}${series ? ` and ${series}` : ""}${only.length ? ` (${only.join(", ")})` : ""}`;
const weakestOn = gating.reduce((lo, k) => ((conf[k] ?? 0) < (conf[lo] ?? 0) ? k : lo), gating[0]);
const weakest = conf[weakestOn] ?? 0;
// other chart types the model rated close behind: cheap to offer, already the same data
const probs = answers.mark?.probabilities || {};
const alternatives = Object.entries(probs).filter(([m, p]) => m !== mark && p >= 0.15).sort((a, b) => b[1] - a[1]).slice(0, 2)
.map(([m, p]) => ({ mark: m, p: Math.round(p * 100) / 100 }));
return { spec, confidence: conf, weakest: Math.round(weakest * 100) / 100, weakestOn, alternatives };
}
export async function decide(env, model, info, prompt, prior) {
const state = { user_request: prompt, table: { columns: info.columns.map((c) => c.name), rows: info.rows },
...(prior ? { current_chart: prior } : {}) };
const input = { state, questions: questions(info) };
if (model.startsWith("@cf/cloudflare/clef")) input.model = model.endsWith("flash") ? "clef-flash" : "clef";
const t0 = Date.now();
const res = await env.AI.run(model, input);
if (!res?.answers) throw new Error("the decision model returned no answers");
return { ...assemble(res.answers, info), model: res.model || model, ms: Date.now() - t0 };
}
+52
View File
@@ -0,0 +1,52 @@
"""Deploy the AICharts demo: one Worker (`time-aicharts`), one D1 database, Workers AI.
python3 .github/time/labs/aicharts/deploy.py # prints {url, worker, db, rev}
Reuses cloudflare.py. Lives where ventures live: aicharts.<TIME_VENTURE_DOMAIN> when that is set (a Workers custom
domain; Cloudflare adds the record and the cert), the account's workers.dev otherwise. Idempotent: the same database is
reused and the script is replaced in place.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[1]))
import cloudflare as cf # noqa: E402
NAME = "time-aicharts"
MAIN = b'import src from "./aicharts.txt";\nimport { make } from "./worker.js";\nexport default make(src);\n'
def parts() -> list[tuple[str, str, bytes]]:
lib = (HERE / "aicharts.js").read_bytes()
return [("main.js", "application/javascript+module", MAIN),
("worker.js", "application/javascript+module", (HERE / "worker.js").read_bytes()),
("aicharts.js", "application/javascript+module", lib),
("decide.js", "application/javascript+module", (HERE / "decide.js").read_bytes()),
("aicharts.txt", "text/plain", lib)]
def deploy() -> dict:
if not cf.enabled():
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
code = parts()
rev = hashlib.sha256(b"".join(d for _, _, d in code)).hexdigest()[:12]
vars_ = {"REV": rev, "AI_MODEL": cf.env("AICHARTS_AI_MODEL", "@cf/meta/llama-3.3-70b-instruct-fp8-fast"), "AI_DAILY": cf.env("AICHARTS_AI_DAILY", "300"),
"SAVE_DAILY": cf.env("AICHARTS_SAVE_DAILY", "1000"),
"BRAIN": cf.env("AICHARTS_BRAIN", "llm"), "FLOOR": cf.env("AICHARTS_FLOOR", "0.5")}
db = cf.database("aicharts") # named time-aicharts, like the script
meta = {"main_module": "main.js", "compatibility_date": cf.COMPAT,
"bindings": [{"type": "d1", "name": "DB", "id": db}, {"type": "ai", "name": "AI"}]
+ [{"type": "plain_text", "name": k, "text": str(v)} for k, v in vars_.items()]}
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()), *code])
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
hostname = cf.host("aicharts")
return {"url": cf.attach(NAME, hostname) if hostname else cf.workers_dev(NAME), "worker": NAME, "db": db, "rev": rev}
if __name__ == "__main__":
print(json.dumps(deploy(), indent=1))
@@ -0,0 +1,250 @@
// Offline test for the AICharts library and demo Worker: a real SQLite behind a D1-shaped shim, and a fake Workers AI.
// Run: node .github/time/labs/aicharts/test_aicharts.mjs
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const lib = await import(join(here, "aicharts.js"));
const W = await import(join(here, "worker.js"));
const src = readFileSync(join(here, "aicharts.js"), "utf8");
// ---------- library ----------
const schema = { columns: [{ name: "month", type: "month" }, { name: "region", type: "string" }, { name: "amount", type: "number" }] };
const rows = [
{ month: "2026-01", region: "West", amount: 10 }, { month: "2026-01", region: "East", amount: 5 },
{ month: "2026-02", region: "West", amount: 7 }, { month: "2026-02", region: "East", amount: 9 },
];
const bySum = { mark: "bar", x: "region", y: "value",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] };
assert.ok(lib.validateSpec(bySum, schema).ok);
assert.deepEqual(lib.applySpec(rows, bySum), [{ region: "West", value: 17 }, { region: "East", value: 14 }]);
const bad = lib.validateSpec({ mark: "radar", x: "month", y: "amount",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount" }, { op: "eval", code: "alert(1)" }] }, schema);
assert.equal(bad.ok, false);
assert.ok(bad.errors.some((e) => e.includes("mark")));
assert.ok(bad.errors.some((e) => e.includes("step 2")));
assert.ok(bad.errors.some((e) => e.startsWith("x must be a field the transform leaves")), "month is gone after the group");
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "region" }, schema).ok, false, "y must be a number");
assert.equal(lib.validateSpec({ mark: "bar", x: "region", y: "amount", transform: Array(9).fill({ op: "limit", n: 1 }) }, schema).ok, false);
assert.equal(lib.validateSpec("[1]", schema).ok, false);
const lines = { mark: "line", x: "month", y: "value", series: "region",
transform: [{ op: "group", by: ["month", "region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "month" }] };
const cfg = lib.chartConfig(lib.applySpec(rows, lines), lines);
assert.deepEqual(cfg.data.labels, ["2026-01", "2026-02"]);
assert.deepEqual(cfg.data.datasets.map((d) => [d.label, d.data]), [["West", [10, 7]], ["East", [5, 9]]]);
const pie = lib.chartConfig(lib.applySpec(rows, { ...bySum, mark: "pie" }), { ...bySum, mark: "pie" });
assert.equal(pie.data.datasets[0].backgroundColor.length, 2);
const binned = lib.applySpec(rows, { transform: [{ op: "bin", field: "amount", step: 5 }, { op: "filter", field: "amount_bin", gte: 5, lte: 5 }] });
assert.deepEqual(binned.map((r) => r.amount), [5, 7, 9]);
// attach() drives an existing chart and keeps the rows in the browser
const sent = [];
globalThis.fetch = async (url, init) => { sent.push({ url, body: JSON.parse(init.body) }); return Response.json({ spec: bySum }); };
const chart = { config: { type: "line", data: {}, options: { responsive: true } }, updates: 0, update() { this.updates++; } };
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example/", dataset: "orders" }).ask("sales by region");
assert.equal(sent[0].url, "https://x.example/api/spec");
assert.deepEqual(Object.keys(sent[0].body).sort(), ["dataset", "prompt"], "no rows go to the server");
assert.equal(chart.config.type, "bar");
assert.equal(chart.config.options.responsive, true);
assert.equal(chart.updates, 1);
await lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" }).ask("now as a pie");
const a = lib.attach(chart, { data: rows, schema, endpoint: "https://x.example", dataset: "orders" });
await a.ask("one"); await a.ask("two");
assert.deepEqual(sent.at(-1).body.prior, bySum, "follow-ups carry the last chart");
// ---------- worker ----------
assert.deepEqual(W.firstObject('Sure!\n```json\n{"a": {"b": "}"}}\n```'), { a: { b: "}" } });
assert.deepEqual(W.firstObject('Sure! {"mark": "bar", "x": "a"} trailing {'), { mark: "bar", x: "a" });
assert.equal(W.firstObject("no json"), null);
assert.deepEqual(W.normalizeSpec({ mark: "Bar", x: "region", y: "value",
transform: [{ type: "groupBy", groupBy: "region", aggregate: "SUM", field: "amount", as: "value" }, { op: "sort", field: "value", order: "DESC" }] }),
{ mark: "bar", x: "region", y: "value", transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }] });
assert.equal(W.normalizeSpec({ mark: "bar", transform: [{ op: "group", agg: "mean" }] }).transform[0].agg, "avg");
class D1 {
constructor() { this.db = new DatabaseSync(":memory:"); this.writes = []; }
prepare(sql) {
const db = this;
const make = (args) => ({
bind: (...a) => make(a),
async all() { db.log(sql); return { results: db.db.prepare(sql).all(...args) }; },
async first() { db.log(sql); return db.db.prepare(sql).get(...args) ?? null; },
async run() { db.log(sql); const s = db.db.prepare(sql); return /RETURNING|^\s*SELECT/i.test(sql) ? { results: s.all(...args) } : (s.run(...args), { results: [] }); },
});
return make([]);
}
log(sql) { if (/^\s*(INSERT|UPDATE|DELETE|CREATE)/i.test(sql)) this.writes.push(sql.split("(")[0].trim()); }
async batch(stmts) { return Promise.all(stmts.map((s) => s.run())); }
}
const answers = [];
const calls = [];
const env = { DB: new D1(), REV: "r1", AI_MODEL: "@cf/test/m", AI_DAILY: "5", SAVE_DAILY: "100",
AI: { async run(model, input) {
if (input.questions) { decisions.push({ model, input }); const d = decided.shift(); if (d instanceof Error) throw d; return d; }
calls.push(input); const a = answers.shift(); if (a instanceof Error) throw a; return a; } } };
const decisions = [], decided = [];
const worker = W.make(src);
const call = (path, init = {}, ip = "1.1.1.1") =>
worker.fetch(new Request("https://aicharts.t.workers.dev" + path, { ...init, headers: { "cf-connecting-ip": ip, ...(init.headers || {}) } }), env);
const post = (path, body, ip) => call(path, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }, ip);
let r = await call("/");
let html = await r.text();
assert.ok(html.includes("<ai-chart") && html.includes("https://aicharts.t.workers.dev/aicharts.js"));
r = await call("/aicharts.js");
assert.equal(await r.text(), src);
assert.equal(r.headers.get("access-control-allow-origin"), "*");
// the schema: what the model sees. Seeded once, deterministic.
r = await call("/api/schema?dataset=orders");
const sch = await r.json();
assert.equal(sch.sample.length, 5);
assert.deepEqual(sch.aggregates.region.values, ["East", "North", "South", "West"]);
assert.equal(sch.schema.columns.find((c) => c.name === "amount").type, "number");
const data = (await (await call("/api/data?dataset=orders")).json()).rows;
assert.equal(data.length, 24 * 4 * 5);
assert.deepEqual(data, W.seedOrders());
assert.equal((await call("/api/schema?dataset=users")).status, 404);
const seeded = env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length;
await call("/api/data");
assert.equal(env.DB.writes.filter((w) => w.startsWith("INSERT OR IGNORE INTO orders")).length, seeded, "seeding happens once");
// a spec: plain prompt; the system prompt carries schema + sample, never all rows
const good = { title: "Revenue by region", mark: "bar", x: "region", y: "value",
transform: [{ op: "group", by: ["region"], agg: "sum", field: "amount", as: "value" }] };
answers.push({ response: good });
r = await post("/api/spec", { dataset: "orders", prompt: "revenue by region" });
assert.equal(r.status, 200);
assert.deepEqual((await r.json()).spec, good);
assert.equal(calls.length, 1);
assert.equal(calls[0].response_format, undefined, "plain prompt first");
const sys = calls[0].messages[0].content;
assert.ok(sys.includes("region (string)") && sys.includes('"North"'));
assert.ok(sys.length < 4000 && !sys.includes(`"id":${data.length}`), "the prompt has a sample, not the table");
// no JSON at all -> JSON mode; prose around JSON is fine; a bad answer gets one repair round with the errors
answers.push({ response: "I think a line chart would be nice." },
{ response: '{"mark": "bar", "x": "month", "y": "amount", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount"}]}' },
{ choices: [{ message: { content: "Fixed: " + JSON.stringify({ ...good, mark: "line", x: "region" }) } }] });
calls.length = 0;
r = await post("/api/spec", { dataset: "orders", prompt: "trend", prior: good });
assert.equal(r.status, 200);
assert.equal((await r.json()).spec.mark, "line");
assert.deepEqual(calls.map((c) => c.response_format?.type ?? null), [null, "json_object", null]);
assert.ok(calls[0].messages.some((m) => m.content.includes("Current chart spec")), "the prior chart is context");
assert.ok(calls.at(-1).messages.at(-1).content.includes("fails these checks"), "one repair round with the errors");
// two bad answers: a 422 with the reasons, nothing drawn
answers.push({ response: "{}" }, { response: '{"mark": "pie"}' });
r = await post("/api/spec", { dataset: "orders", prompt: "nonsense" });
assert.equal(r.status, 422);
const failed = await r.json();
assert.match(failed.error, /valid chart/);
assert.equal(failed.model_said, '{"mark": "pie"}', "the model's answer is shown on failure");
// limits: AI_DAILY=5 overall; we've used 3
answers.push({ response: good }, { response: good });
assert.equal((await post("/api/spec", { prompt: "a" }, "2.2.2.2")).status, 200);
assert.equal((await post("/api/spec", { prompt: "b" }, "2.2.2.2")).status, 200);
assert.equal((await post("/api/spec", { prompt: "c" }, "3.3.3.3")).status, 429);
assert.equal((await post("/api/spec", { prompt: "" })).status, 400);
// ---------- decision brains (Jev / Clef): choice answers -> spec, LLM only as the door ----------
const D = await import(join(here, "decide.js"));
const ans = (choice, confidence = 0.9, probabilities) => ({ type: "choice", choice, confidence, probabilities: probabilities || { [choice]: confidence } });
const westTop3 = { mark: ans("bar", 0.8, { bar: 0.8, pie: 0.17, line: 0.03 }), x: ans("product"), measure: ans("amount"), agg: ans("sum"), series: ans("none"),
order: ans("value_desc"), top: ans("3"), "only.region": ans("West", 0.95), "only.product": ans("any"), "only.channel": ans("any") };
const infoNow = W.describe(data, sch.schema.columns);
const qs = D.questions(infoNow);
assert.deepEqual(Object.keys(qs).sort(), ["agg", "mark", "measure", "only.channel", "only.product", "only.region", "order", "series", "top", "x"]);
assert.ok(!("id" in qs.x.criteria), "id is never an axis");
assert.ok("rows" in qs.measure.criteria && "amount" in qs.measure.criteria && !("region" in qs.measure.criteria));
assert.deepEqual(Object.keys(qs["only.region"].criteria).sort(), ["East", "North", "South", "West", "any"]);
const asm = D.assemble(westTop3, infoNow);
assert.deepEqual(asm.spec.transform, [{ op: "filter", field: "region", eq: "West" },
{ op: "group", by: ["product"], agg: "sum", field: "amount", as: "value" }, { op: "sort", field: "value", order: "desc" }, { op: "limit", n: 3 }]);
assert.equal(asm.spec.title, "Total amount by product (West)");
assert.ok(lib.validateSpec(asm.spec, sch.schema).ok);
assert.equal(lib.applySpec(data, asm.spec).length, 3);
assert.deepEqual(asm.alternatives, [{ mark: "pie", p: 0.17 }]);
assert.equal(asm.weakest, 0.8);
// time on x sorts by time; a series only when asked; count needs no field
const monthly = D.assemble({ ...westTop3, mark: ans("line"), x: ans("month"), measure: ans("rows"), agg: ans("avg", 0.1), series: ans("channel"),
order: ans("natural"), top: ans("all"), "only.region": ans("any") }, infoNow);
assert.deepEqual(monthly.spec.transform, [{ op: "group", by: ["month", "channel"], agg: "count", as: "value" }, { op: "sort", field: "month", order: "asc" }]);
assert.equal(monthly.spec.series, "channel");
assert.equal(monthly.weakest, 0.9, "agg doesn't gate when counting rows");
assert.ok(lib.validateSpec(monthly.spec, sch.schema).ok);
assert.equal(D.assemble({ ...westTop3, mark: ans("pie"), series: ans("channel") }, infoNow).spec.series, undefined, "no series on a pie");
// through the worker: a confident Jev answer is the spec, no LLM call
const resetQuota = () => env.DB.db.exec("DELETE FROM quota");
resetQuota(); calls.length = 0;
decided.push({ model: "jev-1", answers: westTop3, usage: {} });
r = await post("/api/spec", { dataset: "orders", prompt: "top 3 products in the West by revenue", brain: "jev" });
let body = await r.json();
assert.equal(r.status, 200, JSON.stringify(body));
assert.equal(body.brain, "jev");
assert.equal(decisions.at(-1).model, "typesafe/jev");
assert.equal(decisions.at(-1).input.state.user_request, "top 3 products in the West by revenue");
const toJev = JSON.stringify(decisions.at(-1).input), mid = data[200];
assert.ok(!toJev.includes(mid.date) && !toJev.includes(String(mid.amount)) && !("sample" in decisions.at(-1).input.state),
"rows never reach the decision model");
assert.equal(calls.length, 0);
assert.deepEqual(body.spec, asm.spec);
// Clef names its variant in the input
decided.push({ answers: westTop3 });
await post("/api/spec", { dataset: "orders", prompt: "x", brain: "clef" });
assert.equal(decisions.at(-1).model, "@cf/cloudflare/clef-flash");
assert.equal(decisions.at(-1).input.model, "clef-flash");
// unsure -> the door: the LLM answers, and the response says why
decided.push({ answers: { ...westTop3, x: ans("product", 0.31) } });
answers.push({ response: good });
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
assert.equal(body.brain, "llm");
assert.match(body.handoff.reason, /low confidence \(0.31 on x\)/);
assert.equal(body.handoff.confidences.x, 0.31);
assert.deepEqual(body.spec, good);
// the same answer with the caller's own lower bar is accepted, with every confidence shown
decided.push({ answers: { ...westTop3, x: ans("product", 0.31) } });
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev", floor: 0.2 })).json();
assert.equal(body.brain, "jev");
assert.equal(body.confidences.x, 0.31);
// decision model down -> the door too
decided.push(new Error("no such model"));
answers.push({ response: good });
body = await (await post("/api/spec", { dataset: "orders", prompt: "hmm", brain: "jev" })).json();
assert.match(body.handoff.reason, /decision model error: no such model/);
// the server default brain, and an unknown brain falls back to it
resetQuota(); env.BRAIN = "jev"; decided.push({ answers: westTop3 });
assert.equal((await (await post("/api/spec", { prompt: "q", brain: "gpt" })).json()).brain, "jev");
delete env.BRAIN; resetQuota();
// save, list, load; only valid specs are stored
r = await post("/api/charts", { dataset: "orders", prompt: "revenue by region", spec: good });
assert.equal(r.status, 201);
const { id, link } = await r.json();
assert.equal(link, `https://aicharts.t.workers.dev/?c=${id}`);
assert.equal((await post("/api/charts", { dataset: "orders", spec: { mark: "bar", x: "nope", y: "amount" } })).status, 400);
const list = (await (await call("/api/charts")).json()).charts;
assert.deepEqual(list.map((c) => [c.id, c.title]), [[id, "Revenue by region"]]);
const back = await (await call("/api/charts/" + id)).json();
assert.deepEqual(back.spec, good);
assert.equal((await call("/api/charts/zzzzzz")).status, 404);
// read-only: the only tables written after seeding are quota and saved_charts
const after = env.DB.writes.slice(env.DB.writes.lastIndexOf("INSERT OR IGNORE INTO orders") + 1);
assert.ok(after.every((w) => /^(CREATE TABLE IF NOT EXISTS|INSERT INTO quota|INSERT INTO saved_charts)/.test(w)), after.join(", "));
// the saved spec renders with the library against the real rows
const out = lib.applySpec(data, back.spec);
assert.deepEqual(out.map((x) => x.region).sort(), ["East", "North", "South", "West"]);
assert.equal(Math.round(out.reduce((a, x) => a + x.value, 0)), Math.round(data.reduce((a, x) => a + x.amount, 0)));
console.log("aicharts: all good");
+359
View File
@@ -0,0 +1,359 @@
// AICharts demo Worker: the page, the library, a sample dataset in D1, the spec endpoint (Workers AI) and saved charts.
//
// GET / the demo page (Chart.js + <ai-chart>)
// GET /aicharts.js the library, as served to any page that embeds it
// GET /api/schema?dataset=orders columns, a few sample rows and aggregates: all the model ever sees
// GET /api/data?dataset=orders the rows, for the browser to chart
// POST /api/spec { dataset, prompt, prior? } -> { spec } a validated chart spec, never code
// POST /api/charts { dataset, prompt, spec } -> { id, link } save a chart
// GET /api/charts, /api/charts/:id the saved charts
//
// Read-only except saved_charts and quota. Bindings: DB (D1), AI (Workers AI). Vars: AI_MODEL, AI_DAILY, SAVE_DAILY, REV.
// deploy.py uploads main.js, which hands this module the library source: export default make(src).
import { validateSpec, MARKS, AGGS } from "./aicharts.js";
import { decide } from "./decide.js";
const SCHEMA = [
"CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, date TEXT, month TEXT, region TEXT, product TEXT, channel TEXT, units INTEGER, amount REAL)",
"CREATE TABLE IF NOT EXISTS quota (k TEXT PRIMARY KEY, n INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS saved_charts (id TEXT PRIMARY KEY, created TEXT NOT NULL, dataset TEXT NOT NULL, prompt TEXT, title TEXT, spec TEXT NOT NULL)",
];
const COLUMNS = [
{ name: "id", type: "number" }, { name: "date", type: "date" }, { name: "month", type: "month" },
{ name: "region", type: "string" }, { name: "product", type: "string" }, { name: "channel", type: "string" },
{ name: "units", type: "number" }, { name: "amount", type: "number" },
];
const DATASETS = { orders: { title: "Orders (sample shop, 24 months)", columns: COLUMNS } };
const MAX_PROMPT = 400;
// The sample shop: deterministic, so every deploy seeds the same rows and tests can count on them.
export function seedOrders() {
let s = 42;
const rnd = () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const regions = ["North", "South", "East", "West"], channels = ["web", "store"];
const products = [["Matcha", 24], ["Sencha", 14], ["Hojicha", 12], ["Teapot", 48], ["Whisk", 18]];
const rows = [];
for (let m = 0; m < 24; m++) {
const y = 2024 + Math.floor((m + 9) / 12), mo = ((m + 9) % 12) + 1, month = `${y}-${String(mo).padStart(2, "0")}`;
const season = 1 + 0.25 * Math.sin((mo / 12) * 2 * Math.PI) + m * 0.015;
for (const region of regions) for (const [product, price] of products) {
const units = Math.max(1, Math.round((8 + rnd() * 30) * season * (region === "West" ? 1.3 : 1)));
const channel = rnd() < (product === "Teapot" ? 0.4 : 0.65) ? "web" : "store";
const day = String(1 + Math.floor(rnd() * 28)).padStart(2, "0");
rows.push({ id: rows.length + 1, date: `${month}-${day}`, month, region, product, channel, units,
amount: Math.round(units * price * (0.9 + rnd() * 0.2) * 100) / 100 });
}
}
return rows;
}
let ready = null;
async function setup(env) {
ready ??= (async () => {
await env.DB.batch(SCHEMA.map((q) => env.DB.prepare(q)));
const n = await env.DB.prepare("SELECT COUNT(*) AS n FROM orders").first();
if (!n?.n) {
const ins = env.DB.prepare("INSERT OR IGNORE INTO orders (id, date, month, region, product, channel, units, amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
const rows = seedOrders();
for (let i = 0; i < rows.length; i += 100)
await env.DB.batch(rows.slice(i, i + 100).map((r) => ins.bind(r.id, r.date, r.month, r.region, r.product, r.channel, r.units, r.amount)));
}
})().catch((e) => { ready = null; throw e; });
return ready;
}
const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "content-type" };
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", ...CORS } });
async function who(req) {
const ip = req.headers.get("cf-connecting-ip") || "?";
const raw = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`aicharts:${ip}`));
return [...new Uint8Array(raw).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// One counter per (day, kind, person) and one per (day, kind): over either limit means no.
async function spend(env, kind, person, perPerson, total) {
const day = new Date().toISOString().slice(0, 10);
const bump = (k) => env.DB.prepare("INSERT INTO quota (k, n) VALUES (?, 1) ON CONFLICT(k) DO UPDATE SET n = n + 1 RETURNING n").bind(k);
const [mine, all] = await env.DB.batch([bump(`${day}:${kind}:${person}`), bump(`${day}:${kind}:*`)]);
return mine.results[0].n <= perPerson && all.results[0].n <= total;
}
async function rows(env) {
return (await env.DB.prepare("SELECT id, date, month, region, product, channel, units, amount FROM orders ORDER BY id").all()).results;
}
// What the model sees: columns, five rows, distinct values of text columns, ranges of number columns.
export function describe(data, columns) {
const aggregates = {};
for (const c of columns) {
const vals = data.map((r) => r[c.name]);
if (c.type === "number") aggregates[c.name] = { min: Math.min(...vals), max: Math.max(...vals) };
else {
const d = [...new Set(vals)].sort();
aggregates[c.name] = d.length <= 20 ? { values: d } : { first: d[0], last: d.at(-1), distinct: d.length };
}
}
return { columns, sample: data.slice(0, 5), aggregates, rows: data.length };
}
export function systemPrompt(dataset, info) {
return `You turn a question about a table into a chart spec. Answer with ONE JSON object and nothing else.
Table "${dataset}" (${info.rows} rows). Columns: ${info.columns.map((c) => `${c.name} (${c.type})`).join(", ")}.
Values: ${JSON.stringify(info.aggregates)}
Sample rows: ${JSON.stringify(info.sample)}
Spec: {"title": text, "mark": ${MARKS.map((m) => `"${m}"`).join(" | ")}, "transform": [steps, applied in order, at most 8],
"x": field, "y": number field, "series": optional field (one line/bar colour per value)}
Steps:
{"op": "filter", "field": f, "eq": v} or "in": [v, ...] or "gte": v and/or "lte": v
{"op": "group", "by": [up to 2 fields], "agg": ${AGGS.map((a) => `"${a}"`).join(" | ")}, "field": number field (not for count), "as": "value"}
after a group, only the "by" fields and "as" remain
{"op": "sort", "field": f, "order": "asc" | "desc"}
{"op": "bin", "field": number field, "step": n, "as": name}
{"op": "limit", "n": 1..500}
Use "month" for time on x and sort it asc. Use "series" with group by [x, series] to compare. Pie only for shares of a whole.
Example: {"title": "Revenue by region", "mark": "bar", "transform": [{"op": "group", "by": ["region"], "agg": "sum", "field": "amount", "as": "value"}, {"op": "sort", "field": "value", "order": "desc"}], "x": "region", "y": "value"}`;
}
// The first balanced {...} in a model's answer (models wrap JSON in prose or fences when they feel like it).
export function firstObject(text) {
if (text && typeof text === "object") return text;
const s = String(text ?? "");
for (let i = s.indexOf("{"); i >= 0; i = s.indexOf("{", i + 1)) {
let depth = 0, str = false, esc = false;
for (let j = i; j < s.length; j++) {
const ch = s[j];
if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
if (ch === '"') str = true;
else if (ch === "{") depth++;
else if (ch === "}" && --depth === 0) {
try { return JSON.parse(s.slice(i, j + 1)); } catch { break; }
}
}
}
return null;
}
// Small models drift on spelling, not meaning: "SUM", "mean", "aggregate", "groupBy": "region". Fix the spelling, then
// the validator decides. Nothing here invents a field or an operation.
const AGG_ALIAS = { average: "avg", mean: "avg", total: "sum", minimum: "min", maximum: "max", cnt: "count" };
export function normalizeSpec(spec) {
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return spec;
const low = (v) => (typeof v === "string" ? v.trim().toLowerCase() : v);
const out = { ...spec, mark: low(spec.mark) };
if (spec.chart && !spec.mark) out.mark = low(spec.chart.type ?? spec.chart);
if (Array.isArray(spec.transform)) out.transform = spec.transform.map((s0) => {
if (!s0 || typeof s0 !== "object") return s0;
const s = { ...s0, op: low(s0.op ?? s0.type) };
if (s.op === "groupby" || s.op === "aggregate") s.op = "group";
if (s.aggregate != null && s.agg == null) s.agg = s.aggregate;
if (s.groupby != null && s.by == null) s.by = s.groupby;
if (s.groupBy != null && s.by == null) s.by = s.groupBy;
if (typeof s.by === "string") s.by = [s.by];
if (s.agg != null) { s.agg = low(s.agg); s.agg = AGG_ALIAS[s.agg] ?? s.agg; }
if (s.order != null) s.order = low(s.order);
delete s.type; delete s.aggregate; delete s.groupby; delete s.groupBy;
return s;
});
return out;
}
// Plain prompt first: the prompt's grammar and example steer small models better than a constrained decoder does (a
// one-size schema for every step made them drop agg and by). JSON mode only when no object came back at all.
async function model(env, messages) {
const input = { messages, max_tokens: 900, temperature: 0.1 };
const text = (out) => out?.response ?? out?.choices?.[0]?.message?.content ?? "";
let raw = text(await env.AI.run(env.AI_MODEL, input));
if (!firstObject(raw)) {
try { raw = text(await env.AI.run(env.AI_MODEL, { ...input, response_format: { type: "json_object" } })); } catch { /* model has no JSON mode */ }
}
return { spec: normalizeSpec(firstObject(raw)), raw: typeof raw === "string" ? raw : JSON.stringify(raw) };
}
// Ask, check, and give the model one chance to fix what the validator found.
// brain: "llm" writes the spec; "jev" / "clef" answer choice questions and decide.js assembles it. A decision that
// fails validation, or whose weakest needed answer is under FLOOR, goes to the LLM: the cage with a door.
export const BRAINS = ["llm", "jev", "clef"];
async function specVia(env, dataset, prompt, prior, brain, floorAsked) {
const info = describe(await rows(env), DATASETS[dataset].columns);
const schema = { columns: info.columns };
const t0 = Date.now();
let handoff;
if (brain !== "llm") {
const model = brain === "jev" ? env.JEV_MODEL || "typesafe/jev" : env.CLEF_MODEL || "@cf/cloudflare/clef-flash";
try {
const d = await decide(env, model, info, prompt, prior && validateSpec(prior, schema).ok ? prior : undefined);
// a caller may lower or raise the bar for itself (benchmarks; a page that prefers speed); it only changes cost
const floor = Number.isFinite(floorAsked) && floorAsked >= 0 && floorAsked <= 1 ? floorAsked : Number(env.FLOOR || 0.5);
const v = validateSpec(d.spec, schema);
const confidences = Object.fromEntries(Object.entries(d.confidence).map(([k, c]) => [k, Math.round(c * 100) / 100]));
if (v.ok && d.weakest >= floor)
return { spec: d.spec, brain, model: d.model, ms: d.ms, confidence: d.weakest, confidences, alternatives: d.alternatives };
handoff = { reason: v.ok ? `low confidence (${d.weakest} on ${d.weakestOn})` : "decision failed checks: " + v.errors[0],
decided: d.spec, ms: d.ms, confidences };
} catch (e) {
handoff = { reason: "decision model error: " + String(e.message || e).slice(0, 160) };
}
}
const out = await specFor(env, dataset, prompt, prior, info);
return { ...out, brain: "llm", model: env.AI_MODEL, ms: Date.now() - t0, ...(handoff ? { handoff } : {}) };
}
async function specFor(env, dataset, prompt, prior, known) {
const info = known || describe(await rows(env), DATASETS[dataset].columns);
const schema = { columns: info.columns };
const messages = [{ role: "system", content: systemPrompt(dataset, info) }];
if (prior && validateSpec(prior, schema).ok)
messages.push({ role: "user", content: "Current chart spec (change it if the question builds on it): " + JSON.stringify(prior) });
messages.push({ role: "user", content: prompt });
let { spec, raw } = await model(env, messages), v = validateSpec(spec, schema);
if (!v.ok) {
messages.push({ role: "assistant", content: raw.slice(0, 2000) },
{ role: "user", content: `That spec fails these checks: ${v.errors.join("; ")}. Answer with the corrected JSON object only.` });
({ spec, raw } = await model(env, messages));
v = validateSpec(spec, schema);
}
// On failure the model's own answer comes back too: it is shown, never run.
return v.ok ? { spec } : { error: "couldn't turn that into a valid chart: " + v.errors.slice(0, 3).join("; "), model_said: raw.slice(0, 800) };
}
const newId = () => [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 10);
async function api(req, env, url) {
const path = url.pathname;
if (path === "/api/health") return json({ ok: true, rev: env.REV || "", ai: !!env.AI });
await setup(env);
const dataset = url.searchParams.get("dataset") || "orders";
if (req.method === "GET" && (path === "/api/schema" || path === "/api/data")) {
if (!DATASETS[dataset]) return json({ error: "no such dataset" }, 404);
const data = await rows(env);
if (path === "/api/data") return json({ dataset, rows: data });
const { columns, sample, aggregates } = describe(data, DATASETS[dataset].columns);
return json({ dataset, title: DATASETS[dataset].title, schema: { columns }, sample, aggregates });
}
if (req.method === "POST" && (path === "/api/spec" || path === "/api/charts")) {
let body;
try { body = await req.json(); } catch { return json({ error: "send JSON" }, 400); }
const ds = body?.dataset || "orders";
if (!DATASETS[ds]) return json({ error: "no such dataset" }, 404);
const prompt = String(body.prompt || "").trim().slice(0, MAX_PROMPT);
if (path === "/api/spec") {
if (!env.AI) return json({ error: "no AI here" }, 501);
if (!prompt) return json({ error: "ask a question" }, 400);
if (!(await spend(env, "ai", await who(req), 60, Number(env.AI_DAILY || 300)))) return json({ error: "over today's limit, try tomorrow" }, 429);
const brain = BRAINS.includes(body.brain) ? body.brain : BRAINS.includes(env.BRAIN) ? env.BRAIN : "llm";
const out = await specVia(env, ds, prompt, body.prior, brain, body.floor == null ? undefined : Number(body.floor));
return json(out, out.spec ? 200 : 422);
}
const v = validateSpec(body.spec, { columns: DATASETS[ds].columns });
if (!v.ok) return json({ error: "that spec doesn't validate: " + v.errors.join("; ") }, 400);
if (!(await spend(env, "save", await who(req), 50, Number(env.SAVE_DAILY || 1000)))) return json({ error: "over today's limit" }, 429);
const id = newId(), title = String(body.spec.title || prompt || "Chart").slice(0, 120);
await env.DB.prepare("INSERT INTO saved_charts (id, created, dataset, prompt, title, spec) VALUES (?, ?, ?, ?, ?, ?)")
.bind(id, new Date().toISOString(), ds, prompt, title, JSON.stringify(body.spec)).run();
return json({ id, link: `${url.origin}/?c=${id}` }, 201);
}
if (req.method === "GET" && path === "/api/charts") {
const list = (await env.DB.prepare("SELECT id, created, dataset, prompt, title FROM saved_charts ORDER BY created DESC LIMIT 20").all()).results;
return json({ charts: list });
}
const one = path.match(/^\/api\/charts\/([a-z0-9]{4,16})$/);
if (one && req.method === "GET") {
const r = await env.DB.prepare("SELECT id, created, dataset, prompt, title, spec FROM saved_charts WHERE id = ?").bind(one[1]).first();
return r ? json({ ...r, spec: JSON.parse(r.spec) }) : json({ error: "not found" }, 404);
}
return json({ error: "no such endpoint" }, 404);
}
export const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>AICharts</title>
<meta name="description" content="Ask for a chart in plain words. The model writes a small checked spec, never code, and never sees your rows.">
<style>
:root{--bg:#faf9f6;--fg:#1d1d1b;--mute:#6b6a65;--line:#e3e1da;--card:#fff;--accent:#2f6fdf}
@media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--mute:#9b9a94;--line:#2c2b28;--card:#1f1f1c;--accent:#7aa5ff}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif}
main{max-width:860px;margin:0 auto;padding:32px 16px 64px}h1{margin:0 0 4px;font-size:28px}p.lede{margin:0 0 20px;color:var(--mute)}
.aic-ask{display:flex;gap:8px}.aic-ask input{flex:1;min-width:0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
button{padding:10px 14px;border:0;border-radius:8px;background:var(--accent);color:#fff;font:inherit;cursor:pointer}
.aic-msg{min-height:24px;margin:10px 0;color:var(--mute)}.aic-canvas{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
.aic-save{margin-top:10px}.chips{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0}.chips button{background:var(--card);color:var(--fg);border:1px solid var(--line);padding:6px 10px;font-size:14px}
details{margin-top:16px}pre{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:13px}
h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between}
.brain{font-size:14px;color:var(--mute)}.brain select{font:inherit;padding:4px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--fg)}
.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:10px;font-size:14px;color:var(--mute)}.meta button{padding:4px 10px;font-size:14px;background:var(--card);color:var(--fg);border:1px solid var(--line)}ul{padding-left:18px}a{color:var(--accent)}
</style></head><body><main>
<h1>AICharts</h1>
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="fast" selected>Fast (Clef, LLM checks)</option><option value="clef">Clef only</option><option value="jev">Jev</option><option value="llm">LLM only</option></select></label></div>
<ai-chart dataset="orders" brain="fast"></ai-chart>
<div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
<h2>Use it on your page</h2>
<pre>&lt;script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"&gt;&lt;/script&gt;
&lt;script type="module" src="__ORIGIN__/aicharts.js"&gt;&lt;/script&gt;
&lt;ai-chart endpoint="__ORIGIN__" dataset="orders"&gt;&lt;/ai-chart&gt;</pre>
</main>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<script type="module">
import "/aicharts.js";
const el = document.querySelector("ai-chart"), list = document.getElementById("saved");
for (const q of ["Revenue by region", "Monthly revenue, web vs store", "Share of units by product", "Top 3 products in the West by revenue"]) {
const b = document.createElement("button"); b.textContent = q; b.onclick = () => { el.querySelector("input").value = q; el.ask(q); };
document.getElementById("chips").append(b);
}
const brainSel = document.getElementById("brain"), meta = document.getElementById("meta");
brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.value) : el.removeAttribute("brain");
el.addEventListener("spec", (e) => {
const { spec, info = {} } = e.detail;
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
const stage = { fast: "first draw", confirmed: "LLM agrees", corrected: "corrected by the LLM" }[info.stage];
const bits = [stage, info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
for (const alt of info.alternatives || []) {
const b = document.createElement("button"); b.textContent = "or as " + alt.mark + " (" + Math.round(alt.p * 100) + "%)";
b.onclick = () => el.render({ ...spec, mark: alt.mark }, e.detail.prompt).catch((err) => { meta.append(" " + err.message); });
meta.append(b);
}
});
el.onSave = async (d) => {
const r = await fetch("/api/charts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(d) });
const out = await r.json(); if (!r.ok) throw new Error(out.error); history.replaceState(null, "", "?c=" + out.id); await refresh();
};
async function show(id) {
const r = await fetch("/api/charts/" + id); if (!r.ok) return;
const c = await r.json(); await el.render(c.spec, c.prompt); document.getElementById("spec").textContent = JSON.stringify(c.spec, null, 2);
el.querySelector("input").value = c.prompt || "";
}
async function refresh() {
const { charts } = await (await fetch("/api/charts")).json();
list.replaceChildren(...(charts.length ? charts.map((c) => { const li = document.createElement("li"), a = document.createElement("a");
a.href = "?c=" + c.id; a.textContent = c.title; a.onclick = (e) => { e.preventDefault(); history.replaceState(null, "", "?c=" + c.id); show(c.id); };
li.append(a); return li; }) : [Object.assign(document.createElement("li"), { textContent: "None yet." })]));
}
const id = new URLSearchParams(location.search).get("c");
customElements.whenDefined("ai-chart").then(() => { refresh(); if (id) show(id); });
</script></body></html>`;
export function make(librarySource) {
return {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
try {
if (url.pathname.startsWith("/api/")) return await api(req, env, url);
} catch (e) {
return json({ error: "something broke: " + String(e.message || e).slice(0, 160) }, 500);
}
if (url.pathname === "/aicharts.js")
return new Response(librarySource, { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=300", ...CORS } });
if (url.pathname === "/" || url.pathname === "/index.html")
return new Response(PAGE.replaceAll("__ORIGIN__", url.origin), { headers: { "content-type": "text/html; charset=utf-8" } });
return new Response("not found", { status: 404 });
},
};
}
+195
View File
@@ -0,0 +1,195 @@
"""The router: what the factory works on next (time#68, #72).
Astra, the daily planner, gathers the candidates and asks its seat to judge each one; this module turns those
judgments into one ranked list, in arithmetic anyone can check, and picks from the top. In live mode the heartbeat
promotes queued cards in the same order instead of oldest first.
A candidate is an open card a worker can take (a `DONE:` line; not a parent, not a human's, not the venture loop's
own), or work with no card yet that the factory could start (a new venture). Each has a kind: venture, feature,
refinement or optimization.
score = weight[kind] x restless(kind) x age x (value + 0.5 x unblocks) / size
- value (1-5) and size (S, M, L = 1, 2, 4) are the seat's judgment; unblocks counts the open cards that wait on it.
- weight is the owner's dial per kind, in time/router.json (all 1: balanced).
- restless: a kind the factory hasn't picked for a while climbs, 1 + rate x days (at most 10 days), so none starves.
- age: an old card climbs a little, 1 + 0.02 x days open (at most 20 days).
An L is ranked but never queued: a card is what one worker session finishes, so it is split first. Nor is a card that
waits: on a card still open (its `Depends on #N`), or on whatever the seat says blocks it (a PR that can't merge, a
human). Cards that can move come first in the promotion order, the ones that wait after them. The policy is
`score`; a bandit policy (Beth, Jev, Fable's restless-bandit maths) can replace it without touching how candidates are
gathered or how picks are queued.
"""
from __future__ import annotations
import datetime as dt
import json
import re
KINDS = ("venture", "feature", "refinement", "optimization")
SIZES = {"S": 1, "M": 2, "L": 4}
MODES = ("off", "dry", "live")
DEFAULTS = {"mode": "off", "weights": dict.fromkeys(KINDS, 1.0), "restless": 0.05, "cost_usd": {"S": 0.5, "M": 1.5, "L": 4.0}}
LOOP = {"venture", "venture-batch", "signal", "signup"} # the venture loop's own cards: it runs them itself
HELD = {"needs-human", "andon", "astra", "planned"} | LOOP # a human's, an alarm, Astra's log, parents, the loop's
LINED_UP = {"queued", "ready"} # already moving: ranked (for promotion), never queued again
DONE = re.compile(r"^\s*DONE:", re.M | re.I)
NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page",
"body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.",
"labels": [], "lined_up": False, "actionable": False, "kind": "venture", "unblocks": 0, "created": "",
"why_not": "not wired to the router yet; the venture loop is paused (TIME_VENTURE=off)"}
def _num(v, default: float, lo: float = 0.0, hi: float = 100.0) -> float:
try:
return min(hi, max(lo, float(v)))
except (TypeError, ValueError):
return default
def _txt(v, n: int) -> str:
return re.sub(r"\s+", " ", str(v or "")).strip()[:n]
def config(raw: str | None) -> dict:
"""time/router.json over the defaults. Unreadable is the defaults, and an unknown mode is off."""
try:
got = json.loads(raw) if raw else {}
except ValueError:
got = {}
got = got if isinstance(got, dict) else {}
w = got.get("weights") if isinstance(got.get("weights"), dict) else {}
c = got.get("cost_usd") if isinstance(got.get("cost_usd"), dict) else {}
mode = str(got.get("mode", "off")).strip().lower()
return {"mode": mode if mode in MODES else "off",
"weights": {k: _num(w.get(k), 1.0) for k in KINDS},
"restless": _num(got.get("restless"), DEFAULTS["restless"], 0.0, 1.0),
"cost_usd": {s: _num(c.get(s), DEFAULTS["cost_usd"][s]) for s in SIZES}}
def labels_of(issue: dict) -> set[str]:
return {lb["name"] if isinstance(lb, dict) else lb for lb in issue.get("labels") or []}
def guess_kind(title: str, body: str, labels: set[str]) -> str:
"""The kind when the seat gives none: a `kind:<x>` label, the loop's labels, a live venture's folder, then words."""
for k in KINDS:
if f"kind:{k}" in labels:
return k
if labels & LOOP:
return "venture"
if re.search(r"^\s*Base:\s*gh-pages\b", body or "", re.M | re.I):
return "refinement" # work on a launched venture's folder
text = f"{title}\n{body}".lower()
if re.search(r"\b(cost|spend|cach\w*|cheaper|faster|slow\w*|latency|tokens?|optimi[sz]\w*)\b", text):
return "optimization"
if re.search(r"\b(fix\w*|polish\w*|refine\w*|tighten\w*|clean ?up|rename\w*|bug\w*)\b", text):
return "refinement"
return "feature"
def candidates(issues: list[dict]) -> list[dict]:
"""Every open card a worker could take, lined-up ones included (ranked, so the heartbeat promotes in order)."""
waits: dict[int, int] = {}
for i in issues:
for d in set(re.findall(r"Depends on #(\d+)", i.get("body") or "")):
waits[int(d)] = waits.get(int(d), 0) + 1
open_n = {i.get("number") for i in issues}
out = []
for i in issues:
labels, body = labels_of(i), i.get("body") or ""
if labels & HELD or not DONE.search(body):
continue
deps = sorted({int(d) for d in re.findall(r"Depends on #(\d+)", body)} & open_n) # a closed dependency is done
out.append({"key": f"#{i['number']}", "n": i["number"], "title": _txt(i.get("title"), 120), "body": body[:500],
"labels": sorted(labels), "lined_up": bool(labels & LINED_UP), "actionable": not deps,
"kind": guess_kind(i.get("title") or "", body, labels), "unblocks": waits.get(i["number"], 0),
"created": str(i.get("created_at") or ""),
**({"why_not": "waits on " + ", ".join(f"#{d}" for d in deps)} if deps else {})})
return out
def last_picked(beats: list[dict]) -> dict[str, str]:
"""The day each kind was last picked, from Astra's live beats."""
out: dict[str, str] = {}
for b in beats:
if b.get("router") != "live":
continue
day = str(b.get("at") or "")[:10]
for p in b.get("picked") or []:
k = p.get("kind") if isinstance(p, dict) else None
if k in KINDS and day > out.get(k, ""):
out[k] = day
return out
def _days(since: str, today: dt.date, cap: float) -> float:
try:
return float(min(cap, max(0, (today - dt.date.fromisoformat(since[:10])).days)))
except ValueError:
return float(cap) # never picked (or no date): as restless as it gets
def score(c: dict, a: dict, cfg: dict, last: dict[str, str], today: dt.date) -> dict:
"""One candidate, judged (`a`: the seat's kind, value, size, why) and scored, with every factor kept for the table."""
kind = a.get("kind") if a.get("kind") in KINDS else c["kind"]
size = str(a.get("size") or "M").strip().upper()[:1]
size = size if size in SIZES else "M"
value = _num(a.get("value"), 3.0, 1.0, 5.0)
restless = 1 + cfg["restless"] * _days(last.get(kind, ""), today, 10)
age = 1 + 0.02 * (_days(c["created"], today, 20) if c.get("created") else 0)
s = cfg["weights"][kind] * restless * age * (value + 0.5 * c["unblocks"]) / SIZES[size]
out = {**c, "kind": kind, "value": value, "size": size, "why": _txt(a.get("why"), 200),
"restless": round(restless, 2), "age": round(age, 2), "score": round(s, 3), "judged": bool(a)}
if size == "L" and out["actionable"]:
out["actionable"], out["why_not"] = False, "L: split it into cards a worker finishes in one session first"
blocked = _txt(a.get("blocked"), 120) if isinstance(a.get("blocked"), str) else ""
if blocked.lower() not in ("", "no", "none", "false", "-", "n/a") and out["actionable"]:
out["actionable"], out["why_not"] = False, f"blocked: {blocked}" # the seat knows what no Depends line says
return out
def rank(cands: list[dict], assess: dict[str, dict], cfg: dict, last: dict[str, str], today: dt.date) -> list[dict]:
"""Best first; ties to the older card."""
scored = [score(c, assess.get(c["key"]) or {}, cfg, last, today) for c in cands]
return sorted(scored, key=lambda r: (-r["score"], r["n"] if r["n"] is not None else 10**9))
def pick(ranked: list[dict], cfg: dict, left_usd: float, max_n: int) -> list[dict]:
"""From the top: cards not yet lined up, while the day's money left covers their size's estimated cost."""
out, spent = [], 0.0
for r in ranked:
if len(out) >= max_n:
break
if r["lined_up"] or not r["actionable"]:
continue
cost = cfg["cost_usd"][r["size"]]
if spent + cost > left_usd:
continue
out.append(r)
spent += cost
return out
def order(ranked: list[dict]) -> list[int]:
"""Card numbers best first, the ones that can move before the ones that wait: the heartbeat promotes queued cards in
this order (and still skips a card whose dependency is open)."""
cards = [r for r in ranked if r["n"] is not None]
return [r["n"] for r in cards if r["actionable"]] + [r["n"] for r in cards if not r["actionable"]]
def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15) -> str:
chosen = {r["key"] for r in picked}
mark = "queued today" if mode == "live" else "would queue"
rows = []
for k, r in enumerate(ranked[:limit], 1):
note = (f"**{mark}**" if r["key"] in chosen else "; ".join(x for x in ("lined up" if r["lined_up"] else "",
r.get("why_not", "") if not r["actionable"] else "") if x))
name = f"{r['key']} {r['title']}" if r["n"] is not None else r["title"]
rows.append(f"| {k} | {_txt(name, 80).replace('|', '/')} | {r['kind']} | {r['value']:g} | {r['size']} | "
f"{r['unblocks']} | {r['restless']:g} | {r['score']:g} | {_txt(r['why'], 140).replace('|', '/')} | "
f"{_txt(note, 90).replace('|', '/')} |")
more = f"\n\n_{len(ranked) - limit} more ranked below these._" if len(ranked) > limit else ""
return ("| # | card | kind | value | size | waiting | restless | score | why | status |\n|---|---|---|---|---|---|---|---|---|---|\n"
+ "\n".join(rows) + more)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DietrichGebert
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. The best code is the code never written. You solve the whole problem with the least new code. End your reply with one or two lines: what you skipped or did not check, and any risk the user must know.
## Before you write
Read the task and the code it touches. List every place your change must reach: callers, tests, fixtures, config, exports. Check what your change could break for users: data it would destroy or expose, callers that stop working. That is scope. Extra features are not.
## The smallest complete change
Take the first option that fully works:
1. Does it need to exist? Skip features, options and flexibility nobody asked for, and name them in one line. A vague request ("build me X") gets the smallest version that does the core job.
2. Already in this codebase (a helper, component, service, pattern)? Use it the way the surrounding code does.
3. Standard library or a platform feature? Use it, unless the project has its own. A house component beats a native widget.
4. An installed dependency? Use it. Never add a dependency for a few lines.
5. Can it be one line a reader gets at a glance? One line.
6. Otherwise: the minimum code that works.
- Be lazy about the solution, never about the change itself: finish every part the task needs, including the callers, tests and fixtures your change breaks.
- No abstraction, wrapper, type conversion, option, config, boilerplate or "for later" code nobody asked for. Keep values in the form the platform already gives you. Deletion beats addition. Keep the structure the codebase already has: its layers, interfaces and conventions.
- The shortest working diff wins, once you know everything it must touch. A one-liner that needs decoding is not short.
- Comment only the why the code cannot show, in one line.
- Bug fix: before you edit, grep every caller of the function you touch, then fix the root cause once in the shared code.
- Code you move or merge keeps its error handling and validation.
- Between options of equal size, take the one that is correct on edge cases.
- Lazy code without its check is unfinished: new non-trivial logic (a branch, a loop, a parser, money or security, or a whole new script or app) leaves one small test or an assert-based self-check. Trivial changes need none.
- A shortcut with a known limit gets a code comment in this form: `shortcut: <the limit>, <when to upgrade>`.
Never cut: validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs, anything the user asked for.
+9 -1
View File
@@ -10,5 +10,13 @@
"glm": {"family": "glm", "default": "z-ai-glm-5-3", "label": "Zhipu GLM via Venice",
"aliases": {"flash": "z-ai-glm-5-3-flash"}},
"gemini": {"family": "gemini", "default": "gemini-3-1-pro-preview", "label": "Google Gemini via Venice",
"aliases": {"pro": "gemini-3-1-pro-preview", "flash": "gemini-3-8-flash"}}
"aliases": {"pro": "gemini-3-1-pro-preview", "flash": "gemini-3-8-flash"}},
"haiku": {"family": "claude-haiku", "default": "claude-haiku-5-5", "label": "Claude Haiku (Anthropic)", "provider": "anthropic", "price": [0.10, 0.50]},
"sonnet": {"family": "claude-sonnet", "default": "claude-sonnet-5-5", "label": "Claude Sonnet (Anthropic)", "provider": "anthropic", "price": [2, 10]},
"opus": {"family": "claude-opus", "default": "claude-opus-5-5", "label": "Claude Opus (Anthropic)", "provider": "anthropic", "price": [4, 20]},
"fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]},
"luna": {"family": "codex-luna", "default": "luna", "label": "Codex Luna (OpenAI)", "provider": "codex", "route": "cli"},
"terra": {"family": "codex-terra", "default": "terra", "label": "Codex Terra (OpenAI)", "provider": "codex", "route": "cli"},
"sol": {"family": "codex-sol", "default": "sol", "label": "Codex Sol (OpenAI)", "provider": "codex", "route": "cli"},
"astra": {"family": "codex-astra", "default": "astra", "label": "Codex Astra (OpenAI)", "provider": "codex", "route": "cli"}
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Seed a venture on demand: land a site built elsewhere on gh-pages at ventures/<slug>/, which Teahub pages serves at
<slug>.yolopreneur.lol, and keep the venture's line in the ledger (ventures/ventures.json).
Run by .github/workflows/seed.yml (Actions -> seed -> Run workflow). Its inputs arrive in the environment:
SLUG the folder and the subdomain
BUNDLE one or more https URLs, space-separated. Downloaded in order and joined, they make one .tgz whose
top-level folder is <slug>/ (a big bundle can come as parts, e.g. Boring blob download links)
SHA sha256 of the joined .tgz: nothing lands unless it matches
NAME, ONE_LINER the ledger line; a venture already in the ledger keeps whatever is left empty
Nothing in the bundle runs. It is checked (paths, file types, size, an index.html) and copied over ventures/<slug>/,
replacing what was there, in one commit pushed to gh-pages by the Actions identity.
python3 seed.py PAGES_DIR # PAGES_DIR: a checkout of gh-pages
"""
import datetime as dt
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path
MAX_BYTES = 64 * 1024 * 1024
SLUG = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,40}[a-z0-9])?")
RESERVED = {"index.html", "ventures.json", "backlog.json", "shots", "assets", "www"}
SITE = "https://{slug}.yolopreneur.lol/"
class SeedError(Exception):
pass
def fetch(urls):
"""the bundle: every URL in order, joined"""
data = bytearray()
for u in urls:
if not u.startswith("https://"):
raise SeedError(f"not an https URL: {u[:60]}")
with urllib.request.urlopen(u, timeout=120) as r:
data += r.read(MAX_BYTES + 1 - len(data))
if len(data) > MAX_BYTES:
raise SeedError(f"bundle is over {MAX_BYTES // 2**20} MB")
return bytes(data)
def unpack(data, slug, into):
"""write the bundle's <slug>/ tree under `into`, refusing anything that isn't a plain file or folder inside it"""
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
members = tf.getmembers()
for m in members:
parts = Path(m.name).parts
if m.name.startswith("/") or ".." in parts:
raise SeedError(f"unsafe path in bundle: {m.name}")
if not (m.isfile() or m.isdir()):
raise SeedError(f"not a plain file or folder: {m.name}")
if not parts or parts[0] != slug:
raise SeedError(f"everything in the bundle must sit under {slug}/: {m.name}")
for m in members:
target = into / m.name
if m.isdir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(tf.extractfile(m).read())
if not (into / slug / "index.html").is_file():
raise SeedError(f"the bundle has no {slug}/index.html")
def ledger_line(data, slug, name, one_liner, today):
"""the venture's line in the ledger, added (at the top) or refreshed; raises before anything is written"""
vs = data.setdefault("ventures", [])
v = next((v for v in vs if v.get("slug") == slug), None)
if v is None:
if not name or not one_liner:
raise SeedError("a new venture needs a name and a one-liner for the ledger")
v = {"slug": slug, "name": name, "one_liner": one_liner, "url": SITE.format(slug=slug),
"pages": SITE.format(slug=slug), "launched": today, "signups": 0, "source": "yolopreneur"}
vs.insert(0, v)
else:
if name:
v["name"] = name
if one_liner:
v["one_liner"] = one_liner
return v
def land(pages, slug, data, want, name="", one_liner="", today=None):
"""check the bundle and put it at ventures/<slug>/ in the gh-pages checkout `pages`; returns the bundle's sha256.
Every check runs before anything is written, so a refused bundle leaves the checkout as it was."""
if not SLUG.fullmatch(slug or "") or slug in RESERVED:
raise SeedError(f"bad slug: {slug!r}")
if not re.fullmatch(r"[0-9a-f]{64}", (want or "").lower()):
raise SeedError("sha256 must be 64 hex characters")
got = hashlib.sha256(data).hexdigest()
if got != want.lower():
raise SeedError(f"sha256 mismatch: the bundle is {got}")
path = pages / "ventures" / "ventures.json"
book = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"ventures": []}
line = ledger_line(book, slug, name, one_liner, today or dt.date.today().isoformat())
with tempfile.TemporaryDirectory() as tmp:
unpack(data, slug, Path(tmp))
dest = pages / "ventures" / slug
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(Path(tmp) / slug, dest)
if (dest / "shots" / "desktop.png").is_file():
line["shot"] = f"{slug}/shots/desktop.png" # the card on the yolopreneur.lol index
path.write_text(json.dumps(book, indent=1), encoding="utf-8") # the ledger's own layout: indent 1, ASCII
return got
def commit(pages, slug, sha):
git = ["git", "-C", str(pages), "-c", "user.name=github-actions[bot]",
"-c", "user.email=github-actions[bot]@users.noreply.teahub.xyz"]
subprocess.run(git + ["add", "--all", f"ventures/{slug}", "ventures/ventures.json"], check=True)
if subprocess.run(git + ["diff", "--cached", "--quiet"]).returncode == 0:
print("nothing changed: ventures/" + slug + " is already this bundle")
return False
subprocess.run(git + ["commit", "-q", "-m", f"ventures: seed {slug} (bundle {sha[:12]})"], check=True)
if subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"]).returncode != 0:
# gh-pages moved under us (the venture loop writes there too): replay this one commit on top, push again
subprocess.run(git + ["pull", "-q", "--rebase", "origin", "gh-pages"], check=True)
subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"], check=True)
return True
def main():
pages = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
slug = os.environ.get("SLUG", "").strip()
try:
data = fetch(os.environ.get("BUNDLE", "").split())
sha = land(pages, slug, data, os.environ.get("SHA", "").strip(), os.environ.get("NAME", "").strip(),
os.environ.get("ONE_LINER", "").strip())
except (SeedError, OSError, tarfile.TarError) as e:
print(f"::error::seed {slug}: {e}")
sys.exit(1)
pushed = commit(pages, slug, sha)
print(f"{'landed' if pushed else 'unchanged'}: ventures/{slug}/ ({len(data)} bytes, sha256 {sha[:12]}) -> "
+ SITE.format(slug=slug))
if __name__ == "__main__":
main()
+234
View File
@@ -0,0 +1,234 @@
"""Sync: keep a repo's copies on other forges in step with its home (time/sync.json).
python3 .github/time/sync.py # every repo in time/sync.json; this forge is the home
One rule keeps it safe: each repo has one home, and every branch has one writer. Two writers on one branch is how work
gets lost, which is why neither of Gitea's own mirrors fits: a push mirror force-pushes every branch (it would delete a
cloud session's branch on the copy), and a pull mirror makes the receiving repo read-only.
- Out: the home's branches in `out` (default: main) are pushed to every copy, fast-forward only. A copy whose branch
has commits of its own is reported (one issue on this repo), never overwritten. SYNC_FORCE=1 (the workflow's `force`
input) force-pushes once, e.g. to replace an archived copy's old history.
- In: branches on a copy that match its `import` patterns (claude/* for cloud sessions on GitHub) are the copy's to
write. They are pushed to the home under the same name and get a PR there, which Time's council reviews and lands
like any other; the landed work then flows back out with main. A branch already in main, or one whose PR was closed
at the same commit, is left alone.
time/sync.json:
{"repos": [{"repo": "teahubxyz/time", "out": ["main"],
"copies": [{"name": "github", "url": "git@github.com:teahubxyz/time.git",
"ssh_key": "SYNC_GITHUB_KEY", "import": ["claude/*"]}]}]}
`token` names a secret (an environment variable the workflow passes; Gitea refuses secret names starting GITHUB_ or
GITEA_), never the token itself; a copy whose token isn't set is skipped with a note. Or `ssh_key` names a secret holding an SSH private key (a GitHub deploy key: write access
to that one repo and nothing else) for an `ssh://` or `git@host:` URL; the host's keys are pinned (github.com's are
built in, others go in `known_hosts`), never learned on first use. The same script syncs Gitea with Gitea: it runs on the home, so a pair the home can't
reach (hb-5's local Gitea from prod) runs from the side that can reach both, with that side's own sync.json.
"""
from __future__ import annotations
import base64
import fnmatch
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import urllib.parse
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
CONFIG = "time/sync.json"
SECRETS: list[str] = [] # every token used this run, scrubbed from anything printed or posted
# github.com's published SSH host keys (api.github.com/meta), pinned so a copy over SSH never trusts a key on first use
HOST_KEYS = {"github.com": ["github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl",
"github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxk"
"ZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg="]}
ENV: dict[str, str] = {} # extra environment for the git calls to the copy being synced (its SSH command)
def scrub(text: str) -> str:
for s in SECRETS:
if s:
text = text.replace(s, "***").replace(urllib.parse.quote(s, safe=""), "***")
return text
def config() -> list[dict]:
"""time/sync.json on the default branch, through the API (the workflow checks out only .github/time)."""
try:
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{CONFIG}")
except RuntimeError as e:
if "-> 404" in str(e):
return []
raise
data = json.loads(base64.b64decode(f.get("content") or "").decode() or "{}")
return [r for r in data.get("repos") or [] if isinstance(r, dict)]
def authed(url: str, token: str, user: str = "x-access-token") -> str:
"""The URL with credentials, for https; file:// and the like as given."""
p = urllib.parse.urlsplit(url)
if p.scheme not in ("https", "http") or not token:
return url
SECRETS.append(token)
cred = f"{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(token, safe='')}"
return urllib.parse.urlunsplit((p.scheme, f"{cred}@{p.netloc}", p.path, p.query, p.fragment))
def ssh_host(url: str) -> str:
"""The host of an ssh:// or scp-style (git@host:path) URL; '' for anything else."""
if url.startswith("ssh://"):
return (urllib.parse.urlsplit(url).hostname or "")
if "://" not in url and "@" in url and ":" in url.split("@", 1)[1]:
return url.split("@", 1)[1].split(":", 1)[0]
return ""
def ssh_env(key: str, host: str, known: list[str] | None, folder: str) -> dict[str, str]:
"""GIT_SSH_COMMAND for one copy: its deploy key (written 0600 in the run's temp folder) and the host's pinned keys."""
keys = known or HOST_KEYS.get(host)
if not keys:
raise RuntimeError(f"no pinned host key for {host}: put the host's keys in the copy's known_hosts")
kf, hf = Path(folder, "sync_key"), Path(folder, "known_hosts")
kf.write_text(key.strip() + "\n")
kf.chmod(0o600)
hf.write_text("\n".join(keys) + "\n")
return {"GIT_SSH_COMMAND": f"ssh -i {kf} -o IdentitiesOnly=yes -o UserKnownHostsFile={hf} -o StrictHostKeyChecking=yes "
f"-o BatchMode=yes"}
def git(*args: str, cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=300,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0", **ENV})
if check and p.returncode:
raise RuntimeError(f"git {args[0]} failed: {scrub(p.stderr.strip())[-400:]}")
return p
def heads(url: str, cwd: str) -> dict[str, str]:
"""{branch: sha} of a remote."""
out = git("ls-remote", "--heads", url, cwd=cwd).stdout
return {ref.split("refs/heads/", 1)[1]: sha for sha, ref in (line.split("\t") for line in out.splitlines() if "\t" in line)}
def push_out(work: str, home: dict[str, str], url: str, branch: str, force: bool) -> str:
"""One home branch to one copy: 'up to date', 'pushed', 'forced' or 'diverged' (the copy has commits of its own)."""
if branch not in home:
return "missing on the home"
p = git("push", "--porcelain", url, f"{'+' if force else ''}refs/home/{branch}:refs/heads/{branch}", cwd=work, check=False)
text = p.stdout + p.stderr
if p.returncode == 0:
return "up to date" if "[up to date]" in text else ("forced" if force else "pushed")
if "[rejected]" in text or "non-fast-forward" in text or "fetch first" in text:
return "diverged"
raise RuntimeError(f"push {branch} failed: {scrub(text.strip())[-400:]}")
def landed(work: str, ref: str, base: str) -> bool:
return git("merge-base", "--is-ancestor", ref, base, cwd=work, check=False).returncode == 0
def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: str) -> int | None:
"""A PR on the home for an imported branch, unless one is open for it, it is already in the base, or a PR for it
was closed at this same commit (a human or the council said no; a new push brings it back)."""
ref = f"refs/copy/{branch}"
if landed(work, ref, f"refs/home/{base}"):
return None
tip = git("rev-parse", ref, cwd=work).stdout.strip()
for p in tm.gh("GET", f"/repos/{repo}/pulls?state=all&limit=50") or []:
head = p.get("head") or {}
if head.get("ref") == branch and (p.get("state") == "open" or head.get("sha") == tip):
return None
log = git("log", "--reverse", "--format=%s%n%n%b%n---", f"refs/home/{base}..{ref}", cwd=work).stdout.strip()
title = (log.splitlines() or [branch])[0][:200]
body = (f"From {copy}: `{branch}` ({where}), brought in by sync.py. Time's council reviews it like any PR.\n\n"
f"Commits:\n\n{log[:6000]}\n\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the "
f"next sync brings the change here.</sub>")
return tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"]
def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
repo = entry.get("repo") or tm.REPO
out = [b for b in entry.get("out") or ["main"] if isinstance(b, str)]
base = out[0]
report: dict = {"repo": repo, "copies": []}
with tempfile.TemporaryDirectory() as work:
git("init", "--bare", "-q", work)
home = heads(home_url, work)
wanted = [b for b in out if b in home]
if wanted:
git("fetch", "-q", home_url, *[f"+refs/heads/{b}:refs/home/{b}" for b in wanted], cwd=work)
for c in entry.get("copies") or []:
name, where = str(c.get("name") or c.get("url")), str(c.get("url") or "")
secret = str(c.get("ssh_key") or c.get("token") or "")
token = os.environ.get(secret, "") if secret else ""
r: dict = {"copy": name, "out": {}, "imported": [], "prs": [], "note": ""}
report["copies"].append(r)
if secret and not token:
r["note"] = f"skipped: the secret {secret} isn't set"
continue
ENV.clear()
if c.get("ssh_key"):
SECRETS.append(token)
ENV.update(ssh_env(token, ssh_host(where), c.get("known_hosts"), work))
url = where
else:
url = authed(where, token, str(c.get("user") or "x-access-token"))
for b in out:
r["out"][b] = push_out(work, home, url, b, force)
pats = [p for p in c.get("import") or [] if isinstance(p, str)]
if not pats:
continue
theirs = heads(url, work)
mine = heads(home_url, work)
for branch, sha in sorted(theirs.items()):
if branch in out or not any(fnmatch.fnmatch(branch, p) for p in pats):
continue
git("fetch", "-q", url, f"+refs/heads/{branch}:refs/copy/{branch}", cwd=work)
if mine.get(branch) != sha: # the copy writes this branch: the home takes it as it is
git("push", "-q", "--force", home_url, f"refs/copy/{branch}:refs/heads/{branch}", cwd=work)
r["imported"].append(branch)
n = ensure_pr(repo, work, branch, base, name, where)
if n:
r["prs"].append(n)
ENV.clear()
return report
def flag(repo: str, copy: str, branch: str) -> None:
"""One open issue per diverged copy branch, labelled needs-human: whether to force is a human's (or the CEO's)
call, so neither the worker nor the router takes it."""
title = f"Sync: {copy}'s {branch} has commits of its own ({repo})"
if any(i.get("title") == title for i in tm.issues("state=open")):
return
tm.new_issue(title, f"sync.py pushes `{branch}` from the home to {copy} fast-forward only, and {copy}'s `{branch}` has "
f"commits the home doesn't. Nothing was overwritten. Either bring those commits to the home (a branch "
f"and a PR), or replace {copy}'s history: Actions → sync → Run workflow with `force`.\n\n"
f"DONE: {copy}'s `{branch}` follows the home again and this sync run is green.", ["needs-human"])
def main() -> int:
entries = config()
if not entries:
print(f"sync: no {CONFIG}, nothing to sync")
return 0
force = (os.environ.get("SYNC_FORCE") or "").lower() in ("1", "true", "yes")
token = os.environ.get("TIME_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
for e in entries:
repo = e.get("repo") or tm.REPO
rep = sync_repo(e, authed(f"{tm.SERVER}/{repo}.git", token), force)
for c in rep["copies"]:
print(f"sync {repo} -> {c['copy']}: out {c['out']} imported {c['imported']} prs {c['prs']} {c['note']}".rstrip())
for b, state in c["out"].items():
if state == "diverged" and repo == tm.REPO:
flag(repo, c["copy"], b)
return 0
if __name__ == "__main__":
sys.exit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Offline tests for housekeeping.py: a fake forge (like test_time.py's World) that records every write."""
import datetime as dt
import importlib.util
import json
import os
import sys
import urllib.parse
from pathlib import Path
for _k in [k for k in os.environ if k.startswith("TIME_")] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY", "CURSOR_API_KEY"]:
os.environ.pop(_k, None)
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1")
spec = importlib.util.spec_from_file_location("hk", Path(__file__).resolve().parent / "housekeeping.py")
hk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hk)
tm = hk.tm
NOW = dt.datetime.now(dt.timezone.utc)
iso = lambda days=0: (NOW - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
LOG = 57
class Forge:
def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None, repo_labels=None):
self.branches = [{"name": b} if isinstance(b, str) else b for b in branches]
self.prs = list(prs)
self.issues = {i["number"]: {"state": "open", "labels": [], "created_at": iso(0), "updated_at": iso(0), "body": "", **i} for i in issues}
self.milestones = [dict(m) for m in milestones]
self.reactions = reactions or {}
self.cmts = comments or {}
# the repo's labels; by default the ones its issues carry. Gitea ignores a filter on any other label.
self.repo_labels = set(repo_labels) if repo_labels is not None else {l for i in self.issues.values() for l in i["labels"]}
self.writes = [] # every non-GET call
self.posted = [] # (issue, body)
def page(self, rows, path):
q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
p, n = int(q.get("page", ["1"])[0]), int(q.get("limit", ["50"])[0])
return rows[(p - 1) * n: p * n]
def http(self, method, url, token, body=None, timeout=0):
path = url.replace(tm.GH, "")
route = path.split("?")[0]
q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
if method != "GET":
self.writes.append((method, route, body))
if route == "/repos/o/r" and method == "GET":
return {"default_branch": "main"}
if route == "/repos/o/r/branches" and method == "GET":
return self.page(self.branches, path)
if route.startswith("/repos/o/r/branches/") and method == "DELETE":
name = urllib.parse.unquote(route.split("/branches/")[1])
self.branches = [b for b in self.branches if b["name"] != name]
return {}
if route == "/repos/o/r/pulls":
want = q.get("state", ["open"])[0]
return self.page([p for p in self.prs if p["state"] == want], path)
if route == "/repos/o/r/milestones" and method == "GET":
return self.page([m for m in self.milestones if m.get("state", "open") == "open"], path)
if route.startswith("/repos/o/r/milestones/") and method == "PATCH":
for m in self.milestones:
if m["id"] == int(route.split("/")[-1]):
m.update(body)
return {}
if route.endswith("/reactions"): # Gitea: null, not [], for an issue nobody reacted to
n = int(route.split("/")[-2])
return self.page(self.reactions[n], path) if n in self.reactions else None
if route.endswith("/comments") and method == "GET":
return self.page(self.cmts.get(int(route.split("/")[-2]), []), path)
if route.endswith("/comments") and method == "POST":
self.posted.append((int(route.split("/")[-2]), body["body"]))
return {}
if route == "/repos/o/r/issues" and method == "GET":
rows = [i for i in self.issues.values()
if (q.get("state", ["open"])[0] in ("all", i["state"]))
and all(l in i["labels"] for l in q.get("labels", [""])[0].split(",") if l and l in self.repo_labels)]
return self.page([{**i, "labels": [{"name": l} for l in i["labels"]]} for i in rows], path)
if route.startswith("/repos/o/r/issues/") and method == "PATCH":
self.issues[int(route.split("/")[-1])].update(body)
return {}
raise AssertionError(f"unexpected {method} {path}")
def run(self, mode="live", env=None):
os.environ.pop("TIME_MODE", None)
os.environ.pop("TIME_VENTURE_MEASURE_DAYS", None)
if mode:
os.environ["TIME_MODE"] = mode
os.environ.update(env or {})
tm.http = self.http
return hk.main()
def pr(n, head, state="open", merged=False, base="main", idle=0, merged_days=1):
return {"number": n, "title": f"pr {n}", "state": state, "merged": merged, "merged_at": iso(merged_days) if merged else None,
"base": {"ref": base}, "head": {"ref": head, "repo": {"full_name": "o/r"}}, "created_at": iso(idle), "updated_at": iso(idle)}
deleted = lambda f: [urllib.parse.unquote(w[1].split("/branches/")[1]) for w in f.writes if w[0] == "DELETE"]
def test_branches():
f = Forge(branches=["main", "gh-pages", "time/card-53", "ceo/protected-paths", "labs/aicharts-fast", "time/card-90", "feature/open", "feature/closed-unmerged"],
prs=[pr(1, "time/card-53", "closed", True), pr(2, "ceo/protected-paths", "closed", True), pr(3, "labs/aicharts-fast", "closed", True),
pr(4, "main", "closed", True), pr(5, "gh-pages", "closed", True),
pr(6, "feature/open", "open"), pr(7, "feature/open", "closed", True), # merged once, reopened as a new PR: keep
pr(8, "feature/closed-unmerged", "closed", False)])
assert f.run() == 0
assert sorted(deleted(f)) == ["ceo/protected-paths", "labs/aicharts-fast", "time/card-53"], deleted(f)
kept = {b["name"] for b in f.branches}
assert {"main", "gh-pages", "time/card-90", "feature/open", "feature/closed-unmerged"} <= kept
assert any("3 branch(es) deleted" in b for _, b in f.posted), f.posted # the summary line on the Astra log
# idempotent: a second run has nothing to do
f.writes.clear(); f.posted.clear()
f.run()
assert deleted(f) == [] and any("0 branch(es) deleted" in b for _, b in f.posted)
# a branch with commits after its merge is kept
f = Forge(branches=[{"name": "time/card-9", "commit": {"timestamp": iso(0)}}], prs=[pr(9, "time/card-9", "closed", True, merged_days=2)])
f.run(); assert deleted(f) == []
def sig(n, venture, age):
return {"number": n, "title": f"{venture}: want the next version?", "labels": ["signal"], "created_at": iso(age),
"body": f"Join: https://forge.test/o/r/issues/new?template=early-access.yml&title=x&venture={venture}"}
def test_signals():
f = Forge(issues=[sig(10, "spent", 8), sig(11, "fresh", 2), sig(12, "wanted", 9), sig(13, "asked", 9),
{"number": 20, "labels": ["signup"], "user": {"login": "ann"}, "body": "### Venture\n\nasked\n\n### Plan\n\nFree"}],
reactions={12: [{"content": "+1", "user": {"login": "bob"}}, {"content": "+1", "user": {"login": "owner"}}]})
f.run()
st = {n: f.issues[n]["state"] for n in (10, 11, 12, 13)}
assert st == {10: "closed", 11: "open", 12: "open", 13: "open"}, st
closing = [b for n, b in f.posted if n == 10]
assert closing and "0 signups" in closing[0] and not [b for n, b in f.posted if n in (11, 12, 13)]
assert any("1 signal issue(s) closed" in b for _, b in f.posted)
# the window is TIME_VENTURE_MEASURE_DAYS
f = Forge(issues=[sig(10, "spent", 8)]); f.run(env={"TIME_VENTURE_MEASURE_DAYS": "10"})
assert f.issues[10]["state"] == "open"
def test_milestones():
f = Forge(milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3},
{"id": 2, "title": "in progress", "open_issues": 1, "closed_issues": 2},
{"id": 3, "title": "never used", "open_issues": 0, "closed_issues": 0}])
f.run()
st = {m["id"]: m.get("state", "open") for m in f.milestones}
assert st == {1: "closed", 2: "open", 3: "open"}, st
assert any("1 milestone(s) closed" in b for _, b in f.posted)
def test_stale():
ready_err = {"created_at": iso(5), "user": {"login": "gitea-actions"}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': 'error'})} -->"}
f = Forge(issues=[{"number": 30, "title": "old human card", "labels": ["needs-human"], "updated_at": iso(4)},
{"number": 31, "title": "fresh human card", "labels": ["needs-human"], "updated_at": iso(1)},
{"number": 32, "title": "errored card", "labels": ["ready"], "updated_at": iso(5)},
{"number": 33, "title": "fresh error", "labels": ["ready"], "updated_at": iso(0)}],
comments={32: [ready_err], 33: [{**ready_err, "created_at": iso(0)}]},
prs=[pr(40, "feature/idle", "open", idle=7), pr(41, "feature/new", "open", idle=1)])
f.run()
texts = [b for n, b in f.posted if n == LOG and "stale list" in b]
assert len(texts) == 1, f.posted
t = texts[0]
assert "#30" in t and "#40" in t and "#32" in t, t
assert "#31" not in t and "#41" not in t and "#33" not in t, t
assert any("3 stale" in b for _, b in f.posted), f.posted # #30, #32, #40: exactly three
# an empty list posts no stale comment, only the one summary line
f = Forge(); f.run()
assert [b for _, b in f.posted if "stale list" in b] == [] and len(f.posted) == 1 and "0 stale" in f.posted[0][1]
def test_unknown_label():
"""No `parked` label in the repo: Gitea answers `labels=parked` with every open issue. None of them is stale, and
a `signal` filter answered with everything must not close an old issue that only looks like a signal."""
f = Forge(issues=[{"number": 57, "title": "Astra log", "labels": ["astra"], "updated_at": iso(9)},
{"number": 72, "title": "a parent", "labels": ["planned"], "updated_at": iso(9)},
{"number": 96, "title": "queued card", "labels": ["queued"], "updated_at": iso(9)},
{"number": 99, "title": "old note", "labels": [], "created_at": iso(30), "body": "see ?venture=spent"}],
repo_labels={"astra", "planned", "queued"})
f.run()
assert [b for _, b in f.posted if "stale list" in b] == [], f.posted
assert any("0 stale" in b for _, b in f.posted) and f.issues[99]["state"] == "open", f.posted
assert [n for n, _ in f.posted] == [LOG], "the summary goes to the Astra log, found by its own label"
def test_dry_run():
f = Forge(branches=["main", "time/card-53"], prs=[pr(1, "time/card-53", "closed", True)],
issues=[sig(10, "spent", 8), {"number": 30, "labels": ["needs-human"], "updated_at": iso(9)}],
milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3}])
for mode in ("dry-run", None): # explicit and unset
assert f.run(mode) == 0
assert f.writes == [] and f.posted == [], (f.writes, f.posted)
assert {b["name"] for b in f.branches} == {"main", "time/card-53"} and f.issues[10]["state"] == "open"
if __name__ == "__main__":
tests = [test_branches, test_signals, test_milestones, test_stale, test_unknown_label, test_dry_run]
for t in tests:
t()
print("ok", t.__name__)
print(f"{len(tests)} housekeeping tests passed")
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Tests for seed.py: a bundle lands only when it is what it says it is, and the ledger keeps its layout."""
import hashlib
import importlib.util
import io
import json
import sys
import tarfile
import tempfile
from pathlib import Path
spec = importlib.util.spec_from_file_location("seed", Path(__file__).with_name("seed.py"))
seed = importlib.util.module_from_spec(spec)
spec.loader.exec_module(seed)
LEDGER = {"ventures": [{"slug": "mdecks", "name": ".mdeck", "one_liner": "Slides in plain text — fast.",
"launched": "2026-10-12", "signups": 0}], "refreshed": "2026-10-14"}
def tgz(files, links=()):
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, body in files.items():
ti = tarfile.TarInfo(name)
ti.size = len(body)
tf.addfile(ti, io.BytesIO(body))
for name, target in links:
ti = tarfile.TarInfo(name)
ti.type, ti.linkname = tarfile.SYMTYPE, target
tf.addfile(ti)
return buf.getvalue()
def pages_dir():
d = Path(tempfile.mkdtemp())
(d / "ventures").mkdir()
(d / "ventures" / "ventures.json").write_text(json.dumps(LEDGER, indent=1), encoding="utf-8")
return d
def refused(fn, *a, **kw):
try:
fn(*a, **kw)
except seed.SeedError as e:
return str(e)
raise AssertionError("expected SeedError")
def sha(b):
return hashlib.sha256(b).hexdigest()
def test_lands_and_lists():
p = pages_dir()
b = tgz({"hh/index.html": b"<!doctype html>hi", "hh/plates/a.svg": b"<svg/>", "hh/shots/desktop.png": b"png"})
assert seed.land(p, "hh", b, sha(b), "House Hand", "A drawing hand.", today="2026-10-15") == sha(b)
assert (p / "ventures/hh/plates/a.svg").read_bytes() == b"<svg/>"
text = (p / "ventures/ventures.json").read_text()
assert text == json.dumps(json.loads(text), indent=1) # same layout as the file it came from
assert "\\u2014" in text # ASCII escapes kept, like the original
first = json.loads(text)["ventures"][0]
assert first["slug"] == "hh" and first["launched"] == "2026-10-15" and first["shot"] == "hh/shots/desktop.png"
assert first["url"] == "https://hh.yolopreneur.lol/" and json.loads(text)["refreshed"] == "2026-10-14"
def test_replaces_and_keeps_the_ledger_line():
p = pages_dir()
b1 = tgz({"hh/index.html": b"one", "hh/old.txt": b"stale"})
seed.land(p, "hh", b1, sha(b1), "House Hand", "First line.", today="2026-10-15")
b2 = tgz({"hh/index.html": b"two"})
seed.land(p, "hh", b2, sha(b2), today="2026-10-20") # no name or line given: keep them
assert not (p / "ventures/hh/old.txt").exists() and (p / "ventures/hh/index.html").read_bytes() == b"two"
vs = [v for v in json.loads((p / "ventures/ventures.json").read_text())["ventures"] if v["slug"] == "hh"]
assert len(vs) == 1 and vs[0]["one_liner"] == "First line." and vs[0]["launched"] == "2026-10-15"
def test_refuses():
p = pages_dir()
good = tgz({"hh/index.html": b"x"})
assert "mismatch" in refused(seed.land, p, "hh", good, "0" * 64, "n", "o")
assert "bad slug" in refused(seed.land, p, "../etc", good, sha(good), "n", "o")
assert "bad slug" in refused(seed.land, p, "ventures.json", good, sha(good), "n", "o")
for bad, why in ((tgz({"hh/../../x": b"x", "hh/index.html": b"x"}), "unsafe"),
(tgz({"hh/index.html": b"x"}, links=[("hh/link", "/etc/passwd")]), "plain file"),
(tgz({"other/index.html": b"x"}), "under hh/"),
(tgz({"hh/readme.txt": b"x"}), "no hh/index.html")):
assert why in refused(seed.land, p, "hh", bad, sha(bad), "n", "o"), why
assert "needs a name" in refused(seed.land, p, "hh", good, sha(good))
assert not (p / "ventures/hh").exists() # nothing half-landed
assert "https" in refused(seed.fetch, ["http://example.com/x.tgz"])
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for t in tests:
t()
print("ok", t.__name__)
print(f"{len(tests)} seed tests passed")
sys.exit(0)
+2211 -45
View File
File diff suppressed because it is too large. Load diff
+1177 -164
View File
File diff suppressed because it is too large. Load diff
+2025 -117
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
name: astra
# The slow loop: once a day, one planning call (TIME_ASTRA_SEAT, default `opus`) reads the board and the ledger and
# labels a few cards `ready` for the heartbeat. It writes its reasoning to the "Astra log" issue; reply there to steer it.
# Guardrails are code: TIME_ASTRA_MAX_READY (2) / TIME_ASTRA_MAX_NEW (2) per day, nothing while an `andon` is open,
# and the same spend caps as every other call. Off until TIME_ASTRA=on.
on:
schedule:
- cron: "13 13 * * *"
workflow_dispatch:
concurrency:
group: astra
cancel-in-progress: false
jobs:
plan:
if: vars.TIME_ASTRA == 'on'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false
- run: python3 .github/time/astra.py tick
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: Astra's issues and labels go out as the `time` bot
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
TIME_ASTRA_SEAT: ${{ vars.TIME_ASTRA_SEAT }}
TIME_ASTRA_MAX_READY: ${{ vars.TIME_ASTRA_MAX_READY }}
TIME_ASTRA_MAX_NEW: ${{ vars.TIME_ASTRA_MAX_NEW }}
TIME_SPINE: ${{ vars.TIME_SPINE }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
TIME_WORKER: ${{ vars.TIME_WORKER }} # the router's money left is the pool that takes the worker's route (#175)
TIME_PACE: ${{ vars.TIME_PACE }} # and that pool's allowance is paced the same as in the heartbeat
+7
View File
@@ -4,6 +4,8 @@ on:
pull_request:
push:
branches: [main]
paths-ignore: ["time/ledger/**"] # Time appends its own spend ledger every run; that is not a code change
workflow_dispatch: # Time can run CI on a worker's PR branch by hand
permissions:
contents: read
jobs:
@@ -12,4 +14,9 @@ jobs:
timeout-minutes: 3
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4 # node 22+ has node:sqlite, which the passkey test runs on
with:
node-version: 22
- run: python3 .github/time/test_time.py
- run: python3 .github/time/test_seed.py
- run: python3 .github/time/test_housekeeping.py
+7 -1
View File
@@ -1,5 +1,5 @@
name: doctor
# Manual check of Time's setup: label, settings, keys (never printed). Result in the run summary.
# Manual check of Time's setup: label, settings, keys (never printed), today's spend. Result in the run summary.
on: workflow_dispatch
permissions:
contents: read
@@ -18,8 +18,14 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
TIME_WORKER: ${{ vars.TIME_WORKER }}
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
+22 -10
View File
@@ -11,38 +11,49 @@ on:
types: [submitted]
issue_comment:
types: [created]
permissions:
contents: write # squash-merge approved, green PRs
issues: write
pull-requests: write
checks: read
statuses: write # a Venice worker's test run is recorded as the PR's check
# (GitHub-style `permissions:` are ignored by Gitea; the Actions token has write access to this repo)
concurrency:
group: heartbeat
cancel-in-progress: false
jobs:
tick:
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object).
# issues: only `ready`/`spec` labels. comments: only on PRs, only a reviewer verdict (Approve/Object), never Time's own.
if: >-
(github.event_name != 'issues' || contains(fromJSON('["ready","spec"]'), github.event.label.name)) &&
(github.event_name != 'issue_comment' || (github.event.issue.pull_request &&
(github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.comment.user.login != 'gitea-actions' &&
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 55 # workers can overlap inside one job now (#75); under the hourly cadence, and the concurrency group queues the next run
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false # the worker's shell must not find the token in .git/config
- run: python3 .github/time/time.py heartbeat
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine)
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity)
TIME_SEAT_CLAUDE: ${{ secrets.TIME_SEAT_CLAUDE }} # umbrella seat, retired once the per-seat tokens below are in use (#76)
TIME_SEAT_HAIKU: ${{ secrets.TIME_SEAT_HAIKU }} # one Gitea user per seat (model line): a seat's verdict is a real review by its own user (#76)
TIME_SEAT_SONNET: ${{ secrets.TIME_SEAT_SONNET }}
TIME_SEAT_OPUS: ${{ secrets.TIME_SEAT_OPUS }}
TIME_SEAT_FABLE: ${{ secrets.TIME_SEAT_FABLE }}
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_DAILY_CAP: ${{ vars.TIME_DAILY_CAP }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }} # spend governor: dollars per day (default 5) …
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }} # … and per month (default 100); ledger in time/ledger/
TIME_CAP_SLACK: ${{ vars.TIME_CAP_SLACK }} # a run launched within budget may finish up to this x the daily cap (default 1.5; #117)
TIME_ANTHROPIC_API: ${{ vars.TIME_ANTHROPIC_API }} # messages (default, prompt caching) | compat = the OpenAI-compatible endpoint (#119)
TIME_CURSOR_MODEL: ${{ vars.TIME_CURSOR_MODEL }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }}
TIME_REVIEW: ${{ vars.TIME_REVIEW }} # one seat, or a comma-separated council (#77)
TIME_REVIEW_TIEBREAK: ${{ vars.TIME_REVIEW_TIEBREAK }} # the seat called only on a split, e.g. venice:kimi (#77)
TIME_REVIEW_QUORUM: ${{ vars.TIME_REVIEW_QUORUM }} # approves minus objects needed to land (default 1; #77)
TIME_STUCK_HOURS: ${{ vars.TIME_STUCK_HOURS }}
TIME_AUTOMERGE: ${{ vars.TIME_AUTOMERGE }}
TIME_REQUIRE_CHECKS: ${{ vars.TIME_REQUIRE_CHECKS }}
@@ -51,3 +62,4 @@ jobs:
TIME_PLANNER: ${{ vars.TIME_PLANNER }}
TIME_WORKER: ${{ vars.TIME_WORKER }}
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
TIME_WORKER_MAX_TOKENS: ${{ vars.TIME_WORKER_MAX_TOKENS }}
+26
View File
@@ -0,0 +1,26 @@
name: housekeeping
# The daily sweep (#83, .github/time/housekeeping.py): prunes merged branches, closes spent signal issues and empty
# milestones, lists the stale on the Astra log. Deterministic, no model calls; dry-run unless TIME_MODE=live.
# SOP: .agents/skills/housekeeping/SKILL.md
on:
schedule:
- cron: "17 13 * * *" # a few minutes after the Astra tick (13:13)
workflow_dispatch:
concurrency:
group: housekeeping
cancel-in-progress: false
jobs:
sweep:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false
- run: python3 .github/time/housekeeping.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branch and issue writes go out as the `time` user
TIME_MODE: ${{ vars.TIME_MODE }}
TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }}
+38
View File
@@ -0,0 +1,38 @@
name: labs-aicharts
# Deploy the AICharts demo Worker (aicharts.<venture domain>, one D1, Workers AI), then smoke-test it. Manual only.
on: workflow_dispatch
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node .github/time/labs/aicharts/test_aicharts.mjs
- id: deploy
run: |
set -o pipefail
python3 .github/time/labs/aicharts/deploy.py | tee out.json
echo "url=$(python3 -c 'import json; print(json.load(open("out.json"))["url"])')" >> "$GITHUB_OUTPUT"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
- name: smoke
env:
u: ${{ steps.deploy.outputs.url }} # through env, never interpolated into the script
run: |
set -o pipefail
for i in $(seq 1 18); do curl -fsS "${u}api/health" && break; sleep 10; done
curl -fsS "${u}api/schema?dataset=orders" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert len(d["sample"])==5, d; print("schema ok")'
curl -fsS -X POST "${u}api/spec" -H 'content-type: application/json' \
-d '{"dataset":"orders","prompt":"total revenue by region"}' | tee spec.json
echo
echo "### AICharts: ${u}" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"; cat spec.json >> "$GITHUB_STEP_SUMMARY"; echo '```' >> "$GITHUB_STEP_SUMMARY"
+9 -4
View File
@@ -14,12 +14,13 @@ concurrency:
cancel-in-progress: false
jobs:
reply:
# cheap filter before a runner spins up: a mention, not from a bot, and only from people with a say in this repo
# (public repo: strangers can comment, but must not be able to spend the seats' model credits)
# cheap filter before a runner spins up: a mention, and not Time's own comment (Gitea fires events for the Actions
# token's writes). Who may spend the seats' credits is checked in time.py (collaborator permission); Gitea has no
# author_association in the event.
if: >-
contains(github.event.comment.body || github.event.issue.body, '@') &&
(github.event.comment.user.type || github.event.issue.user.type) != 'Bot' &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association || github.event.issue.author_association)
(github.event.comment.user.login || github.event.issue.user.login) != 'gitea-actions' &&
(github.event.comment.user.login || github.event.issue.user.login) != 'time'
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
@@ -30,3 +31,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
+95
View File
@@ -0,0 +1,95 @@
name: refresh
# Refresh a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated
# code): run ventures/<slug>/tools/build.sh on the runner, in place, then commit what changed to gh-pages in one
# commit and ask Teahub pages to pull. Actions -> refresh -> Run workflow.
# `from` takes the venture's folder from another branch (say, a rework in progress), so the new version is built and
# lands whole instead of file by file. The build runs in a checkout without credentials; only ventures/<slug>/ comes
# back. Chrome for screenshots: CHROME (Playwright's Chromium is installed when the runner has none), with
# CHROME_FLAGS for running as root.
on:
workflow_dispatch:
inputs:
slug:
description: "The venture: ventures/<slug>/ on gh-pages, with a tools/build.sh"
required: true
type: string
from:
description: "Branch to take the venture's folder from (empty: gh-pages itself)"
required: false
type: string
permissions:
contents: write
concurrency:
group: venture # the venture loop and seed write gh-pages too: take turns
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: check the inputs
env: # inputs reach the scripts through the environment, never interpolated into them
SLUG: ${{ inputs.slug }}
FROM: ${{ inputs.from }}
run: |
printf '%s' "$SLUG" | grep -Eqx '[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?' || { echo "::error::bad slug: $SLUG"; exit 1; }
[ -z "$FROM" ] || printf '%s' "$FROM" | grep -Eqx '[A-Za-z0-9._/-]{1,100}' || { echo "::error::bad branch: $FROM"; exit 1; }
- uses: actions/checkout@v5
with:
ref: ${{ inputs.from || 'gh-pages' }}
path: work
fetch-depth: 1
sparse-checkout: ventures/${{ inputs.slug }}
persist-credentials: false # the build that runs next must not find a token in .git/config
- uses: actions/setup-node@v4
with:
node-version: 22
- name: find Chrome, or install Playwright's Chromium
run: |
set -eu
for c in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
echo "CHROME=$(command -v "$c")" >> "$GITHUB_ENV"; echo "using $(command -v "$c")"; exit 0
fi
done
echo "no Chrome on this runner: installing Playwright's Chromium"
npx -y playwright@1.55.0 install --with-deps chromium > /tmp/pw.log 2>&1 || { tail -20 /tmp/pw.log; exit 1; }
c=$(find "$HOME/.cache/ms-playwright" -type f -name chrome -path '*chrome-linux*' | head -1)
[ -n "$c" ] && [ -x "$c" ] || { echo "::error::no Chromium after the install"; exit 1; }
echo "CHROME=$c" >> "$GITHUB_ENV"; echo "using $c"
- name: build
env:
SLUG: ${{ inputs.slug }}
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage --disable-gpu"
run: |
set -eu
[ -f "work/ventures/$SLUG/tools/build.sh" ] || { echo "::error::ventures/$SLUG has no tools/build.sh"; exit 1; }
sh "work/ventures/$SLUG/tools/build.sh"
- uses: actions/checkout@v5
with:
ref: gh-pages
path: pages
fetch-depth: 1
- name: commit what changed
env:
SLUG: ${{ inputs.slug }}
FROM: ${{ inputs.from }}
run: |
set -eu
[ -f "pages/ventures/ventures.json" ] || { echo "::error::no ledger on gh-pages"; exit 1; }
rm -rf "pages/ventures/$SLUG"
cp -R "work/ventures/$SLUG" "pages/ventures/$SLUG"
find "pages/ventures/$SLUG" -name __pycache__ -type d -prune -exec rm -rf {} +
cd pages
git add --all "ventures/$SLUG"
if git diff --cached --quiet; then echo "nothing changed in ventures/$SLUG"; exit 0; fi
git diff --cached --stat | tail -25
git -c user.name='github-actions[bot]' -c user.email='github-actions[bot]@users.noreply.teahub.xyz' \
commit -q -m "ventures: refresh $SLUG${FROM:+ from $FROM}"
if ! git push -q origin HEAD:gh-pages; then # gh-pages moved under us: replay this one commit on top
git pull -q --rebase origin gh-pages
git push -q origin HEAD:gh-pages
fi
echo "refreshed: ventures/$SLUG -> https://$SLUG.yolopreneur.lol/"
- name: tell Teahub pages to pull
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
+59
View File
@@ -0,0 +1,59 @@
name: seed
# Seed a venture on demand: land a site that was built somewhere else (by hand, on the Mac, by another agent) on
# yolopreneur.lol. Actions -> seed -> Run workflow, with the inputs below.
# The bundle is a .tgz whose top-level folder is the slug. .github/time/seed.py checks it against its sha256, copies
# it over ventures/<slug>/ on gh-pages in one commit, adds or refreshes the venture's line in ventures/ventures.json,
# and the last step asks Teahub pages to pull, so <slug>.yolopreneur.lol serves it within the minute.
# Nothing in the bundle runs: it is checked (plain files and folders under <slug>/, an index.html) and copied.
on:
workflow_dispatch:
inputs:
slug:
description: "Folder and subdomain: ventures/<slug>/ is served at <slug>.yolopreneur.lol"
required: true
type: string
bundle:
description: "https URL of the .tgz, or of its parts, space-separated and in order"
required: true
type: string
sha256:
description: "sha256 of the whole .tgz"
required: true
type: string
name:
description: "Name in the ledger (needed for a new venture)"
required: false
type: string
one_liner:
description: "One line in the ledger (needed for a new venture)"
required: false
type: string
permissions:
contents: write
concurrency:
group: venture # the venture loop writes gh-pages too: take turns
cancel-in-progress: false
jobs:
seed:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
path: engine
persist-credentials: false
- uses: actions/checkout@v5
with:
ref: gh-pages
path: pages
fetch-depth: 1
- run: python3 engine/.github/time/seed.py pages
env: # inputs reach the script through the environment, never interpolated into it
SLUG: ${{ inputs.slug }}
BUNDLE: ${{ inputs.bundle }}
SHA: ${{ inputs.sha256 }}
NAME: ${{ inputs.name }}
ONE_LINER: ${{ inputs.one_liner }}
- name: tell Teahub pages to pull
run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes"
+39
View File
@@ -0,0 +1,39 @@
name: sync
# Keeps this repo's copies on other forges in step with it (time/sync.json, .github/time/sync.py). One home, one writer
# per branch: main goes out to every copy, fast-forward only (a copy with commits of its own is reported, never
# overwritten); the branches a copy is told to send back (claude/* from cloud sessions on GitHub) come in as PRs here,
# and Time's council reviews and lands them like any other. Each copy's token is a secret named in sync.json and passed
# below (a token for https, a deploy key for ssh); a copy without one is skipped. On a GitHub copy, turn GitHub Actions off: these workflows must run here only.
on:
push:
branches: [main]
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
inputs:
force:
description: "Force-push main to the copies once (replaces a copy's own history, e.g. an archived copy's)"
type: boolean
default: false
concurrency:
group: sync
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false
# a copy over SSH (a deploy key) needs the ssh client; the job image may not have it
- run: command -v ssh || (apt-get update -qq && apt-get install -y -qq --no-install-recommends openssh-client >/dev/null)
- run: python3 .github/time/sync.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branches and PRs on the home go in as the `time` user
SYNC_FORCE: ${{ inputs.force && '1' || '' }}
# Gitea refuses secret names starting GITHUB_ or GITEA_, hence SYNC_
SYNC_GITHUB_KEY: ${{ secrets.SYNC_GITHUB_KEY }} # github.com/teahubxyz/time: a deploy key with write access
SYNC_GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }} # or a token, for a copy over https
+3 -2
View File
@@ -24,12 +24,12 @@ jobs:
- name: copy engine, open PR if changed
run: |
set -e
for f in .github/time/time.py .github/time/venture.py .github/time/test_time.py .github/time/seats.json .claude/skills/time/SKILL.md; do
for f in .github/time/time.py .github/time/venture.py .github/time/cloudflare.py .github/time/cloudflare/worker.js .github/time/cloudflare/time.js .github/time/cloudflare/test_worker.mjs .github/time/cloudflare/test_auth.mjs .github/time/test_time.py .github/time/seats.json .github/ISSUE_TEMPLATE/early-access.yml .claude/skills/time/SKILL.md; do
mkdir -p "$(dirname "$f")"; cp "/tmp/up/$f" "$f"
done
python3 .github/time/test_time.py > /dev/null
wf=$(for f in /tmp/up/.github/workflows/*.yml; do b=.github/workflows/$(basename "$f"); cmp -s "$f" "$b" || echo "- \`$b\`"; done)
if git diff --quiet; then
if git diff --quiet && [ -z "$(git status --porcelain .github/time)" ]; then
echo "Time engine is up to date." | tee -a "$GITHUB_STEP_SUMMARY"
[ -n "$wf" ] && printf 'Workflow files differ upstream, copy by hand:\n%s\n' "$wf" | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
@@ -37,6 +37,7 @@ jobs:
sha=$(git -C /tmp/up rev-parse --short HEAD)
branch="time-update-$sha"
git switch -c "$branch"
git add .github/time .github/ISSUE_TEMPLATE .claude/skills/time
git -c user.name=time -c user.email=time@users.noreply.github.com commit -qam "Update Time to $UPSTREAM@$sha"
git push -q origin "$branch"
body="Fresh Time engine from \`$UPSTREAM@$CHANNEL\` ($sha). Offline tests pass."
+46 -7
View File
@@ -1,31 +1,70 @@
name: venture
# The venture loop: one stage per tick. Daily kickoff plus a nudge every 20 minutes while a card is open
# (cheap: a tick with no open card and TIME_VENTURE!=on exits at once). Off until TIME_VENTURE=on.
# The venture loop: trending repos -> pitches -> board -> launch plan -> launch page on Pages -> 7 days of signups.
# Every 6 hours it advances whatever is due (cheap when nothing is); a new venture card starts once a day,
# or right away: Actions -> venture -> Run workflow -> tick "Start a new venture now" (gh workflow run venture.yml -f start=true).
# Batches: TIME_VENTURE_BATCH=3 (or Run workflow -> batch) runs k ventures at once in one milestone.
# Off until TIME_VENTURE=on. Signups come in through .github/ISSUE_TEMPLATE/early-access.yml.
on:
schedule:
- cron: "*/20 * * * *"
- cron: "27 */6 * * *" # every 6 hours (was every 20 minutes, paused for Venice spend); Claude seats now
workflow_dispatch:
inputs:
start:
description: "Start a new venture now, even if today's has already run"
type: boolean
default: false
batch:
description: "Start a venture batch now: one scout, the board's top k, launched together (k = TIME_VENTURE_BATCH, or 3)"
type: boolean
default: false
permissions:
contents: write
issues: write
pages: write
statuses: write
concurrency:
group: venture
cancel-in-progress: false
jobs:
tick:
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 40
steps:
- uses: actions/checkout@v5
with:
sparse-checkout: .github/time
persist-credentials: false # the worker's shell must not find the token in .git/config
# the browser pass (headless checks + the contact sheet the portfolio shows) needs Chrome; the job image has none
- run: command -v chromium || (apt-get update -qq && apt-get install -y -qq --no-install-recommends chromium >/dev/null)
- run: python3 .github/time/venture.py tick
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # the `time` bot: opens and merges PRs (Gitea denies that to the Actions identity)
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # optional: a backend per venture (cloudflare.py)
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN }}
TIME_VENTURE_AI_MODEL: ${{ vars.TIME_VENTURE_AI_MODEL }}
TIME_VENTURE: ${{ vars.TIME_VENTURE }}
TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }}
TIME_VENTURE_BOARD: ${{ vars.TIME_VENTURE_BOARD }}
TIME_WORKER: ${{ vars.TIME_WORKER }}
TIME_VENTURE_START: ${{ inputs.start && '1' || '' }}
TIME_VENTURE_BATCH_START: ${{ inputs.batch && '1' || '' }}
TIME_VENTURE_BATCH: ${{ vars.TIME_VENTURE_BATCH }} # k; set, a batch replaces the daily single card
TIME_VENTURE_BATCH_HOLD_HOURS: ${{ vars.TIME_VENTURE_BATCH_HOLD_HOURS }} # launch-together hold (default 6)
TIME_VENTURE_HEAVY: ${{ vars.TIME_VENTURE_HEAVY }} # worker runs at once per tick (default 3)
TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }} # default haiku,sonnet,opus
TIME_VENTURE_BOARD: ${{ vars.TIME_VENTURE_BOARD }} # default haiku,sonnet,opus
TIME_VENTURE_CHAIR: ${{ vars.TIME_VENTURE_CHAIR }} # default fable
TIME_VENTURE_JUDGE: ${{ vars.TIME_VENTURE_JUDGE }} # default fable
TIME_VENTURE_ESCALATE: ${{ vars.TIME_VENTURE_ESCALATE }} # second fix round, default claude:opus
TIME_VENTURE_PAGES_DOMAIN: ${{ vars.TIME_VENTURE_PAGES_DOMAIN }} # default yolopreneur.lol
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # whose /time retry counts
TIME_CAP_SLACK: ${{ vars.TIME_CAP_SLACK }}
TIME_ANTHROPIC_API: ${{ vars.TIME_ANTHROPIC_API }}
TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }}
TIME_WORKER: ${{ vars.TIME_VENTURE_WORKER || 'claude:sonnet' }} # the builder; the heartbeat's TIME_WORKER is its own
TIME_WORKER_STEPS: ${{ vars.TIME_WORKER_STEPS }}
TIME_WORKER_MAX_TOKENS: ${{ vars.TIME_WORKER_MAX_TOKENS }}
+54
View File
@@ -2,5 +2,59 @@
## [Unreleased]
### Security
- A label filter is checked on every row. Gitea ignores `labels=<name>` for a label the repo does not have and returns every issue: housekeeping's first live run listed all 59 open issues as parked cards (there is no `parked` label), and the same answer to `labels=spec` would have had Time split every open issue as a spec. `issues()` in time.py and the sweep's queries now keep only rows that carry the labels they asked for.
- A commit status from one of Time's own workflows (`heartbeat /`, `mention /`, `venture /`, `astra /`, `doctor /`, `update /`, `steward /`) is not a check: it neither blocks nor earns a merge. A seat's review is a `pull_request_review` event that wakes `heartbeat.yml` during a heartbeat; the concurrency group cancels the run and Gitea records it as a failure on the PR head, which had stopped approved, CI-green PRs from landing.
- The worker's own commit status (`time/worker-run`) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (`TIME_CI_WORKFLOW`, default `ci.yml`, which must accept `workflow_dispatch`; GitHub skips `pull_request` runs for pushes made with the Actions token) and merges on that. `off` keeps the old "no checks" stop.
- `.github/` is protected from auto-merge whatever `TIME_PROTECTED` says.
- `labs-aicharts.yml` passes the deploy URL through `env` instead of interpolating it into the shell.
- Time's state comments (`<!-- time {...} -->` beats) count only when written by `github-actions[bot]`; a pasted marker from anyone else is ignored. A stranger on a public repo could otherwise steer a card to merge a chosen PR or build a chosen page.
- `/time retry` and "Approve"/"Object" comments count only from the owner, members, collaborators and Time itself; any other bot is a stranger too.
- The protected-path check reads every file of a PR (not the first 100) and counts a rename under both names.
- The browser pass clones the Pages branch, then drops `.git` (the clone URL carried the token), serves only the venture's own folder, and runs headless Chrome with no network except localhost, so a model-written page can't read or send anything.
- The worker's `run` tool executes inside a throwaway container (`docker run`, the checkout mounted; the workflows set `TIME_RUN_IMAGE: node:22-bookworm`), so a command can't read the orchestrator's environment through `/proc`. Unset, or without a docker daemon, it is the host shell as before.
- A venture build writes only inside its own `ventures/<slug>/` folder on the Pages branch, which also carries the studio's site and every other venture: a fix round once wrote its page over the site's front page.
- The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials.
### Changed
- The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its `Depends on #N` card is still open, or when the seat marks it `blocked` with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. `astra.yml` now passes `TIME_WORKER` and `TIME_PACE`, without which Astra's money left fell back to the daily cap.
- The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written".
- The lead runs the day's venture alone, and fable is out. One seat, `TIME_VENTURE_LEAD` (opus), does a research round (the top six trending repos with the start of their READMEs, the rest as a list, and every studio venture with its status and signups), lists `TIME_VENTURE_FIELD` candidates with a verdict each, and picks one; no pitches, no vote. A pick that repeats a studio venture gets one more go, told so. The lead writes the launch plan alone (no synthesis) and judges the build; it hands the launch-post drafts to its helper, `TIME_VENTURE_HELPER` (sonnet). The chair and the judge default to opus (they were fable, the dearest seat, on calls opus does as well). Batches keep the board, and `TIME_VENTURE_LEAD=board` brings it back for the daily venture.
- One venture a day, built for quality. The day's card starts only once every other venture card is launched or held, so one venture at a time gets the day's worker runs and budget; the seats pitch a field of `TIME_VENTURE_FIELD` (6) in rounds, each told what is already pitched, and the board picks one from it (it was one pitch per seat); `claude:opus` builds (`BUILDER`, and `TIME_VENTURE_WORKER` on teahubxyz/time); and the judge scores the product 1–10 as a stranger would. A page under the bar (`TIME_VENTURE_BAR`, 8) gets a polish round with the judge's top three changes; fix and polish rounds share `TIME_VENTURE_FIXES` (now 3); polish never takes the last round, which stays free to fix whatever a polish broke; then a page under the bar launches with its score in the launch post, so quality never holds a launch for a human, only a broken page does. An open batch card runs only while batches are on (`TIME_VENTURE_BATCH`) or once it has Time's beats: batch #26, migrated from GitHub with none, had started a three-venture batch on a single-venture day.
- The venture loop runs on Teahub with Claude seats. Fable chairs (synthesizes `LAUNCH.md`, writes the launch drafts, judges the build); haiku, sonnet and opus pitch, vote and write plans; `claude:sonnet` builds and `claude:opus` takes the second fix round. `TIME_WORKER` is no longer coerced to a Venice seat, which is how a Claude-configured factory kept spending Venice credits on venture builds; any `claude:` or `venice:` spec is taken as given. The port to Gitea: the scout asks GitHub's public search unauthenticated (the forge has no repository search, and no token of ours goes to GitHub); venture, batch and signal cards are listed through `tm.issues` (Gitea's `pull_request: null` had kept every card out) and created with label ids; reactions answered with null and Gitea's 50-row pages are handled; a `/time retry` counts from a trusted login (Gitea has no `author_association`); signups are the form's Venture field, or the prefilled `Early access: <name>` title on a body the form wrote when the field was left empty, never the owner, Time's users or a seat; there is no Pages API to call, and each venture is served at `https://<slug>.<TIME_VENTURE_PAGES_DOMAIN>/` (`yolopreneur.lol`). The domain's root is the Pages branch's `ventures/`, whose index is the hand-made yolopreneur.lol portfolio reading `ventures.json`: the loop writes `ventures.json` (now with `shot`, `sheet` and `source` at launch, which the portfolio shows) and writes its own list page only where the page is its own, instead of replacing the site's front page on its first save. Links point at the forge (`/src/branch/`, `/raw/commit/`). Astra never queues the loop's own issues.
- Claude calls go to Anthropic's own Messages API with prompt caching. The tools and system prompt are one cache breakpoint and the conversation so far the other, so each step of a worker run pays for the context the step before already sent at a tenth of the price (the OpenAI-compatible endpoint has no caching: a failed 43-step run cost $2.80, most of it the same context re-sent 43 times). The engine still speaks the OpenAI shape inside; an assistant turn that issued tool calls goes back exactly as the API gave it. The ledger prices cached tokens (`cached`, `written` on the line). `TIME_ANTHROPIC_API=compat` goes back to the old endpoint without a code change.
- Housekeeping reads a null page as an empty one. Gitea answers an issue nobody reacted to with `null`, not `[]`, so the signal step failed (`'NoneType' object is not iterable`) on 2026-10-19, the first day a signal issue reached its 7-day window; the fake forge in `test_housekeeping.py` now answers the same way.
- The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it.
### Added
- A card waiting on a person holds no `ready` slot (#135). `promote_next` counts toward `TIME_PARALLEL` only the `ready` cards that are moving; one whose last real beat is `needs-human`, `error` or `stuck` (an objection, or a failure Time won't retry again) keeps its label, so `/time retry` still finds it, but frees its slot. Three such cards held every slot twice, the second time for ten hours on 2026-10-19 while 22 cards sat `queued`. The run log prints `promote: ready=N moving=M`.
- Sync (`sync.py`, `time/sync.json`, the `sync` workflow): a repo's copies on other forges follow its home, under one rule, one home per repo and one writer per branch. The home's `main` (the `out` branches) goes to every copy fast-forward only; a copy with commits of its own is reported in a `needs-human` issue and never overwritten, unless the `force` dispatch input replaces its history once. A copy's branches matching its `import` patterns (`claude/*` from cloud sessions) are the copy's to write: they come back to the home under the same name with a PR, commit messages in the body (so a `Closes #N` adopts the card), and Time's council reviews and lands them; a branch already in `main`, or one whose PR was closed at the same commit, is left alone. Gitea's own mirrors don't fit: a push mirror force-pushes every branch (it would delete the copy's work branches) and a pull mirror makes the home read-only. Tokens are secrets named in the file, scrubbed from every message; a copy can instead use an SSH deploy key (`ssh_key`: write access to that one repo and nothing else), with the host's keys pinned, never learned on first use. Gitea refuses secret names starting `GITHUB_` or `GITEA_`, so the names start `SYNC_`. The first copy is `github.com/teahubxyz/time`, so cloud sessions clone a current engine; it pushes with the deploy key in `SYNC_GITHUB_KEY`, and GitHub Actions is off on it. The same script syncs Gitea with Gitea, run from whichever side can reach both.
- Adaptive budget: spend is paced to each capacity pool instead of one fixed daily cap. `time/pools.json` (kept by the operator) lists the pools: `expiring` credit allows `(balance - spent since as_of) / days to resets_at x TIME_PACE` a day (default 1.0), so a quiet day raises tomorrow's share; a `window` pool (`weekly:mon`, `hours:5`) allows `window_usd` per window and refills at its reset; a `balance` pool keeps `TIME_DAILY_USD`. A call is checked against the pool its provider books to, and `TIME_DAILY_USD` is the fallback for a route with no pool; the monthly cap stays global. Ledger rows carry a `pool` field, `doctor` prints each pool's balance, spend, next reset and today's allowance, and Astra's log line reports spend per pool. It ships with an empty `pools.json`, so nothing changes until the operator fills it in. `pools.json` is read through the API once per run, like the ledger, since every workflow checks out only `.github/time`.
- The router (time#68, #72; `router.py`, `time/router.json`): the factory picks its own next work across ventures, features, refinements and optimizations. Astra's seat no longer picks cards in prose; it judges every candidate (each open card with a `DONE:` line that isn't a parent, a human's or the venture loop's own, plus "start a new venture") with a kind, a value 1–5 and a size S/M/L, and code ranks them: `weight[kind] × restless(kind) × age × (value + 0.5 × cards waiting on it) / size`. The weights are the owner's dial per kind (all 1, balanced); a kind not picked lately climbs (`restless`, 0.05 a day for up to 10 days) so none starves; an L is ranked but never queued (split it first). The Astra log shows the ranked table. `mode: dry` posts it and touches nothing; `live` queues from the top while the day's money left covers each card's estimated cost (`cost_usd`), writes the seat's new cards unqueued (candidates the next day), and the heartbeat's `promote_next` promotes queued cards in the live ranking's order instead of oldest first. `off`, or no file, is the planner as before. It starts `dry`. The policy is one function, so a bandit policy (Beth, Jev, Fable's maths) can replace it. Astra also reads the last 14 days of its log instead of the thread's first twenty comments.
- A signup's Venture field matches the venture's slug, name, home folder or an old slug, ignoring case, spaces and punctuation. The field is typed by a person (Teahub does not prefill it from the link), who writes "Cartridge" or "Loot Drop", not `box-art-night`; those signups were not counted.
- A worker's tool call that is cut off at the output limit, or arrives with malformed arguments, is an error the worker reads on its next step instead of a crash that throws the run away. The first Claude venture builds (time#148, #149) died on `'content'`: a whole launch page written in one `write_file` ran past the output limit and arrived with its path and no content. The error says so and asks for smaller files.
- Ponytail on prod: a seat worker (and so the venture builder) carries Ponytail's rules in its system prompt: the smallest complete change, reuse before new code, a check for new logic, never cut validation, security or accessibility, and one closing line on what it skipped. Vendored from DietrichGebert/ponytail (MIT) at `.github/time/rules/ponytail.md`, upstream commit 09c434a. Reviewers, Astra and the venture board never get it (a coding ruleset distorts answers that aren't a diff). `TIME_PONYTAIL=off` turns it off. It ran on hb-5's seats since 2026-10-12; prod had never had it.
- Time retries its own parked cards. A card parked on `error` or `stuck` with no open PR is dispatched again on the next beat, up to `TIME_AUTO_RETRY` times (default 1; 0 leaves it to a human), instead of waiting for a human `/time retry`; the beats carry `auto_retry`, a seat worker resumes the card's branch with half as many turns again (`TIME_WORKER_STEPS` x 1.5), and a card with an open PR still goes to review. Dry-run retries nothing. The `stuck` and `error` comments say what Time will do next.
- Council verdict: `TIME_REVIEW` takes a comma-separated list of seats (`claude:haiku, claude:opus`); every seat reviews each PR and posts as itself, and each identity's latest vote counts once (Time's own comments are one identity). `approve` when approves minus objects reaches `TIME_REVIEW_QUORUM` (default 1), `object` when objects exceed approves. `TIME_REVIEW_TIEBREAK` names one seat (e.g. `venice:kimi`) called only when every standing seat has voted and they split; if it doesn't settle it either, one "the council is split N–N; over to a human" comment with a `needs-human` beat. One seat and quorum 1 behave as before. Codex's ladder (`luna`, `terra`, `sol`, `astra`) is in `seats.json` on the CLI route (its review run lands with #79; the `astra` login is the Codex seat, not the planning tick).
- Seats review as themselves: with a `TIME_SEAT_<SEAT>` token (one Gitea user per seat, e.g. `TIME_SEAT_KIMI`) the reviewer's verdict is a real Gitea review by that user (Approve → approved, Object → changes requested, prose → comment), so reviews carry an identity and can count. Without a token, or if Gitea refuses (a seat on its own PR), it is Time's comment as before.
- Cards can live on another branch. A `Base: <branch>` line in a card (`Base: gh-pages` for a venture) makes the seat worker cut its branch from that base, name it `time/card-N-<base>`, see that branch's files, and open the PR against it; without the line nothing changes. Until now every worker branched from the default branch whatever the card said, which is why the cards on `gh-pages` ran out of turns or opened PRs against `main`.
- A launched worker finishes what it starts. The daily spend cap gates launches; a run that began within budget may go on to `TIME_CAP_SLACK` x the cap (default 1.5), and a PR that is already open gets its review on a capped day too. A run stopped mid-way had cost its whole spend and produced nothing. The monthly cap stays hard everywhere; `TIME_CAP_SLACK=1` restores the old stop.
- The tick takes the oldest `ready` card first, the order `promote_next` already uses, instead of the newest the API lists first, so a card promoted first is built first.
- `TIME_PROTECTED` now governs every protected path except the workflow directories (`.github/workflows/`, `.gitea/workflows/`), which stay a human's merge whatever it says. The default still protects all of `.github/`; a repo that trusts its reviewer can set `TIME_PROTECTED=.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret` so the engine under `.github/time/` merges itself on Approve + green like any other code.
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
- Venture batches (multi-ceremony): one batch card scouts once, the seats pitch a field (rounds until there are 2k pitches), and the board ranks the whole field; the top k, each wanted by two seats, become venture cards in the batch's own milestone. They run the ordinary loop side by side and launch together: a child that passes its checks waits at `ready` until every sibling is settled or the hold (`TIME_VENTURE_BATCH_HOLD_HOURS`, 6) runs out; stragglers then launch alone. No child left behind: a held child gets one batch retry, and held again it is counted dead with its reason. A retro on the batch card compares the cohort's signups against each plan's lines, then the card and milestone close. Set `TIME_VENTURE_BATCH=3` to make batches the daily default, or Actions → venture → Run workflow → "batch".
- Parallel builds: a tick runs up to `TIME_VENTURE_HEAVY` (default 3) worker runs at once, one per card, so a batch builds in one tick instead of three. Each build keeps its own folder lock, `ventures.json` is updated under a lock with a fresh read, and a Pages write that loses the race for the branch head (409) is retried. `TIME_VENTURE_HEAVY=1` is the old one-at-a-time.
- Fix rounds yield the worker runs to cards still due their first build, so a page that keeps failing can't starve the others.
- Accounts for ventures, with passkeys: each venture's backend can sign people up and in (Face ID, Touch ID, Windows Hello, a phone), keep a 30-day session, and list what each person saved. No passwords, no email, nothing to configure; accounts live in the venture's own database on its own domain. `time.js` gains `Time.signUp`, `Time.signIn`, `Time.signOut`, `Time.user` and `Time.mine`, and builds are told to use them for the paid tier's promise without ever putting sign-in in front of the free product.
- Venture backends use Gemma 4 (`@cf/google/gemma-4-26b-a4b-it`) by default and read both the older `{response}` and the OpenAI-style answer from Workers AI (the first default had been retired).
- Ventures can be backed by Cloudflare: with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, each venture gets its own Worker, D1 database and Workers AI, and lives at `<slug>.<TIME_VENTURE_DOMAIN>` (or workers.dev) while its page stays on GitHub Pages. The page's `time.js` gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token.
- Venture loop launches products, not pages: a launch round where board seats write plans and one synthesizes `LAUNCH.md` with attribution; launch pages where the working product fills the first screen under a slim header with one call to action, then pricing and honest limits, in a look that follows the product; early-access signups through a GitHub issue form, thanked on their issues; deterministic and headless-Chrome checks with fix rounds and an andon; a 7-day decision against success and kill lines; a studio index at `/ventures/`.
- Start a venture whenever you like: Actions → venture → Run workflow → "Start a new venture now" (`gh workflow run venture.yml -f start=true`); the schedule still starts one a day.
- The board's taste: a delight anyone gets within a minute ranks above another developer utility, and when a product makes something people read (a deck, a card), its preloaded sample is the product's own pitch.
- The studio index at `/ventures/` wears the Time site's blueprint theme (title block, grid paper, type), with each launched venture's laptop screenshot.
- The headless-Chrome check fails a launch page where something sits on top of the product (a page fixed to the screen's height lets the next section slide over it).
- The board sees the studio's own ventures: pitches that repeat one (by slug, name or an earlier slug) are set aside before the vote, since the week's trending list barely moves from day to day.
- Cards adopt an open PR that already closes them. A `ready` card with no beats and an open PR whose body closes it (or a card whose body says `PR: #N`) gets a `pr` beat with `adopted: true` and a review in the same tick, then lands through `land()` on Approve + green like any card PR, instead of launching a worker to build it again. No launch is counted and no worker budget is spent; closed and merged PRs are never adopted; dry-run says what it would adopt. Work built outside the worker (Codex, a seat's chat) no longer waits for a human merge.
- Hands-off upgrade support with spec planner, auto-merge, and Venice worker integration
+16 -6
View File
@@ -1,15 +1,25 @@
# time
A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are the clock (Time), the worker is a Venice seat with hands or a Cursor cloud agent, and chat is where you plan.
A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea repo is the ledger, Gitea Actions are the clock (Time), the worker is a Claude or Venice seat with hands or a Cursor cloud agent, and chat is where you plan.
- **Cards**: an issue with a `DONE:` line, labelled `ready`.
- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a Venice seat such as `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day.
- **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice).
- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a seat such as `claude:sonnet` or `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. **Spend governor:** every model call is booked to `time/ledger/<YYYY-MM>.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend.
- **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply.
- **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human.
- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): scouts the repos that gained the most stars this week, seats pitch one small static-page product each, the board votes (Borda), a seat builds it on `gh-pages`, and GitHub Pages ships it at `/ventures/<slug>/`. One card per venture (label `venture`), progress as comments.
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys.
- **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures/<slug>/`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo.
- **Batches** (`TIME_VENTURE_BATCH=3`, or Run workflow → "batch"): one scout pass, a field of pitches, and the board's top k become venture cards in one milestone, built side by side and launched together. A held child gets one batch retry before it's counted dead; a retro compares the cohort once every child is measured.
- **Seed** (`seed.yml`, manual): land a venture that was built somewhere else. Give it a slug, the URL of a .tgz whose top folder is that slug (or of its parts, in order) and the .tgz's sha256, plus a name and a one-liner for a new venture. `seed.py` checks the bundle, copies it to `ventures/<slug>/` on `gh-pages` in one commit, adds or refreshes its line in `ventures/ventures.json`, and asks Teahub pages to pull, so it is live at `<slug>.yolopreneur.lol` within the minute. Nothing in the bundle runs.
- **Refresh** (`refresh.yml`, manual): for a venture that lives on gh-pages and makes some of its own files (fonts, art, screenshots, generated code). It runs `ventures/<slug>/tools/build.sh` on the runner, in place and without credentials, then commits what changed to gh-pages in one commit and asks Teahub pages to pull. `from` takes the folder from another branch first, so a reworked venture lands whole.
- **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `<slug>.<TIME_VENTURE_DOMAIN>`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`.
- **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings, keys and today's spend.
- **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.
## Setup
Open this repo in Claude Code or Cursor and say "set up Time". The agent follows `.claude/skills/time/SKILL.md`. You'll be asked to install Cursor's GitHub app on the repo and to paste your Cursor API key into `gh secret set CURSOR_API_KEY`.
Started on GitHub as a proof of concept (teahubxyz/time, now read-only); ported to Gitea 2026-10-14.
Open this repo in Claude Code or Cursor and say "set up Time": the agent follows `.claude/skills/time/SKILL.md`. The recommended path needs one secret, `ANTHROPIC_API_KEY`: Claude seats plan, build and review (`TIME_WORKER=claude:sonnet`, `TIME_REVIEW=claude:haiku`; `haiku`, `sonnet`, `opus`, `fable` are the seats, priced in `.github/time/seats.json`). With `VENICE_API_KEY` instead, Venice seats do the same (`TIME_WORKER=venice:qwen coder`, `TIME_REVIEW=venice:grok`). A Cursor cloud agent can be the worker instead if you have a Cursor API key. The landing page walks through it: https://teahubxyz.github.io/time/
## Status
For changes and release history, see [CHANGELOG.md](CHANGELOG.md).
+790
View File
@@ -0,0 +1,790 @@
{"at":"2026-10-14T19:52:34Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
{"at":"2026-10-14T19:55:22Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
{"at":"2026-10-14T19:55:24Z","model":"claude-sonnet-5-5","in":1957,"out":196,"usd":0.005874}
{"at":"2026-10-14T19:55:25Z","model":"claude-sonnet-5-5","in":5086,"out":61,"usd":0.010782}
{"at":"2026-10-14T19:55:31Z","model":"claude-sonnet-5-5","in":6918,"out":790,"usd":0.021736}
{"at":"2026-10-14T19:55:34Z","model":"claude-sonnet-5-5","in":7932,"out":230,"usd":0.018164}
{"at":"2026-10-14T19:55:36Z","model":"claude-sonnet-5-5","in":8729,"out":171,"usd":0.019168}
{"at":"2026-10-14T19:55:41Z","model":"claude-sonnet-5-5","in":8902,"out":497,"usd":0.022774}
{"at":"2026-10-14T20:24:06Z","model":"claude-sonnet-5-5","in":1404,"out":71,"usd":0.003518}
{"at":"2026-10-14T20:24:08Z","model":"claude-sonnet-5-5","in":1972,"out":173,"usd":0.005674}
{"at":"2026-10-14T20:24:10Z","model":"claude-sonnet-5-5","in":4228,"out":137,"usd":0.009826}
{"at":"2026-10-14T20:24:11Z","model":"claude-sonnet-5-5","in":5142,"out":121,"usd":0.011494}
{"at":"2026-10-14T20:24:13Z","model":"claude-sonnet-5-5","in":5270,"out":81,"usd":0.01135}
{"at":"2026-10-14T20:24:14Z","model":"claude-sonnet-5-5","in":5477,"out":135,"usd":0.012304}
{"at":"2026-10-14T20:24:16Z","model":"claude-sonnet-5-5","in":6192,"out":105,"usd":0.013434}
{"at":"2026-10-14T20:24:18Z","model":"claude-sonnet-5-5","in":6504,"out":245,"usd":0.015458}
{"at":"2026-10-14T20:24:22Z","model":"claude-sonnet-5-5","in":7887,"out":475,"usd":0.020524}
{"at":"2026-10-14T20:24:30Z","model":"claude-sonnet-5-5","in":8409,"out":844,"usd":0.025258}
{"at":"2026-10-14T20:26:11Z","model":"claude-haiku-5-5","in":1660,"out":1406,"usd":0.000869}
{"at":"2026-10-14T21:03:34Z","model":"claude-sonnet-5-5","in":1541,"out":79,"usd":0.003872}
{"at":"2026-10-14T21:03:42Z","model":"claude-sonnet-5-5","in":29380,"out":970,"usd":0.06846}
{"at":"2026-10-14T21:03:48Z","model":"claude-sonnet-5-5","in":30236,"out":267,"usd":0.063142}
{"at":"2026-10-14T21:03:53Z","model":"claude-sonnet-5-5","in":30731,"out":639,"usd":0.067852}
{"at":"2026-10-14T21:08:04Z","model":"claude-haiku-5-5","in":1532,"out":1201,"usd":0.000754}
{"at":"2026-10-14T21:18:09Z","model":"claude-sonnet-5-5","in":1449,"out":26,"usd":0.003158}
{"at":"2026-10-14T21:18:11Z","model":"claude-sonnet-5-5","in":1928,"out":100,"usd":0.004856}
{"at":"2026-10-14T21:18:13Z","model":"claude-sonnet-5-5","in":2470,"out":107,"usd":0.00601}
{"at":"2026-10-14T21:18:15Z","model":"claude-sonnet-5-5","in":5197,"out":151,"usd":0.011904}
{"at":"2026-10-14T21:18:17Z","model":"claude-sonnet-5-5","in":8086,"out":211,"usd":0.018282}
{"at":"2026-10-14T21:18:19Z","model":"claude-sonnet-5-5","in":10670,"out":136,"usd":0.0227}
{"at":"2026-10-14T21:18:24Z","model":"claude-sonnet-5-5","in":11881,"out":438,"usd":0.028142}
{"at":"2026-10-14T21:18:35Z","model":"claude-sonnet-5-5","in":13013,"out":1796,"usd":0.043986}
{"at":"2026-10-14T21:18:44Z","model":"claude-sonnet-5-5","in":14752,"out":1511,"usd":0.044614}
{"at":"2026-10-14T21:18:51Z","model":"claude-sonnet-5-5","in":16199,"out":460,"usd":0.036998}
{"at":"2026-10-14T21:22:16Z","model":"claude-opus-5-5","in":8564,"out":713,"usd":0.048516}
{"at":"2026-10-14T21:23:11Z","model":"claude-haiku-5-5","in":2858,"out":1705,"usd":0.001138}
{"at":"2026-10-14T21:39:57Z","model":"claude-sonnet-5-5","in":1527,"out":26,"usd":0.003314}
{"at":"2026-10-14T21:39:59Z","model":"claude-sonnet-5-5","in":2006,"out":94,"usd":0.004952}
{"at":"2026-10-14T21:40:00Z","model":"claude-sonnet-5-5","in":4528,"out":54,"usd":0.009596}
{"at":"2026-10-14T21:40:03Z","model":"claude-sonnet-5-5","in":28923,"out":182,"usd":0.059666}
{"at":"2026-10-14T21:40:06Z","model":"claude-sonnet-5-5","in":30029,"out":254,"usd":0.062598}
{"at":"2026-10-14T21:40:09Z","model":"claude-sonnet-5-5","in":33924,"out":187,"usd":0.069718}
{"at":"2026-10-14T21:40:12Z","model":"claude-sonnet-5-5","in":39941,"out":108,"usd":0.080962}
{"at":"2026-10-14T21:40:15Z","model":"claude-sonnet-5-5","in":42992,"out":246,"usd":0.088444}
{"at":"2026-10-14T21:40:17Z","model":"claude-sonnet-5-5","in":46955,"out":146,"usd":0.09537}
{"at":"2026-10-14T21:41:13Z","model":"claude-sonnet-5-5","in":49497,"out":6680,"usd":0.165794}
{"at":"2026-10-14T21:42:37Z","model":"claude-sonnet-5-5","in":50026,"out":10161,"usd":0.201662}
{"at":"2026-10-14T21:44:19Z","model":"claude-sonnet-5-5","in":51291,"out":13054,"usd":0.233122}
{"at":"2026-10-14T21:45:26Z","model":"claude-sonnet-5-5","in":54098,"out":9726,"usd":0.205456}
{"at":"2026-10-14T21:45:34Z","model":"claude-sonnet-5-5","in":59375,"out":708,"usd":0.12583}
{"at":"2026-10-14T21:46:19Z","model":"claude-sonnet-5-5","in":59860,"out":6021,"usd":0.17993}
{"at":"2026-10-14T21:46:23Z","model":"claude-sonnet-5-5","in":63582,"out":383,"usd":0.130994}
{"at":"2026-10-14T21:46:31Z","model":"claude-sonnet-5-5","in":64284,"out":690,"usd":0.135468}
{"at":"2026-10-14T21:46:37Z","model":"claude-sonnet-5-5","in":64859,"out":488,"usd":0.134598}
{"at":"2026-10-14T21:46:45Z","model":"claude-sonnet-5-5","in":65253,"out":650,"usd":0.137006}
{"at":"2026-10-14T21:46:52Z","model":"claude-sonnet-5-5","in":65768,"out":637,"usd":0.137906}
{"at":"2026-10-14T22:08:11Z","model":"claude-haiku-5-5","in":11278,"out":2444,"usd":0.00235}
{"at":"2026-10-15T13:13:31Z","model":"claude-opus-5-5","in":23639,"out":614,"usd":0.106836}
{"at":"2026-10-15T21:07:27Z","model":"claude-sonnet-5-5","in":1824,"out":26,"usd":0.003908}
{"at":"2026-10-15T21:07:30Z","model":"claude-sonnet-5-5","in":2303,"out":172,"usd":0.006326}
{"at":"2026-10-15T21:07:32Z","model":"claude-sonnet-5-5","in":2899,"out":154,"usd":0.007338}
{"at":"2026-10-15T21:07:35Z","model":"claude-sonnet-5-5","in":5409,"out":257,"usd":0.013388}
{"at":"2026-10-15T21:07:38Z","model":"claude-sonnet-5-5","in":7805,"out":223,"usd":0.01784}
{"at":"2026-10-15T21:07:41Z","model":"claude-sonnet-5-5","in":10371,"out":169,"usd":0.022432}
{"at":"2026-10-15T21:07:46Z","model":"claude-sonnet-5-5","in":11385,"out":395,"usd":0.02672}
{"at":"2026-10-15T21:07:50Z","model":"claude-sonnet-5-5","in":13978,"out":300,"usd":0.030956}
{"at":"2026-10-15T21:07:57Z","model":"claude-sonnet-5-5","in":16724,"out":661,"usd":0.040058}
{"at":"2026-10-15T21:08:04Z","model":"claude-sonnet-5-5","in":17799,"out":493,"usd":0.040528}
{"at":"2026-10-15T21:08:09Z","model":"claude-sonnet-5-5","in":18178,"out":229,"usd":0.038646}
{"at":"2026-10-15T21:08:12Z","model":"claude-sonnet-5-5","in":19392,"out":121,"usd":0.039994}
{"at":"2026-10-15T21:08:16Z","model":"claude-sonnet-5-5","in":19681,"out":279,"usd":0.042152}
{"at":"2026-10-15T21:08:22Z","model":"claude-sonnet-5-5","in":20505,"out":168,"usd":0.04269}
{"at":"2026-10-15T21:08:29Z","model":"claude-sonnet-5-5","in":22991,"out":158,"usd":0.047562}
{"at":"2026-10-15T21:08:34Z","model":"claude-sonnet-5-5","in":25613,"out":290,"usd":0.054126}
{"at":"2026-10-15T21:08:41Z","model":"claude-sonnet-5-5","in":29778,"out":279,"usd":0.062346}
{"at":"2026-10-15T21:08:48Z","model":"claude-sonnet-5-5","in":31149,"out":220,"usd":0.064498}
{"at":"2026-10-15T21:08:55Z","model":"claude-sonnet-5-5","in":31813,"out":316,"usd":0.066786}
{"at":"2026-10-15T21:09:04Z","model":"claude-sonnet-5-5","in":33214,"out":175,"usd":0.068178}
{"at":"2026-10-15T21:09:11Z","model":"claude-sonnet-5-5","in":35083,"out":239,"usd":0.072556}
{"at":"2026-10-15T21:09:25Z","model":"claude-sonnet-5-5","in":36773,"out":1171,"usd":0.085256}
{"at":"2026-10-15T21:09:33Z","model":"claude-sonnet-5-5","in":38114,"out":680,"usd":0.083028}
{"at":"2026-10-15T21:09:43Z","model":"claude-sonnet-5-5","in":40762,"out":520,"usd":0.086724}
{"at":"2026-10-15T21:09:47Z","model":"claude-sonnet-5-5","in":43289,"out":163,"usd":0.088208}
{"at":"2026-10-15T21:09:54Z","model":"claude-sonnet-5-5","in":45917,"out":617,"usd":0.098004}
{"at":"2026-10-15T21:10:10Z","model":"claude-sonnet-5-5","in":47228,"out":1790,"usd":0.112356}
{"at":"2026-10-15T21:10:14Z","model":"claude-sonnet-5-5","in":48839,"out":100,"usd":0.098678}
{"at":"2026-10-15T21:10:18Z","model":"claude-sonnet-5-5","in":51169,"out":364,"usd":0.105978}
{"at":"2026-10-15T21:10:22Z","model":"claude-sonnet-5-5","in":52631,"out":115,"usd":0.106412}
{"at":"2026-10-15T21:11:02Z","model":"claude-sonnet-5-5","in":53793,"out":4582,"usd":0.153406}
{"at":"2026-10-15T21:11:14Z","model":"claude-sonnet-5-5","in":56077,"out":1296,"usd":0.125114}
{"at":"2026-10-15T21:12:05Z","model":"claude-sonnet-5-5","in":56783,"out":3167,"usd":0.145236}
{"at":"2026-10-15T21:12:22Z","model":"claude-sonnet-5-5","in":59396,"out":1933,"usd":0.138122}
{"at":"2026-10-15T21:13:04Z","model":"claude-sonnet-5-5","in":59720,"out":3191,"usd":0.15135}
{"at":"2026-10-15T21:13:29Z","model":"claude-sonnet-5-5","in":60806,"out":2548,"usd":0.147092}
{"at":"2026-10-15T21:14:48Z","model":"claude-sonnet-5-5","in":61169,"out":3172,"usd":0.154058}
{"at":"2026-10-15T21:15:32Z","model":"claude-sonnet-5-5","in":61526,"out":294,"usd":0.125992}
{"at":"2026-10-15T21:16:46Z","model":"claude-sonnet-5-5","in":63016,"out":3974,"usd":0.165772}
{"at":"2026-10-15T21:17:56Z","model":"claude-sonnet-5-5","in":64261,"out":8283,"usd":0.211352}
{"at":"2026-10-15T21:18:27Z","model":"claude-sonnet-5-5","in":1910,"out":26,"usd":0.00408}
{"at":"2026-10-15T21:18:32Z","model":"claude-sonnet-5-5","in":2389,"out":127,"usd":0.006048}
{"at":"2026-10-15T21:18:37Z","model":"claude-sonnet-5-5","in":2820,"out":178,"usd":0.00742}
{"at":"2026-10-15T21:18:43Z","model":"claude-sonnet-5-5","in":3412,"out":200,"usd":0.008824}
{"at":"2026-10-15T21:18:49Z","model":"claude-sonnet-5-5","in":8239,"out":200,"usd":0.018478}
{"at":"2026-10-15T21:19:15Z","model":"claude-sonnet-5-5","in":9837,"out":311,"usd":0.022784}
{"at":"2026-10-15T21:19:21Z","model":"claude-sonnet-5-5","in":10098,"out":318,"usd":0.023376}
{"at":"2026-10-15T21:19:48Z","model":"claude-sonnet-5-5","in":10435,"out":488,"usd":0.02575}
{"at":"2026-10-15T21:20:27Z","model":"claude-sonnet-5-5","in":13110,"out":1545,"usd":0.04167}
{"at":"2026-10-15T21:21:24Z","model":"claude-sonnet-5-5","in":18362,"out":1083,"usd":0.047554}
{"at":"2026-10-15T21:22:03Z","model":"claude-sonnet-5-5","in":20427,"out":1811,"usd":0.058964}
{"at":"2026-10-15T21:22:09Z","model":"claude-sonnet-5-5","in":22877,"out":174,"usd":0.047494}
{"at":"2026-10-15T21:22:13Z","model":"claude-sonnet-5-5","in":23372,"out":117,"usd":0.047914}
{"at":"2026-10-15T21:22:17Z","model":"claude-sonnet-5-5","in":25793,"out":120,"usd":0.052786}
{"at":"2026-10-15T21:22:20Z","model":"claude-sonnet-5-5","in":27604,"out":135,"usd":0.056558}
{"at":"2026-10-15T21:22:26Z","model":"claude-sonnet-5-5","in":30017,"out":195,"usd":0.061984}
{"at":"2026-10-15T21:22:31Z","model":"claude-sonnet-5-5","in":33384,"out":172,"usd":0.068488}
{"at":"2026-10-15T21:22:36Z","model":"claude-sonnet-5-5","in":35727,"out":234,"usd":0.073794}
{"at":"2026-10-15T21:22:42Z","model":"claude-sonnet-5-5","in":38098,"out":235,"usd":0.078546}
{"at":"2026-10-15T21:22:48Z","model":"claude-sonnet-5-5","in":39963,"out":223,"usd":0.082156}
{"at":"2026-10-15T21:22:55Z","model":"claude-sonnet-5-5","in":41469,"out":389,"usd":0.086828}
{"at":"2026-10-15T21:23:02Z","model":"claude-sonnet-5-5","in":43852,"out":563,"usd":0.093334}
{"at":"2026-10-15T21:23:11Z","model":"claude-sonnet-5-5","in":45712,"out":299,"usd":0.094414}
{"at":"2026-10-15T21:23:18Z","model":"claude-sonnet-5-5","in":48051,"out":359,"usd":0.099692}
{"at":"2026-10-15T21:23:25Z","model":"claude-sonnet-5-5","in":49292,"out":591,"usd":0.104494}
{"at":"2026-10-15T21:23:32Z","model":"claude-sonnet-5-5","in":49954,"out":535,"usd":0.105258}
{"at":"2026-10-15T21:23:47Z","model":"claude-sonnet-5-5","in":50931,"out":1659,"usd":0.118452}
{"at":"2026-10-15T21:24:16Z","model":"claude-sonnet-5-5","in":53002,"out":241,"usd":0.108414}
{"at":"2026-10-15T21:25:18Z","model":"claude-sonnet-5-5","in":55414,"out":858,"usd":0.119408}
{"at":"2026-10-16T00:07:24Z","model":"claude-sonnet-5-5","in":1748,"out":26,"usd":0.003756}
{"at":"2026-10-16T00:07:26Z","model":"claude-sonnet-5-5","in":2262,"out":145,"usd":0.005974}
{"at":"2026-10-16T00:07:31Z","model":"claude-sonnet-5-5","in":2801,"out":177,"usd":0.007372}
{"at":"2026-10-16T00:07:33Z","model":"claude-sonnet-5-5","in":3066,"out":134,"usd":0.007472}
{"at":"2026-10-16T00:07:35Z","model":"claude-sonnet-5-5","in":5714,"out":131,"usd":0.012738}
{"at":"2026-10-16T00:07:38Z","model":"claude-sonnet-5-5","in":6240,"out":207,"usd":0.01455}
{"at":"2026-10-16T00:07:41Z","model":"claude-sonnet-5-5","in":7994,"out":306,"usd":0.019048}
{"at":"2026-10-16T00:07:45Z","model":"claude-sonnet-5-5","in":8511,"out":312,"usd":0.020142}
{"at":"2026-10-16T00:07:48Z","model":"claude-sonnet-5-5","in":8726,"out":183,"usd":0.019282}
{"at":"2026-10-16T00:07:52Z","model":"claude-sonnet-5-5","in":11089,"out":342,"usd":0.025598}
{"at":"2026-10-16T00:07:57Z","model":"claude-sonnet-5-5","in":13674,"out":315,"usd":0.030498}
{"at":"2026-10-16T00:08:00Z","model":"claude-sonnet-5-5","in":13998,"out":176,"usd":0.029756}
{"at":"2026-10-16T00:08:03Z","model":"claude-sonnet-5-5","in":14657,"out":217,"usd":0.031484}
{"at":"2026-10-16T00:08:06Z","model":"claude-sonnet-5-5","in":14836,"out":296,"usd":0.032632}
{"at":"2026-10-16T00:08:09Z","model":"claude-sonnet-5-5","in":15055,"out":240,"usd":0.03251}
{"at":"2026-10-16T00:08:13Z","model":"claude-sonnet-5-5","in":15556,"out":323,"usd":0.034342}
{"at":"2026-10-16T00:08:19Z","model":"claude-sonnet-5-5","in":16047,"out":604,"usd":0.038134}
{"at":"2026-10-16T00:08:24Z","model":"claude-sonnet-5-5","in":18731,"out":405,"usd":0.041512}
{"at":"2026-10-16T00:08:27Z","model":"claude-sonnet-5-5","in":21495,"out":225,"usd":0.04524}
{"at":"2026-10-16T00:08:31Z","model":"claude-sonnet-5-5","in":22853,"out":250,"usd":0.048206}
{"at":"2026-10-16T00:08:34Z","model":"claude-sonnet-5-5","in":24396,"out":190,"usd":0.050692}
{"at":"2026-10-16T00:08:41Z","model":"claude-sonnet-5-5","in":25335,"out":686,"usd":0.05753}
{"at":"2026-10-16T00:08:50Z","model":"claude-sonnet-5-5","in":28112,"out":930,"usd":0.065524}
{"at":"2026-10-16T00:08:57Z","model":"claude-sonnet-5-5","in":30633,"out":802,"usd":0.069286}
{"at":"2026-10-16T00:09:02Z","model":"claude-sonnet-5-5","in":32302,"out":349,"usd":0.068094}
{"at":"2026-10-16T00:09:05Z","model":"claude-sonnet-5-5","in":33274,"out":208,"usd":0.068628}
{"at":"2026-10-16T00:09:11Z","model":"claude-sonnet-5-5","in":36049,"out":684,"usd":0.078938}
{"at":"2026-10-16T00:09:14Z","model":"claude-sonnet-5-5","in":38622,"out":324,"usd":0.080484}
{"at":"2026-10-16T00:09:18Z","model":"claude-sonnet-5-5","in":39575,"out":201,"usd":0.08116}
{"at":"2026-10-16T00:09:23Z","model":"claude-sonnet-5-5","in":40835,"out":318,"usd":0.08485}
{"at":"2026-10-16T00:09:26Z","model":"claude-sonnet-5-5","in":41086,"out":177,"usd":0.083942}
{"at":"2026-10-16T00:09:29Z","model":"claude-sonnet-5-5","in":41232,"out":173,"usd":0.084194}
{"at":"2026-10-16T00:09:32Z","model":"claude-sonnet-5-5","in":43542,"out":130,"usd":0.088384}
{"at":"2026-10-16T00:09:34Z","model":"claude-sonnet-5-5","in":44461,"out":132,"usd":0.090242}
{"at":"2026-10-16T00:09:48Z","model":"claude-sonnet-5-5","in":46675,"out":1681,"usd":0.11016}
{"at":"2026-10-16T00:10:04Z","model":"claude-sonnet-5-5","in":47241,"out":1595,"usd":0.110432}
{"at":"2026-10-16T00:10:25Z","model":"claude-sonnet-5-5","in":47413,"out":2384,"usd":0.118666}
{"at":"2026-10-16T00:10:39Z","model":"claude-sonnet-5-5","in":47674,"out":1158,"usd":0.106928}
{"at":"2026-10-16T00:11:05Z","model":"claude-sonnet-5-5","in":49438,"out":3150,"usd":0.130376}
{"at":"2026-10-16T00:11:37Z","model":"claude-sonnet-5-5","in":51544,"out":3967,"usd":0.142758}
{"at":"2026-10-16T13:13:33Z","model":"claude-opus-5-5","in":27046,"out":777,"usd":0.123724}
{"at":"2026-10-17T13:13:44Z","model":"claude-opus-5-5","in":26444,"out":667,"usd":0.119116}
{"at":"2026-10-17T18:18:07Z","model":"claude-sonnet-5-5","in":1751,"out":26,"usd":0.003762}
{"at":"2026-10-17T18:18:09Z","model":"claude-sonnet-5-5","in":2279,"out":284,"usd":0.007398}
{"at":"2026-10-17T18:18:13Z","model":"claude-sonnet-5-5","in":28494,"out":393,"usd":0.060918}
{"at":"2026-10-17T18:18:16Z","model":"claude-sonnet-5-5","in":31262,"out":208,"usd":0.064604}
{"at":"2026-10-17T18:18:42Z","model":"claude-sonnet-5-5","in":34213,"out":3118,"usd":0.099606}
{"at":"2026-10-17T18:19:44Z","model":"claude-sonnet-5-5","in":35944,"out":7606,"usd":0.147948}
{"at":"2026-10-17T18:20:25Z","model":"claude-sonnet-5-5","in":37180,"out":4821,"usd":0.12257}
{"at":"2026-10-17T18:20:51Z","model":"claude-sonnet-5-5","in":38270,"out":3106,"usd":0.1076}
{"at":"2026-10-17T18:22:14Z","model":"claude-sonnet-5-5","in":41295,"out":11757,"usd":0.20016}
{"at":"2026-10-17T18:22:29Z","model":"claude-sonnet-5-5","in":45974,"out":1680,"usd":0.108748}
{"at":"2026-10-17T18:23:09Z","model":"claude-sonnet-5-5","in":46382,"out":6616,"usd":0.158924}
{"at":"2026-10-17T18:23:11Z","model":"claude-sonnet-5-5","in":51455,"out":72,"usd":0.10363}
{"at":"2026-10-17T18:23:14Z","model":"claude-sonnet-5-5","in":51814,"out":351,"usd":0.107138}
{"at":"2026-10-17T18:23:24Z","model":"claude-sonnet-5-5","in":52210,"out":1496,"usd":0.11938}
{"at":"2026-10-17T18:23:31Z","model":"claude-sonnet-5-5","in":53661,"out":655,"usd":0.113872}
{"at":"2026-10-17T18:23:43Z","model":"claude-sonnet-5-5","in":54212,"out":1549,"usd":0.123914}
{"at":"2026-10-17T18:27:28Z","model":"claude-haiku-5-5","in":14087,"out":3022,"usd":0.00292}
{"at":"2026-10-17T18:27:32Z","model":"claude-sonnet-5-5","in":1363,"out":26,"usd":0.002986}
{"at":"2026-10-17T18:27:34Z","model":"claude-sonnet-5-5","in":1891,"out":100,"usd":0.004782}
{"at":"2026-10-17T18:27:36Z","model":"claude-sonnet-5-5","in":3064,"out":115,"usd":0.007278}
{"at":"2026-10-17T18:27:38Z","model":"claude-sonnet-5-5","in":5804,"out":98,"usd":0.012588}
{"at":"2026-10-17T18:27:44Z","model":"claude-sonnet-5-5","in":7456,"out":857,"usd":0.023482}
{"at":"2026-10-17T18:27:49Z","model":"claude-sonnet-5-5","in":8267,"out":820,"usd":0.024734}
{"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764}
{"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512}
{"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114}
{"at":"2026-10-17T20:20:54Z","model":"claude-sonnet-5-5","in":2158,"out":26,"usd":0.004576}
{"at":"2026-10-17T20:20:56Z","model":"claude-sonnet-5-5","in":2637,"out":163,"usd":0.006904}
{"at":"2026-10-17T20:20:58Z","model":"claude-sonnet-5-5","in":3153,"out":187,"usd":0.008176}
{"at":"2026-10-17T20:21:01Z","model":"claude-sonnet-5-5","in":5843,"out":191,"usd":0.013596}
{"at":"2026-10-17T20:21:06Z","model":"claude-sonnet-5-5","in":6251,"out":341,"usd":0.015912}
{"at":"2026-10-17T20:21:09Z","model":"claude-sonnet-5-5","in":6655,"out":381,"usd":0.01712}
{"at":"2026-10-17T20:21:16Z","model":"claude-sonnet-5-5","in":7036,"out":659,"usd":0.020662}
{"at":"2026-10-17T20:21:20Z","model":"claude-sonnet-5-5","in":7483,"out":397,"usd":0.018936}
{"at":"2026-10-17T20:21:37Z","model":"claude-sonnet-5-5","in":7960,"out":1855,"usd":0.03447}
{"at":"2026-10-17T20:21:49Z","model":"claude-sonnet-5-5","in":10584,"out":1157,"usd":0.032738}
{"at":"2026-10-17T20:21:57Z","model":"claude-sonnet-5-5","in":11307,"out":837,"usd":0.030984}
{"at":"2026-10-17T20:22:04Z","model":"claude-sonnet-5-5","in":16615,"out":513,"usd":0.03836}
{"at":"2026-10-17T20:22:17Z","model":"claude-sonnet-5-5","in":19010,"out":942,"usd":0.04744}
{"at":"2026-10-17T20:22:23Z","model":"claude-sonnet-5-5","in":19216,"out":477,"usd":0.043202}
{"at":"2026-10-17T20:22:33Z","model":"claude-sonnet-5-5","in":19515,"out":970,"usd":0.04873}
{"at":"2026-10-17T20:22:45Z","model":"claude-sonnet-5-5","in":19943,"out":1162,"usd":0.051506}
{"at":"2026-10-17T20:22:47Z","model":"claude-sonnet-5-5","in":22260,"out":116,"usd":0.04568}
{"at":"2026-10-17T20:22:50Z","model":"claude-sonnet-5-5","in":22837,"out":187,"usd":0.047544}
{"at":"2026-10-17T20:22:54Z","model":"claude-sonnet-5-5","in":23284,"out":220,"usd":0.048768}
{"at":"2026-10-17T20:22:56Z","model":"claude-sonnet-5-5","in":24921,"out":165,"usd":0.051492}
{"at":"2026-10-17T20:22:58Z","model":"claude-sonnet-5-5","in":25358,"out":70,"usd":0.051416}
{"at":"2026-10-17T20:23:00Z","model":"claude-sonnet-5-5","in":27711,"out":210,"usd":0.057522}
{"at":"2026-10-17T20:23:04Z","model":"claude-sonnet-5-5","in":31018,"out":347,"usd":0.065506}
{"at":"2026-10-17T20:23:08Z","model":"claude-sonnet-5-5","in":33756,"out":253,"usd":0.070042}
{"at":"2026-10-17T20:23:10Z","model":"claude-sonnet-5-5","in":35236,"out":161,"usd":0.072082}
{"at":"2026-10-17T20:23:16Z","model":"claude-sonnet-5-5","in":37623,"out":401,"usd":0.079256}
{"at":"2026-10-17T20:23:22Z","model":"claude-sonnet-5-5","in":40686,"out":229,"usd":0.083662}
{"at":"2026-10-17T20:23:23Z","model":"claude-sonnet-5-5","in":42769,"out":101,"usd":0.086548}
{"at":"2026-10-17T20:23:26Z","model":"claude-sonnet-5-5","in":45100,"out":186,"usd":0.09206}
{"at":"2026-10-17T20:23:28Z","model":"claude-sonnet-5-5","in":47113,"out":261,"usd":0.096836}
{"at":"2026-10-17T20:23:32Z","model":"claude-sonnet-5-5","in":49280,"out":332,"usd":0.10188}
{"at":"2026-10-17T20:23:34Z","model":"claude-sonnet-5-5","in":51809,"out":190,"usd":0.105518}
{"at":"2026-10-17T20:23:37Z","model":"claude-sonnet-5-5","in":53813,"out":206,"usd":0.109686}
{"at":"2026-10-17T20:23:38Z","model":"claude-sonnet-5-5","in":54057,"out":85,"usd":0.108964}
{"at":"2026-10-17T20:23:40Z","model":"claude-sonnet-5-5","in":54152,"out":114,"usd":0.109444}
{"at":"2026-10-17T20:23:43Z","model":"claude-sonnet-5-5","in":54408,"out":310,"usd":0.111916}
{"at":"2026-10-17T20:23:47Z","model":"claude-sonnet-5-5","in":55850,"out":193,"usd":0.11363}
{"at":"2026-10-17T20:23:49Z","model":"claude-sonnet-5-5","in":58100,"out":213,"usd":0.11833}
{"at":"2026-10-17T20:23:53Z","model":"claude-sonnet-5-5","in":60500,"out":471,"usd":0.12571}
{"at":"2026-10-17T20:23:59Z","model":"claude-sonnet-5-5","in":62407,"out":618,"usd":0.130994}
{"at":"2026-10-18T01:56:39Z","model":"kimi-k3","in":551,"out":4000,"usd":null}
{"at":"2026-10-18T01:57:03Z","model":"kimi-k3","in":501,"out":3119,"usd":null}
{"at":"2026-10-18T01:57:15Z","model":"claude-sonnet-5-5","in":1727,"out":55,"usd":0.004004}
{"at":"2026-10-18T01:57:17Z","model":"claude-sonnet-5-5","in":1791,"out":26,"usd":0.003842}
{"at":"2026-10-18T01:57:19Z","model":"claude-sonnet-5-5","in":2319,"out":145,"usd":0.006088}
{"at":"2026-10-18T01:57:21Z","model":"claude-sonnet-5-5","in":2905,"out":140,"usd":0.00721}
{"at":"2026-10-18T01:57:23Z","model":"claude-sonnet-5-5","in":3879,"out":155,"usd":0.009308}
{"at":"2026-10-18T01:57:26Z","model":"claude-sonnet-5-5","in":5731,"out":145,"usd":0.012912}
{"at":"2026-10-18T01:57:28Z","model":"claude-sonnet-5-5","in":5893,"out":159,"usd":0.013376}
{"at":"2026-10-18T01:57:34Z","model":"claude-sonnet-5-5","in":6477,"out":485,"usd":0.017804}
{"at":"2026-10-18T01:57:40Z","model":"claude-sonnet-5-5","in":9000,"out":449,"usd":0.02249}
{"at":"2026-10-18T01:57:46Z","model":"claude-sonnet-5-5","in":14302,"out":392,"usd":0.032524}
{"at":"2026-10-18T01:57:52Z","model":"claude-sonnet-5-5","in":18183,"out":277,"usd":0.039136}
{"at":"2026-10-18T01:57:54Z","model":"claude-sonnet-5-5","in":20570,"out":117,"usd":0.04231}
{"at":"2026-10-18T01:57:57Z","model":"claude-sonnet-5-5","in":22918,"out":155,"usd":0.047386}
{"at":"2026-10-18T01:58:02Z","model":"claude-sonnet-5-5","in":25289,"out":246,"usd":0.053038}
{"at":"2026-10-18T01:58:05Z","model":"claude-sonnet-5-5","in":29676,"out":137,"usd":0.060722}
{"at":"2026-10-18T01:58:07Z","model":"claude-sonnet-5-5","in":30008,"out":137,"usd":0.061386}
{"at":"2026-10-18T01:58:10Z","model":"claude-sonnet-5-5","in":31487,"out":159,"usd":0.064564}
{"at":"2026-10-18T01:58:14Z","model":"claude-sonnet-5-5","in":31874,"out":214,"usd":0.065888}
{"at":"2026-10-18T01:58:18Z","model":"claude-sonnet-5-5","in":34808,"out":173,"usd":0.071346}
{"at":"2026-10-18T01:58:21Z","model":"claude-sonnet-5-5","in":35490,"out":295,"usd":0.07393}
{"at":"2026-10-18T01:58:26Z","model":"claude-sonnet-5-5","in":36311,"out":203,"usd":0.074652}
{"at":"2026-10-18T01:58:32Z","model":"claude-sonnet-5-5","in":38059,"out":410,"usd":0.080218}
{"at":"2026-10-18T01:58:43Z","model":"claude-sonnet-5-5","in":40152,"out":1218,"usd":0.092484}
{"at":"2026-10-18T01:58:52Z","model":"claude-sonnet-5-5","in":43041,"out":833,"usd":0.094412}
{"at":"2026-10-18T01:58:56Z","model":"claude-sonnet-5-5","in":43754,"out":158,"usd":0.089088}
{"at":"2026-10-18T01:58:58Z","model":"claude-sonnet-5-5","in":44894,"out":223,"usd":0.092018}
{"at":"2026-10-18T01:59:03Z","model":"claude-sonnet-5-5","in":46474,"out":360,"usd":0.096548}
{"at":"2026-10-18T01:59:05Z","model":"claude-sonnet-5-5","in":47416,"out":89,"usd":0.095722}
{"at":"2026-10-18T01:59:09Z","model":"claude-sonnet-5-5","in":48704,"out":446,"usd":0.101868}
{"at":"2026-10-18T01:59:13Z","model":"claude-sonnet-5-5","in":49422,"out":265,"usd":0.101494}
{"at":"2026-10-18T01:59:30Z","model":"claude-sonnet-5-5","in":50006,"out":2066,"usd":0.120672}
{"at":"2026-10-18T01:59:59Z","model":"claude-sonnet-5-5","in":52558,"out":3267,"usd":0.137786}
{"at":"2026-10-18T02:00:12Z","model":"claude-sonnet-5-5","in":53089,"out":1369,"usd":0.119868}
{"at":"2026-10-18T02:01:24Z","model":"claude-sonnet-5-5","in":53909,"out":4905,"usd":0.156868}
{"at":"2026-10-18T02:01:56Z","model":"claude-sonnet-5-5","in":54081,"out":3464,"usd":0.142802}
{"at":"2026-10-18T02:02:39Z","model":"claude-sonnet-5-5","in":56406,"out":5010,"usd":0.162912}
{"at":"2026-10-18T02:02:49Z","model":"claude-sonnet-5-5","in":57895,"out":902,"usd":0.12481}
{"at":"2026-10-18T02:02:54Z","model":"claude-sonnet-5-5","in":58155,"out":454,"usd":0.12085}
{"at":"2026-10-18T02:02:59Z","model":"claude-sonnet-5-5","in":58339,"out":412,"usd":0.120798}
{"at":"2026-10-18T02:03:21Z","model":"claude-sonnet-5-5","in":58576,"out":2342,"usd":0.140572}
{"at":"2026-10-18T02:03:23Z","model":"claude-sonnet-5-5","in":1763,"out":55,"usd":0.004076}
{"at":"2026-10-18T02:03:24Z","model":"claude-sonnet-5-5","in":1827,"out":26,"usd":0.003914}
{"at":"2026-10-18T02:03:26Z","model":"claude-sonnet-5-5","in":2355,"out":212,"usd":0.00683}
{"at":"2026-10-18T02:03:28Z","model":"claude-sonnet-5-5","in":3439,"out":160,"usd":0.008478}
{"at":"2026-10-18T02:03:31Z","model":"claude-sonnet-5-5","in":3718,"out":108,"usd":0.008516}
{"at":"2026-10-18T02:03:33Z","model":"claude-sonnet-5-5","in":3957,"out":155,"usd":0.009464}
{"at":"2026-10-18T02:03:37Z","model":"claude-sonnet-5-5","in":5502,"out":273,"usd":0.013734}
{"at":"2026-10-18T02:03:42Z","model":"claude-sonnet-5-5","in":6651,"out":407,"usd":0.017372}
{"at":"2026-10-18T02:03:49Z","model":"claude-sonnet-5-5","in":9178,"out":806,"usd":0.026416}
{"at":"2026-10-18T02:03:55Z","model":"claude-sonnet-5-5","in":10903,"out":497,"usd":0.026776}
{"at":"2026-10-18T02:04:04Z","model":"claude-sonnet-5-5","in":12818,"out":971,"usd":0.035346}
{"at":"2026-10-18T02:04:11Z","model":"claude-sonnet-5-5","in":14529,"out":639,"usd":0.035448}
{"at":"2026-10-18T02:04:24Z","model":"claude-sonnet-5-5","in":14719,"out":1393,"usd":0.043368}
{"at":"2026-10-18T02:04:45Z","model":"claude-sonnet-5-5","in":14922,"out":2760,"usd":0.057444}
{"at":"2026-10-18T02:04:58Z","model":"claude-sonnet-5-5","in":16390,"out":1316,"usd":0.04594}
{"at":"2026-10-18T02:05:01Z","model":"claude-sonnet-5-5","in":1854,"out":109,"usd":0.004798}
{"at":"2026-10-18T02:05:04Z","model":"claude-sonnet-5-5","in":4304,"out":76,"usd":0.009368}
{"at":"2026-10-18T02:05:06Z","model":"claude-sonnet-5-5","in":6549,"out":83,"usd":0.013928}
{"at":"2026-10-18T02:05:08Z","model":"claude-sonnet-5-5","in":8739,"out":87,"usd":0.018348}
{"at":"2026-10-18T02:05:12Z","model":"claude-sonnet-5-5","in":11087,"out":145,"usd":0.023624}
{"at":"2026-10-18T02:05:15Z","model":"claude-sonnet-5-5","in":13825,"out":81,"usd":0.02846}
{"at":"2026-10-18T02:05:18Z","model":"claude-sonnet-5-5","in":16833,"out":191,"usd":0.035576}
{"at":"2026-10-18T02:05:22Z","model":"claude-sonnet-5-5","in":44658,"out":230,"usd":0.091616}
{"at":"2026-10-18T02:06:02Z","model":"claude-sonnet-5-5","in":45947,"out":5089,"usd":0.142784}
{"at":"2026-10-18T02:06:09Z","model":"claude-sonnet-5-5","in":48809,"out":662,"usd":0.104238}
{"at":"2026-10-18T02:06:24Z","model":"claude-sonnet-5-5","in":49311,"out":2669,"usd":0.125312}
{"at":"2026-10-18T02:06:33Z","model":"claude-sonnet-5-5","in":53209,"out":918,"usd":0.115598}
{"at":"2026-10-18T02:06:36Z","model":"claude-sonnet-5-5","in":1767,"out":109,"usd":0.004624}
{"at":"2026-10-18T02:06:38Z","model":"claude-sonnet-5-5","in":2032,"out":90,"usd":0.004964}
{"at":"2026-10-18T02:06:39Z","model":"claude-sonnet-5-5","in":3049,"out":157,"usd":0.007668}
{"at":"2026-10-18T02:06:41Z","model":"claude-sonnet-5-5","in":3606,"out":135,"usd":0.008562}
{"at":"2026-10-18T02:06:44Z","model":"claude-sonnet-5-5","in":4993,"out":98,"usd":0.010966}
{"at":"2026-10-18T02:06:46Z","model":"claude-sonnet-5-5","in":5985,"out":192,"usd":0.01389}
{"at":"2026-10-18T02:06:52Z","model":"claude-sonnet-5-5","in":8112,"out":272,"usd":0.018944}
{"at":"2026-10-18T02:07:05Z","model":"claude-sonnet-5-5","in":8707,"out":243,"usd":0.019844}
{"at":"2026-10-18T02:07:09Z","model":"claude-sonnet-5-5","in":11005,"out":320,"usd":0.02521}
{"at":"2026-10-18T02:07:11Z","model":"claude-sonnet-5-5","in":11254,"out":243,"usd":0.024938}
{"at":"2026-10-18T02:07:16Z","model":"claude-sonnet-5-5","in":11861,"out":319,"usd":0.026912}
{"at":"2026-10-18T02:07:19Z","model":"claude-sonnet-5-5","in":12251,"out":342,"usd":0.027922}
{"at":"2026-10-18T02:07:22Z","model":"claude-sonnet-5-5","in":14924,"out":285,"usd":0.032698}
{"at":"2026-10-18T02:07:28Z","model":"claude-sonnet-5-5","in":17307,"out":321,"usd":0.037824}
{"at":"2026-10-18T02:07:35Z","model":"claude-sonnet-5-5","in":20420,"out":540,"usd":0.04624}
{"at":"2026-10-18T02:07:46Z","model":"claude-sonnet-5-5","in":20700,"out":1659,"usd":0.05799}
{"at":"2026-10-18T02:07:54Z","model":"claude-sonnet-5-5","in":21516,"out":534,"usd":0.048372}
{"at":"2026-10-18T02:08:01Z","model":"claude-sonnet-5-5","in":21835,"out":578,"usd":0.04945}
{"at":"2026-10-18T02:08:10Z","model":"claude-sonnet-5-5","in":22012,"out":977,"usd":0.053794}
{"at":"2026-10-18T02:08:29Z","model":"claude-sonnet-5-5","in":22239,"out":2156,"usd":0.066038}
{"at":"2026-10-18T02:08:51Z","model":"claude-sonnet-5-5","in":23312,"out":3127,"usd":0.077894}
{"at":"2026-10-18T02:08:59Z","model":"claude-sonnet-5-5","in":25395,"out":540,"usd":0.05619}
{"at":"2026-10-18T02:09:09Z","model":"claude-sonnet-5-5","in":25790,"out":1327,"usd":0.06485}
{"at":"2026-10-18T02:09:18Z","model":"claude-sonnet-5-5","in":27350,"out":479,"usd":0.05949}
{"at":"2026-10-18T02:10:25Z","model":"claude-sonnet-5-5","in":27839,"out":1544,"usd":0.071118}
{"at":"2026-10-18T02:10:28Z","model":"claude-sonnet-5-5","in":29104,"out":312,"usd":0.061328}
{"at":"2026-10-18T04:06:03Z","model":"claude-haiku-5-5","in":5317,"out":2107,"usd":0.001585}
{"at":"2026-10-18T04:06:06Z","model":"claude-sonnet-5-5","in":2134,"out":26,"usd":0.004528}
{"at":"2026-10-18T04:06:09Z","model":"claude-sonnet-5-5","in":2662,"out":376,"usd":0.009084}
{"at":"2026-10-18T04:06:12Z","model":"claude-sonnet-5-5","in":29849,"out":442,"usd":0.064118}
{"at":"2026-10-18T04:06:17Z","model":"claude-sonnet-5-5","in":38782,"out":401,"usd":0.081574}
{"at":"2026-10-18T04:06:19Z","model":"claude-sonnet-5-5","in":44310,"out":211,"usd":0.09073}
{"at":"2026-10-18T04:06:22Z","model":"claude-sonnet-5-5","in":46904,"out":256,"usd":0.096368}
{"at":"2026-10-18T04:06:24Z","model":"claude-sonnet-5-5","in":48169,"out":279,"usd":0.099128}
{"at":"2026-10-18T04:06:29Z","model":"claude-sonnet-5-5","in":50276,"out":483,"usd":0.105382}
{"at":"2026-10-18T04:06:34Z","model":"claude-sonnet-5-5","in":52765,"out":612,"usd":0.11165}
{"at":"2026-10-18T04:06:39Z","model":"claude-sonnet-5-5","in":55365,"out":517,"usd":0.1159}
{"at":"2026-10-18T04:07:30Z","model":"claude-sonnet-5-5","in":57769,"out":6235,"usd":0.177888}
{"at":"2026-10-18T04:07:38Z","model":"claude-sonnet-5-5","in":60362,"out":821,"usd":0.128934}
{"at":"2026-10-18T04:08:31Z","model":"claude-sonnet-5-5","in":61644,"out":6597,"usd":0.189258}
{"at":"2026-10-18T04:09:16Z","model":"claude-sonnet-5-5","in":62547,"out":5473,"usd":0.179824}
{"at":"2026-10-18T04:11:13Z","model":"claude-sonnet-5-5","in":62660,"out":16000,"usd":0.28532}
{"at":"2026-10-18T04:13:02Z","model":"claude-sonnet-5-5","in":62694,"out":15930,"usd":0.284688}
{"at":"2026-10-18T04:13:30Z","model":"claude-sonnet-5-5","in":67478,"out":4905,"usd":0.184006}
{"at":"2026-10-18T04:13:32Z","model":"claude-sonnet-5-5","in":72483,"out":183,"usd":0.146796}
{"at":"2026-10-18T04:13:47Z","model":"claude-sonnet-5-5","in":73099,"out":2129,"usd":0.167488}
{"at":"2026-10-18T04:13:51Z","model":"claude-sonnet-5-5","in":75235,"out":325,"usd":0.15372}
{"at":"2026-10-18T04:14:02Z","model":"claude-sonnet-5-5","in":75591,"out":1385,"usd":0.165032}
{"at":"2026-10-18T04:14:06Z","model":"claude-sonnet-5-5","in":2473,"out":170,"usd":0.006646}
{"at":"2026-10-18T04:14:08Z","model":"claude-sonnet-5-5","in":5358,"out":173,"usd":0.012446}
{"at":"2026-10-18T04:14:10Z","model":"claude-sonnet-5-5","in":6785,"out":179,"usd":0.01536}
{"at":"2026-10-18T04:14:12Z","model":"claude-sonnet-5-5","in":9531,"out":152,"usd":0.020582}
{"at":"2026-10-18T04:14:14Z","model":"claude-sonnet-5-5","in":12333,"out":194,"usd":0.026606}
{"at":"2026-10-18T04:14:29Z","model":"claude-sonnet-5-5","in":14662,"out":2551,"usd":0.054834}
{"at":"2026-10-18T04:14:31Z","model":"claude-sonnet-5-5","in":17568,"out":121,"usd":0.036346}
{"at":"2026-10-18T04:14:34Z","model":"claude-sonnet-5-5","in":18361,"out":324,"usd":0.039962}
{"at":"2026-10-18T04:14:41Z","model":"claude-sonnet-5-5","in":18986,"out":760,"usd":0.045572}
{"at":"2026-10-18T04:16:11Z","model":"claude-haiku-5-5","in":13815,"out":3027,"usd":0.002895}
{"at":"2026-10-18T04:16:22Z","model":"claude-haiku-5-5","in":5746,"out":1940,"usd":0.001545}
{"at":"2026-10-18T04:26:47Z","model":"claude-sonnet-5-5","in":2728,"out":93,"usd":0.006386}
{"at":"2026-10-18T04:26:49Z","model":"claude-sonnet-5-5","in":3392,"out":101,"usd":0.007794}
{"at":"2026-10-18T04:26:50Z","model":"claude-sonnet-5-5","in":6396,"out":108,"usd":0.013872}
{"at":"2026-10-18T04:26:52Z","model":"claude-sonnet-5-5","in":8797,"out":213,"usd":0.019724}
{"at":"2026-10-18T04:27:00Z","model":"claude-sonnet-5-5","in":10642,"out":1044,"usd":0.031724}
{"at":"2026-10-18T04:27:04Z","model":"claude-sonnet-5-5","in":11793,"out":253,"usd":0.026116}
{"at":"2026-10-18T04:27:11Z","model":"claude-sonnet-5-5","in":12126,"out":651,"usd":0.030762}
{"at":"2026-10-18T04:27:31Z","model":"claude-haiku-5-5","in":5399,"out":2909,"usd":0.001994}
{"at":"2026-10-18T04:39:54Z","model":"claude-sonnet-5-5","in":2243,"out":26,"usd":0.004746}
{"at":"2026-10-18T04:39:55Z","model":"claude-sonnet-5-5","in":2819,"out":113,"usd":0.006768}
{"at":"2026-10-18T04:39:57Z","model":"claude-sonnet-5-5","in":5095,"out":101,"usd":0.0112}
{"at":"2026-10-18T04:39:59Z","model":"claude-sonnet-5-5","in":7855,"out":104,"usd":0.01675}
{"at":"2026-10-18T04:40:01Z","model":"claude-sonnet-5-5","in":10087,"out":79,"usd":0.020964}
{"at":"2026-10-18T04:40:02Z","model":"claude-sonnet-5-5","in":34365,"out":102,"usd":0.06975}
{"at":"2026-10-18T04:40:05Z","model":"claude-sonnet-5-5","in":37054,"out":136,"usd":0.075468}
{"at":"2026-10-18T04:40:06Z","model":"claude-sonnet-5-5","in":40085,"out":80,"usd":0.08097}
{"at":"2026-10-18T04:40:49Z","model":"claude-sonnet-5-5","in":67623,"out":4962,"usd":0.184866}
{"at":"2026-10-18T04:41:49Z","model":"claude-sonnet-5-5","in":70376,"out":7033,"usd":0.211082}
{"at":"2026-10-18T04:41:51Z","model":"claude-sonnet-5-5","in":72287,"out":229,"usd":0.146864}
{"at":"2026-10-18T04:43:14Z","model":"claude-sonnet-5-5","in":73842,"out":10120,"usd":0.248884}
{"at":"2026-10-18T04:45:05Z","model":"claude-sonnet-5-5","in":75696,"out":15450,"usd":0.305892}
{"at":"2026-10-18T04:45:51Z","model":"claude-sonnet-5-5","in":79917,"out":5774,"usd":0.217574}
{"at":"2026-10-18T04:46:07Z","model":"claude-sonnet-5-5","in":82584,"out":1747,"usd":0.182638}
{"at":"2026-10-18T04:46:28Z","model":"claude-sonnet-5-5","in":83296,"out":2437,"usd":0.190962}
{"at":"2026-10-18T04:46:59Z","model":"claude-sonnet-5-5","in":84097,"out":4593,"usd":0.214124}
{"at":"2026-10-18T04:47:05Z","model":"claude-sonnet-5-5","in":87773,"out":616,"usd":0.181706}
{"at":"2026-10-18T04:47:10Z","model":"claude-sonnet-5-5","in":88420,"out":467,"usd":0.18151}
{"at":"2026-10-18T04:47:14Z","model":"claude-sonnet-5-5","in":89051,"out":340,"usd":0.181502}
{"at":"2026-10-18T04:47:18Z","model":"claude-sonnet-5-5","in":89624,"out":370,"usd":0.182948}
{"at":"2026-10-18T04:47:24Z","model":"claude-sonnet-5-5","in":90024,"out":661,"usd":0.186658}
{"at":"2026-10-18T04:47:34Z","model":"claude-sonnet-5-5","in":90605,"out":1296,"usd":0.19417}
{"at":"2026-10-18T04:50:11Z","model":"claude-haiku-5-5","in":10125,"out":2282,"usd":0.002154}
{"at":"2026-10-18T04:50:26Z","model":"claude-haiku-5-5","in":10121,"out":2848,"usd":0.002436}
{"at":"2026-10-18T04:50:46Z","model":"claude-haiku-5-5","in":12621,"out":3932,"usd":0.003228}
{"at":"2026-10-18T04:59:19Z","model":"claude-haiku-5-5","in":2520,"out":1939,"usd":0.001221}
{"at":"2026-10-18T05:03:52Z","model":"claude-sonnet-5-5","in":2257,"out":109,"usd":0.006731,"cached":0,"written":2253}
{"at":"2026-10-18T05:03:55Z","model":"claude-sonnet-5-5","in":4997,"out":225,"usd":0.00956,"cached":2253,"written":2742}
{"at":"2026-10-18T05:03:58Z","model":"claude-sonnet-5-5","in":50332,"out":224,"usd":0.116581,"cached":4995,"written":45335}
{"at":"2026-10-18T05:04:03Z","model":"claude-sonnet-5-5","in":78131,"out":365,"usd":0.083217,"cached":50330,"written":27799}
{"at":"2026-10-18T05:04:10Z","model":"claude-sonnet-5-5","in":82203,"out":578,"usd":0.03159,"cached":78129,"written":4072}
{"at":"2026-10-18T05:04:15Z","model":"claude-sonnet-5-5","in":87691,"out":216,"usd":0.032324,"cached":82201,"written":5488}
{"at":"2026-10-18T05:04:20Z","model":"claude-sonnet-5-5","in":90518,"out":323,"usd":0.027839,"cached":87689,"written":2827}
{"at":"2026-10-18T05:04:23Z","model":"claude-sonnet-5-5","in":92509,"out":91,"usd":0.023995,"cached":90516,"written":1991}
{"at":"2026-10-18T05:05:49Z","model":"claude-sonnet-5-5","in":94102,"out":11939,"usd":0.141878,"cached":92507,"written":1593}
{"at":"2026-10-18T05:05:54Z","model":"claude-sonnet-5-5","in":106065,"out":492,"usd":0.053651,"cached":94100,"written":11963}
{"at":"2026-10-18T05:05:59Z","model":"claude-sonnet-5-5","in":108387,"out":269,"usd":0.029712,"cached":106063,"written":2322}
{"at":"2026-10-18T05:06:05Z","model":"claude-sonnet-5-5","in":108739,"out":727,"usd":0.029831,"cached":108385,"written":352}
{"at":"2026-10-18T05:06:09Z","model":"claude-sonnet-5-5","in":110175,"out":415,"usd":0.029491,"cached":108737,"written":1436}
{"at":"2026-10-18T05:06:21Z","model":"claude-sonnet-5-5","in":111100,"out":1784,"usd":0.042191,"cached":110173,"written":925}
{"at":"2026-10-18T05:06:26Z","model":"claude-sonnet-5-5","in":113095,"out":295,"usd":0.030161,"cached":111098,"written":1995}
{"at":"2026-10-18T05:06:51Z","model":"claude-sonnet-5-5","in":113647,"out":3968,"usd":0.063683,"cached":113093,"written":552}
{"at":"2026-10-18T05:07:09Z","model":"claude-sonnet-5-5","in":117641,"out":2409,"usd":0.056808,"cached":113645,"written":3994}
{"at":"2026-10-18T05:07:16Z","model":"claude-sonnet-5-5","in":120756,"out":175,"usd":0.033069,"cached":117639,"written":3115}
{"at":"2026-10-18T05:07:19Z","model":"claude-sonnet-5-5","in":120972,"out":105,"usd":0.025745,"cached":120754,"written":216}
{"at":"2026-10-18T05:08:12Z","model":"claude-sonnet-5-5","in":121478,"out":252,"usd":0.027983,"cached":120970,"written":506}
{"at":"2026-10-18T05:08:40Z","model":"claude-sonnet-5-5","in":122164,"out":222,"usd":0.028234,"cached":121476,"written":686}
{"at":"2026-10-18T05:08:49Z","model":"claude-sonnet-5-5","in":122537,"out":176,"usd":0.027129,"cached":122162,"written":373}
{"at":"2026-10-18T05:10:03Z","model":"claude-sonnet-5-5","in":123091,"out":1552,"usd":0.041416,"cached":122535,"written":554}
{"at":"2026-10-18T05:10:08Z","model":"claude-sonnet-5-5","in":1824,"out":109,"usd":0.005648,"cached":0,"written":1820}
{"at":"2026-10-18T05:10:10Z","model":"claude-sonnet-5-5","in":4564,"out":228,"usd":0.009503,"cached":1820,"written":2742}
{"at":"2026-10-18T05:10:14Z","model":"claude-sonnet-5-5","in":42414,"out":313,"usd":0.098671,"cached":4562,"written":37850}
{"at":"2026-10-18T05:10:17Z","model":"claude-sonnet-5-5","in":43036,"out":157,"usd":0.011611,"cached":42412,"written":622}
{"at":"2026-10-18T05:10:21Z","model":"claude-sonnet-5-5","in":43381,"out":153,"usd":0.011003,"cached":43034,"written":345}
{"at":"2026-10-18T05:10:23Z","model":"claude-sonnet-5-5","in":43695,"out":108,"usd":0.010545,"cached":43379,"written":314}
{"at":"2026-10-18T05:10:26Z","model":"claude-sonnet-5-5","in":44016,"out":142,"usd":0.010965,"cached":43693,"written":321}
{"at":"2026-10-18T05:10:30Z","model":"claude-sonnet-5-5","in":44647,"out":166,"usd":0.012044,"cached":44014,"written":631}
{"at":"2026-10-18T05:10:35Z","model":"claude-sonnet-5-5","in":45526,"out":336,"usd":0.01449,"cached":44645,"written":879}
{"at":"2026-10-18T05:10:39Z","model":"claude-sonnet-5-5","in":46546,"out":257,"usd":0.014229,"cached":45524,"written":1020}
{"at":"2026-10-18T05:10:46Z","model":"claude-sonnet-5-5","in":47339,"out":770,"usd":0.018995,"cached":46544,"written":793}
{"at":"2026-10-18T05:10:58Z","model":"claude-sonnet-5-5","in":54274,"out":1666,"usd":0.043469,"cached":47337,"written":6935}
{"at":"2026-10-18T05:11:07Z","model":"claude-sonnet-5-5","in":56078,"out":169,"usd":0.017058,"cached":54272,"written":1804}
{"at":"2026-10-18T05:11:13Z","model":"claude-sonnet-5-5","in":56315,"out":510,"usd":0.016912,"cached":56076,"written":237}
{"at":"2026-10-18T05:11:19Z","model":"claude-sonnet-5-5","in":56992,"out":312,"usd":0.016079,"cached":56313,"written":677}
{"at":"2026-10-18T05:12:32Z","model":"claude-sonnet-5-5","in":57791,"out":1095,"usd":0.024349,"cached":56990,"written":799}
{"at":"2026-10-18T05:12:35Z","model":"claude-sonnet-5-5","in":1868,"out":55,"usd":0.005218,"cached":0,"written":1864}
{"at":"2026-10-18T05:12:38Z","model":"claude-sonnet-5-5","in":2063,"out":225,"usd":0.003119,"cached":1864,"written":197}
{"at":"2026-10-18T05:12:42Z","model":"claude-sonnet-5-5","in":11971,"out":314,"usd":0.028326,"cached":2061,"written":9908}
{"at":"2026-10-18T05:12:46Z","model":"claude-sonnet-5-5","in":13561,"out":216,"usd":0.008533,"cached":11969,"written":1590}
{"at":"2026-10-18T05:12:57Z","model":"claude-sonnet-5-5","in":14832,"out":1570,"usd":0.021593,"cached":13559,"written":1271}
{"at":"2026-10-18T05:13:04Z","model":"claude-sonnet-5-5","in":16641,"out":299,"usd":0.010483,"cached":14830,"written":1809}
{"at":"2026-10-18T05:13:12Z","model":"claude-sonnet-5-5","in":17121,"out":763,"usd":0.012162,"cached":16639,"written":480}
{"at":"2026-10-18T05:13:17Z","model":"claude-sonnet-5-5","in":1899,"out":55,"usd":0.005295,"cached":0,"written":1895}
{"at":"2026-10-18T05:13:19Z","model":"claude-sonnet-5-5","in":2094,"out":228,"usd":0.003155,"cached":1895,"written":197}
{"at":"2026-10-18T05:13:29Z","model":"claude-sonnet-5-5","in":27220,"out":826,"usd":0.071497,"cached":2092,"written":25126}
{"at":"2026-10-18T05:13:32Z","model":"claude-sonnet-5-5","in":28865,"out":139,"usd":0.01095,"cached":27218,"written":1645}
{"at":"2026-10-18T05:13:53Z","model":"claude-sonnet-5-5","in":29209,"out":2985,"usd":0.036487,"cached":28863,"written":344}
{"at":"2026-10-18T05:14:06Z","model":"claude-sonnet-5-5","in":32288,"out":1466,"usd":0.028203,"cached":29207,"written":3079}
{"at":"2026-10-18T05:14:12Z","model":"claude-sonnet-5-5","in":33781,"out":599,"usd":0.016184,"cached":32286,"written":1493}
{"at":"2026-10-18T05:14:18Z","model":"claude-sonnet-5-5","in":34717,"out":486,"usd":0.01396,"cached":33779,"written":936}
{"at":"2026-10-18T05:14:22Z","model":"claude-sonnet-5-5","in":35992,"out":247,"usd":0.012604,"cached":34715,"written":1275}
{"at":"2026-10-18T05:14:27Z","model":"claude-sonnet-5-5","in":36419,"out":308,"usd":0.01135,"cached":35990,"written":427}
{"at":"2026-10-18T05:14:36Z","model":"claude-sonnet-5-5","in":36809,"out":1491,"usd":0.023172,"cached":36417,"written":390}
{"at":"2026-10-18T05:14:41Z","model":"claude-sonnet-5-5","in":38327,"out":73,"usd":0.01189,"cached":36807,"written":1518}
{"at":"2026-10-18T05:14:44Z","model":"claude-sonnet-5-5","in":38733,"out":199,"usd":0.010674,"cached":38325,"written":406}
{"at":"2026-10-18T05:14:48Z","model":"claude-sonnet-5-5","in":39827,"out":112,"usd":0.011605,"cached":38731,"written":1094}
{"at":"2026-10-18T05:14:54Z","model":"claude-sonnet-5-5","in":41728,"out":730,"usd":0.020022,"cached":39825,"written":1901}
{"at":"2026-10-18T05:15:03Z","model":"claude-sonnet-5-5","in":42526,"out":828,"usd":0.018624,"cached":41726,"written":798}
{"at":"2026-10-18T06:07:52Z","model":"claude-haiku-5-5","in":27464,"out":3009,"usd":0.004937,"cached":0,"written":27460}
{"at":"2026-10-18T06:08:00Z","model":"claude-haiku-5-5","in":3962,"out":1507,"usd":0.001249,"cached":0,"written":3958}
{"at":"2026-10-18T06:08:07Z","model":"claude-haiku-5-5","in":2754,"out":1220,"usd":0.000954,"cached":0,"written":2750}
{"at":"2026-10-18T06:08:18Z","model":"claude-haiku-5-5","in":6134,"out":1788,"usd":0.001661,"cached":0,"written":6130}
{"at":"2026-10-18T07:07:48Z","model":"claude-sonnet-5-5","in":2102,"out":26,"usd":0.005513,"cached":0,"written":2098}
{"at":"2026-10-18T07:07:50Z","model":"claude-sonnet-5-5","in":2678,"out":134,"usd":0.003209,"cached":2098,"written":578}
{"at":"2026-10-18T07:07:52Z","model":"claude-sonnet-5-5","in":4991,"out":231,"usd":0.008632,"cached":2676,"written":2313}
{"at":"2026-10-18T07:07:54Z","model":"claude-sonnet-5-5","in":6589,"out":54,"usd":0.005537,"cached":4989,"written":1598}
{"at":"2026-10-18T07:07:57Z","model":"claude-sonnet-5-5","in":30667,"out":112,"usd":0.062636,"cached":6587,"written":24078}
{"at":"2026-10-18T07:07:59Z","model":"claude-sonnet-5-5","in":32913,"out":88,"usd":0.012632,"cached":30665,"written":2246}
{"at":"2026-10-18T07:08:02Z","model":"claude-sonnet-5-5","in":35520,"out":226,"usd":0.015364,"cached":32911,"written":2607}
{"at":"2026-10-18T07:08:06Z","model":"claude-sonnet-5-5","in":35913,"out":352,"usd":0.01161,"cached":35518,"written":393}
{"at":"2026-10-18T07:08:19Z","model":"claude-sonnet-5-5","in":39629,"out":1610,"usd":0.032576,"cached":35911,"written":3716}
{"at":"2026-10-18T07:08:22Z","model":"claude-sonnet-5-5","in":43054,"out":65,"usd":0.017142,"cached":39627,"written":3425}
{"at":"2026-10-18T07:08:28Z","model":"claude-sonnet-5-5","in":46006,"out":620,"usd":0.022194,"cached":43052,"written":2952}
{"at":"2026-10-18T07:08:31Z","model":"claude-sonnet-5-5","in":49494,"out":267,"usd":0.020595,"cached":46004,"written":3488}
{"at":"2026-10-18T07:09:06Z","model":"claude-sonnet-5-5","in":52293,"out":5287,"usd":0.06977,"cached":49492,"written":2799}
{"at":"2026-10-18T07:09:14Z","model":"claude-sonnet-5-5","in":57662,"out":784,"usd":0.031725,"cached":52291,"written":5369}
{"at":"2026-10-18T07:09:36Z","model":"claude-sonnet-5-5","in":58993,"out":3676,"usd":0.051624,"cached":57660,"written":1331}
{"at":"2026-10-18T07:09:41Z","model":"claude-sonnet-5-5","in":63021,"out":211,"usd":0.023982,"cached":58991,"written":4028}
{"at":"2026-10-18T07:09:45Z","model":"claude-sonnet-5-5","in":63463,"out":320,"usd":0.016913,"cached":63019,"written":442}
{"at":"2026-10-18T07:09:50Z","model":"claude-sonnet-5-5","in":64216,"out":556,"usd":0.020139,"cached":63461,"written":753}
{"at":"2026-10-18T07:09:57Z","model":"claude-sonnet-5-5","in":65125,"out":577,"usd":0.020889,"cached":64214,"written":909}
{"at":"2026-10-18T07:10:07Z","model":"claude-sonnet-5-5","in":66044,"out":1237,"usd":0.027696,"cached":65123,"written":919}
{"at":"2026-10-18T07:10:30Z","model":"claude-haiku-5-5","in":11415,"out":3997,"usd":0.003425,"cached":0,"written":11411}
{"at":"2026-10-18T07:10:32Z","model":"claude-sonnet-5-5","in":2031,"out":26,"usd":0.005335,"cached":0,"written":2027}
{"at":"2026-10-18T07:10:33Z","model":"claude-sonnet-5-5","in":2607,"out":107,"usd":0.002924,"cached":2027,"written":578}
{"at":"2026-10-18T07:10:35Z","model":"claude-sonnet-5-5","in":5378,"out":111,"usd":0.008563,"cached":2605,"written":2771}
{"at":"2026-10-18T07:10:38Z","model":"claude-sonnet-5-5","in":8071,"out":255,"usd":0.010362,"cached":5376,"written":2693}
{"at":"2026-10-18T07:10:40Z","model":"claude-sonnet-5-5","in":13713,"out":206,"usd":0.017783,"cached":8069,"written":5642}
{"at":"2026-10-18T07:10:43Z","model":"claude-sonnet-5-5","in":16679,"out":202,"usd":0.012181,"cached":13711,"written":2966}
{"at":"2026-10-18T07:10:45Z","model":"claude-sonnet-5-5","in":18260,"out":238,"usd":0.009672,"cached":16677,"written":1581}
{"at":"2026-10-18T07:10:48Z","model":"claude-sonnet-5-5","in":21090,"out":334,"usd":0.014071,"cached":18258,"written":2830}
{"at":"2026-10-18T07:10:50Z","model":"claude-sonnet-5-5","in":25045,"out":118,"usd":0.015289,"cached":21088,"written":3955}
{"at":"2026-10-18T07:10:57Z","model":"claude-sonnet-5-5","in":27562,"out":831,"usd":0.019615,"cached":25043,"written":2517}
{"at":"2026-10-18T07:11:35Z","model":"claude-sonnet-5-5","in":29563,"out":4581,"usd":0.056328,"cached":27560,"written":2001}
{"at":"2026-10-18T07:11:43Z","model":"claude-sonnet-5-5","in":35660,"out":885,"usd":0.030009,"cached":29561,"written":6097}
{"at":"2026-10-18T07:11:45Z","model":"claude-sonnet-5-5","in":38941,"out":136,"usd":0.016698,"cached":35658,"written":3281}
{"at":"2026-10-18T07:11:47Z","model":"claude-sonnet-5-5","in":40960,"out":158,"usd":0.014419,"cached":38939,"written":2019}
{"at":"2026-10-18T07:12:06Z","model":"claude-sonnet-5-5","in":41760,"out":2272,"usd":0.032916,"cached":40958,"written":800}
{"at":"2026-10-18T07:13:14Z","model":"claude-sonnet-5-5","in":45408,"out":8863,"usd":0.106106,"cached":41758,"written":3648}
{"at":"2026-10-18T07:13:26Z","model":"claude-sonnet-5-5","in":56504,"out":1661,"usd":0.053435,"cached":45406,"written":11096}
{"at":"2026-10-18T07:13:30Z","model":"claude-sonnet-5-5","in":58178,"out":524,"usd":0.020729,"cached":56502,"written":1674}
{"at":"2026-10-18T07:14:02Z","model":"claude-sonnet-5-5","in":59007,"out":5643,"usd":0.070142,"cached":58176,"written":829}
{"at":"2026-10-18T07:14:15Z","model":"claude-sonnet-5-5","in":64663,"out":1786,"usd":0.043805,"cached":59005,"written":5656}
{"at":"2026-10-18T07:14:19Z","model":"claude-sonnet-5-5","in":66466,"out":332,"usd":0.020764,"cached":64661,"written":1803}
{"at":"2026-10-18T07:14:21Z","model":"claude-sonnet-5-5","in":66828,"out":139,"usd":0.015592,"cached":66464,"written":362}
{"at":"2026-10-18T07:15:20Z","model":"claude-sonnet-5-5","in":67078,"out":9812,"usd":0.112114,"cached":66826,"written":250}
{"at":"2026-10-18T07:15:24Z","model":"claude-sonnet-5-5","in":77559,"out":249,"usd":0.042112,"cached":67076,"written":10481}
{"at":"2026-10-18T07:15:28Z","model":"claude-sonnet-5-5","in":78555,"out":374,"usd":0.021745,"cached":77557,"written":996}
{"at":"2026-10-18T07:15:35Z","model":"claude-sonnet-5-5","in":79289,"out":753,"usd":0.02508,"cached":78553,"written":734}
{"at":"2026-10-18T07:15:41Z","model":"claude-sonnet-5-5","in":80393,"out":492,"usd":0.023541,"cached":79287,"written":1104}
{"at":"2026-10-18T07:15:56Z","model":"claude-sonnet-5-5","in":81057,"out":1802,"usd":0.035762,"cached":80391,"written":664}
{"at":"2026-10-18T07:16:02Z","model":"claude-sonnet-5-5","in":83122,"out":231,"usd":0.023688,"cached":81055,"written":2065}
{"at":"2026-10-18T07:16:15Z","model":"claude-sonnet-5-5","in":83513,"out":1423,"usd":0.031836,"cached":83120,"written":391}
{"at":"2026-10-18T07:17:53Z","model":"claude-haiku-5-5","in":18736,"out":4000,"usd":0.004342,"cached":0,"written":18732}
{"at":"2026-10-18T07:17:57Z","model":"claude-sonnet-5-5","in":1942,"out":26,"usd":0.005113,"cached":0,"written":1938}
{"at":"2026-10-18T07:17:59Z","model":"claude-sonnet-5-5","in":2518,"out":122,"usd":0.003057,"cached":1938,"written":578}
{"at":"2026-10-18T07:18:01Z","model":"claude-sonnet-5-5","in":5509,"out":167,"usd":0.009655,"cached":2516,"written":2991}
{"at":"2026-10-18T07:18:04Z","model":"claude-sonnet-5-5","in":32331,"out":195,"usd":0.07011,"cached":5507,"written":26822}
{"at":"2026-10-18T07:18:06Z","model":"claude-sonnet-5-5","in":35233,"out":225,"usd":0.015975,"cached":32329,"written":2902}
{"at":"2026-10-18T07:18:09Z","model":"claude-sonnet-5-5","in":39452,"out":225,"usd":0.019848,"cached":35231,"written":4219}
{"at":"2026-10-18T07:18:12Z","model":"claude-sonnet-5-5","in":42480,"out":273,"usd":0.018194,"cached":39450,"written":3028}
{"at":"2026-10-18T07:18:21Z","model":"claude-sonnet-5-5","in":46038,"out":870,"usd":0.026095,"cached":42478,"written":3558}
{"at":"2026-10-18T07:18:23Z","model":"claude-sonnet-5-5","in":47850,"out":80,"usd":0.014541,"cached":46036,"written":1812}
{"at":"2026-10-18T07:19:02Z","model":"claude-sonnet-5-5","in":50200,"out":4988,"usd":0.065329,"cached":47848,"written":2350}
{"at":"2026-10-18T07:20:32Z","model":"claude-sonnet-5-5","in":57255,"out":13912,"usd":0.166801,"cached":50198,"written":7055}
{"at":"2026-10-18T07:20:41Z","model":"claude-sonnet-5-5","in":71184,"out":1125,"usd":0.057527,"cached":57253,"written":13929}
{"at":"2026-10-18T07:20:43Z","model":"claude-sonnet-5-5","in":72331,"out":102,"usd":0.018128,"cached":71182,"written":1147}
{"at":"2026-10-18T07:21:39Z","model":"claude-sonnet-5-5","in":72445,"out":9470,"usd":0.109455,"cached":72329,"written":114}
{"at":"2026-10-18T07:21:42Z","model":"claude-sonnet-5-5","in":81934,"out":278,"usd":0.040995,"cached":72443,"written":9489}
{"at":"2026-10-18T07:21:46Z","model":"claude-sonnet-5-5","in":82653,"out":421,"usd":0.022398,"cached":81932,"written":719}
{"at":"2026-10-18T07:21:50Z","model":"claude-sonnet-5-5","in":83512,"out":307,"usd":0.021752,"cached":82651,"written":859}
{"at":"2026-10-18T07:21:54Z","model":"claude-sonnet-5-5","in":83925,"out":327,"usd":0.021008,"cached":83510,"written":413}
{"at":"2026-10-18T07:21:57Z","model":"claude-sonnet-5-5","in":84422,"out":197,"usd":0.020001,"cached":83923,"written":497}
{"at":"2026-10-18T07:22:00Z","model":"claude-sonnet-5-5","in":85294,"out":207,"usd":0.021138,"cached":84420,"written":872}
{"at":"2026-10-18T07:22:04Z","model":"claude-sonnet-5-5","in":85733,"out":472,"usd":0.02288,"cached":85292,"written":439}
{"at":"2026-10-18T07:25:06Z","model":"claude-sonnet-5-5","in":86258,"out":149,"usd":0.019953,"cached":85731,"written":525}
{"at":"2026-10-18T07:25:10Z","model":"claude-sonnet-5-5","in":86489,"out":475,"usd":0.022583,"cached":86256,"written":231}
{"at":"2026-10-18T07:25:24Z","model":"claude-sonnet-5-5","in":87081,"out":1693,"usd":0.035711,"cached":86487,"written":592}
{"at":"2026-10-18T08:07:51Z","model":"claude-haiku-5-5","in":19938,"out":2693,"usd":0.003839,"cached":0,"written":19934}
{"at":"2026-10-18T16:22:14Z","model":"claude-haiku-5-5","in":6894,"out":2083,"usd":0.001903,"cached":0,"written":6890}
{"at":"2026-10-18T16:59:58Z","model":"claude-haiku-5-5","in":25774,"out":3616,"usd":0.00503,"cached":0,"written":25770}
{"at":"2026-10-18T17:16:18Z","model":"claude-haiku-5-5","in":26253,"out":4000,"usd":0.005282,"cached":0,"written":26249}
{"at":"2026-10-18T17:18:57Z","model":"claude-haiku-5-5","in":26280,"out":2800,"usd":0.004685,"cached":0,"written":26276}
{"at":"2026-10-18T17:24:11Z","model":"claude-haiku-5-5","in":5935,"out":1557,"usd":0.00152,"cached":0,"written":5931}
{"at":"2026-10-18T17:24:26Z","model":"claude-haiku-5-5","in":28152,"out":2782,"usd":0.00491,"cached":0,"written":28148}
{"at":"2026-10-18T17:28:57Z","model":"claude-haiku-5-5","in":3464,"out":1105,"usd":0.000985,"cached":0,"written":3460}
{"at":"2026-10-18T17:28:57Z","model":"claude-sonnet-5-5","in":3466,"out":695,"usd":0.015613,"cached":0,"written":3462}
{"at":"2026-10-18T17:29:06Z","model":"claude-opus-5-5","in":3462,"out":1321,"usd":0.043726,"cached":0,"written":3458}
{"at":"2026-10-18T17:29:15Z","model":"claude-haiku-5-5","in":3704,"out":1466,"usd":0.001196,"cached":0,"written":3700}
{"at":"2026-10-18T17:29:23Z","model":"claude-sonnet-5-5","in":3706,"out":1556,"usd":0.024823,"cached":0,"written":3702}
{"at":"2026-10-18T17:29:23Z","model":"claude-opus-5-5","in":3702,"out":1388,"usd":0.046266,"cached":0,"written":3698}
{"at":"2026-10-18T17:29:28Z","model":"claude-haiku-5-5","in":5358,"out":794,"usd":0.001067,"cached":0,"written":5354}
{"at":"2026-10-18T17:29:31Z","model":"claude-sonnet-5-5","in":5360,"out":810,"usd":0.021498,"cached":0,"written":5356}
{"at":"2026-10-18T17:29:32Z","model":"claude-opus-5-5","in":5356,"out":691,"usd":0.040596,"cached":0,"written":5352}
{"at":"2026-10-18T17:29:51Z","model":"claude-haiku-5-5","in":1436,"out":2160,"usd":0.001259,"cached":0,"written":1432}
{"at":"2026-10-18T17:29:51Z","model":"claude-haiku-5-5","in":1344,"out":2206,"usd":0.001271,"cached":0,"written":1340}
{"at":"2026-10-18T17:29:52Z","model":"claude-haiku-5-5","in":1517,"out":2226,"usd":0.001303,"cached":0,"written":1513}
{"at":"2026-10-18T17:29:52Z","model":"claude-sonnet-5-5","in":1346,"out":1607,"usd":0.019433,"cached":0,"written":1342}
{"at":"2026-10-18T17:29:52Z","model":"claude-sonnet-5-5","in":1438,"out":1539,"usd":0.018983,"cached":0,"written":1434}
{"at":"2026-10-18T17:29:53Z","model":"claude-sonnet-5-5","in":1519,"out":1616,"usd":0.019956,"cached":0,"written":1515}
{"at":"2026-10-18T17:30:01Z","model":"claude-opus-5-5","in":1342,"out":2107,"usd":0.048846,"cached":0,"written":1338}
{"at":"2026-10-18T17:30:03Z","model":"claude-opus-5-5","in":1434,"out":2124,"usd":0.049646,"cached":0,"written":1430}
{"at":"2026-10-18T17:30:06Z","model":"claude-opus-5-5","in":1515,"out":2334,"usd":0.054251,"cached":0,"written":1511}
{"at":"2026-10-18T17:30:32Z","model":"claude-fable-5-1","in":5312,"out":3074,"usd":0.22009,"cached":0,"written":5308}
{"at":"2026-10-18T17:30:33Z","model":"claude-fable-5-1","in":5134,"out":3028,"usd":0.215565,"cached":0,"written":5130}
{"at":"2026-10-18T17:30:37Z","model":"claude-fable-5-1","in":5368,"out":3023,"usd":0.21824,"cached":0,"written":5364}
{"at":"2026-10-18T17:30:50Z","model":"claude-sonnet-5-5","in":4715,"out":115,"usd":0.012936,"cached":0,"written":4711}
{"at":"2026-10-18T17:30:50Z","model":"claude-sonnet-5-5","in":4702,"out":56,"usd":0.012313,"cached":0,"written":4698}
{"at":"2026-10-18T17:30:51Z","model":"claude-sonnet-5-5","in":4753,"out":117,"usd":0.01305,"cached":0,"written":4749}
{"at":"2026-10-18T17:30:52Z","model":"claude-sonnet-5-5","in":4781,"out":87,"usd":0.002016,"cached":4698,"written":81}
{"at":"2026-10-18T17:31:55Z","model":"claude-sonnet-5-5","in":12802,"out":6489,"usd":0.085971,"cached":4749,"written":8051}
{"at":"2026-10-18T17:33:09Z","model":"claude-sonnet-5-5","in":20397,"out":16000,"usd":0.2,"cached":4779,"written":15616}
{"at":"2026-10-18T17:33:16Z","model":"claude-sonnet-5-5","in":12473,"out":16000,"usd":0.180346,"cached":4711,"written":7760}
{"at":"2026-10-18T17:33:53Z","model":"claude-sonnet-5-5","in":19907,"out":16000,"usd":0.180327,"cached":12800,"written":7105}
{"at":"2026-10-18T17:35:22Z","model":"claude-sonnet-5-5","in":20431,"out":16000,"usd":0.164167,"cached":20395,"written":32}
{"at":"2026-10-18T17:35:39Z","model":"claude-sonnet-5-5","in":12507,"out":16000,"usd":0.162582,"cached":12471,"written":32}
{"at":"2026-10-18T17:38:06Z","model":"claude-sonnet-5-5","in":12538,"out":16000,"usd":0.162586,"cached":12503,"written":31}
{"at":"2026-10-18T17:40:30Z","model":"claude-sonnet-5-5","in":12569,"out":16000,"usd":0.162592,"cached":12534,"written":31}
{"at":"2026-10-18T17:42:58Z","model":"claude-sonnet-5-5","in":12600,"out":16000,"usd":0.162599,"cached":12565,"written":31}
{"at":"2026-10-18T17:45:09Z","model":"claude-sonnet-5-5","in":12631,"out":16000,"usd":0.162605,"cached":12596,"written":31}
{"at":"2026-10-18T17:45:19Z","model":"claude-haiku-5-5","in":3655,"out":1304,"usd":0.001109,"cached":0,"written":3651}
{"at":"2026-10-18T17:45:27Z","model":"claude-sonnet-5-5","in":3657,"out":1612,"usd":0.025261,"cached":0,"written":3653}
{"at":"2026-10-18T17:45:29Z","model":"claude-opus-5-5","in":3653,"out":1463,"usd":0.047521,"cached":0,"written":3649}
{"at":"2026-10-18T17:45:34Z","model":"claude-haiku-5-5","in":3940,"out":736,"usd":0.00086,"cached":0,"written":3936}
{"at":"2026-10-18T17:45:34Z","model":"claude-sonnet-5-5","in":3942,"out":482,"usd":0.014673,"cached":0,"written":3938}
{"at":"2026-10-18T17:45:38Z","model":"claude-opus-5-5","in":3938,"out":699,"usd":0.033666,"cached":0,"written":3934}
{"at":"2026-10-18T17:45:50Z","model":"claude-haiku-5-5","in":1358,"out":1937,"usd":0.001138,"cached":0,"written":1354}
{"at":"2026-10-18T17:45:53Z","model":"claude-sonnet-5-5","in":1360,"out":1766,"usd":0.021058,"cached":0,"written":1356}
{"at":"2026-10-18T17:46:04Z","model":"claude-opus-5-5","in":1356,"out":2268,"usd":0.052136,"cached":0,"written":1352}
{"at":"2026-10-18T17:46:40Z","model":"claude-fable-5-1","in":5273,"out":3618,"usd":0.246803,"cached":0,"written":5269}
{"at":"2026-10-18T17:47:28Z","model":"claude-sonnet-5-5","in":3898,"out":157,"usd":0.011313,"cached":0,"written":3894}
{"at":"2026-10-18T17:47:30Z","model":"claude-sonnet-5-5","in":6150,"out":147,"usd":0.007888,"cached":3894,"written":2254}
{"at":"2026-10-18T17:47:32Z","model":"claude-sonnet-5-5","in":9134,"out":190,"usd":0.010594,"cached":6148,"written":2984}
{"at":"2026-10-18T17:47:34Z","model":"claude-sonnet-5-5","in":12173,"out":123,"usd":0.010658,"cached":9132,"written":3039}
{"at":"2026-10-18T17:47:39Z","model":"claude-sonnet-5-5","in":14318,"out":474,"usd":0.012541,"cached":12171,"written":2145}
{"at":"2026-10-18T17:47:43Z","model":"claude-sonnet-5-5","in":15198,"out":377,"usd":0.008837,"cached":14316,"written":880}
{"at":"2026-10-18T17:47:58Z","model":"claude-sonnet-5-5","in":18227,"out":2417,"usd":0.034786,"cached":15196,"written":3029}
{"at":"2026-10-18T17:48:03Z","model":"claude-sonnet-5-5","in":20854,"out":226,"usd":0.012476,"cached":18225,"written":2627}
{"at":"2026-10-18T17:48:11Z","model":"claude-sonnet-5-5","in":21120,"out":515,"usd":0.009989,"cached":20852,"written":266}
{"at":"2026-10-18T17:48:14Z","model":"claude-sonnet-5-5","in":3624,"out":113,"usd":0.010188,"cached":0,"written":3620}
{"at":"2026-10-18T17:48:17Z","model":"claude-sonnet-5-5","in":4223,"out":255,"usd":0.004781,"cached":3620,"written":601}
{"at":"2026-10-18T17:48:19Z","model":"claude-sonnet-5-5","in":9732,"out":178,"usd":0.016401,"cached":4221,"written":5509}
{"at":"2026-10-18T17:48:21Z","model":"claude-sonnet-5-5","in":12453,"out":182,"usd":0.010573,"cached":9730,"written":2721}
{"at":"2026-10-18T17:48:25Z","model":"claude-sonnet-5-5","in":13689,"out":433,"usd":0.009914,"cached":12451,"written":1236}
{"at":"2026-10-18T17:48:31Z","model":"claude-sonnet-5-5","in":14354,"out":699,"usd":0.011394,"cached":13687,"written":665}
{"at":"2026-10-18T17:48:35Z","model":"claude-sonnet-5-5","in":15348,"out":158,"usd":0.006939,"cached":14352,"written":994}
{"at":"2026-10-18T17:48:37Z","model":"claude-sonnet-5-5","in":15777,"out":200,"usd":0.006146,"cached":15346,"written":429}
{"at":"2026-10-18T17:48:43Z","model":"claude-sonnet-5-5","in":16079,"out":458,"usd":0.008494,"cached":15775,"written":302}
{"at":"2026-10-18T17:48:46Z","model":"claude-sonnet-5-5","in":2797,"out":74,"usd":0.007731,"cached":0,"written":2793}
{"at":"2026-10-18T17:48:48Z","model":"claude-sonnet-5-5","in":3862,"out":159,"usd":0.00482,"cached":2793,"written":1067}
{"at":"2026-10-18T17:48:50Z","model":"claude-sonnet-5-5","in":56853,"out":270,"usd":0.135954,"cached":3860,"written":52991}
{"at":"2026-10-18T17:48:53Z","model":"claude-sonnet-5-5","in":59350,"out":220,"usd":0.019817,"cached":56851,"written":2497}
{"at":"2026-10-18T17:48:56Z","model":"claude-sonnet-5-5","in":60989,"out":350,"usd":0.019471,"cached":59348,"written":1639}
{"at":"2026-10-18T17:48:59Z","model":"claude-sonnet-5-5","in":64052,"out":295,"usd":0.022809,"cached":60987,"written":3063}
{"at":"2026-10-18T17:50:07Z","model":"claude-sonnet-5-5","in":67704,"out":8473,"usd":0.106674,"cached":64050,"written":3652}
{"at":"2026-10-18T17:51:54Z","model":"claude-sonnet-5-5","in":77348,"out":16000,"usd":0.197654,"cached":67702,"written":9644}
{"at":"2026-10-18T17:52:01Z","model":"claude-haiku-5-5","in":3928,"out":1374,"usd":0.001178,"cached":0,"written":3924}
{"at":"2026-10-18T17:52:20Z","model":"claude-haiku-5-5","in":18053,"out":2551,"usd":0.003532,"cached":0,"written":18049}
{"at":"2026-10-18T17:52:35Z","model":"claude-haiku-5-5","in":19250,"out":2751,"usd":0.003782,"cached":0,"written":19246}
{"at":"2026-10-18T17:56:25Z","model":"claude-sonnet-5-5","in":4715,"out":115,"usd":0.012936,"cached":0,"written":4711}
{"at":"2026-10-18T17:56:25Z","model":"claude-sonnet-5-5","in":4702,"out":117,"usd":0.012923,"cached":0,"written":4698}
{"at":"2026-10-18T17:56:26Z","model":"claude-sonnet-5-5","in":4753,"out":117,"usd":0.01305,"cached":0,"written":4749}
{"at":"2026-10-18T17:58:35Z","model":"claude-sonnet-5-5","in":12802,"out":16000,"usd":0.181081,"cached":4749,"written":8051}
{"at":"2026-10-18T17:58:43Z","model":"claude-sonnet-5-5","in":12673,"out":16000,"usd":0.180876,"cached":4698,"written":7973}
{"at":"2026-10-18T17:58:50Z","model":"claude-sonnet-5-5","in":12473,"out":16000,"usd":0.180346,"cached":4711,"written":7760}
{"at":"2026-10-18T17:59:03Z","model":"claude-sonnet-5-5","in":27194,"out":5609,"usd":0.094634,"cached":12800,"written":14392}
{"at":"2026-10-18T17:59:24Z","model":"claude-sonnet-5-5","in":32824,"out":3554,"usd":0.055057,"cached":27192,"written":5630}
{"at":"2026-10-18T17:59:42Z","model":"claude-sonnet-5-5","in":36399,"out":2641,"usd":0.041916,"cached":32822,"written":3575}
{"at":"2026-10-18T17:59:48Z","model":"claude-sonnet-5-5","in":39128,"out":281,"usd":0.016916,"cached":36397,"written":2729}
{"at":"2026-10-18T18:00:03Z","model":"claude-sonnet-5-5","in":39447,"out":1772,"usd":0.026347,"cached":39126,"written":319}
{"at":"2026-10-18T18:00:09Z","model":"claude-sonnet-5-5","in":41240,"out":488,"usd":0.017256,"cached":39445,"written":1793}
{"at":"2026-10-18T18:00:52Z","model":"claude-sonnet-5-5","in":12707,"out":16000,"usd":0.162622,"cached":12671,"written":32}
{"at":"2026-10-18T18:01:12Z","model":"claude-sonnet-5-5","in":12507,"out":16000,"usd":0.162582,"cached":12471,"written":32}
{"at":"2026-10-18T18:01:19Z","model":"claude-sonnet-5-5","in":26655,"out":5206,"usd":0.08948,"cached":12703,"written":13950}
{"at":"2026-10-18T18:01:25Z","model":"claude-sonnet-5-5","in":31882,"out":411,"usd":0.022512,"cached":26653,"written":5227}
{"at":"2026-10-18T18:01:29Z","model":"claude-sonnet-5-5","in":32580,"out":68,"usd":0.008805,"cached":31880,"written":698}
{"at":"2026-10-18T18:02:02Z","model":"claude-sonnet-5-5","in":32887,"out":5499,"usd":0.062277,"cached":32578,"written":307}
{"at":"2026-10-18T18:02:09Z","model":"claude-sonnet-5-5","in":38474,"out":243,"usd":0.022978,"cached":32885,"written":5587}
{"at":"2026-10-18T18:02:17Z","model":"claude-sonnet-5-5","in":38751,"out":815,"usd":0.016541,"cached":38472,"written":277}
{"at":"2026-10-18T18:03:43Z","model":"claude-sonnet-5-5","in":12538,"out":16000,"usd":0.162586,"cached":12503,"written":31}
{"at":"2026-10-18T18:06:23Z","model":"claude-sonnet-5-5","in":12569,"out":16000,"usd":0.162592,"cached":12534,"written":31}
{"at":"2026-10-18T18:08:46Z","model":"claude-sonnet-5-5","in":12600,"out":16000,"usd":0.162599,"cached":12565,"written":31}
{"at":"2026-10-18T18:11:10Z","model":"claude-sonnet-5-5","in":12631,"out":16000,"usd":0.162605,"cached":12596,"written":31}
{"at":"2026-10-18T18:13:26Z","model":"claude-sonnet-5-5","in":12662,"out":16000,"usd":0.162611,"cached":12627,"written":31}
{"at":"2026-10-18T18:14:06Z","model":"claude-sonnet-5-5","in":26206,"out":6973,"usd":0.106131,"cached":12658,"written":13546}
{"at":"2026-10-18T18:14:12Z","model":"claude-sonnet-5-5","in":33199,"out":386,"usd":0.026587,"cached":26204,"written":6993}
{"at":"2026-10-18T18:14:46Z","model":"claude-sonnet-5-5","in":33687,"out":5118,"usd":0.059043,"cached":33197,"written":488}
{"at":"2026-10-18T18:14:57Z","model":"claude-sonnet-5-5","in":38931,"out":639,"usd":0.026241,"cached":33685,"written":5244}
{"at":"2026-10-18T18:15:03Z","model":"claude-sonnet-5-5","in":39820,"out":272,"usd":0.012732,"cached":38929,"written":889}
{"at":"2026-10-18T18:15:11Z","model":"claude-sonnet-5-5","in":40156,"out":543,"usd":0.014238,"cached":39818,"written":336}
{"at":"2026-10-18T18:15:32Z","model":"claude-fable-5-1","in":9025,"out":1490,"usd":0.187303,"cached":0,"written":9021}
{"at":"2026-10-18T18:16:02Z","model":"claude-fable-5-1","in":2797,"out":2431,"usd":0.156502,"cached":0,"written":2793}
{"at":"2026-10-18T18:16:21Z","model":"claude-fable-5-1","in":7843,"out":1231,"usd":0.159578,"cached":0,"written":7839}
{"at":"2026-10-18T18:16:49Z","model":"claude-fable-5-1","in":2893,"out":2350,"usd":0.153652,"cached":0,"written":2889}
{"at":"2026-10-18T18:17:08Z","model":"claude-fable-5-1","in":7342,"out":1165,"usd":0.150015,"cached":0,"written":7338}
{"at":"2026-10-18T18:17:43Z","model":"claude-fable-5-1","in":2801,"out":3047,"usd":0.187353,"cached":0,"written":2797}
{"at":"2026-10-18T18:27:48Z","model":"claude-sonnet-5-5","in":4720,"out":117,"usd":0.012968,"cached":0,"written":4716}
{"at":"2026-10-18T18:29:47Z","model":"claude-sonnet-5-5","in":12576,"out":13837,"usd":0.158962,"cached":4716,"written":7858}
{"at":"2026-10-18T18:30:23Z","model":"claude-sonnet-5-5","in":26501,"out":6400,"usd":0.101331,"cached":12574,"written":13925}
{"at":"2026-10-18T18:30:28Z","model":"claude-sonnet-5-5","in":32922,"out":496,"usd":0.026316,"cached":26499,"written":6421}
{"at":"2026-10-18T18:30:39Z","model":"claude-sonnet-5-5","in":33846,"out":1162,"usd":0.020518,"cached":32920,"written":924}
{"at":"2026-10-18T18:31:10Z","model":"claude-sonnet-5-5","in":35052,"out":4961,"usd":0.059398,"cached":33844,"written":1206}
{"at":"2026-10-18T18:31:19Z","model":"claude-sonnet-5-5","in":40142,"out":407,"usd":0.023809,"cached":35050,"written":5090}
{"at":"2026-10-18T18:31:26Z","model":"claude-sonnet-5-5","in":40580,"out":415,"usd":0.013277,"cached":40140,"written":438}
{"at":"2026-10-18T18:31:56Z","model":"claude-fable-5-1","in":8181,"out":2179,"usd":0.211202,"cached":0,"written":8177}
{"at":"2026-10-18T18:32:17Z","model":"claude-fable-5-1","in":2529,"out":1863,"usd":0.124753,"cached":0,"written":2525}
{"at":"2026-10-19T00:07:41Z","model":"claude-haiku-5-5","in":3186,"out":1139,"usd":0.000968,"cached":0,"written":3182}
{"at":"2026-10-19T00:07:49Z","model":"claude-sonnet-5-5","in":2797,"out":26,"usd":0.007251,"cached":0,"written":2793}
{"at":"2026-10-19T00:07:51Z","model":"claude-sonnet-5-5","in":3426,"out":185,"usd":0.00399,"cached":2793,"written":631}
{"at":"2026-10-19T00:07:53Z","model":"claude-sonnet-5-5","in":5963,"out":149,"usd":0.008521,"cached":3424,"written":2537}
{"at":"2026-10-19T00:07:54Z","model":"claude-sonnet-5-5","in":8111,"out":54,"usd":0.007106,"cached":5961,"written":2148}
{"at":"2026-10-19T00:07:56Z","model":"claude-sonnet-5-5","in":32214,"out":297,"usd":0.064853,"cached":8109,"written":24103}
{"at":"2026-10-19T00:08:00Z","model":"claude-sonnet-5-5","in":38235,"out":440,"usd":0.025899,"cached":32212,"written":6021}
{"at":"2026-10-19T00:08:54Z","model":"claude-sonnet-5-5","in":44089,"out":6614,"usd":0.088426,"cached":38233,"written":5854}
{"at":"2026-10-19T00:09:22Z","model":"claude-sonnet-5-5","in":51729,"out":3663,"usd":0.064551,"cached":44087,"written":7640}
{"at":"2026-10-19T00:09:24Z","model":"claude-sonnet-5-5","in":57900,"out":192,"usd":0.027697,"cached":51727,"written":6171}
{"at":"2026-10-19T00:09:27Z","model":"claude-sonnet-5-5","in":58826,"out":194,"usd":0.015839,"cached":57898,"written":926}
{"at":"2026-10-19T00:09:29Z","model":"claude-sonnet-5-5","in":59476,"out":90,"usd":0.014294,"cached":58824,"written":650}
{"at":"2026-10-19T00:09:50Z","model":"claude-sonnet-5-5","in":60206,"out":3811,"usd":0.051834,"cached":59474,"written":730}
{"at":"2026-10-19T00:09:55Z","model":"claude-sonnet-5-5","in":64135,"out":331,"usd":0.025177,"cached":60204,"written":3929}
{"at":"2026-10-19T00:10:08Z","model":"claude-sonnet-5-5","in":64969,"out":2141,"usd":0.036326,"cached":64133,"written":834}
{"at":"2026-10-19T00:10:13Z","model":"claude-sonnet-5-5","in":67509,"out":545,"usd":0.024797,"cached":64967,"written":2540}
{"at":"2026-10-19T00:10:18Z","model":"claude-sonnet-5-5","in":68242,"out":351,"usd":0.018848,"cached":67507,"written":733}
{"at":"2026-10-19T00:10:24Z","model":"claude-sonnet-5-5","in":69045,"out":959,"usd":0.02525,"cached":68240,"written":803}
{"at":"2026-10-19T00:10:27Z","model":"claude-sonnet-5-5","in":70105,"out":132,"usd":0.017783,"cached":69043,"written":1060}
{"at":"2026-10-19T00:10:35Z","model":"claude-sonnet-5-5","in":70355,"out":1101,"usd":0.02566,"cached":70103,"written":250}
{"at":"2026-10-19T01:02:00Z","model":"claude-opus-5-5","in":20032,"out":3957,"usd":0.179296,"cached":0,"written":20028}
{"at":"2026-10-19T01:07:53Z","model":"claude-haiku-5-5","in":11408,"out":3520,"usd":0.003186,"cached":0,"written":11404}
{"at":"2026-10-19T02:07:39Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3307,"out":26,"usd":0.008526,"cached":0,"written":3303}
{"at":"2026-10-19T02:07:42Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3958,"out":216,"usd":0.004457,"cached":3303,"written":653}
{"at":"2026-10-19T02:07:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":6749,"out":113,"usd":0.008903,"cached":3956,"written":2791}
{"at":"2026-10-19T02:07:46Z","model":"claude-sonnet-5-5","pool":"anthropic","in":9380,"out":142,"usd":0.009351,"cached":6747,"written":2631}
{"at":"2026-10-19T02:07:48Z","model":"claude-sonnet-5-5","pool":"anthropic","in":11942,"out":198,"usd":0.010265,"cached":9378,"written":2562}
{"at":"2026-10-19T02:07:51Z","model":"claude-sonnet-5-5","pool":"anthropic","in":16307,"out":269,"usd":0.015994,"cached":11940,"written":4365}
{"at":"2026-10-19T02:07:55Z","model":"claude-sonnet-5-5","pool":"anthropic","in":19036,"out":342,"usd":0.013508,"cached":16305,"written":2729}
{"at":"2026-10-19T02:07:59Z","model":"claude-sonnet-5-5","pool":"anthropic","in":20445,"out":373,"usd":0.011063,"cached":19034,"written":1409}
{"at":"2026-10-19T02:08:12Z","model":"claude-sonnet-5-5","pool":"anthropic","in":25425,"out":1330,"usd":0.029843,"cached":20443,"written":4980}
{"at":"2026-10-19T02:08:21Z","model":"claude-sonnet-5-5","pool":"anthropic","in":29260,"out":903,"usd":0.023706,"cached":25423,"written":3835}
{"at":"2026-10-19T02:08:31Z","model":"claude-sonnet-5-5","pool":"anthropic","in":32952,"out":1146,"usd":0.026546,"cached":29258,"written":3692}
{"at":"2026-10-19T02:09:08Z","model":"claude-sonnet-5-5","pool":"anthropic","in":35705,"out":4364,"usd":0.057117,"cached":32950,"written":2753}
{"at":"2026-10-19T02:09:39Z","model":"claude-sonnet-5-5","pool":"anthropic","in":44491,"out":4047,"usd":0.06958,"cached":35703,"written":8786}
{"at":"2026-10-19T02:09:48Z","model":"claude-sonnet-5-5","pool":"anthropic","in":51032,"out":1030,"usd":0.035554,"cached":44489,"written":6541}
{"at":"2026-10-19T02:10:15Z","model":"claude-sonnet-5-5","pool":"anthropic","in":53192,"out":3055,"usd":0.04616,"cached":51030,"written":2160}
{"at":"2026-10-19T02:10:22Z","model":"claude-sonnet-5-5","pool":"anthropic","in":57353,"out":661,"usd":0.027654,"cached":53190,"written":4161}
{"at":"2026-10-19T02:12:23Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59287,"out":16000,"usd":0.176309,"cached":57351,"written":1934}
{"at":"2026-10-19T02:14:24Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59321,"out":16000,"usd":0.171945,"cached":59285,"written":32}
{"at":"2026-10-19T02:16:25Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59352,"out":16000,"usd":0.171949,"cached":59317,"written":31}
{"at":"2026-10-19T02:18:26Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59383,"out":16000,"usd":0.171955,"cached":59348,"written":31}
{"at":"2026-10-19T02:18:55Z","model":"claude-sonnet-5-5","pool":"anthropic","in":74848,"out":5475,"usd":0.105297,"cached":59379,"written":15467}
{"at":"2026-10-19T02:19:16Z","model":"claude-sonnet-5-5","pool":"anthropic","in":80339,"out":3413,"usd":0.062831,"cached":74846,"written":5491}
{"at":"2026-10-19T02:19:21Z","model":"claude-sonnet-5-5","pool":"anthropic","in":83767,"out":639,"usd":0.031031,"cached":80337,"written":3428}
{"at":"2026-10-19T02:19:36Z","model":"claude-sonnet-5-5","pool":"anthropic","in":84477,"out":2605,"usd":0.044582,"cached":83765,"written":710}
{"at":"2026-10-19T02:19:54Z","model":"claude-sonnet-5-5","pool":"anthropic","in":87278,"out":2510,"usd":0.049002,"cached":84475,"written":2801}
{"at":"2026-10-19T02:20:05Z","model":"claude-sonnet-5-5","pool":"anthropic","in":89994,"out":1337,"usd":0.037619,"cached":87276,"written":2716}
{"at":"2026-10-19T02:20:27Z","model":"claude-sonnet-5-5","pool":"anthropic","in":91361,"out":3561,"usd":0.05703,"cached":89992,"written":1367}
{"at":"2026-10-19T02:20:32Z","model":"claude-sonnet-5-5","pool":"anthropic","in":95072,"out":270,"usd":0.030253,"cached":91359,"written":3711}
{"at":"2026-10-19T02:20:36Z","model":"claude-sonnet-5-5","pool":"anthropic","in":95806,"out":383,"usd":0.024683,"cached":95070,"written":734}
{"at":"2026-10-19T02:20:43Z","model":"claude-sonnet-5-5","pool":"anthropic","in":96226,"out":830,"usd":0.028515,"cached":95804,"written":420}
{"at":"2026-10-19T02:20:57Z","model":"claude-sonnet-5-5","pool":"anthropic","in":97520,"out":1916,"usd":0.041644,"cached":96224,"written":1294}
{"at":"2026-10-19T02:21:04Z","model":"claude-sonnet-5-5","pool":"anthropic","in":99523,"out":772,"usd":0.032235,"cached":97518,"written":2003}
{"at":"2026-10-19T02:21:11Z","model":"claude-sonnet-5-5","pool":"anthropic","in":100425,"out":763,"usd":0.029793,"cached":99521,"written":902}
{"at":"2026-10-19T02:21:16Z","model":"claude-sonnet-5-5","pool":"anthropic","in":101367,"out":201,"usd":0.024454,"cached":100423,"written":942}
{"at":"2026-10-19T02:21:28Z","model":"claude-sonnet-5-5","pool":"anthropic","in":101729,"out":1382,"usd":0.035002,"cached":101365,"written":362}
{"at":"2026-10-19T03:07:58Z","model":"claude-haiku-5-5","pool":"anthropic","in":22586,"out":4000,"usd":0.004823,"cached":0,"written":22582}
{"at":"2026-10-19T13:14:08Z","model":"claude-opus-5-5","pool":"anthropic","in":19523,"out":3284,"usd":0.163291,"cached":0,"written":19519}
{"at":"2026-10-19T13:50:27Z","model":"claude-opus-5-5","pool":"anthropic","in":20861,"out":3664,"usd":0.177581,"cached":0,"written":20857}
{"at":"2026-10-19T13:55:57Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2491,"out":55,"usd":0.006776,"cached":0,"written":2487}
{"at":"2026-10-19T13:55:59Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2791,"out":245,"usd":0.003706,"cached":2487,"written":302}
{"at":"2026-10-19T13:56:09Z","model":"claude-sonnet-5-5","pool":"anthropic","in":8271,"out":1349,"usd":0.027752,"cached":2789,"written":5480}
{"at":"2026-10-19T13:56:17Z","model":"claude-sonnet-5-5","pool":"anthropic","in":10007,"out":1249,"usd":0.018488,"cached":8269,"written":1736}
{"at":"2026-10-19T13:56:24Z","model":"claude-sonnet-5-5","pool":"anthropic","in":11489,"out":537,"usd":0.01108,"cached":10005,"written":1482}
{"at":"2026-10-19T13:56:34Z","model":"claude-haiku-5-5","pool":"anthropic","in":3137,"out":1198,"usd":0.000991,"cached":0,"written":3133}
{"at":"2026-10-19T13:56:38Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2663,"out":107,"usd":0.007725,"cached":0,"written":2659}
{"at":"2026-10-19T13:56:40Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3184,"out":346,"usd":0.005303,"cached":2659,"written":523}
{"at":"2026-10-19T13:56:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":17587,"out":335,"usd":0.039998,"cached":3182,"written":14403}
{"at":"2026-10-19T13:57:02Z","model":"claude-sonnet-5-5","pool":"anthropic","in":18166,"out":2583,"usd":0.030798,"cached":17585,"written":579}
{"at":"2026-10-19T13:57:10Z","model":"claude-sonnet-5-5","pool":"anthropic","in":20888,"out":313,"usd":0.013572,"cached":18164,"written":2722}
{"at":"2026-10-19T13:57:23Z","model":"claude-sonnet-5-5","pool":"anthropic","in":21299,"out":2319,"usd":0.028399,"cached":20886,"written":411}
{"at":"2026-10-19T13:57:29Z","model":"claude-sonnet-5-5","pool":"anthropic","in":23638,"out":654,"usd":0.016651,"cached":21297,"written":2339}
{"at":"2026-10-19T13:57:36Z","model":"claude-sonnet-5-5","pool":"anthropic","in":24659,"out":597,"usd":0.013254,"cached":23636,"written":1021}
{"at":"2026-10-19T13:57:43Z","model":"claude-sonnet-5-5","pool":"anthropic","in":25290,"out":800,"usd":0.014513,"cached":24657,"written":631}
{"at":"2026-10-19T14:07:48Z","model":"claude-haiku-5-5","pool":"anthropic","in":4307,"out":1785,"usd":0.001431,"cached":0,"written":4303}
{"at":"2026-10-19T14:07:52Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2655,"out":106,"usd":0.007696,"cached":0,"written":2651}
{"at":"2026-10-19T14:07:55Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3167,"out":343,"usd":0.005249,"cached":2651,"written":514}
{"at":"2026-10-19T14:07:58Z","model":"claude-sonnet-5-5","pool":"anthropic","in":15237,"out":214,"usd":0.032952,"cached":3165,"written":12070}
{"at":"2026-10-19T14:08:21Z","model":"claude-sonnet-5-5","pool":"anthropic","in":16704,"out":4321,"usd":0.049929,"cached":15235,"written":1467}
{"at":"2026-10-19T14:08:33Z","model":"claude-sonnet-5-5","pool":"anthropic","in":21200,"out":896,"usd":0.023544,"cached":16702,"written":4496}
{"at":"2026-10-19T14:08:41Z","model":"claude-sonnet-5-5","pool":"anthropic","in":22258,"out":769,"usd":0.014579,"cached":21198,"written":1058}
{"at":"2026-10-19T14:08:57Z","model":"claude-haiku-5-5","pool":"anthropic","in":3820,"out":1520,"usd":0.001237,"cached":0,"written":3816}
{"at":"2026-10-19T14:09:00Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2671,"out":108,"usd":0.007756,"cached":0,"written":2667}
{"at":"2026-10-19T14:09:02Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3201,"out":293,"usd":0.004797,"cached":2667,"written":532}
{"at":"2026-10-19T14:09:06Z","model":"claude-sonnet-5-5","pool":"anthropic","in":20395,"out":273,"usd":0.046359,"cached":3199,"written":17194}
{"at":"2026-10-19T14:09:08Z","model":"claude-sonnet-5-5","pool":"anthropic","in":22932,"out":176,"usd":0.012185,"cached":20393,"written":2537}
{"at":"2026-10-19T14:09:32Z","model":"claude-sonnet-5-5","pool":"anthropic","in":23259,"out":4379,"usd":0.049197,"cached":22930,"written":327}
{"at":"2026-10-19T14:09:47Z","model":"claude-sonnet-5-5","pool":"anthropic","in":27821,"out":1239,"usd":0.02845,"cached":23257,"written":4562}
{"at":"2026-10-19T14:09:51Z","model":"claude-sonnet-5-5","pool":"anthropic","in":29557,"out":103,"usd":0.010938,"cached":27819,"written":1736}
{"at":"2026-10-19T14:10:00Z","model":"claude-sonnet-5-5","pool":"anthropic","in":29719,"out":1236,"usd":0.01868,"cached":29555,"written":162}
{"at":"2026-10-19T14:10:15Z","model":"claude-haiku-5-5","pool":"anthropic","in":4902,"out":1889,"usd":0.001557,"cached":0,"written":4898}
{"at":"2026-10-19T14:10:18Z","model":"claude-sonnet-5-5","pool":"anthropic","in":2610,"out":26,"usd":0.006783,"cached":0,"written":2606}
{"at":"2026-10-19T14:10:20Z","model":"claude-sonnet-5-5","pool":"anthropic","in":3291,"out":99,"usd":0.003223,"cached":2606,"written":683}
{"at":"2026-10-19T14:10:23Z","model":"claude-sonnet-5-5","pool":"anthropic","in":5566,"out":113,"usd":0.007479,"cached":3289,"written":2275}
{"at":"2026-10-19T14:10:25Z","model":"claude-sonnet-5-5","pool":"anthropic","in":8329,"out":141,"usd":0.009434,"cached":5564,"written":2763}
{"at":"2026-10-19T14:10:27Z","model":"claude-sonnet-5-5","pool":"anthropic","in":13035,"out":71,"usd":0.014144,"cached":8327,"written":4706}
{"at":"2026-10-19T14:10:31Z","model":"claude-sonnet-5-5","pool":"anthropic","in":15314,"out":239,"usd":0.010698,"cached":13033,"written":2279}
{"at":"2026-10-19T14:10:33Z","model":"claude-sonnet-5-5","pool":"anthropic","in":17437,"out":132,"usd":0.009694,"cached":15312,"written":2123}
{"at":"2026-10-19T14:10:37Z","model":"claude-sonnet-5-5","pool":"anthropic","in":20075,"out":251,"usd":0.012596,"cached":17435,"written":2638}
{"at":"2026-10-19T14:10:39Z","model":"claude-sonnet-5-5","pool":"anthropic","in":26184,"out":145,"usd":0.020741,"cached":20073,"written":6109}
{"at":"2026-10-19T14:10:42Z","model":"claude-sonnet-5-5","pool":"anthropic","in":29116,"out":178,"usd":0.01435,"cached":26182,"written":2932}
{"at":"2026-10-19T14:10:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":31437,"out":96,"usd":0.012589,"cached":29114,"written":2321}
{"at":"2026-10-19T14:10:56Z","model":"claude-sonnet-5-5","pool":"anthropic","in":34243,"out":1284,"usd":0.026146,"cached":31435,"written":2806}
{"at":"2026-10-19T14:11:12Z","model":"claude-sonnet-5-5","pool":"anthropic","in":38005,"out":1687,"usd":0.033127,"cached":34241,"written":3762}
{"at":"2026-10-19T14:11:36Z","model":"claude-sonnet-5-5","pool":"anthropic","in":41977,"out":2695,"usd":0.044485,"cached":38003,"written":3972}
{"at":"2026-10-19T14:11:41Z","model":"claude-sonnet-5-5","pool":"anthropic","in":45466,"out":362,"usd":0.020741,"cached":41975,"written":3489}
{"at":"2026-10-19T14:11:43Z","model":"claude-sonnet-5-5","pool":"anthropic","in":45901,"out":73,"usd":0.010914,"cached":45464,"written":435}
{"at":"2026-10-19T14:11:58Z","model":"claude-sonnet-5-5","pool":"anthropic","in":46573,"out":1467,"usd":0.025534,"cached":45899,"written":672}
{"at":"2026-10-19T14:12:14Z","model":"claude-sonnet-5-5","pool":"anthropic","in":49871,"out":2618,"usd":0.043743,"cached":46571,"written":3298}
{"at":"2026-10-19T14:12:22Z","model":"claude-sonnet-5-5","pool":"anthropic","in":52577,"out":581,"usd":0.022553,"cached":49869,"written":2706}
{"at":"2026-10-19T14:12:30Z","model":"claude-sonnet-5-5","pool":"anthropic","in":53932,"out":848,"usd":0.022387,"cached":52575,"written":1355}
{"at":"2026-10-19T14:12:32Z","model":"claude-sonnet-5-5","pool":"anthropic","in":55527,"out":144,"usd":0.016217,"cached":53930,"written":1595}
{"at":"2026-10-19T14:12:50Z","model":"claude-sonnet-5-5","pool":"anthropic","in":56533,"out":2848,"usd":0.042104,"cached":55525,"written":1006}
{"at":"2026-10-19T14:12:55Z","model":"claude-sonnet-5-5","pool":"anthropic","in":59692,"out":179,"usd":0.020998,"cached":56531,"written":3159}
{"at":"2026-10-19T14:12:59Z","model":"claude-sonnet-5-5","pool":"anthropic","in":60130,"out":328,"usd":0.016317,"cached":59690,"written":438}
{"at":"2026-10-19T14:13:16Z","model":"claude-sonnet-5-5","pool":"anthropic","in":60759,"out":2816,"usd":0.041762,"cached":60128,"written":629}
{"at":"2026-10-19T14:13:23Z","model":"claude-sonnet-5-5","pool":"anthropic","in":63971,"out":308,"usd":0.023265,"cached":60757,"written":3212}
{"at":"2026-10-19T14:13:29Z","model":"claude-sonnet-5-5","pool":"anthropic","in":64818,"out":322,"usd":0.018135,"cached":63969,"written":847}
{"at":"2026-10-19T14:13:35Z","model":"claude-sonnet-5-5","pool":"anthropic","in":65320,"out":278,"usd":0.017002,"cached":64816,"written":502}
{"at":"2026-10-19T14:13:44Z","model":"claude-sonnet-5-5","pool":"anthropic","in":65750,"out":603,"usd":0.020173,"cached":65318,"written":430}
{"at":"2026-10-19T14:13:53Z","model":"claude-sonnet-5-5","pool":"anthropic","in":66410,"out":825,"usd":0.023054,"cached":65748,"written":660}
+43
View File
@@ -0,0 +1,43 @@
# Spend ledger
Time keeps a record of what it spends on models. This folder holds it.
- **One file per month:** `time/ledger/<YYYY-MM>.jsonl`, for example `2026-10.jsonl`.
- **One JSON line per model call.** Fields:
- `at`: when the call was made, ISO 8601 in UTC (`2026-10-14T09:30:00Z`).
- `model`: the model that was called.
- `in`: input (prompt) tokens.
- `out`: output (completion) tokens.
- `pool`: the provider the call booked to (`anthropic`, `venice`); `time/pools.json` maps it to a capacity pool.
- `usd`: the cost in US dollars, or `null` if the seat has no price (unpriced seats).
Example line:
```json
{"at":"2026-10-14T09:30:00Z","model":"some-model","in":1200,"out":350,"usd":0.0123}
```
## Who writes and reads it
- **Written by Time itself.** At the end of each run, Time appends that run's calls to the current month's file in one `ledger:` commit.
- **Read before every model call.** Whichever workflow is about to call a model (heartbeat, mentions, venture loop, Astra) adds up `usd` for today and for the month and refuses the call if either total has reached its cap (`TIME_DAILY_USD`, `TIME_MONTHLY_USD`). A `null` counts as zero.
## When a cap trips
Time stops calling models. The next time the heartbeat has a card to run, it opens one issue labelled `andon` (never a second while that one is open). A monthly cap also switches `TIME_MODE` to `dry-run`, or says in the issue that it couldn't, until a human raises the cap. A daily cap clears itself at midnight UTC. Close the issue once it is handled.
## Don't edit it by hand
Humans should never edit these files. The caps rely on them being exact, and Time only ever appends. A hand edit can hide spend or break the next append.
## Pools (`time/pools.json`)
The operator keeps one entry per capacity pool; a route with no entry is paced by `TIME_DAILY_USD`. Time reads the file through the API once per run. Fields: `name`, `kind`, `routes`, and by kind:
- `expiring`: `balance_usd` (as of `as_of`, a date), `resets_at` (the date the credit lapses). Allows `(balance - spent since as_of) / days to resets_at x TIME_PACE` a day.
- `window`: `window_usd`, `resets_at` as `weekly:mon` or `hours:5`. Allows `window_usd` per window.
- `balance`: `balance_usd`. Allows `TIME_DAILY_USD` a day.
```json
[{"name": "anthropic-credits", "kind": "expiring", "balance_usd": 30, "as_of": "2026-10-19", "resets_at": "2026-10-29", "routes": ["anthropic"]}]
```
+3
View File
@@ -0,0 +1,3 @@
[
{"name": "anthropic-credits", "kind": "expiring", "balance_usd": 161.49, "as_of": "2026-10-19", "resets_at": "2026-11-08", "routes": ["anthropic"]}
]
+7
View File
@@ -0,0 +1,7 @@
{
"_about": "The router's dials (.github/time/router.py). mode: off (Astra plans as before), dry (Astra posts the ranking on the Astra log and queues nothing), live (Astra queues from the top and the heartbeat promotes queued cards in ranked order). weights: the owner's dial per kind; all 1 is balanced, 2 doubles a kind, 0 shuts it out. restless: how fast a kind not picked lately climbs, per day, for at most 10 days. cost_usd: the estimated spend of an S, M or L card, which Astra checks against the day's money left before it queues one.",
"mode": "live",
"weights": {"venture": 1, "feature": 1, "refinement": 1, "optimization": 1},
"restless": 0.05,
"cost_usd": {"S": 0.5, "M": 1.5, "L": 4}
}
+21
View File
@@ -0,0 +1,21 @@
{
"_about": "Copies of repos on other forges, kept in step by .github/time/sync.py (the sync workflow). Each repo has one home (this forge) and every branch one writer: the `out` branches go from the home to each copy, fast-forward only; a copy's branches matching its `import` patterns come back to the home as PRs. `token` (https) or `ssh_key` (a deploy key, for ssh/git@ URLs; host keys pinned) names the secret that holds the credential, never the credential itself (Gitea refuses secret names starting GITHUB_ or GITEA_); a copy whose secret isn't set is skipped. A GitHub copy needs GitHub Actions turned off, since these workflows must only run here.",
"repos": [
{
"repo": "teahubxyz/time",
"out": [
"main"
],
"copies": [
{
"name": "github",
"url": "git@github.com:teahubxyz/time.git",
"ssh_key": "SYNC_GITHUB_KEY",
"import": [
"claude/*"
]
}
]
}
]
}