@rackbops/plugin-tracker 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +24 -5
  2. package/dist/plugin.js +143 -6
  3. package/package.json +9 -2
package/README.md CHANGED
@@ -13,7 +13,8 @@ slice 3) -- the JSON task API with personal API tokens (#80, slice 4), for a pro
13
13
  you, such as a later intake agent (plan E10) -- and the one-off research request (#82, plan E8,
14
14
  category 5): a question looked into on the web by a model run, checked by a second run against its
15
15
  sources, and DMed once, with its claims kept as findings -- and an admin raising one person's
16
- daily model ceiling from the web (#82, plan 5.7). The model runs never happen here: they go
16
+ daily model ceiling from the web (#82, plan 5.7), with an operator switch that turns the daily
17
+ ceilings off for the alpha and an admin page of daily model usage to evaluate it by. The model runs never happen here: they go
17
18
  through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
18
19
  lane stays off until the city-hall Executor is configured (Configuration below).
19
20
 
@@ -29,7 +30,7 @@ as in a server (plan 5.5, item 39).
29
30
  | `/remind text [when] [repeat]` | a registered person | A reminder by DM. `when` is docket's `parseWhen` grammar (`in 20 minutes`, `tomorrow 9am`, `fri at 17:30`); `repeat` is once (the default), daily, weekly or monthly. A repeating one with no `when` starts today at the preferred hour. |
30
31
  | `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
31
32
  | `/price url [name] [hours] [drop] [baseline] [near]` | registered | A price (docket's `price`, a `poll` schedule every `hours`, default 12, at most 168). The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of `drop` percent or more (default 10) from the `baseline` -- the last (default), first or highest price seen -- DMs the owner once per crossing. `near` is the words just before the price, for a page with no structured price. At most 20 per person. |
32
- | `/research question [context] [deadline] [at]` | registered | A one-off research request (docket's `research`, a `once` schedule at `at`, default now). A research run looks it up on the web (read-and-web tools only, plan 5.6), a reviewer run checks the draft against its sources, and only a passed answer is DMed to you (and accepted recipients); its claims are kept as findings on the task's page. `deadline` (docket's `parseWhen`) goes into the request; a run that would start past it makes no call and tells you. At most 5 waiting per person; every run counts against the daily budget (below). Answers that research is not available while the city-hall Executor is not set up. |
33
+ | `/research question [context] [deadline] [at]` | registered | A one-off research request (docket's `research`, a `once` schedule at `at`, default now). A research run looks it up on the web (read-and-web tools only, plan 5.6), a reviewer run checks the draft against its sources, and only a passed answer is DMed to you (and accepted recipients); its claims are kept as findings on the task's page. `deadline` (docket's `parseWhen`) goes into the request; a run that would start past it makes no call and tells you. `question` must hold the question itself: fewer than 3 characters, or no letter at all (a stray `0`), is refused before anything is made. At most 5 waiting per person; every run counts against the daily budget (below). Answers that research is not available while the city-hall Executor is not set up. |
33
34
  | `/tasks` | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones. |
34
35
  | `/task done task` / `/task snooze task [until]` | the task's owner | Answers the task's latest reminder (snooze: an hour, or until `until`). |
35
36
  | `/task decide task choice [amount]` | the renewal's owner | Answers the renewal's latest ask -- keep, cancel or renewed -- with the amount actually paid when it changed (a button cannot carry one). Use it instead of the button, not after it: a run is answered once. |
@@ -127,7 +128,11 @@ says research is not available; it does not list the waiting ones (`/tasks` does
127
128
  calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
128
129
  DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
129
130
  person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
130
- together.
131
+ together. **For the alpha the ceilings can be switched off** (0.14.0; roshne, 2026-10-02, choosing option
132
+ A: "A. but lets build in an unlimited budget "flag" during alpha, evaluate usage during alpha, then
133
+ test budgets during beta"): with `TRACKER_BUDGET_UNLIMITED` on (Configuration, below) no daily ceiling holds a run,
134
+ while each Job's own caps (15 turns and 1 USD for a research run) still bound it and every run is
135
+ still charged, so `/admin/usage` (below) shows what the defaults would have held.
131
136
 
132
137
  **Findings.** A passed research answer's claims are stored with their first source (docket's
133
138
  `findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
@@ -284,6 +289,16 @@ the function the command path runs:
284
289
  when) and a log line (`u1 set u2's daily ceiling to 4 USD / 40 calls (c1)`).
285
290
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
286
291
 
292
+ **Usage** (0.14.0). `/admin/usage`, linked from every admin page, shows the model spend per budget
293
+ day (midnight Eastern) for the last 14 days, newest first: dollars and calls for everyone together,
294
+ then per person. Each row is measured against the default ceilings, not a person's raise -- a
295
+ "would have hit the 20 call limit" (or "2.00 USD") marker on a day that reached one, and how many
296
+ calls were charged after it had, roughly what that ceiling would have held (docket checks before a
297
+ run and charges at its end). It reads docket's `usage` table, which every run is charged to whether
298
+ budgets are on or off -- the way to evaluate the alpha with `TRACKER_BUDGET_UNLIMITED` on
299
+ (Configuration, below). Read-only, and like every admin page answers anyone who is not an admin
300
+ with the unknown page's 404.
301
+
287
302
  **Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
288
303
  settled `failed`, `unconfirmed` or `deferred` in the last 30 days, newest first, at most 200 rows
289
304
  (the page says how many matched when the bound cuts it). Each row: the task (its title, linking to
@@ -294,8 +309,10 @@ say). Before 0.10.0 these showed only in the bot log, where the warnings still g
294
309
  read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
295
310
 
296
311
  Not here yet, though plan 5.10 lists them: grants and retry, and an admin pause of someone else's
297
- task. Of budgets, raising one person's ceiling is here; the global ceiling and the defaults are
298
- docket's constants.
312
+ task. Of budgets, raising one person's ceiling and the usage page are here; the global ceiling and
313
+ the defaults are docket's constants, and turning budgets off is the operator's
314
+ `TRACKER_BUDGET_UNLIMITED`, not a web control. While it is on, the ceiling section says the ceiling
315
+ is not enforced; a change is still recorded and applies once it is off.
299
316
 
300
317
  **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
301
318
  asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
@@ -499,6 +516,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
499
516
  | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. Schema 5 (0.9.0) is docket 0.4.0's: a run's `record`, a series point's `key`, `deliveries` (from `delivery_claims`), `usage` and `notices`. Schema 6 (0.11.0) is docket 0.5.0's, all additive: `findings`, a charge's `usage.key`, and the Executor's `executor_jobs`. Forget-me erases a person's findings and their runs' Job records too. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
500
517
  | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, one pass per task with work (a due run, a run in flight, a DM owed or claimed), each under that task's lock through a view of the store limited to that task: `notify` runs every type but the page readers, `poll` runs only them (`price`) with the Fetch port, so a slow page never holds up a reminder. A task the execute tick holds is left for the next notify tick, never waited on. Registers `reminder`, `renewal`, `price` and `research`. |
501
518
  | Execute lane | `src/execute-lane.ts`, `src/executor.ts`, `src/research.ts` | Only when the city-hall Executor is configured: a third host tick, `execute`, that starts docket's `Lanes.tickExecute` in the background (one at a time, at most once a minute) over every task with a due execute-lane run, reserving those tasks and then holding their locks in id order, through one docket `Lanes` kept while the plugin is active (docket keeps its usage-limit pause on it). The Executor is city-hall#18's source pair (above), its I/O injected; `executor_jobs` keeps each Job key's city-hall id. `/research`'s rules. |
519
+ | Budgets, usage | `src/ceilings.ts`, `src/usage.ts`; `src/web/ceiling-pages.ts`, `src/web/usage-pages.ts` | The execute lane's `BudgetPolicy` (docket's defaults plus each person's raise, or no ceiling at all while `TRACKER_BUDGET_UNLIMITED` is on), the ceiling changes, and the pure usage report `/admin/usage` renders from docket's `usage` table. |
502
520
  | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price`: http or https on the default port, no credentials, every resolved address public (no loopback, private, link-local, CGNAT, multicast or reserved range, IPv4 or IPv6), redirects followed by hand and re-checked (at most 5), 15 s including the name lookup, at most 3 MB kept. The body is then rebuilt in linear time (`src/page.ts`) to just what extraction reads -- JSON-LD, meta tags, and the page with every `<` blanked, so `near` still reads text, attributes and script data -- because docket's extraction patterns take quadratic time on a page of unclosed tags. A read the tick's abort cuts short requeues its run instead of counting a miss. A DNS answer that changes between the check and the read is not caught here. |
503
521
  | Renewals, prices | `src/tracked.ts`, `src/price.ts`, `src/series.ts`, `src/page.ts` | `/renewal`, `/price` and `/task decide`; the series lines of `/task history`. The series (docket's `series` table, schema 1) holds a renewal's paid amounts and a price's readings. |
504
522
  | Delivery | `src/notifier.ts`, `src/buttons.ts` | docket's `Notifier` over `host.dm`, with the buttons. docket claims each (run, person) in the store's `deliveries` before the DM and settles it after. The notifier maps the host's answers to docket's errors: cannot be messaged (50007) or an unknown user is `DeliveryFailedError(msg, true)` (failed for good; 50007 and 10013 count toward the pause); a message refused for its content is a plain `DeliveryFailedError` (retried); a paused person is `ExecutorUnavailableError` (deferred); anything else is rethrown, so docket settles it unconfirmed and never resends it. |
@@ -519,6 +537,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
519
537
  | `TRACKER_ADMIN_DISCORD_IDS` | no | Comma-separated Discord user ids (spaces around commas allowed; an empty entry, as from a trailing comma, is refused and the plugin does not load) made admin at start. Unset = none. Removing an id does not revoke it. |
520
538
  | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker, or a comma-separated list of them (spaces around commas allowed; a repeated id counts once): a member of any listed server passes. Checked through the interaction's client with a single-member lookup per server (no privileged intent), all asked at once; a yes from any server is a member, a no from every server is not, and otherwise the answer is unknown (refused, never revoked). Inside a listed server, the person running a command needs no lookup. Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed or empty entry anywhere in the list refuses to load, naming it. One store and one admission list serve every listed server; there are no per-server admins. |
521
539
  | `TRACKER_WEB_URL` | no | The https origin the bot's HTTP is reached at through its tunnel, e.g. `https://clerk.example.com` (no path). `/web` links and the allowed `Origin` come from it, never from a request's `Host`. Unset = no web area (`/web` says so, the pages answer 404); anything but a bare https origin refuses to load. |
540
+ | `TRACKER_BUDGET_UNLIMITED` | no | Budgets off for the alpha (0.14.0): `true` or `1` means no daily ceiling -- a person's or the global one -- holds a model run, bot-wide. Each Job's own caps still apply, usage is still recorded (see `/admin/usage`), the bot logs one line at start saying so (or, with the execute lane off, that no model work runs), the People and Usage admin pages show a "Budgets off (alpha): unlimited" banner, and the person page's ceiling section shows a "not enforced" note. Unset, empty, `false` or `0` = the ceilings apply (the default); anything else refuses to load. |
522
541
  | `TRACKER_CITY_HALL_URL` | no | The https origin of the city-hall that queues the tracker's model Jobs (no path). |
523
542
  | `TRACKER_CITY_HALL_KEY` | yes | The source bearer key city-hall checks on `/api/execute/jobs` (its `CITY_HALL_API_KEY`, city-hall#18). Never logged. Kept in the instance's own env, never in a repo. |
524
543
  | `TRACKER_CITY_HALL_CAPABILITY` | no | The capability tag every Job names, one only docket-runner carries (plan item 71), e.g. `claude-cli:subscription`. No default: a guessed tag could send the tracker's Jobs to another agent. |
package/dist/plugin.js CHANGED
@@ -3331,6 +3331,7 @@ function isRaised(latest) {
3331
3331
  function budgetNoticeKey(userId, at) {
3332
3332
  return `budget:person:${userId}:${budgetDay(at).day}`;
3333
3333
  }
3334
+ var NOT_ENFORCED = "Budgets are off (alpha): TRACKER_BUDGET_UNLIMITED is on, so no ceiling is enforced; this one applies once it is turned off.";
3334
3335
  var HISTORY_SHOWN = 20;
3335
3336
  function toChange(r) {
3336
3337
  return { id: `c${r.seq}`, userId: r.user_id, usd: r.usd, calls: r.calls, setBy: r.set_by, at: r.at };
@@ -3385,8 +3386,9 @@ async function setCeiling(d, admin, targetId, input) {
3385
3386
  return { ok: true, text: `${who2(target)} is already on the default ceiling.` };
3386
3387
  const change = d.ceilings.record(target.id, decided.limits, admin.id, d.clock.now().toISOString());
3387
3388
  const now = limitsOf(change);
3389
+ const note = d.budgetUnlimited ? ` ${NOT_ENFORCED}` : "";
3388
3390
  d.log.info(`${admin.id} set ${target.id}'s daily ceiling to ${decided.limits === null ? "the default" : `${now.usd} USD / ${now.calls} calls`} (${change.id})`);
3389
- return decided.limits === null ? { ok: true, text: `${who2(target)} is back on the default ceiling: ${describeLimits(now)}.` } : { ok: true, text: `${who2(target)}'s ceiling is now ${describeLimits(now)}, until an admin changes it.` };
3391
+ return decided.limits === null ? { ok: true, text: `${who2(target)} is back on the default ceiling: ${describeLimits(now)}.${note}` } : { ok: true, text: `${who2(target)}'s ceiling is now ${describeLimits(now)}, until an admin changes it.${note}` };
3390
3392
  }
3391
3393
 
3392
3394
  // plugins/tracker/src/delivery-health.ts
@@ -5368,6 +5370,11 @@ async function remind(d, user, input) {
5368
5370
  // plugins/tracker/src/research.ts
5369
5371
  var MAX_LIVE_RESEARCH = 5;
5370
5372
  var RESEARCH_OFF = "Research is not available on this bot yet: it needs the model runner, which is not set up here.";
5373
+ var MIN_QUESTION_CHARS = 3;
5374
+ var SHORT_QUESTION = `Put the whole question in \`question\`: it needs at least ${MIN_QUESTION_CHARS} characters, with a letter in it. \`context\` is only for background.`;
5375
+ function tooShort(question) {
5376
+ return question.length < MIN_QUESTION_CHARS || !/\p{L}/u.test(question);
5377
+ }
5371
5378
  async function createResearch(d, user, input) {
5372
5379
  const type = d.types.research;
5373
5380
  if (!type || !d.research)
@@ -5377,6 +5384,8 @@ async function createResearch(d, user, input) {
5377
5384
  return { ok: false, error: "Say what to look into." };
5378
5385
  if (question.length > MAX_QUESTION_CHARS)
5379
5386
  return { ok: false, error: `That question is longer than ${MAX_QUESTION_CHARS} characters.` };
5387
+ if (tooShort(question))
5388
+ return { ok: false, error: SHORT_QUESTION };
5380
5389
  const context = input.context?.trim() ?? "";
5381
5390
  if (context.length > MAX_CONTEXT_CHARS)
5382
5391
  return { ok: false, error: `\`context\` is longer than ${MAX_CONTEXT_CHARS} characters.` };
@@ -8869,7 +8878,8 @@ function ceilingSection(v, personId, view, now) {
8869
8878
  const { min, max } = view.bounds;
8870
8879
  return html`<section>
8871
8880
  <h2>Daily model budget</h2>
8872
- <p>Today: ${view.today.usd.toFixed(2)} USD and ${view.today.calls} model call(s). Ceiling: ${describeLimits(limits)}${raised ? html` <span class="rb-badge">raised</span>` : " (the default)"}.</p>
8881
+ ${view.unlimited ? html`<div class="rb-alert rb-alert--warning" role="status"><p>${NOT_ENFORCED}</p></div>` : null}
8882
+ <p>Today: ${view.today.usd.toFixed(2)} USD and ${view.today.calls} model call(s). Ceiling: ${describeLimits(limits)}${raised ? html` <span class="rb-badge">raised</span>` : " (the default)"}${view.unlimited ? " -- not enforced" : null}.</p>
8873
8883
  <p class="rb-muted">A raise stands until an admin changes it. Setting exactly the default puts them back on it. It may not go below the default (${describeLimits(view.defaults)}) or above the global ceiling (${describeLimits(max)}), which still applies to everyone together. A day ends at midnight Eastern.</p>
8874
8884
  <form method="post" action="${action}" class="tr-stack">
8875
8885
  ${csrf}
@@ -8887,6 +8897,74 @@ ${view.history.length === 0 ? html`<p class="rb-muted">No admin has changed thei
8887
8897
  </section>`;
8888
8898
  }
8889
8899
 
8900
+ // plugins/tracker/src/usage.ts
8901
+ var BUDGET_UNLIMITED_KEY = "TRACKER_BUDGET_UNLIMITED";
8902
+ function parseBudgetUnlimited(raw) {
8903
+ const value = (raw ?? "").trim().toLowerCase();
8904
+ if (value === "" || value === "false" || value === "0")
8905
+ return false;
8906
+ if (value === "true" || value === "1")
8907
+ return true;
8908
+ throw new Error(`${BUDGET_UNLIMITED_KEY} must be true, 1, false or 0 (or unset)`);
8909
+ }
8910
+ var NO_LIMITS = Object.freeze({ usd: null, calls: null });
8911
+ var UNLIMITED_BUDGET = Object.freeze({ person: NO_LIMITS, global: NO_LIMITS });
8912
+ function executeBudget(ceilings, unlimited) {
8913
+ return unlimited ? UNLIMITED_BUDGET : budgetPolicy(ceilings);
8914
+ }
8915
+ var UNLIMITED_LOG = `budgets off (alpha): ${BUDGET_UNLIMITED_KEY} is on, so no daily ceiling holds a model run; each Job's own caps still apply and usage is still recorded`;
8916
+ var UNLIMITED_IDLE_LOG = `${BUDGET_UNLIMITED_KEY} is on, but the execute lane is off, so no model work runs for it to leave unbudgeted`;
8917
+ var UNLIMITED_BANNER = "Budgets off (alpha): unlimited. No daily ceiling holds a model run while TRACKER_BUDGET_UNLIMITED is on; usage is still recorded.";
8918
+ var USAGE_DAYS = 14;
8919
+ function usageWindow(now, days = USAGE_DAYS) {
8920
+ const out = [budgetDay(now)];
8921
+ while (out.length < days)
8922
+ out.unshift(budgetDay(new Date(Date.parse(out[0].start) - 1)));
8923
+ return out;
8924
+ }
8925
+ function measure(rows, limits) {
8926
+ const total = { usd: 0, calls: 0 };
8927
+ let past = 0;
8928
+ for (const r of rows) {
8929
+ if (reached(total, limits) !== null)
8930
+ past += r.calls;
8931
+ total.usd += r.costUsd;
8932
+ total.calls += r.calls;
8933
+ }
8934
+ return { ...total, reached: reached(total, limits), past };
8935
+ }
8936
+ function byAt(a, b) {
8937
+ return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
8938
+ }
8939
+ function usageReport(rows, now, days = USAGE_DAYS, policy = DEFAULT_BUDGET) {
8940
+ const window = usageWindow(now, days);
8941
+ const index = new Map(window.map((w, i) => [w.day, i]));
8942
+ const buckets = window.map(() => []);
8943
+ for (const r of [...rows].sort(byAt)) {
8944
+ const i = index.get(budgetDay(new Date(r.at)).day);
8945
+ if (i !== undefined)
8946
+ buckets[i]?.push(r);
8947
+ }
8948
+ const out = window.map((w, i) => {
8949
+ const dayRows = buckets[i] ?? [];
8950
+ const users = [...new Set(dayRows.map((r) => r.userId))];
8951
+ const people = users.map((userId) => ({ userId, ...measure(dayRows.filter((r) => r.userId === userId), policy.person) })).sort((a, b) => b.usd - a.usd || b.calls - a.calls || (a.userId < b.userId ? -1 : 1));
8952
+ return { day: w.day, everyone: measure(dayRows, policy.global), people };
8953
+ });
8954
+ return {
8955
+ days: out.reverse(),
8956
+ person: policy.person,
8957
+ global: policy.global,
8958
+ since: window[0].start,
8959
+ before: window.at(-1).end
8960
+ };
8961
+ }
8962
+ async function loadUsage(store, now, days = USAGE_DAYS) {
8963
+ const window = usageWindow(now, days);
8964
+ const rows = await store.listUsage({ since: window[0].start, before: window.at(-1).end });
8965
+ return usageReport(rows, now, days);
8966
+ }
8967
+
8890
8968
  // plugins/tracker/src/web/admin-pages.ts
8891
8969
  var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
8892
8970
  var CONFIRM_WORD = "forget";
@@ -8908,7 +8986,10 @@ function hidden(v) {
8908
8986
  return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8909
8987
  }
8910
8988
  function adminNav(v) {
8911
- return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a> <a class="rb-link" href="${v.base}/admin/deliveries">Deliveries</a></p>`;
8989
+ return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a> <a class="rb-link" href="${v.base}/admin/deliveries">Deliveries</a> <a class="rb-link" href="${v.base}/admin/usage">Usage</a></p>`;
8990
+ }
8991
+ function budgetsOffBanner(unlimited) {
8992
+ return unlimited ? html`<div class="rb-alert rb-alert--warning" role="status"><p><strong>${UNLIMITED_BANNER}</strong></p></div>` : null;
8912
8993
  }
8913
8994
  function personHref(v, id, action = "") {
8914
8995
  return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
@@ -8943,6 +9024,7 @@ function adminPage(v, data, o = {}) {
8943
9024
  return framed(v, "Admin", html`<section>
8944
9025
  <h1>People</h1>
8945
9026
  ${adminNav(v)}
9027
+ ${budgetsOffBanner(o.unlimited === true)}
8946
9028
  ${flash(o.result ?? null)}
8947
9029
  <div class="rb-table-scroll"><table class="rb-table">
8948
9030
  <thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
@@ -9078,6 +9160,48 @@ ${hidden(v)}
9078
9160
  </section>`);
9079
9161
  }
9080
9162
 
9163
+ // plugins/tracker/src/web/usage-pages.ts
9164
+ function marker(m, limits) {
9165
+ if (m.reached === null)
9166
+ return html`<span class="rb-muted">under</span>`;
9167
+ const which = m.reached === "calls" ? `${limits.calls} call` : `${(limits.usd ?? 0).toFixed(2)} USD`;
9168
+ const past = m.past > 0 ? `; ${m.past} call(s) after it` : "";
9169
+ return html`<span class="rb-badge">would have hit the ${which} limit</span>${past}`;
9170
+ }
9171
+ function row(day, who, m, limits) {
9172
+ return html`<tr>
9173
+ <td>${day ?? ""}</td>
9174
+ <td>${who}</td>
9175
+ <td>${m.usd.toFixed(2)}</td>
9176
+ <td>${m.calls}</td>
9177
+ <td>${marker(m, limits)}</td>
9178
+ </tr>`;
9179
+ }
9180
+ function adminUsagePage(v, data) {
9181
+ const { report, names } = data;
9182
+ const rows = report.days.map((d) => {
9183
+ if (d.everyone.calls === 0 && d.everyone.usd === 0) {
9184
+ return html`<tr><td>${d.day}</td><td colspan="4" class="rb-muted">No model use.</td></tr>`;
9185
+ }
9186
+ return [
9187
+ row(d.day, html`<strong>Everyone</strong>`, d.everyone, report.global),
9188
+ ...d.people.map((p) => row(null, html`<a class="rb-link" href="${personHref(v, p.userId)}">${names.get(p.userId) ?? p.userId}</a>`, p, report.person))
9189
+ ];
9190
+ });
9191
+ const total = report.days.reduce((s, d) => ({ usd: s.usd + d.everyone.usd, calls: s.calls + d.everyone.calls }), { usd: 0, calls: 0 });
9192
+ return framed(v, "Usage", html`<section>
9193
+ <h1>Model usage</h1>
9194
+ ${adminNav(v)}
9195
+ ${budgetsOffBanner(data.unlimited)}
9196
+ <p>The last ${report.days.length} days, newest first: ${total.usd.toFixed(2)} USD and ${total.calls} model call(s) in all. A day ends at midnight Eastern. Dollars are the CLI's list-price estimate, not a bill.</p>
9197
+ <p class="rb-muted">Each row is measured against the default ceilings -- ${describeLimits(report.person)} a person, ${describeLimits(report.global)} for everyone together -- not a person's raised one. "Would have hit" means that day's spend reached the ceiling; "calls after it" are the calls charged once it had, roughly what it would have held (the ceiling is checked before a run and the run charged at its end).${data.unlimited ? " While budgets are off, nothing is held." : ""}</p>
9198
+ <div class="rb-table-scroll"><table class="rb-table">
9199
+ <thead><tr><th scope="col">Day</th><th scope="col">Who</th><th scope="col">USD</th><th scope="col">Calls</th><th scope="col">Default ceiling</th></tr></thead>
9200
+ <tbody>${rows}</tbody>
9201
+ </table></div>
9202
+ </section>`);
9203
+ }
9204
+
9081
9205
  // plugins/tracker/src/web/admin.ts
9082
9206
  function unknownPage(base) {
9083
9207
  return htmlResponse(notFoundPage(base), 404);
@@ -9101,7 +9225,7 @@ function confirmed(form) {
9101
9225
  var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
9102
9226
  async function peoplePage(a, result, status, allow) {
9103
9227
  const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
9104
- return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
9228
+ return htmlResponse(adminPage(a.v, data, { result, unlimited: a.d.budgetUnlimited === true, ...allow !== undefined ? { allow } : {} }), status);
9105
9229
  }
9106
9230
  async function onePerson(a, id, result = null) {
9107
9231
  const everyone = a.d.roster.people();
@@ -9117,7 +9241,8 @@ async function onePerson(a, id, result = null) {
9117
9241
  today: await spentToday(a.d.store, p.id, now),
9118
9242
  defaults: DEFAULT_BUDGET.person,
9119
9243
  bounds: boundsOf(),
9120
- names: new Map(everyone.map((x) => [x.id, nameOf(x)]))
9244
+ names: new Map(everyone.map((x) => [x.id, nameOf(x)])),
9245
+ unlimited: a.d.budgetUnlimited === true
9121
9246
  };
9122
9247
  return htmlResponse(personPage(a.v, p, tasks, now, result, isConfiguredAdmin(a.d, p), tokens, budget), result && !result.ok ? 400 : 200);
9123
9248
  }
@@ -9126,6 +9251,11 @@ async function adminGet(a, r) {
9126
9251
  return peoplePage(a, null, 200);
9127
9252
  if (r.kind === "admin-person")
9128
9253
  return onePerson(a, r.id);
9254
+ if (r.kind === "admin-usage") {
9255
+ const now = a.d.clock.now();
9256
+ const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
9257
+ return htmlResponse(adminUsagePage(a.v, { report: await loadUsage(a.d.store, now), names: people, unlimited: a.d.budgetUnlimited === true }));
9258
+ }
9129
9259
  const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
9130
9260
  if (r.kind === "admin-deliveries") {
9131
9261
  const now = a.d.clock.now();
@@ -10131,6 +10261,8 @@ function route2(path) {
10131
10261
  return { kind: "admin-tasks" };
10132
10262
  if (path === "/admin/deliveries")
10133
10263
  return { kind: "admin-deliveries" };
10264
+ if (path === "/admin/usage")
10265
+ return { kind: "admin-usage" };
10134
10266
  if (path === "/admin/allow")
10135
10267
  return { kind: "admin-allow" };
10136
10268
  const ceiling = PERSON_CEILING.exec(path);
@@ -10484,6 +10616,7 @@ function createWebHandler(w) {
10484
10616
  case "admin":
10485
10617
  case "admin-tasks":
10486
10618
  case "admin-deliveries":
10619
+ case "admin-usage":
10487
10620
  case "admin-person":
10488
10621
  return adminGet(a, r);
10489
10622
  default:
@@ -10534,6 +10667,7 @@ function createPlugin(host, options = {}) {
10534
10667
  const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
10535
10668
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
10536
10669
  const cityHall = parseCityHallConfig(host.env);
10670
+ const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
10537
10671
  const cityHallConfig = cityHall.config;
10538
10672
  const executeOn = cityHallConfig !== null || options.executor !== undefined;
10539
10673
  const clock = options.clock ?? { now: () => new Date };
@@ -10740,7 +10874,9 @@ function createPlugin(host, options = {}) {
10740
10874
  });
10741
10875
  }
10742
10876
  const ceilings = new Ceilings(opened);
10743
- executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: budgetPolicy(ceilings) }) : null;
10877
+ executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: executeBudget(ceilings, budgetUnlimited) }) : null;
10878
+ if (budgetUnlimited)
10879
+ host.log.info(executeLane ? UNLIMITED_LOG : UNLIMITED_IDLE_LOG);
10744
10880
  if (cityHallConfig)
10745
10881
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
10746
10882
  else if ("missing" in cityHall && cityHall.missing.length > 0)
@@ -10761,6 +10897,7 @@ function createPlugin(host, options = {}) {
10761
10897
  apiTokens: new ApiTokens(opened),
10762
10898
  roster: new Roster(opened),
10763
10899
  ceilings,
10900
+ budgetUnlimited,
10764
10901
  configuredAdmins: new Set(adminIds),
10765
10902
  lanes,
10766
10903
  webEditor: webOrigin !== null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.13.0",
4
- "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops and one-off research (through city-hall, with its findings) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed), an admin view (with the DMs that did not arrive, and raising a person's daily model ceiling), forget-me, and a JSON task API with personal API tokens",
3
+ "version": "0.14.1",
4
+ "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops and one-off research (through city-hall, with its findings) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed), an admin view (with the DMs that did not arrive, raising a person's daily model ceiling, and daily model usage per person), an operator switch that turns the daily budgets off for the alpha, forget-me, and a JSON task API with personal API tokens",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],
@@ -44,6 +44,13 @@
44
44
  "secret": false,
45
45
  "description": "The https origin the bot's HTTP is reached at through its tunnel, e.g. https://clerk.example.com (no path). /web links to <this>/tracker/login, and a form post from any other origin is refused. Unset = no web area: /web says so and its pages answer 404."
46
46
  },
47
+ {
48
+ "key": "TRACKER_BUDGET_UNLIMITED",
49
+ "format": "^ *([Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0)? *$",
50
+ "required": false,
51
+ "secret": false,
52
+ "description": "Budgets off for the alpha: true or 1 means no daily ceiling (per person or for everyone) holds a model run, bot-wide. Each Job's own caps still apply, usage is still recorded, the People and Usage admin pages show a budgets-off banner, and the person page shows a not-enforced note. Unset, empty, false or 0 = the daily ceilings apply (the default); anything else refuses to load."
53
+ },
47
54
  {
48
55
  "key": "TRACKER_CITY_HALL_URL",
49
56
  "format": "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$",