@rackbops/plugin-tracker 0.31.0 → 0.31.3

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 +3 -3
  2. package/dist/plugin.js +145 -33
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -51,8 +51,8 @@ as in a server (plan 5.5, item 39).
51
51
  | `/tasks` | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones, each naming up to three people it could not DM (then how many more). A list longer than one message (Discord's 2000 characters) is cut between whole lines and keeps your paused tasks in preference to the active ones that do not fit, since their ids are what `/task resume` needs (any paused ones that still do not fit are counted); it says how many active ones it left out and, when the web area is set up, points to `/web`, which lists them all. |
52
52
  | `/task done task` / `/task snooze task [until]` | the task's owner | Answers the task's latest reminder (snooze: an hour, or until `until`). |
53
53
  | `/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. |
54
- | `/task history task` | the owner, an accepted recipient, or an admin | The newest 10 runs -- due, status, how it was answered, text replies -- and the newest 5 of the task's changes, older ones counted; sent with Discord's suppress-embeds flag, so a link in a reply, a finding or an error does not unfurl into a preview; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high; for a want-list watch, how many listings it has kept, and once there are any, a second message with the newest of them, up to 20 and as many as fit in one message, then how many more there are (and, while the web area is set up, that the task's page there shows the newest 50). |
55
- | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. |
54
+ | `/task history task` | the owner, an accepted recipient, or an admin | The newest 10 runs -- due, status, how it was answered, text replies -- and the newest 5 of the task's changes, older ones counted (only the shown rows are loaded; SQLite still counts the rest, and for the runs sorts them too); sent with Discord's suppress-embeds flag, so a link in a reply, a finding or an error does not unfurl into a preview; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high; for a want-list watch, how many listings it has kept, and once there are any, a second message with the newest of them, up to 20 and as many as fit in one message, then how many more there are (and, while the web area is set up, that the task's page there shows the newest 50). |
55
+ | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. An invitation still open when the task is deleted or finishes is withdrawn, with a history event; a late Accept or Decline is refused with "That task was deleted." or "That task is done." and records nothing (one on a paused task still counts). |
56
56
  | `/task resume task` | the task's owner | Resumes a paused task: one paused because a recipient could not be DMed goes on without that recipient; one its owner paused on the web simply goes on. |
57
57
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
58
58
  | `/web` | registered | A one-time link to sign in to the web area (below), good for 10 minutes and one use. Says so when `TRACKER_WEB_URL` is unset. |
@@ -703,7 +703,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
703
703
  |---|---|---|
704
704
  | 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. Schema 8 (0.19.0) adds `want_inbox`, the listings sent in for an inbox watch. Schema 9 (0.20.0) adds `users.usr_subject`, a person's usr user id, null until linked, unique when set. Schema 10 (0.25.0) adds `mail_addresses`. Schema 11 (0.28.0) adds `research_sources`, the pages a browser sent in for a research request (at most 10 per request, one per address, 99,800 characters of text in all), dropped once the request ends (by the execute tick's housekeeping, so only while the execute lane is configured) and always erased by forget-me. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
705
705
  | 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`, `wantlist`) 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`, `research`, `scout`, `wantlist` and `wantjudge`. |
706
- | Want-list watcher | `src/want.ts`, `src/inbox.ts`, `src/web/api-listings.ts`; the types and sources are `@rackbops/docket-types`' (0.7.0: `wantlist`, `wantjudge`, `pageSource`, `bggSource`, `inboxSource`) | `/want`'s rules; the `wantlist` type over a `Source` port; the `inbox` source over the `want_inbox` table (schema 8, 0.19.0: the listings sent in per watch, keyed `ebay-` or `bgg-` and 24 random hex digits, the newest 100 kept, as many as one look reads; forget-me erases them with the person's tasks), filled by the task API's listings route; the `page` source (JSON-LD listings through the fenced Fetch port, no HTML guessing) and the `bgg` source (BGG's XML API with the Bearer token, on `boardgamegeek.com`, never `www.`, at least 5 s between requests (a read due sooner is put back for the next tick, never waited on), read by a bounded `indexOf` parser, never a pattern over the whole body; a 401 or 403 is the token refused, a 202, 429 or 5xx a miss). The BGG fixture is hand-written to BGG's documented shape: BGG declined the application, so there is no token to capture a real response with. The judged watch (`wantjudge`) is an execute-lane type: its `prepare` reads the source in plain code (an uncharged `NoJob` when nothing is new), keeps new listings in the task's state as `pending` and asks for a follow-up, whose `prepare` makes the judge Job; `finish` maps the verdicts back by number, so a Job collected after a restart still has its listings. On this lane a BGG read due too soon waits out the spacing once. |
706
+ | Want-list watcher | `src/want.ts`, `src/inbox.ts`, `src/web/api-listings.ts`; the types and sources are `@rackbops/docket-types`' (0.7.0: `wantlist`, `wantjudge`, `pageSource`, `bggSource`, `inboxSource`) | `/want`'s rules; the `wantlist` type over a `Source` port; the `inbox` source over the `want_inbox` table (schema 8, 0.19.0: the listings sent in per watch, keyed `ebay-` or `bgg-` and 24 random hex digits, the newest 100 kept, as many as one look reads; a watch's rows go when it is deleted or finishes (#208), and forget-me erases them with the person's tasks), filled by the task API's listings route; the `page` source (JSON-LD listings through the fenced Fetch port, no HTML guessing) and the `bgg` source (BGG's XML API with the Bearer token, on `boardgamegeek.com`, never `www.`, at least 5 s between requests (a read due sooner is put back for the next tick, never waited on), read by a bounded `indexOf` parser, never a pattern over the whole body; a 401 or 403 is the token refused, a 202, 429 or 5xx a miss). The BGG fixture is hand-written to BGG's documented shape: BGG declined the application, so there is no token to capture a real response with. The judged watch (`wantjudge`) is an execute-lane type: its `prepare` reads the source in plain code (an uncharged `NoJob` when nothing is new), keeps new listings in the task's state as `pending` and asks for a follow-up, whose `prepare` makes the judge Job; `finish` maps the verdicts back by number, so a Job collected after a restart still has its listings. On this lane a BGG read due too soon waits out the spacing once. |
707
707
  | Execute lane | `src/execute-lane.ts`, `src/executor.ts`, `src/research.ts`, `src/research-sources.ts`, `src/web/api-sources.ts`, `src/scout.ts` (the `scout` and `research` types are docket-types'; `research` over `researchType({ readSources })`, 0.8.0, reading a browser request's pages from `research_sources`, and since 0.29.0 a fuller pass's, given only with the web area) | 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 per host tick, about 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 and `/scout`'s rules, and the scout type. |
708
708
  | 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. |
709
709
  | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price` and the want-list's `page` source: 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; a redirect to something that is not a web address is refused), 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 -- at most 200 JSON-LD blocks and 300 meta tags, the first ones in page order (room for a shop search page with one JSON-LD block per product beside its other blocks; the `page` source keeps at most 100 listings a page, docket-types' `MAX_LISTINGS`, and a look takes at most 20 new ones, the rest on the next look), 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. Tag names are matched with only `A`-`Z` folded to lower case, so a letter that changes length when lower-cased (the Turkish `İ`) does not shift the tags after it out of reach. 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. A refusal says why -- the name did not resolve, or it is not on the public internet -- so anyone who may make a price tracker or page watch (a command, the web editor or the task API) can learn which names the bot host's resolver maps to private addresses; one read at a time per person and the API's rate limit slow that, they do not stop it. A want-list page read (docket-types' `page` source) carries `NEVER_EBAY` and is refused on any hop to eBay; a price read is not. |
package/dist/plugin.js CHANGED
@@ -120,13 +120,6 @@ async function visibleOccurrences(store, actor, taskId) {
120
120
  const runs = await store.listOccurrences({ taskId });
121
121
  return seesAll(actor, task) ? runs : runs.map((o) => ({ ...o, record: null }));
122
122
  }
123
- async function visibleReplies(store, actor, taskId) {
124
- const task = await visibleTask(store, actor, taskId);
125
- if (!task)
126
- return null;
127
- const replies = await store.listReplies(taskId);
128
- return seesAll(actor, task) ? replies : replies.filter((r) => r.userId === actor.userId);
129
- }
130
123
  var ABOUT_A_RECIPIENT = new Set([
131
124
  "recipient_invited",
132
125
  "recipient_accepted",
@@ -136,15 +129,6 @@ var ABOUT_A_RECIPIENT = new Set([
136
129
  "blocked",
137
130
  "block_lifted"
138
131
  ]);
139
- async function visibleHistory(store, actor, taskId) {
140
- const task = await visibleTask(store, actor, taskId);
141
- if (!task)
142
- return null;
143
- const events = await store.listTaskEvents(taskId);
144
- if (seesAll(actor, task))
145
- return events;
146
- return events.filter((e) => !ABOUT_A_RECIPIENT.has(e.kind) || e.detail.split(" ")[0] === actor.userId);
147
- }
148
132
  async function visibleSeries(store, actor, taskId, filter) {
149
133
  if (!await visibleTask(store, actor, taskId))
150
134
  return null;
@@ -3110,6 +3094,57 @@ function sourcesExpiry(now, deadline) {
3110
3094
  };
3111
3095
  }
3112
3096
 
3097
+ // node_modules/@rackbops/docket-types/dist/research-web.js
3098
+ var FIRECRAWL_TOOLS = [
3099
+ "mcp__firecrawl__firecrawl_search",
3100
+ "mcp__firecrawl__firecrawl_scrape"
3101
+ ];
3102
+ var WEB_ALLOWED_TOOLS = [...DEFAULT_ALLOWED_TOOLS, ...FIRECRAWL_TOOLS];
3103
+ function firecrawlLines() {
3104
+ return [
3105
+ "Read pages with WebFetch first. When a page refuses WebFetch (a 403, a block, a CAPTCHA " + "or challenge page), read it with `firecrawl_scrape` instead. Always pass " + '`proxy: "basic"` to it; never "stealth", "enhanced" or "auto", and nothing else that ' + "disguises the request. Scrape one page at a time: never several pages of the same site " + "in parallel. Never pass any eBay URL to any tool, Firecrawl included. You may use " + "`firecrawl_search` as you would web search. If Firecrawl is refused too, the page did " + "not open: handle it as a page that refused you, as above."
3106
+ ];
3107
+ }
3108
+ function firecrawlChecks() {
3109
+ return [
3110
+ "- To open a source, use WebFetch first; when a page refuses it (a 403, a block, a CAPTCHA), " + 'read it with `firecrawl_scrape`, always with `proxy: "basic"` (never "stealth", ' + '"enhanced" or "auto", nothing that disguises the request), one page at a time, never ' + "several pages of one site in parallel. Never pass any eBay URL to any tool. You may use " + "`firecrawl_search` as you would web search."
3111
+ ];
3112
+ }
3113
+ var BGG_CATEGORIES = [
3114
+ { name: "Strategy", familyId: 5497 },
3115
+ { name: "Family", familyId: 5499 },
3116
+ { name: "Thematic", familyId: 5496 },
3117
+ { name: "Party", familyId: 5498 },
3118
+ { name: "Abstract", familyId: 4666 },
3119
+ { name: "Customizable", familyId: 4667 },
3120
+ { name: "Children's", familyId: 4665 },
3121
+ { name: "Wargames", familyId: 4664 }
3122
+ ];
3123
+ function bggCategoryUrl(familyId, year) {
3124
+ return "https://boardgamegeek.com/search/boardgame?sort=bggrating&advsearch=1" + `&range[yearpublished][min]=${year}&range[yearpublished][max]=${year}` + `&range[numvoters][min]=30&nosubtypes[0]=boardgameexpansion&familyids[0]=${familyId}`;
3125
+ }
3126
+ function categoryNames() {
3127
+ const names = BGG_CATEGORIES.map((c) => c.name);
3128
+ return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
3129
+ }
3130
+ function categoryLines(second = false) {
3131
+ return [
3132
+ 'When the question asks about categories (or "each of the major" kinds of something) and ' + "the best source defines its own categories, use that site's categories and say so" + (second ? "" : " in the summary") + "; do not invent genres, and do not say that no source defines them. For " + `board games these are BoardGameGeek's ${BGG_CATEGORIES.length} category rankings: ` + `${categoryNames()}. A category with no entry that qualifies is answered "none yet", ` + "with the reason; never leave it out." + (second ? " In this update, the summary names any category whose pick changed." : ` ${COMPACT_RUN}`)
3133
+ ];
3134
+ }
3135
+ var COMPACT_RUN = `The summary itself names every category's pick in one compact run, such as "Strategy: X; ` + `Family: Y; ...; Children's: none yet (no 2026 game has 30 ratings)", so none is lost when ` + "a long answer is cut; the findings carry each pick's source.";
3136
+ function bggSearchHint() {
3137
+ const ids = BGG_CATEGORIES.map((c) => `${c.name} ${c.familyId}`).join(", ");
3138
+ return [
3139
+ "A hint you may use: a BoardGameGeek advanced search shows one category for one year, such " + `as ${bggCategoryUrl(5497, 2026)} (Strategy, 2026). ` + `Change the year and the \`familyids[0]\` value for the category: ${ids}. These ids were ` + "seen on the site, not documented by it: if one shows the wrong category, use that " + "category's own ranking page."
3140
+ ];
3141
+ }
3142
+ function categoryChecks() {
3143
+ return [
3144
+ "- Where the question asks about categories and the best source defines its own (for " + `board games, BoardGameGeek's ${BGG_CATEGORIES.length}: ${categoryNames()}), the answer ` + "uses them and says so. An answer that invents its own categories, or says no source " + "defines them, while the source does, is `revise`d onto the source's categories. A " + 'category with no qualifying entry is answered "none yet" with the reason, never left ' + "out: `revise` an answer that skips one. The summary names every category's pick in " + 'one compact run ("Strategy: X; Family: Y; ..."), since the findings may be cut from ' + "the DM: `revise` a summary that leaves a category to the findings alone."
3145
+ ];
3146
+ }
3147
+
3113
3148
  // node_modules/@rackbops/docket-types/dist/research.js
3114
3149
  function usesSentSources(config) {
3115
3150
  return typeof config.sources === "string" && config.sources.trim().toLowerCase() === "sent";
@@ -3203,9 +3238,13 @@ function earlierBlock(earlier) {
3203
3238
  function disallowed(sent) {
3204
3239
  return [...DEFAULT_DISALLOWED_TOOLS, ...sent ? SENT_DISALLOWED_TOOLS : WEB_DISALLOWED_TOOLS];
3205
3240
  }
3241
+ function allowed(webFirst) {
3242
+ return [...webFirst ? WEB_ALLOWED_TOOLS : DEFAULT_ALLOWED_TOOLS];
3243
+ }
3206
3244
  function researchJob(config, pages, earlier, options = {}) {
3207
3245
  const canWait = options.canWait === true && pages === undefined && earlier === undefined;
3208
3246
  const sent = pages !== undefined;
3247
+ const webFirst = !sent && earlier === undefined;
3209
3248
  const prompt = [
3210
3249
  "You are researching a one-off question for a person, who will read your answer as a " + "Discord DM.",
3211
3250
  "",
@@ -3219,8 +3258,10 @@ function researchJob(config, pages, earlier, options = {}) {
3219
3258
  ""
3220
3259
  ] : [],
3221
3260
  ...partsLines(earlier !== undefined),
3261
+ ...categoryLines(earlier !== undefined),
3222
3262
  "",
3223
3263
  ...bestSourceLines(sent, canWait),
3264
+ ...webFirst ? ["", ...firecrawlLines(), ...bggSearchHint()] : [],
3224
3265
  "",
3225
3266
  "Use web search and fetch pages to check facts. Prefer primary sources (manufacturers, " + "government, utilities, standards bodies). Every claim that a reader might act on needs a " + "source you actually opened. Say plainly where sources disagree or where you are unsure.",
3226
3267
  "",
@@ -3236,7 +3277,7 @@ function researchJob(config, pages, earlier, options = {}) {
3236
3277
  return {
3237
3278
  prompt,
3238
3279
  jsonSchema: ANSWER_SCHEMA,
3239
- allowedTools: [...DEFAULT_ALLOWED_TOOLS],
3280
+ allowedTools: allowed(webFirst),
3240
3281
  disallowedTools: disallowed(sent),
3241
3282
  maxTurns: RESEARCH_MAX_TURNS,
3242
3283
  maxBudgetUsd: RESEARCH_MAX_BUDGET_USD,
@@ -3254,7 +3295,7 @@ function partsLines(second) {
3254
3295
  function bestSourceLines(pages, canWait) {
3255
3296
  return [
3256
3297
  "For each part, decide first which source is the best one for it: the site people in that " + "field treat as the reference (for board games, BoardGameGeek; for listings and sold " + "prices, eBay; otherwise the maker, the standards body or the official record). The " + "answer should rest on it.",
3257
- (pages ? "BoardGameGeek and eBay are not opened from this run: do not try. When the best source " + "is one of them and no gathered page is from it, or a page refused you (a 403, a " + "CAPTCHA or challenge page, an empty or blocked body, a sign-in wall)" : "Open BoardGameGeek like any other site: an ordinary read, with nothing to get round a " + "refusal. eBay is not opened from this run: do not try it. When the best source is " + "eBay, or its page refused you (BoardGameGeek or any other: a 403, a CAPTCHA or " + "challenge page, an empty or blocked body, a sign-in wall)") + (canWait ? ", list it in `blocked` with its site, its URL only when that is the page's real " + "address (such as BoardGameGeek's ranking or browse page), never a guessed one, and " + "why; then answer from the best sources you could open. Never write in `summary` or " + '`uncertain` that you "didn\'t check" it: `blocked` is where that goes, and the ' + "person's own browser can gather it for a fuller pass." : ", answer from the best sources you could open and name the missing source in one " + "short `uncertain` line, such as " + (pages ? '"BoardGameGeek was not among the pages read"' : '"BoardGameGeek refused the request, so its ranking is not used"') + ": no fuller pass follows this answer, so that line is how the person learns of it.")
3298
+ (pages ? "BoardGameGeek and eBay are not opened from this run: do not try. When the best source " + "is one of them and no gathered page is from it, or a page refused you (a 403, a " + "CAPTCHA or challenge page, an empty or blocked body, a sign-in wall)" : "Open BoardGameGeek like any other site: an ordinary read, with nothing that disguises " + "the request; a page that refuses WebFetch gets one more try through " + "`firecrawl_scrape`, as below (0.11.0). eBay is not opened from this run: do not try it. When the best source is " + "eBay, or its page refused you (BoardGameGeek or any other: a 403, a CAPTCHA or " + "challenge page, an empty or blocked body, a sign-in wall)") + (canWait ? ", list it in `blocked` with its site, its URL only when that is the page's real " + "address (such as BoardGameGeek's ranking or browse page), never a guessed one, and " + "why; then answer from the best sources you could open. Never write in `summary` or " + '`uncertain` that you "didn\'t check" it: `blocked` is where that goes, and the ' + "person's own browser can gather it for a fuller pass." : ", answer from the best sources you could open and name the missing source in one " + "short `uncertain` line, such as " + (pages ? '"BoardGameGeek was not among the pages read"' : '"BoardGameGeek refused the request, so its ranking is not used"') + ": no fuller pass follows this answer, so that line is how the person learns of it.")
3258
3299
  ];
3259
3300
  }
3260
3301
  function reviewJob(config, draft, pages, earlier, options = {}) {
@@ -3278,8 +3319,8 @@ function reviewJob(config, draft, pages, earlier, options = {}) {
3278
3319
  "- A claim citing a gathered page's `url`: check it against that page's text above; " + "do not try to open it, and do not open BoardGameGeek or eBay at all.",
3279
3320
  "- A claim resting only on a BoardGameGeek or eBay address that is not one of the " + "gathered pages cannot be checked: move it to `uncertain`."
3280
3321
  ] : [],
3281
- ...earlier ? updateChecks() : answerChecks(),
3282
- ...earlier || sent ? [] : bestSourceChecks(canWait),
3322
+ ...earlier ? updateChecks() : [...answerChecks(), ...categoryChecks()],
3323
+ ...earlier || sent ? [] : [...bestSourceChecks(canWait), ...firecrawlChecks()],
3283
3324
  "",
3284
3325
  "Then give a verdict: `approve` if the draft stands as is and covers every part (return it " + "unchanged in `answer`); `revise` if it stands once corrected (return the corrected " + "answer in `answer`); `reject` if editing cannot fix it. List each problem you found in " + "`problems`, one line each."
3285
3326
  ].join(`
@@ -3287,7 +3328,7 @@ function reviewJob(config, draft, pages, earlier, options = {}) {
3287
3328
  return {
3288
3329
  prompt,
3289
3330
  jsonSchema: REVIEW_SCHEMA,
3290
- allowedTools: [...DEFAULT_ALLOWED_TOOLS],
3331
+ allowedTools: allowed(!sent && earlier === undefined),
3291
3332
  disallowedTools: disallowed(sent),
3292
3333
  maxTurns: REVIEW_MAX_TURNS,
3293
3334
  maxBudgetUsd: REVIEW_MAX_BUDGET_USD,
@@ -3305,7 +3346,7 @@ function answerChecks() {
3305
3346
  function bestSourceChecks(canWait) {
3306
3347
  return [
3307
3348
  "- Name the best source for each part: the site its field treats as the reference (for " + "board games, BoardGameGeek; for listings and sold prices, eBay). If the answer leans on " + "secondary sources while that source is neither among its findings' sources nor " + (canWait ? "in `blocked`, the verdict is `revise`: add it to `blocked` (its site, its URL only " + "when that is the page's real address, such as BoardGameGeek's ranking or browse " + "page, never a guessed one, and why) and keep the answer from the sources it has." : "named in `uncertain`, the verdict is `revise`: add one short `uncertain` line naming " + "it, and keep the answer from the sources it has."),
3308
- "- You may open BoardGameGeek like any site. eBay is never opened by these runs: a claim " + "resting only on an eBay address came from a search listing, not a page anyone opened, " + "so move it to `uncertain`.",
3349
+ "- You may open BoardGameGeek like any site. eBay is never opened by these runs, through " + "any tool (WebFetch, Firecrawl or another): a claim resting only on an eBay address, " + "whichever tool listed or fetched it, is not one an answer may rest on, so move it to " + "`uncertain`.",
3309
3350
  ...canWait ? [
3310
3351
  "- A page that refused is not read, so a claim must not say a source was checked " + `unless a finding cites it. A line saying a source was not checked ("I didn't ` + 'check BoardGameGeek") is no answer: move it to `blocked` on a `revise`.'
3311
3352
  ] : []
@@ -5014,6 +5055,17 @@ function toDelivery(r) {
5014
5055
  settledAt: str2(r.settled_at)
5015
5056
  };
5016
5057
  }
5058
+ var ABOUT_A_RECIPIENT2 = ["recipient_invited", "recipient_accepted", "recipient_declined", "recipient_opted_out", "recipient_removed", "blocked", "block_lifted"];
5059
+ var PREV_PAUSE_OR_RESUME = "(SELECT x.kind FROM task_events x WHERE x.task_id = e.task_id AND x.kind IN ('paused', 'resumed') AND x.seq < e.seq ORDER BY x.seq DESC LIMIT 1)";
5060
+ function recipientShown(taskId, viewerId) {
5061
+ const about = ABOUT_A_RECIPIENT2.map((k) => `'${k}'`).join(", ");
5062
+ const sql = `e.task_id = ? AND (
5063
+ (e.kind NOT IN ('paused', 'resumed') AND (e.kind NOT IN (${about}) OR e.detail = ? OR instr(e.detail, ?) = 1))
5064
+ OR (e.kind = 'resumed' AND ${PREV_PAUSE_OR_RESUME} = 'paused')
5065
+ OR (e.kind = 'paused' AND NOT (${PREV_PAUSE_OR_RESUME} IS 'paused' AND instr(e.detail, 'delivery to ') = 1 AND instr(e.detail, ?) <> 1))
5066
+ )`;
5067
+ return [sql, [taskId, viewerId, `${viewerId} `, `delivery to {${viewerId}} paused:`]];
5068
+ }
5017
5069
  function deleteDeliveriesOf(db, userId) {
5018
5070
  return db.query("DELETE FROM deliveries WHERE user_id = ?").run(userId).changes;
5019
5071
  }
@@ -5111,6 +5163,21 @@ class SqliteStore {
5111
5163
  };
5112
5164
  this.run(`UPDATE tasks SET title = ?, config = ?, state = ?, schedule = ?, capabilities = ?, status = ?, updated_at = ?
5113
5165
  WHERE seq = ?`, next.title, json(next.config), json(next.state), json(next.schedule), json(next.capabilities), next.status, next.updatedAt, seqOf("t", id));
5166
+ const ended = (status) => status === "done" || status === "archived";
5167
+ if (ended(next.status) && !ended(cur.status)) {
5168
+ this.run("DELETE FROM want_inbox WHERE task_id = ?", id);
5169
+ const why = next.status === "archived" ? "deleted" : "finished";
5170
+ for (const r of this.all("SELECT * FROM task_recipients WHERE task_id = ? AND state = 'invited' ORDER BY seq", id).map(toRecipient)) {
5171
+ this.run("DELETE FROM task_recipients WHERE task_id = ? AND user_id = ?", id, r.userId);
5172
+ this.insertTaskEvent({
5173
+ taskId: id,
5174
+ actorId: null,
5175
+ kind: "recipient_removed",
5176
+ detail: `${r.userId}: the task was ${why}, so the invitation is withdrawn`,
5177
+ at: patch.at
5178
+ });
5179
+ }
5180
+ }
5114
5181
  return toTask(this.mustGet("tasks", "t", id, "task"));
5115
5182
  }
5116
5183
  async listRecipients(taskId) {
@@ -5228,6 +5295,9 @@ class SqliteStore {
5228
5295
  return this.all("SELECT * FROM events WHERE occurrence_id = ? AND seq > ? ORDER BY seq", occurrenceId, cursor ? Number(cursor.seq) : 0).map(toEvent);
5229
5296
  }
5230
5297
  async addTaskEvent(e) {
5298
+ return this.insertTaskEvent(e);
5299
+ }
5300
+ insertTaskEvent(e) {
5231
5301
  const { lastInsertRowid } = this.run("INSERT INTO task_events (task_id, actor_id, kind, detail, at) VALUES (?, ?, ?, ?, ?)", e.taskId, e.actorId, e.kind, e.detail, e.at);
5232
5302
  return toTaskEvent(this.mustGet("task_events", "h", idOf("h", lastInsertRowid), "task event"));
5233
5303
  }
@@ -5241,6 +5311,33 @@ class SqliteStore {
5241
5311
  async listReplies(taskId) {
5242
5312
  return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
5243
5313
  }
5314
+ async runsTail(taskId, now, limit) {
5315
+ const past = "task_id = ? AND NOT (status = 'queued' AND due_at > ?)";
5316
+ const total = Number(this.one(`SELECT COUNT(*) AS n FROM occurrences WHERE ${past}`, taskId, now)?.n ?? 0);
5317
+ const rows = this.all(`SELECT * FROM occurrences WHERE ${past} ORDER BY due_at DESC, created_at DESC, seq DESC LIMIT ?`, taskId, now, limit);
5318
+ const next = this.one("SELECT * FROM occurrences WHERE task_id = ? AND status = 'queued' AND due_at > ? ORDER BY due_at, created_at, seq LIMIT 1", taskId, now);
5319
+ return { rows: rows.reverse().map(toOccurrence), total, next: next ? toOccurrence(next) : null };
5320
+ }
5321
+ async repliesTo(taskId, occurrenceIds, userId) {
5322
+ if (occurrenceIds.length === 0)
5323
+ return [];
5324
+ const ids = `occurrence_id IN (${occurrenceIds.map(() => "?").join(", ")})`;
5325
+ const own = userId === null ? "" : " AND user_id = ?";
5326
+ return this.all(`SELECT * FROM replies WHERE task_id = ? AND ${ids}${own} ORDER BY seq`, taskId, ...occurrenceIds, ...userId === null ? [] : [userId]).map(toReply);
5327
+ }
5328
+ async changesTail(taskId, viewerId, limit) {
5329
+ if (viewerId === null) {
5330
+ const total = Number(this.one("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?", taskId)?.n ?? 0);
5331
+ const rows = this.all("SELECT * FROM task_events WHERE task_id = ? ORDER BY seq DESC LIMIT ?", taskId, limit).reverse();
5332
+ return { rows: rows.map(toTaskEvent), total, before: null };
5333
+ }
5334
+ const [shown, params] = recipientShown(taskId, viewerId);
5335
+ const total = Number(this.one(`SELECT COUNT(*) AS n FROM task_events e WHERE ${shown}`, ...params)?.n ?? 0);
5336
+ const rows = this.all(`SELECT e.* FROM task_events e WHERE ${shown} ORDER BY e.seq DESC LIMIT ?`, ...params, limit).reverse();
5337
+ const first = rows[0];
5338
+ const before = first ? this.one(`SELECT e.* FROM task_events e WHERE ${shown} AND e.kind IN ('paused', 'resumed') AND e.seq < ? ORDER BY e.seq DESC LIMIT 1`, ...params, Number(first.seq)) : null;
5339
+ return { rows: rows.map(toTaskEvent), total, before: before ? toTaskEvent(before) : null };
5340
+ }
5244
5341
  async addSeriesPoint(p) {
5245
5342
  const key = p.key ?? null;
5246
5343
  const { changes, lastInsertRowid } = this.run("INSERT INTO series (task_id, key, at, value, unit, note) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) WHERE key IS NOT NULL DO NOTHING", p.taskId, key, p.at, p.value, p.unit ?? null, p.note ?? null);
@@ -9150,7 +9247,22 @@ async function press(d, user, ref) {
9150
9247
  const taskId = await taskOfRef(d, ref);
9151
9248
  return taskId === null ? pressLocked(d, user, ref) : d.locks.turn(taskId, () => pressLocked(d, user, ref), () => ({ ok: false, error: TASK_BUSY }));
9152
9249
  }
9250
+ async function consentRefusal(d, ref) {
9251
+ const decoded = decodeReplyRef(ref);
9252
+ if (decoded?.kind !== "accept" && decoded?.kind !== "decline")
9253
+ return null;
9254
+ const taskId = await taskOfRef(d, ref);
9255
+ const task = taskId === null ? null : await d.store.getTask(taskId);
9256
+ if (!task || task.status === "archived")
9257
+ return "That task was deleted.";
9258
+ if (task.status === "done")
9259
+ return "That task is done.";
9260
+ return null;
9261
+ }
9153
9262
  async function pressLocked(d, user, ref) {
9263
+ const refused = await consentRefusal(d, ref);
9264
+ if (refused)
9265
+ return { ok: false, error: refused };
9154
9266
  const verdict = await replyForRef(d.store, ref, user.id);
9155
9267
  if (!verdict.ok)
9156
9268
  return { ok: false, error: verdict.error };
@@ -9616,14 +9728,14 @@ async function loadHistory(d, user, taskId) {
9616
9728
  if (!task)
9617
9729
  return null;
9618
9730
  const now = d.clock.now();
9619
- const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
9620
- const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
9621
- const runs = all.filter((o) => !upcoming(o));
9622
- const next = all.find(upcoming);
9623
- const replies = await visibleReplies(d.store, actor, task.id) ?? [];
9624
9731
  const seesEveryone = user.admin || task.ownerId === user.id;
9625
- const visible = await visibleHistory(d.store, actor, task.id) ?? [];
9626
- const changes = seesEveryone ? visible : changesForRecipient(visible, user.id);
9732
+ const tailed = await d.store.runsTail(task.id, now.toISOString(), HISTORY_RUNS);
9733
+ const runs = seesEveryone ? tailed : { ...tailed, rows: tailed.rows.map((o) => ({ ...o, record: null })) };
9734
+ const next = runs.next;
9735
+ const replies = await d.store.repliesTo(task.id, runs.rows.map((o) => o.id), seesEveryone ? null : user.id);
9736
+ const tail = await d.store.changesTail(task.id, seesEveryone ? null : user.id, HISTORY_CHANGES);
9737
+ const lead = tail.before?.kind === "paused" ? [tail.before] : [];
9738
+ const changes = seesEveryone ? tail.rows : changesForRecipient([...lead, ...tail.rows], user.id).slice(lead.length);
9627
9739
  const people = new Map;
9628
9740
  for (const r of replies) {
9629
9741
  if (!people.has(r.userId)) {
@@ -9648,10 +9760,10 @@ async function loadHistory(d, user, taskId) {
9648
9760
  task,
9649
9761
  cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
9650
9762
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
9651
- runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now, seesEveryone)),
9652
- earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
9653
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
9654
- earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES),
9763
+ runs: runs.rows.map((o) => runView(o, replies, people, user, now, seesEveryone)),
9764
+ earlierRuns: Math.max(0, runs.total - HISTORY_RUNS),
9765
+ changes: changes.map((e) => changeView(e, user, now, names)),
9766
+ earlierChanges: Math.max(0, tail.total - HISTORY_CHANGES),
9655
9767
  findings: findings.map((f) => findingView(f, user, now))
9656
9768
  };
9657
9769
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.31.0",
3
+ "version": "0.31.3",
4
4
  "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research (on the web, or on pages your own browser gathers and sends in through the task API, with a fuller pass on pages the research could not open) and an interest scout (through city-hall, with their findings), a want-list watcher (listing pages you paste, BoardGameGeek through its API once its token is set, and eBay and BoardGameGeek listings you send in through the task API or that eBay's saved-search alert emails forwarded to your alert address carry, which the watcher never opens; with the model checking each new listing and its seller first, through city-hall) by slash command, delivered by DM with buttons, and a daily DM at each person's hour of what is due today and overdue, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /scout, /want, /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, and scouts, made and 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",
@@ -11,8 +11,8 @@
11
11
  "directory": "plugins/tracker"
12
12
  },
13
13
  "devDependencies": {
14
- "@rackbops/docket-core": "^0.10.0",
15
- "@rackbops/docket-types": "^0.10.0",
14
+ "@rackbops/docket-core": "^0.11.0",
15
+ "@rackbops/docket-types": "^0.11.0",
16
16
  "@rackbops/styles": "^0.2.42"
17
17
  },
18
18
  "peerDependencies": {