Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268b0ee626 | ||
|
|
16d7a8993c | ||
|
|
f78613c5c7 | ||
|
|
7c2a2aa45f | ||
|
|
c466dfbb28 | ||
|
|
5aefa807be | ||
|
|
815649f59f | ||
|
|
bc1da90c33 | ||
|
|
8fdb2f9469 | ||
|
|
5a0ffcfee6 | ||
|
|
32943339e1 | ||
|
|
d235afb7f3 | ||
|
|
805e20d695 | ||
|
|
2efe67c9a9 | ||
|
|
745231844f | ||
|
|
e34994bbc6 | ||
|
|
ab3c5f5200 | ||
|
|
896fd3c872 | ||
|
|
17c2fa0377 | ||
|
|
b7bd03c6fd | ||
|
|
ec043b2985 | ||
|
|
463a6fecbb | ||
|
|
0fdce2c58f | ||
|
|
b8d52252f4 | ||
|
|
b8041955ab | ||
|
|
3d1865ecb9 | ||
|
|
ef00a80f7f | ||
|
|
97e3e1f35f | ||
|
|
c268e75234 | ||
|
|
e6f04f614a | ||
|
|
4512c59c8f | ||
|
|
3f81c2cfa9 | ||
|
|
facd11d390 | ||
|
|
f329066a51 | ||
|
|
a7c92b6497 | ||
|
|
80f21b58ea | ||
|
|
8fe83ac05a | ||
|
|
ebedc7a5bc | ||
|
|
8ab719e2f2 | ||
|
|
4b521f7a86 | ||
|
|
8c0d93db9d | ||
|
|
9abd17f979 | ||
|
|
5fac881ec5 | ||
|
|
385b1a48d0 | ||
|
|
429595a543 | ||
|
|
0543771a02 | ||
|
|
501192977d | ||
|
|
61e0712f63 | ||
|
|
4c91878f93 | ||
|
|
0cf097c1c3 | ||
|
|
e6bee7ccfe | ||
|
|
cbd9d7536b | ||
|
|
bed8743c94 | ||
|
|
a6f4542203 | ||
|
|
b61b185201 | ||
|
|
86b1207c4d | ||
|
|
bcdc71383e | ||
|
|
2bfaf6f672 | ||
|
|
b8bd19eaf8 | ||
|
|
7a471e39ca | ||
|
|
31ae884fe1 | ||
|
|
1747f07521 | ||
|
|
f4058061e0 | ||
|
|
183e09332d | ||
|
|
97e863b9fb | ||
|
|
999abbf590 | ||
|
|
4f8eda2483 | ||
|
|
b539fff1b2 | ||
|
|
0d893d7fc5 | ||
|
|
5c8254cc50 | ||
|
|
b53016007a | ||
|
|
449f89fc38 | ||
|
|
5e2d211609 | ||
|
|
fb1df02f64 | ||
|
|
0e582aa70b | ||
|
|
d786ec6500 | ||
|
|
498285e136 | ||
|
|
e705ca00ac | ||
|
|
aaeb9417ca | ||
|
|
721212ba7e | ||
|
|
08bba21dfa | ||
|
|
7d76ab4338 | ||
|
|
b02e73c0e6 | ||
|
|
d30be0208d | ||
|
|
1da01fcd68 | ||
|
|
0caaad4a1a | ||
|
|
495494b0e7 | ||
|
|
56b74116e7 | ||
|
|
97b7667970 | ||
|
|
195c65cb55 | ||
|
|
f19429bccb | ||
|
|
7e9ae24beb | ||
|
|
c2055e85eb | ||
|
|
b13ddff34f | ||
|
|
f718a14295 | ||
|
|
e46ec071c1 | ||
|
|
10d0466ad9 | ||
|
|
f3a841a762 | ||
|
|
bbc62970cb | ||
|
|
8a14773aa0 | ||
|
|
223a4a0617 | ||
|
|
e12728c5ff | ||
|
|
a694c10428 | ||
|
|
0eb9234a7e | ||
|
|
444b01abca | ||
|
|
4086657dc8 | ||
|
|
aecc4ee90d | ||
|
|
c92313b62d | ||
|
|
dc2a3e8a05 | ||
|
|
a871722024 | ||
|
|
6bf0b0411b | ||
|
|
7c2b6a00a3 | ||
|
|
d05679b11a | ||
|
|
ee0302f763 | ||
|
|
387fe44e15 | ||
|
|
9ffbb793bf | ||
|
|
bd6fcd7ed7 | ||
|
|
8c874e8115 | ||
|
|
c360639456 | ||
|
|
8c6ad054b9 | ||
|
|
17e51a6b08 | ||
|
|
e52e116815 | ||
|
|
2d0f997030 | ||
|
|
3086cb52ff | ||
|
|
9e065e416d | ||
|
|
165d499644 | ||
|
|
8eff78170b | ||
|
|
ba4174326a | ||
|
|
88f9d509ed | ||
|
|
c6451370a7 | ||
|
|
a933c17fdb | ||
|
|
fbe1b0c9a2 | ||
|
|
bc6f232a56 | ||
|
|
c54cbf2596 | ||
|
|
cb687873d3 | ||
|
|
134a03ce41 | ||
|
|
e1dc4f2790 | ||
|
|
32fa6ee49d | ||
|
|
7a821c5cf2 | ||
|
|
6d0c600de0 | ||
|
|
e8843b7e62 | ||
|
|
51811c2ba0 | ||
|
|
99511c6c08 | ||
|
|
398af5a9ac | ||
|
|
c97198ea15 | ||
|
|
23d6383060 | ||
|
|
f64a48300d | ||
|
|
35860f4bf8 | ||
|
|
3bf9fbe94d | ||
|
|
67f7b4d2d9 | ||
|
|
16cd9a6306 | ||
|
|
c287a2acf6 | ||
|
|
4fc82f0f9a | ||
|
|
0f74a7d7c4 | ||
|
|
0c569ca5c2 | ||
|
|
79e38fa894 | ||
|
|
91b11019db | ||
|
|
cdfe4c7199 | ||
|
|
66c427eba4 | ||
|
|
5a185facd3 | ||
|
|
a4970e3e2b | ||
|
|
bae0bdc0ef | ||
|
|
fd23310271 |
No files matched your search
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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__)
|
||||
@@ -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__)
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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><script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<script type="module" src="__ORIGIN__/aicharts.js"></script>
|
||||
<ai-chart endpoint="__ORIGIN__" dataset="orders"></ai-chart></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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1177
-164
File diff suppressed because it is too large.
Load diff
+2025
-117
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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}
|
||||
@@ -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"]}]
|
||||
```
|
||||
@@ -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"]}
|
||||
]
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user