@rackbops/plugin-tracker 0.11.0 → 0.13.0

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 +68 -33
  2. package/dist/plugin.js +376 -121
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,7 +12,8 @@ your own tasks from the web, under the commands' own rules -- the admin view and
12
12
  slice 3) -- the JSON task API with personal API tokens (#80, slice 4), for a program acting as
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
- sources, and DMed once, with its claims kept as findings. The model runs never happen here: they go
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
17
  through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
17
18
  lane stays off until the city-hall Executor is configured (Configuration below).
18
19
 
@@ -69,7 +70,7 @@ At start the plugin runs docket's `recover()`: a run left running is requeued, a
69
70
  by a stop mid-send is settled unconfirmed and logged.
70
71
 
71
72
  **Research and the execute lane** (docket 0.5.0, #82). A research request is two model Jobs, run
72
- one after the other through city-hall's execute lane (Lepid-Labs/city-hall#18) by the runner that
73
+ one after the other through city-hall's execute lane ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18)) by the runner that
73
74
  carries `TRACKER_CITY_HALL_CAPABILITY` -- only docket-runner, on roshne's subscription (plan items
74
75
  70, 71). The tracker submits each Job with `POST /api/execute/jobs` under a prefixed key,
75
76
  `rackbops-tracker:<database id>:<docket's Job key>` -- the database id is random, made once and kept
@@ -103,15 +104,18 @@ the first sight (a judgement, not a measured figure): past it the Job answers "p
103
104
  docket gives the run up, and the admins are told once. It logs "the model runner is paused" naming
104
105
  the outcome once an hour per Job, and tells the admins once per Job and outcome. docket's own
105
106
  usage-limit pause (which waits for the reset the CLI named) applies only to a `usage_limit` result
106
- that reaches the tracker: city-hall#18 as proposed sends one back only when it stores that result,
107
- as when a job that was requeued later fails at its maximum of expired leases.
107
+ that reaches the tracker: under city-hall#18 as merged, that happens only when the job ends failed
108
+ with that result still stored, as when a job that was requeued later fails at its maximum of
109
+ expired leases.
108
110
  The plugin never calls a model, holds no Claude credential and no `ANTHROPIC_*` variable; model
109
111
  output is data, cleaned by docket before it reaches a DM or a finding, and every research DM goes
110
112
  out with no allowed mentions: the host sends every `dm` with `allowedMentions: { parse: [] }`
111
- (CONTEXT.md). **The wire is city-hall#18's
112
- proposal, not yet agreed by Nazu**: the source pair is a stand-in for the source and responder
113
- contracts (its decision 0002), so it may change, and this adapter with it -- the requeue reading
114
- above and `lastOutcome` (city-hall#18 at 2ba40d3) included.
113
+ (CONTEXT.md). **The wire is city-hall#18's**
114
+ ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18), merged as 90a06ec),
115
+ but its decision record 0002 is still "proposed" pending Nazu's review
116
+ ([Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17)): the source pair is
117
+ a stand-in for the source and responder contracts, so it may change, and this adapter with it --
118
+ the requeue reading above and `lastOutcome` (present in the merged 90a06ec) included.
115
119
 
116
120
  **Research while the lane is off.** If an instance that had city-hall configured loses that
117
121
  configuration, its research requests stay queued: nothing runs them until research is available
@@ -121,8 +125,9 @@ says research is not available; it does not list the waiting ones (`/tasks` does
121
125
 
122
126
  **Budgets** (plan 5.7, docket's defaults): 2 USD and 20 model calls a person a day, 10 USD and 100
123
127
  calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
124
- DM and the admins one each. Reminders, renewals and prices never count. There is no web control to
125
- raise a person's ceiling yet.
128
+ DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
129
+ person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
130
+ together.
126
131
 
127
132
  **Findings.** A passed research answer's claims are stored with their first source (docket's
128
133
  `findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
@@ -148,8 +153,8 @@ served at a hashed path, cached for a year).
148
153
  | Path | What |
149
154
  |---|---|
150
155
  | `/` | My tasks: the same list as `/tasks` (active tasks owned and received, next run in your zone; paused ones and why), each linking to its history, and links to make a new one. |
151
- | `/tasks/<id>` | A task's history: the same as `/task history`, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. The owner also sees Edit, Pause or Resume, and Delete. |
152
- | `/new/reminder`, `/new/renewal`, `/new/price` | The editor's new-task forms (GET), and making one (POST). |
156
+ | `/tasks/<id>` | A task's history: the same as `/task history`, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. The owner also sees Edit (not for a research request), Pause or Resume, and Delete. |
157
+ | `/new/reminder`, `/new/renewal`, `/new/price`, `/new/research` | The editor's new-task forms (GET), and making one (POST). `/new/research` is linked from My tasks only while research is available; without it the page says research is not available instead of showing a form. |
153
158
  | `/tasks/<id>/edit` | The owner's edit form (GET) and saving it (POST). |
154
159
  | `/tasks/<id>/pause`, `/resume`, `/delete` | POST only. Delete answers a confirmation first; only a second post carrying `confirm=yes` deletes. |
155
160
  | `/settings` | Preferred hour and time zone, checked as `/register` checks them. Links to API tokens and Forget me. |
@@ -184,8 +189,8 @@ than `TRACKER_WEB_URL`'s is refused. The sign-in post has no session yet, so it
184
189
  double-submit token from a `SameSite=Strict` cookie the link's page sets: another site cannot sign
185
190
  you in as someone else. Sign out is a POST too.
186
191
 
187
- **The task editor** (slice 2). The forms take the options of `/remind`, `/renewal` and `/price`,
188
- and the same functions check and make them: the same defaults, limits and messages, the page read
192
+ **The task editor** (slice 2). The forms take the options of `/remind`, `/renewal`, `/price` and
193
+ `/research` (#82), and the same functions check and make them: the same defaults, limits and messages, the page read
189
194
  once before a price tracker is made, at most 20 price trackers per person wherever they were made,
190
195
  and `near` turned into the same bounded pattern; a person has one new price tracker's page read in
191
196
  flight at a time. Every person may have at most 200 active or paused tasks of all types together,
@@ -208,7 +213,11 @@ are dropped and nothing more is sent, but its history is kept (an admin can see
208
213
  its page stays for the owner. Every editor post passes the session, CSRF and `Origin` checks; the
209
214
  task is loaded by the id in the path and must be the viewer's own, checked again in the write
210
215
  queue, and anyone else's -- even one an admin can see -- answers the same 404 as an unknown id. A
211
- refused form comes back with what was typed and why. Form bodies are capped at 32 KiB, read no
216
+ refused form comes back with what was typed and why. A research request made on the web is
217
+ `/research`'s exactly -- the question, the optional context, deadline and start in `/research`'s
218
+ words, the cap of 5 waiting, the daily budget -- and, as in Discord, it is never edited: its page
219
+ has Pause, Resume and Delete but no Edit. Making one only queues its run; nothing reaches city-hall
220
+ until the execute tick. Form bodies are capped at 32 KiB, read no
212
221
  further than that.
213
222
 
214
223
  **Headers.** `Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'none';
@@ -255,6 +264,24 @@ the function the command path runs:
255
264
  - **Lift** a decline block in force: docket's `liftBlock`, recorded as that admin's lift.
256
265
  - **Revoke an API token** of anyone's, from their page, which lists their tokens (never the
257
266
  secret). It stops working at once; the owner is not told. Logged by token id.
267
+ - **Raise a person's daily ceiling** (0.13.0, plan 5.7: "The admin raises a person's ceiling from
268
+ the web area"). Their page shows what they spent today (the budget day ends at midnight Eastern),
269
+ their ceiling now, a form with both numbers -- dollars to the cent and whole model calls -- and
270
+ the latest 20 changes an admin made, newest first (older ones stay in the table). Each number
271
+ must be at least the default (2 USD, 20 calls) and at most the global ceiling (10 USD, 100
272
+ calls), which docket checks first anyway; those bounds are proposed on #82, roshne's to confirm.
273
+ Setting exactly the default is a reset to it, not a raise.
274
+ **A raise stands until an admin changes it** -- raises it again or presses **Back to the
275
+ default**. That default is proposed on #82 and is roshne's to confirm; a today-only raise
276
+ would be a small change (`limitsOf` and `isRaised` plus a clock in `Ceilings`, and the page's
277
+ badge). Enforcement stays docket's: the
278
+ execute lane hands docket's `budgetHold` a policy whose `personFor` reads the person's newest
279
+ change, before every run, so a raise lets a held request run on the next tick. docket
280
+ sends its ceiling notices once a day per person (`noticeOnce`, key `budget:person:<id>:<day>`),
281
+ so every change -- raise or reset -- deletes that person's key for the current budget day in the
282
+ same transaction: meeting the new ceiling the same day DMs the person and the admins again (plan
283
+ 5.7: "nothing fails silently"). Every change is an append-only `ceiling_changes` row (who, for whom, the values,
284
+ when) and a log line (`u1 set u2's daily ceiling to 4 USD / 40 calls (c1)`).
258
285
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
259
286
 
260
287
  **Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
@@ -266,8 +293,9 @@ have gone out and is never resent; `deferred` is still owed (a recipient whose d
266
293
  say). Before 0.10.0 these showed only in the bot log, where the warnings still go. The page is
267
294
  read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
268
295
 
269
- Not here yet, though plan 5.10 lists them: grants, budgets and retry (they belong to the execute
270
- lane, which is not built) and an admin pause of someone else's task.
296
+ 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.
271
299
 
272
300
  **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
273
301
  asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
@@ -279,14 +307,15 @@ in one SQLite transaction in the write queue, **deleted, not archived**:
279
307
  - on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
280
308
  made or that name them (an invitation of them, a pause for them), the run events and deliveries
281
309
  of DMs to them (docket's `deleteDeliveries`), charges made for them, and their pause rows;
282
- - the budget notices kept for them (`budget:person:<id>:...`);
310
+ - the budget notices kept for them (`budget:person:<id>:...`) and the changes to their ceiling;
283
311
  - every decline block they are either side of;
284
312
  - their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
285
313
  person row.
286
314
 
287
315
  What stays, and why: another person's task that they received stays that owner's; one paused only
288
316
  because DMs to them failed goes back on. Another person's row keeps its own audit fact but not their
289
- id -- `admitted_by` of someone they admitted, and `lifted_by` of a block they lifted, become
317
+ id -- `admitted_by` of someone they admitted, `lifted_by` of a block they lifted, and `set_by` of a
318
+ change they made to someone's ceiling become
290
319
  `forgotten` ("an admin since forgotten"; null already means "the configuration"). Text is matched
291
320
  only in the forms the code writes a person's id into -- docket's consent rows (`u5`, `u5 24h`), the
292
321
  tracker's `u5: ...` removals and `delivery to {u5} paused` pauses, docket's `u5 <message>` delivery
@@ -393,15 +422,18 @@ a price back the page's address -- exactly as the web editor's empty fields do.
393
422
  value: it is refused as the wrong JSON type (send `""` to clear a note).
394
423
 
395
424
  Which types: `GET /tasks`, `GET /tasks/<id>`, pause, resume and `DELETE` work on any task you own,
396
- whatever its type; `POST /tasks` and `PATCH` take the three editor types (reminder, renewal,
397
- price), and a `PATCH` of a task of another type answers `404`, as the web editor does.
425
+ whatever its type. `POST /tasks` takes the three editor types (reminder, renewal, price) and,
426
+ while research is available on this bot, `research` (#82), by `/research`'s rules; while it is not,
427
+ a `research` create answers `503 unavailable`. `PATCH` takes the three editor types only: a
428
+ research request cannot be edited, here or in Discord, and a `PATCH` of one answers
429
+ `409 conflict`.
398
430
 
399
431
  | Method and path | What |
400
432
  |---|---|
401
433
  | `GET /api/v1/me` | The token's owner (id, name, zone, preferred hour) and the token (id, name, made, expires). |
402
- | `GET /api/v1/types` | Each type's create and edit fields: name, JSON type, required (never, for an edit), description, and limits (`maxLength`, `minimum`, `maximum`, `enum`). This describes the tracker's own editor fields -- what these endpoints take -- and is **not** docket-core's `TaskType.intake` (`IntakeSpec`), which describes a type's config. |
434
+ | `GET /api/v1/types` | Each type this bot makes now (research only while it is available), whether a `PATCH` takes it (`editable`; false for research, whose `edit` is empty), and its create and edit fields: name, JSON type, required (never, for an edit), description, and limits (`maxLength`, `minimum`, `maximum`, `enum`). This describes the tracker's own editor fields -- what these endpoints take -- and is **not** docket-core's `TaskType.intake` (`IntakeSpec`), which describes a type's config. |
403
435
  | `GET /api/v1/tasks` | Your tasks that are not deleted -- active, paused and done -- oldest first. Not the ones shared with you. |
404
- | `POST /api/v1/tasks` | Makes one: `type` is `reminder`, `renewal` or `price`, and the rest are that type's create fields. `201`, with `Location`. A price's page is read first, and nothing is made unless a price is found in it. |
436
+ | `POST /api/v1/tasks` | Makes one: `type` is `reminder`, `renewal`, `price` or `research`, and the rest are that type's create fields. `201`, with `Location`. A price's page is read first, and nothing is made unless a price is found in it. A research request's fields are `question` (required), `context`, `deadline` and `at`, all text. |
405
437
  | `GET /api/v1/tasks/<id>` | One of your tasks (a deleted one too), with its history: the newest runs and changes, as `/task history` shows them. |
406
438
  | `PATCH /api/v1/tasks/<id>` | Edits it: the fields to change. A price's page is not editable. |
407
439
  | `POST /api/v1/tasks/<id>/pause`, `/resume` | Body `{}`. As the web's Pause and Resume, and `/task resume`. |
@@ -411,7 +443,8 @@ A task is `{"id", "type", "title", "status", "cadence", "nextAt", "createdAt", "
411
443
  "settings"}`, and a price also has a top-level `url`: `status` is `active`, `paused`, `done` or
412
444
  `deleted`; `cadence` is the schedule in words, in your zone; `nextAt` the next run's instant (UTC),
413
445
  null when paused or nothing is due; `settings` exactly the fields a `PATCH` takes, as the task has
414
- them. A price's `url` is read-only -- another page is another tracker -- so it is not in `settings`
446
+ them. A research request's `settings` are what was asked -- `question`, and `context` and
447
+ `deadline` (an instant) when given -- shown to you alone and changed by no `PATCH`. A price's `url` is read-only -- another page is another tracker -- so it is not in `settings`
415
448
  and a `PATCH` naming it is refused (`unknown_field`). A write answers
416
449
  `{"task", "message"}`, `message` being the words the web and the command say.
417
450
 
@@ -428,9 +461,9 @@ and a `PATCH` naming it is refused (`unknown_field`). A write answers
428
461
  | 403 | `not_member` | The owner has left every `TRACKER_GUILD_ID` server; their tokens are revoked. |
429
462
  | 404 | `not_found` | No such endpoint, or no such task of yours. Anyone else's task -- one shared with you, or any task to an admin's token -- answers exactly as an unknown id. |
430
463
  | 405 | `method_not_allowed` | With `Allow`. |
431
- | 409 | `conflict` | A finished task edited, or a pause of a task not active (a resume of one not paused). |
432
- | 409 | `limit_reached` | 200 active or paused tasks, or 20 price trackers, as the commands count them. |
433
- | 409 | `busy` | Your last new price tracker's page is still being read. |
464
+ | 409 | `conflict` | A finished task edited, a research request edited, or a pause of a task not active (a resume of one not paused). |
465
+ | 409 | `limit_reached` | 200 active or paused tasks, 20 price trackers, or 5 research requests waiting, as the commands count them. |
466
+ | 409 | `busy` | Your last new price tracker's page is still being read, or the task is with the model runner right now (try again in a minute). |
434
467
  | 413 | `too_large` | Body over 16 KiB. |
435
468
  | 415 | `unsupported_media_type` | Not `application/json`. |
436
469
  | 429 | `rate_limited` | With `Retry-After`: this token's bucket, or the shared one for bad tokens. |
@@ -506,15 +539,17 @@ key>`, so two databases never collide.
506
539
 
507
540
  ## Not yet
508
541
 
509
- - The rest of the web area (E5): sharing a task from the web, and the admin view's grants, budgets
510
- (raising a person's ceiling) and retry. Research is made in Discord only (the web editor and
511
- the task API cover reminders, renewals and prices). The task API covers reminders, renewals and prices only --
512
- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
542
+ - The rest of the web area (E5): sharing a task from the web, and the admin view's grants and
543
+ retry. The task API makes reminders, renewals, prices and
544
+ research requests -- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
513
545
  (E10, deferred). Discord OAuth2 as a second sign-in
514
546
  method, if chosen (plan item 41).
515
547
  - The rest of #82: per-type grants beyond tier 0 (research needs only `notify`), the transcripts
516
- policy, and live verification against a real city-hall and docket-runner (city-hall#18 is not
517
- merged). The scout and the want-list (E9).
548
+ policy, and live verification against a real city-hall and docket-runner
549
+ ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18) is merged as
550
+ 90a06ec, its decision record still "proposed" pending
551
+ [Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17); the research lane
552
+ still needs a deployed city-hall with a runner). The scout and the want-list (E9).
518
553
  - Editing in Discord (the editor is on the web only), and changing a price tracker's page or `near` after it is made (make a new one); a free-form pattern for `price` (`near` is the safe subset: a user's regular expression run on a large page could hang the bot).
519
554
  - An optional Discord-role gate (plan 5.5), and showing unconfirmed deliveries to admins anywhere but the log, or a run's deliveries in its history.
520
555
 
package/dist/plugin.js CHANGED
@@ -3189,6 +3189,206 @@ class Admissions {
3189
3189
  }
3190
3190
  }
3191
3191
 
3192
+ // plugins/tracker/src/admin.ts
3193
+ var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
3194
+ var NO_SUCH_BLOCK = "There is no such block in force.";
3195
+ var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
3196
+ var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
3197
+ var TICK_WAIT_MS = 20000;
3198
+ var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
3199
+ var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
3200
+ function isConfiguredAdmin(d, u) {
3201
+ return u.discordId !== null && d.configuredAdmins.has(u.discordId);
3202
+ }
3203
+ function ticksSettled(d) {
3204
+ return d.lanes.idle(TICK_WAIT_MS);
3205
+ }
3206
+ function isPersonId(id) {
3207
+ return /^u[1-9][0-9]{0,15}$/.test(id);
3208
+ }
3209
+ async function person(d, id) {
3210
+ return isPersonId(id) ? d.store.getUser(id) : null;
3211
+ }
3212
+ function who(u) {
3213
+ return u.displayName ?? u.discordId ?? u.id;
3214
+ }
3215
+ async function setAdminFlag(d, admin, targetId, flag) {
3216
+ if (!admin.admin)
3217
+ return { ok: false, error: NOT_ADMIN };
3218
+ const target = await person(d, targetId);
3219
+ if (!target)
3220
+ return { ok: false, error: NO_SUCH_PERSON };
3221
+ if (target.admin === flag)
3222
+ return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
3223
+ if (!flag && d.roster.admins() <= 1)
3224
+ return { ok: false, error: LAST_ADMIN };
3225
+ if (!flag && isConfiguredAdmin(d, target))
3226
+ return { ok: false, error: CONFIGURED_ADMIN };
3227
+ await d.store.updateUser(target.id, { admin: flag });
3228
+ d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
3229
+ return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
3230
+ }
3231
+ async function resumeDelivery(d, admin, targetId) {
3232
+ if (!admin.admin)
3233
+ return { ok: false, error: NOT_ADMIN };
3234
+ const target = await person(d, targetId);
3235
+ if (!target)
3236
+ return { ok: false, error: NO_SUCH_PERSON };
3237
+ const resumed = await d.health.resume(target.id, d.clock.now());
3238
+ if (!resumed)
3239
+ return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
3240
+ return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
3241
+ }
3242
+ async function liftDeclineBlock(d, admin, blockId) {
3243
+ if (!admin.admin)
3244
+ return { ok: false, error: NOT_ADMIN };
3245
+ const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
3246
+ const now = d.clock.now();
3247
+ if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
3248
+ return { ok: false, error: NO_SUCH_BLOCK };
3249
+ await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
3250
+ const owner = await d.store.getUser(block.ownerId);
3251
+ const recipient = await d.store.getUser(block.recipientId);
3252
+ return {
3253
+ ok: true,
3254
+ text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
3255
+ };
3256
+ }
3257
+ async function forgetPerson(d, actor, targetId) {
3258
+ if (actor.id !== targetId && !actor.admin)
3259
+ return { ok: false, error: NOT_ADMIN };
3260
+ const target = await person(d, targetId);
3261
+ if (!target)
3262
+ return { ok: false, error: NO_SUCH_PERSON };
3263
+ if (target.admin && d.roster.admins() <= 1)
3264
+ return { ok: false, error: LAST_ADMIN };
3265
+ if (actor.id !== target.id && isConfiguredAdmin(d, target))
3266
+ return { ok: false, error: CONFIGURED_ADMIN };
3267
+ const now = d.clock.now();
3268
+ const erased = await d.health.exclusive(async () => {
3269
+ if (d.lanes.busy())
3270
+ return null;
3271
+ const e = d.roster.erase(target.id);
3272
+ for (const taskId of e.freed)
3273
+ await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
3274
+ return e;
3275
+ });
3276
+ if (erased === null)
3277
+ return { ok: false, error: BUSY };
3278
+ if (!d.roster.checkpoint())
3279
+ d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
3280
+ const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
3281
+ d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
3282
+ return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
3283
+ }
3284
+
3285
+ // plugins/tracker/src/ceilings.ts
3286
+ function boundsOf(policy = DEFAULT_BUDGET) {
3287
+ return { min: policy.person, max: policy.global };
3288
+ }
3289
+ function limitsOf(latest, defaults = DEFAULT_BUDGET.person) {
3290
+ if (!latest || latest.usd === null || latest.calls === null)
3291
+ return defaults;
3292
+ return { usd: latest.usd, calls: latest.calls };
3293
+ }
3294
+ var USD = /^[0-9]{1,4}(\.[0-9]{1,2})?$/;
3295
+ var CALLS = /^[0-9]{1,5}$/;
3296
+ function money2(n) {
3297
+ return `${n.toFixed(2)} USD`;
3298
+ }
3299
+ function decideCeiling(input, bounds = boundsOf()) {
3300
+ if (input.reset)
3301
+ return { ok: true, limits: null };
3302
+ const usdText = input.usd.trim();
3303
+ const callsText = input.calls.trim();
3304
+ if (!USD.test(usdText))
3305
+ return { ok: false, error: "Give the dollars as a number, like 4 or 4.50." };
3306
+ if (!CALLS.test(callsText))
3307
+ return { ok: false, error: "Give the model calls as a whole number, like 40." };
3308
+ const usd = Number(usdText);
3309
+ const calls = Number(callsText);
3310
+ const { min, max } = bounds;
3311
+ if (min.usd !== null && usd < min.usd)
3312
+ return { ok: false, error: `The dollars cannot go below the default, ${money2(min.usd)}.` };
3313
+ if (min.calls !== null && calls < min.calls)
3314
+ return { ok: false, error: `The model calls cannot go below the default, ${min.calls}.` };
3315
+ if (max.usd !== null && usd > max.usd)
3316
+ return { ok: false, error: `The dollars cannot go above the global ceiling, ${money2(max.usd)} a day.` };
3317
+ if (max.calls !== null && calls > max.calls)
3318
+ return { ok: false, error: `The model calls cannot go above the global ceiling, ${max.calls} a day.` };
3319
+ if (usd === min.usd && calls === min.calls)
3320
+ return { ok: true, limits: null };
3321
+ return { ok: true, limits: { usd, calls } };
3322
+ }
3323
+ function describeLimits(l) {
3324
+ const usd = l.usd === null ? "no dollar ceiling" : money2(l.usd);
3325
+ const calls = l.calls === null ? "no call ceiling" : `${l.calls} model calls`;
3326
+ return `${usd} and ${calls} a day`;
3327
+ }
3328
+ function isRaised(latest) {
3329
+ return latest !== null && latest.usd !== null && latest.calls !== null;
3330
+ }
3331
+ function budgetNoticeKey(userId, at) {
3332
+ return `budget:person:${userId}:${budgetDay(at).day}`;
3333
+ }
3334
+ var HISTORY_SHOWN = 20;
3335
+ function toChange(r) {
3336
+ return { id: `c${r.seq}`, userId: r.user_id, usd: r.usd, calls: r.calls, setBy: r.set_by, at: r.at };
3337
+ }
3338
+
3339
+ class Ceilings {
3340
+ db;
3341
+ constructor(db) {
3342
+ this.db = db;
3343
+ }
3344
+ latest(userId) {
3345
+ const r = this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT 1").get(userId);
3346
+ return r ? toChange(r) : null;
3347
+ }
3348
+ history(userId, limit = HISTORY_SHOWN) {
3349
+ return this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT ?").all(userId, limit).map(toChange);
3350
+ }
3351
+ record(userId, limits, setBy, at) {
3352
+ return this.db.transaction(() => {
3353
+ const r = this.db.query("INSERT INTO ceiling_changes (user_id, usd, calls, set_by, at) VALUES (?, ?, ?, ?, ?) RETURNING *").get(userId, limits?.usd ?? null, limits?.calls ?? null, setBy, at);
3354
+ this.db.query("DELETE FROM notices WHERE key = ?").run(budgetNoticeKey(userId, new Date(at)));
3355
+ return toChange(r);
3356
+ })();
3357
+ }
3358
+ personFor(user) {
3359
+ const latest = this.latest(user.id);
3360
+ return isRaised(latest) ? limitsOf(latest) : null;
3361
+ }
3362
+ }
3363
+ function budgetPolicy(ceilings, base = DEFAULT_BUDGET) {
3364
+ return { person: base.person, global: base.global, personFor: (u) => ceilings.personFor(u) };
3365
+ }
3366
+ async function spentToday(store, userId, now) {
3367
+ const day = budgetDay(now);
3368
+ const rows = await store.listUsage({ userId, since: day.start, before: day.end });
3369
+ return spent(rows);
3370
+ }
3371
+ function who2(u) {
3372
+ return u.displayName ?? u.discordId ?? u.id;
3373
+ }
3374
+ async function setCeiling(d, admin, targetId, input) {
3375
+ if (!admin.admin)
3376
+ return { ok: false, error: NOT_ADMIN };
3377
+ const target = isPersonId(targetId) ? await d.store.getUser(targetId) : null;
3378
+ if (!target)
3379
+ return { ok: false, error: NO_SUCH_PERSON };
3380
+ const decided = decideCeiling(input);
3381
+ if (!decided.ok)
3382
+ return { ok: false, error: decided.error };
3383
+ const before = d.ceilings.latest(target.id);
3384
+ if (decided.limits === null && !isRaised(before))
3385
+ return { ok: true, text: `${who2(target)} is already on the default ceiling.` };
3386
+ const change = d.ceilings.record(target.id, decided.limits, admin.id, d.clock.now().toISOString());
3387
+ const now = limitsOf(change);
3388
+ 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.` };
3390
+ }
3391
+
3192
3392
  // plugins/tracker/src/delivery-health.ts
3193
3393
  var PAUSE_AFTER = 3;
3194
3394
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
@@ -4201,6 +4401,18 @@ var MIGRATIONS = [
4201
4401
  paused_at TEXT
4202
4402
  );
4203
4403
  CREATE INDEX executor_jobs_occurrence ON executor_jobs (occurrence_id);
4404
+ `,
4405
+ `
4406
+ CREATE TABLE ceiling_changes (
4407
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4408
+ user_id TEXT NOT NULL,
4409
+ usd REAL,
4410
+ calls INTEGER,
4411
+ set_by TEXT NOT NULL,
4412
+ at TEXT NOT NULL,
4413
+ CHECK ((usd IS NULL) = (calls IS NULL))
4414
+ );
4415
+ CREATE INDEX ceiling_changes_user ON ceiling_changes (user_id, seq);
4204
4416
  `
4205
4417
  ];
4206
4418
  var BUSY_TIMEOUT_MS = 5000;
@@ -6849,6 +7061,8 @@ class Roster {
6849
7061
  del("web_login_tokens", "user_id = ?", userId);
6850
7062
  del("api_tokens", "user_id = ?", userId);
6851
7063
  del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
7064
+ del("ceiling_changes", "user_id = ?", userId);
7065
+ this.db.query("UPDATE ceiling_changes SET set_by = ? WHERE set_by = ?").run(FORGOTTEN, userId);
6852
7066
  del("users", "seq = ?", Number(seq));
6853
7067
  const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
6854
7068
  const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
@@ -6857,99 +7071,6 @@ class Roster {
6857
7071
  }
6858
7072
  }
6859
7073
 
6860
- // plugins/tracker/src/admin.ts
6861
- var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
6862
- var NO_SUCH_BLOCK = "There is no such block in force.";
6863
- var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
6864
- var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
6865
- var TICK_WAIT_MS = 20000;
6866
- var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
6867
- var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
6868
- function isConfiguredAdmin(d, u) {
6869
- return u.discordId !== null && d.configuredAdmins.has(u.discordId);
6870
- }
6871
- function ticksSettled(d) {
6872
- return d.lanes.idle(TICK_WAIT_MS);
6873
- }
6874
- function isPersonId(id) {
6875
- return /^u[1-9][0-9]{0,15}$/.test(id);
6876
- }
6877
- async function person(d, id) {
6878
- return isPersonId(id) ? d.store.getUser(id) : null;
6879
- }
6880
- function who(u) {
6881
- return u.displayName ?? u.discordId ?? u.id;
6882
- }
6883
- async function setAdminFlag(d, admin, targetId, flag) {
6884
- if (!admin.admin)
6885
- return { ok: false, error: NOT_ADMIN };
6886
- const target = await person(d, targetId);
6887
- if (!target)
6888
- return { ok: false, error: NO_SUCH_PERSON };
6889
- if (target.admin === flag)
6890
- return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
6891
- if (!flag && d.roster.admins() <= 1)
6892
- return { ok: false, error: LAST_ADMIN };
6893
- if (!flag && isConfiguredAdmin(d, target))
6894
- return { ok: false, error: CONFIGURED_ADMIN };
6895
- await d.store.updateUser(target.id, { admin: flag });
6896
- d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
6897
- return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
6898
- }
6899
- async function resumeDelivery(d, admin, targetId) {
6900
- if (!admin.admin)
6901
- return { ok: false, error: NOT_ADMIN };
6902
- const target = await person(d, targetId);
6903
- if (!target)
6904
- return { ok: false, error: NO_SUCH_PERSON };
6905
- const resumed = await d.health.resume(target.id, d.clock.now());
6906
- if (!resumed)
6907
- return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
6908
- return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
6909
- }
6910
- async function liftDeclineBlock(d, admin, blockId) {
6911
- if (!admin.admin)
6912
- return { ok: false, error: NOT_ADMIN };
6913
- const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
6914
- const now = d.clock.now();
6915
- if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
6916
- return { ok: false, error: NO_SUCH_BLOCK };
6917
- await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
6918
- const owner = await d.store.getUser(block.ownerId);
6919
- const recipient = await d.store.getUser(block.recipientId);
6920
- return {
6921
- ok: true,
6922
- text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
6923
- };
6924
- }
6925
- async function forgetPerson(d, actor, targetId) {
6926
- if (actor.id !== targetId && !actor.admin)
6927
- return { ok: false, error: NOT_ADMIN };
6928
- const target = await person(d, targetId);
6929
- if (!target)
6930
- return { ok: false, error: NO_SUCH_PERSON };
6931
- if (target.admin && d.roster.admins() <= 1)
6932
- return { ok: false, error: LAST_ADMIN };
6933
- if (actor.id !== target.id && isConfiguredAdmin(d, target))
6934
- return { ok: false, error: CONFIGURED_ADMIN };
6935
- const now = d.clock.now();
6936
- const erased = await d.health.exclusive(async () => {
6937
- if (d.lanes.busy())
6938
- return null;
6939
- const e = d.roster.erase(target.id);
6940
- for (const taskId of e.freed)
6941
- await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
6942
- return e;
6943
- });
6944
- if (erased === null)
6945
- return { ok: false, error: BUSY };
6946
- if (!d.roster.checkpoint())
6947
- d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
6948
- const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
6949
- d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
6950
- return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
6951
- }
6952
-
6953
7074
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
6954
7075
  var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
6955
7076
  box-sizing: border-box;
@@ -8289,14 +8410,15 @@ function cadence(entry, viewer, now) {
8289
8410
  return "once";
8290
8411
  return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
8291
8412
  }
8292
- function newLinks(v) {
8413
+ function newLinks(v, research) {
8293
8414
  return html`<p class="tr-row">
8294
8415
  <a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
8295
8416
  <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
8296
8417
  <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
8418
+ ${research ? html`<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/research">New research request</a>` : null}
8297
8419
  </p>`;
8298
8420
  }
8299
- function tasksPage(v, data, now, flash = null) {
8421
+ function tasksPage(v, data, now, flash = null, research = false) {
8300
8422
  const rows = data.entries.map((e) => html`<tr>
8301
8423
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
8302
8424
  <td>${e.from ? name3(e.from) : "you"}</td>
@@ -8315,7 +8437,7 @@ function tasksPage(v, data, now, flash = null) {
8315
8437
  <h1>My tasks</h1>
8316
8438
  ${flash}
8317
8439
  <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
8318
- ${newLinks(v)}
8440
+ ${newLinks(v, research)}
8319
8441
  ${active}
8320
8442
  </section>
8321
8443
  ${paused}
@@ -8405,6 +8527,7 @@ function errorPage(base, title, message) {
8405
8527
 
8406
8528
  // plugins/tracker/src/web/editor-pages.ts
8407
8529
  var EDITOR_TYPES = ["reminder", "renewal", "price"];
8530
+ var NEW_TYPES = [...EDITOR_TYPES, "research"];
8408
8531
  var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
8409
8532
  var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
8410
8533
  var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
@@ -8414,8 +8537,16 @@ var PRICE_SETTINGS = [
8414
8537
  { name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
8415
8538
  { name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
8416
8539
  ];
8540
+ var RESEARCH_FIELDS = [
8541
+ { name: "question", label: "What to look into", kind: "textarea", required: true, maxlength: MAX_QUESTION_CHARS },
8542
+ { name: "context", label: "Context", kind: "textarea", maxlength: MAX_CONTEXT_CHARS, help: "Anything that narrows it down. Only you see it here; it goes to the model with the question." },
8543
+ { name: "deadline", label: "Deadline", kind: "text", maxlength: MAX_WHEN, help: 'For example "fri 17:00". Past it nothing more is run.' },
8544
+ { name: "at", label: "Start", kind: "text", maxlength: MAX_WHEN, help: "Leave it empty to start now." }
8545
+ ];
8417
8546
  function fieldsFor(type, mode) {
8418
8547
  switch (type) {
8548
+ case "research":
8549
+ return mode === "new" ? RESEARCH_FIELDS : [];
8419
8550
  case "reminder":
8420
8551
  return [
8421
8552
  { name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
@@ -8458,6 +8589,13 @@ function field(f, values) {
8458
8589
  <label class="rb-label" for="${f.name}">${f.label}</label>
8459
8590
  <select class="rb-select" id="${f.name}" name="${f.name}">${(f.options ?? []).map(([v, label]) => html`<option value="${v}"${v === value ? html` selected` : null}>${label}</option>`)}</select>
8460
8591
  ${help}
8592
+ </div>`;
8593
+ }
8594
+ if (f.kind === "textarea") {
8595
+ return html`<div class="rb-field">
8596
+ <label class="rb-label" for="${f.name}">${f.label}</label>
8597
+ <textarea class="rb-textarea" id="${f.name}" name="${f.name}" rows="4"${f.maxlength ? html` maxlength="${f.maxlength}"` : null}${f.required ? html` required` : null}>${value}</textarea>
8598
+ ${help}
8461
8599
  </div>`;
8462
8600
  }
8463
8601
  const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
@@ -8473,19 +8611,25 @@ function alert(error) {
8473
8611
  function taskHref(v, id, action = "") {
8474
8612
  return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
8475
8613
  }
8476
- var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
8477
- function newTaskPage(v, type, values, error) {
8478
- const intro = type === "price" ? html`<p class="rb-muted">I read the page once now; nothing is made unless I find a price in it.</p>` : null;
8479
- return framed(v, `New ${NOUN[type]}`, html`<section>
8480
- <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
8481
- <h1>New ${NOUN[type]}</h1>
8482
- ${intro}
8483
- ${alert(error)}
8614
+ var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker", research: "research request" };
8615
+ var INTRO = {
8616
+ price: "I read the page once now; nothing is made unless I find a price in it.",
8617
+ research: "A research run looks it up on the web, then a second run checks the answer against its sources; only an answer that passes is DMed to you. It can take a while, and requests share a daily budget."
8618
+ };
8619
+ function newTaskPage(v, type, values, error, off) {
8620
+ const text = INTRO[type];
8621
+ const intro = text ? html`<p class="rb-muted">${text}</p>` : null;
8622
+ const form = off ? html`<div class="rb-alert rb-alert--warning" role="status"><p>${prose(off)}</p></div>` : html`${alert(error)}
8484
8623
  <form method="post" action="${v.base}/new/${type}" class="tr-stack">
8485
8624
  <input type="hidden" name="csrf" value="${v.csrf}">
8486
8625
  ${fieldsFor(type, "new").map((f) => field(f, values))}
8487
8626
  <div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
8488
- </form>
8627
+ </form>`;
8628
+ return framed(v, `New ${NOUN[type]}`, html`<section>
8629
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
8630
+ <h1>New ${NOUN[type]}</h1>
8631
+ ${intro}
8632
+ ${form}
8489
8633
  </section>`);
8490
8634
  }
8491
8635
  function editTaskPage(v, task, values, o) {
@@ -8704,6 +8848,45 @@ ${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
8704
8848
  </section>`);
8705
8849
  }
8706
8850
 
8851
+ // plugins/tracker/src/web/ceiling-pages.ts
8852
+ function setBy(id, names) {
8853
+ if (id === FORGOTTEN)
8854
+ return "an admin since forgotten";
8855
+ return names.get(id) ?? id;
8856
+ }
8857
+ function change(c, v, now, names) {
8858
+ const what = c.usd === null || c.calls === null ? "back to the default" : `raised to ${describeLimits({ usd: c.usd, calls: c.calls })}`;
8859
+ return html`<li>${formatInstant(c.at, v.user.timeZone, now)}: ${what}, by ${setBy(c.setBy, names)}</li>`;
8860
+ }
8861
+ function limitText(n, unit) {
8862
+ return n === null ? `no ${unit} ceiling` : String(n);
8863
+ }
8864
+ function ceilingSection(v, personId, view, now) {
8865
+ const limits = limitsOf(view.latest, view.defaults);
8866
+ const raised = isRaised(view.latest);
8867
+ const action = `${v.base}/admin/people/${encodeURIComponent(personId)}/ceiling`;
8868
+ const csrf = html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8869
+ const { min, max } = view.bounds;
8870
+ return html`<section>
8871
+ <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>
8873
+ <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
+ <form method="post" action="${action}" class="tr-stack">
8875
+ ${csrf}
8876
+ <div class="tr-row">
8877
+ <div class="rb-field"><label class="rb-label" for="ceiling-usd">Dollars a day</label>
8878
+ <input class="rb-input" id="ceiling-usd" name="usd" inputmode="decimal" maxlength="7" value="${limits.usd === null ? "" : limits.usd.toFixed(2)}" required></div>
8879
+ <div class="rb-field"><label class="rb-label" for="ceiling-calls">Model calls a day</label>
8880
+ <input class="rb-input" id="ceiling-calls" name="calls" inputmode="numeric" maxlength="5" value="${limits.calls === null ? "" : String(limits.calls)}" required></div>
8881
+ </div>
8882
+ <p class="rb-muted">From ${limitText(min.usd, "dollar")} to ${limitText(max.usd, "dollar")} dollars; from ${limitText(min.calls, "call")} to ${limitText(max.calls, "call")} calls.</p>
8883
+ <div class="tr-row"><button class="rb-btn rb-btn--accent" type="submit">Set ceiling</button></div>
8884
+ </form>
8885
+ ${raised ? html`<form method="post" action="${action}">${csrf}<input type="hidden" name="reset" value="yes"><button class="rb-btn rb-btn--ghost" type="submit">Back to the default</button></form>` : null}
8886
+ ${view.history.length === 0 ? html`<p class="rb-muted">No admin has changed their ceiling.</p>` : html`<h3>Changes</h3><p class="rb-muted">The latest ${HISTORY_SHOWN} at most, newest first.</p><ul>${view.history.map((c) => change(c, v, now, view.names))}</ul>`}
8887
+ </section>`;
8888
+ }
8889
+
8707
8890
  // plugins/tracker/src/web/admin-pages.ts
8708
8891
  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.";
8709
8892
  var CONFIRM_WORD = "forget";
@@ -8834,7 +9017,7 @@ ${data.rows.length === 0 ? html`<p class="rb-muted">None in that time.</p>` : ht
8834
9017
  function post2(v, p, action, label, style) {
8835
9018
  return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
8836
9019
  }
8837
- function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
9020
+ function personPage(v, p, tasks, now, result = null, configured = false, tokens = [], budget) {
8838
9021
  return framed(v, nameOf(p), html`<section>
8839
9022
  ${adminNav(v)}
8840
9023
  <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
@@ -8853,6 +9036,7 @@ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn
8853
9036
  ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
8854
9037
  </div>
8855
9038
  </section>
9039
+ ${budget ? ceilingSection(v, p.id, budget, now) : null}
8856
9040
  <section>
8857
9041
  <h2>Their tasks</h2>
8858
9042
  ${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
@@ -8920,12 +9104,22 @@ async function peoplePage(a, result, status, allow) {
8920
9104
  return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
8921
9105
  }
8922
9106
  async function onePerson(a, id, result = null) {
8923
- const p = a.d.roster.people().find((x) => x.id === id);
9107
+ const everyone = a.d.roster.people();
9108
+ const p = everyone.find((x) => x.id === id);
8924
9109
  if (!p)
8925
9110
  return htmlResponse(notFoundPage(a.v.base, a.v), 404);
8926
9111
  const tasks = await a.d.store.listTasks({ ownerId: p.id });
8927
- const tokens = a.d.apiTokens.listFor(p.id, a.d.clock.now());
8928
- return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p), tokens), result && !result.ok ? 400 : 200);
9112
+ const now = a.d.clock.now();
9113
+ const tokens = a.d.apiTokens.listFor(p.id, now);
9114
+ const budget = {
9115
+ latest: a.d.ceilings.latest(p.id),
9116
+ history: a.d.ceilings.history(p.id),
9117
+ today: await spentToday(a.d.store, p.id, now),
9118
+ defaults: DEFAULT_BUDGET.person,
9119
+ bounds: boundsOf(),
9120
+ names: new Map(everyone.map((x) => [x.id, nameOf(x)]))
9121
+ };
9122
+ return htmlResponse(personPage(a.v, p, tasks, now, result, isConfiguredAdmin(a.d, p), tokens, budget), result && !result.ok ? 400 : 200);
8929
9123
  }
8930
9124
  async function adminGet(a, r) {
8931
9125
  if (r.kind === "admin")
@@ -8976,6 +9170,15 @@ async function adminPost(a, r, form) {
8976
9170
  return htmlResponse(notFoundPage(base, a.v), 404);
8977
9171
  return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
8978
9172
  }
9173
+ if (r.kind === "admin-ceiling") {
9174
+ const input = form.get("reset") === "yes" ? { reset: true } : { reset: false, usd: form.get("usd") ?? "", calls: form.get("calls") ?? "" };
9175
+ const done = await fresh(a, true, (me) => setCeiling(a.d, me, r.id, input));
9176
+ if (done === null)
9177
+ return unknownPage(base);
9178
+ if (!done.ok && done.error === NO_SUCH_PERSON)
9179
+ return htmlResponse(notFoundPage(base, a.v), 404);
9180
+ return onePerson(a, r.id, said2(done));
9181
+ }
8979
9182
  if (r.kind !== "admin-act")
8980
9183
  return unknownPage(base);
8981
9184
  if (r.action === "forget") {
@@ -9233,6 +9436,21 @@ function reminderInput(form) {
9233
9436
  const when = opt(form, "when");
9234
9437
  return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
9235
9438
  }
9439
+ function lines(v) {
9440
+ return v?.replace(/\r\n?/g, `
9441
+ `);
9442
+ }
9443
+ function researchInput(form) {
9444
+ const context = lines(opt(form, "context"));
9445
+ const deadline = opt(form, "deadline");
9446
+ const at = opt(form, "at");
9447
+ return {
9448
+ question: lines(str2(form, "question")) ?? "",
9449
+ ...context !== undefined ? { context } : {},
9450
+ ...deadline !== undefined ? { deadline } : {},
9451
+ ...at !== undefined ? { at } : {}
9452
+ };
9453
+ }
9236
9454
  function renewalInput(form) {
9237
9455
  const every = int(form, "every");
9238
9456
  const lead = int(form, "lead");
@@ -9336,6 +9554,8 @@ async function makeTask(w, type, form) {
9336
9554
  return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
9337
9555
  if (type === "renewal")
9338
9556
  return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
9557
+ if (type === "research")
9558
+ return asUser(w, (u) => createResearch(w.d, u, researchInput(form)));
9339
9559
  const input = priceInput(form);
9340
9560
  if (w.reading.has(w.userId))
9341
9561
  return { ok: false, error: READING };
@@ -9369,9 +9589,12 @@ function actOn(w, taskId, action) {
9369
9589
  var DEFAULTS = {
9370
9590
  reminder: { repeat: "none" },
9371
9591
  renewal: { unit: "year", every: "1", lead: "7" },
9372
- price: { hours: "12", drop: "10", baseline: "last" }
9592
+ price: { hours: "12", drop: "10", baseline: "last" },
9593
+ research: {}
9373
9594
  };
9374
9595
  function newGet(e, type) {
9596
+ if (type === "research" && !e.d.research)
9597
+ return htmlResponse(newTaskPage(e.v, type, {}, undefined, RESEARCH_OFF));
9375
9598
  return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
9376
9599
  }
9377
9600
  async function newPost(e, type, form) {
@@ -9438,7 +9661,11 @@ function jsonKind(f) {
9438
9661
  return "integer";
9439
9662
  return f.decimal ? "number" : "string";
9440
9663
  }
9441
- function typesAnswer() {
9664
+ function offeredTypes(d) {
9665
+ return d.research ? NEW_TYPES : EDITOR_TYPES;
9666
+ }
9667
+ var NOT_EDITABLE = "A research request cannot be edited. Delete it and ask again.";
9668
+ function typesAnswer(d) {
9442
9669
  const describe = (fields, mode) => fields.map((f) => ({
9443
9670
  name: f.name,
9444
9671
  type: jsonKind(f),
@@ -9451,7 +9678,14 @@ function typesAnswer() {
9451
9678
  }));
9452
9679
  return {
9453
9680
  status: 200,
9454
- body: { types: EDITOR_TYPES.map((type) => ({ type, create: describe(fieldsFor(type, "new"), "new"), edit: describe(fieldsFor(type, "edit"), "edit") })) }
9681
+ body: {
9682
+ types: offeredTypes(d).map((type) => ({
9683
+ type,
9684
+ editable: type !== "research",
9685
+ create: describe(fieldsFor(type, "new"), "new"),
9686
+ edit: describe(fieldsFor(type, "edit"), "edit")
9687
+ }))
9688
+ }
9455
9689
  };
9456
9690
  }
9457
9691
  function asFields(body, type, mode) {
@@ -9481,8 +9715,12 @@ function refusal(error) {
9481
9715
  return notFound();
9482
9716
  if (error === NO_LONGER_LISTED)
9483
9717
  return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
9484
- if (error === READING)
9718
+ if (error === READING || error === TASK_BUSY)
9485
9719
  return problem(409, "busy", error);
9720
+ if (error === NOT_EDITABLE)
9721
+ return problem(409, "conflict", error);
9722
+ if (error === RESEARCH_OFF)
9723
+ return problem(503, "unavailable", error);
9486
9724
  if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
9487
9725
  return problem(409, "conflict", error);
9488
9726
  if (/^You already (have|track) [0-9]+ /.test(error))
@@ -9501,6 +9739,14 @@ async function taskJson(d, user, task) {
9501
9739
  const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
9502
9740
  const values = editValues(d, user, task);
9503
9741
  const settings = {};
9742
+ if (task.type === "research") {
9743
+ const c = task.config;
9744
+ settings.question = c.question;
9745
+ if (c.context !== undefined)
9746
+ settings.context = c.context;
9747
+ if (c.deadline !== undefined)
9748
+ settings.deadline = c.deadline;
9749
+ }
9504
9750
  if (type) {
9505
9751
  for (const f of fieldsFor(type, "edit")) {
9506
9752
  if (f.name === "when")
@@ -9559,9 +9805,11 @@ async function written(w, user, result, status, base) {
9559
9805
  }
9560
9806
  async function createAnswer(w, user, body, base) {
9561
9807
  const type = body.type;
9562
- if (typeof type !== "string" || !EDITOR_TYPES.includes(type)) {
9563
- return problem(400, "invalid", `\`type\` is one of ${EDITOR_TYPES.join(", ")}.`);
9808
+ if (typeof type !== "string" || !NEW_TYPES.includes(type)) {
9809
+ return problem(400, "invalid", `\`type\` is one of ${offeredTypes(w.d).join(", ")}.`);
9564
9810
  }
9811
+ if (type === "research" && !w.d.research)
9812
+ return refusal(RESEARCH_OFF);
9565
9813
  const form = asFields(body, type, "new");
9566
9814
  if (!(form instanceof URLSearchParams))
9567
9815
  return form;
@@ -9570,7 +9818,7 @@ async function createAnswer(w, user, body, base) {
9570
9818
  async function editAnswer(w, user, id, body, base) {
9571
9819
  const task = await editableTask(w.d, user, id);
9572
9820
  if (!task)
9573
- return notFound();
9821
+ return (await ownLiveTask(w.d, user, id))?.type === "research" ? refusal(NOT_EDITABLE) : notFound();
9574
9822
  const form = asFields(body, task.type, "edit");
9575
9823
  if (!(form instanceof URLSearchParams))
9576
9824
  return form;
@@ -9756,7 +10004,7 @@ async function handleApi(w, request, path) {
9756
10004
  if (method === "DELETE" && r.kind === "task")
9757
10005
  return json2(await actAnswer(writer, user, r.id, "delete"));
9758
10006
  if (r.kind === "types")
9759
- return json2(typesAnswer());
10007
+ return json2(typesAnswer(w.d));
9760
10008
  if (r.kind === "tasks")
9761
10009
  return json2(await listAnswer(w.d, user));
9762
10010
  if (r.kind === "task")
@@ -9846,6 +10094,7 @@ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
9846
10094
  var NEW_PATH = /^\/new\/([a-z]+)$/;
9847
10095
  var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
9848
10096
  var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
10097
+ var PERSON_CEILING = /^\/admin\/people\/([^/]+)\/ceiling$/;
9849
10098
  var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
9850
10099
  var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
9851
10100
  var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
@@ -9884,6 +10133,9 @@ function route2(path) {
9884
10133
  return { kind: "admin-deliveries" };
9885
10134
  if (path === "/admin/allow")
9886
10135
  return { kind: "admin-allow" };
10136
+ const ceiling = PERSON_CEILING.exec(path);
10137
+ if (ceiling)
10138
+ return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
9887
10139
  const personAct = PERSON_ACTION.exec(path);
9888
10140
  if (personAct)
9889
10141
  return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
@@ -9896,7 +10148,7 @@ function route2(path) {
9896
10148
  const created = NEW_PATH.exec(path);
9897
10149
  if (created) {
9898
10150
  const type = created[1];
9899
- return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
10151
+ return NEW_TYPES.includes(type) ? { kind: "new", type } : null;
9900
10152
  }
9901
10153
  const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
9902
10154
  if (!one)
@@ -9910,7 +10162,7 @@ function route2(path) {
9910
10162
  return { kind: "act", id, action };
9911
10163
  }
9912
10164
  function methodsOf(r) {
9913
- if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
10165
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
9914
10166
  return "POST";
9915
10167
  }
9916
10168
  if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
@@ -10206,6 +10458,7 @@ function createWebHandler(w) {
10206
10458
  return tokenRevokePost(d, v, r.id);
10207
10459
  case "admin-allow":
10208
10460
  case "admin-act":
10461
+ case "admin-ceiling":
10209
10462
  case "admin-lift":
10210
10463
  case "admin-token-revoke":
10211
10464
  return adminPost(a, r, form);
@@ -10217,7 +10470,7 @@ function createWebHandler(w) {
10217
10470
  const done = url.searchParams.get("done");
10218
10471
  switch (r.kind) {
10219
10472
  case "tasks":
10220
- return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
10473
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done), d.research === true));
10221
10474
  case "task":
10222
10475
  return taskPage(e, r.id, done);
10223
10476
  case "edit":
@@ -10486,7 +10739,8 @@ function createPlugin(host, options = {}) {
10486
10739
  ...options.cityHallFetch ? { fetchImpl: options.cityHallFetch } : {}
10487
10740
  });
10488
10741
  }
10489
- executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks }) : null;
10742
+ const ceilings = new Ceilings(opened);
10743
+ executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: budgetPolicy(ceilings) }) : null;
10490
10744
  if (cityHallConfig)
10491
10745
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
10492
10746
  else if ("missing" in cityHall && cityHall.missing.length > 0)
@@ -10506,6 +10760,7 @@ function createPlugin(host, options = {}) {
10506
10760
  sessions: new Sessions(opened),
10507
10761
  apiTokens: new ApiTokens(opened),
10508
10762
  roster: new Roster(opened),
10763
+ ceilings,
10509
10764
  configuredAdmins: new Set(adminIds),
10510
10765
  lanes,
10511
10766
  webEditor: webOrigin !== null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.11.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, an admin view (with the DMs that did not arrive), forget-me, and a JSON task API with personal API tokens",
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",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],