@magpie-community/opencode-workbuddy-auth 0.1.47 → 0.2.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 +96 -4
  2. package/index.mjs +668 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -289,6 +289,79 @@ credits are read from the enterprise meter
289
289
  page reads: the cycle's credits used of its limit, and when the cycle
290
290
  resets (magpie #1904).
291
291
 
292
+ ## The free model's daily window (WorkBuddy AI)
293
+
294
+ The credits above are not the only limit. `deepseek-v4.1-flash` is served at
295
+ `x0.00` and costs the plan nothing, yet WorkBuddy AI refuses it long before
296
+ 160 credits are touched:
297
+
298
+ ```
299
+ 429 usage exceeds frequency limit, but don't worry, your usage will reset at
300
+ 2026-10-20 19:47:41 UTC+8, alternatively, you can switch to the other models
301
+ to continue using it.
302
+ ```
303
+
304
+ That is a token allowance of its own — per account, per 24 hours, about 1e8
305
+ tokens, counted as prompt + cache-read + completion. Nothing the vendor
306
+ answers with reports it, so the plugin rebuilds the current window from
307
+ magpie's `usage.jsonl` and gives it to the card as **Free model tokens**
308
+ beside **Credits**. `quota alert` and `magpie quota` show it too; before,
309
+ the card read 0% on an account that was already refused.
310
+
311
+ What is measured, and what is easy to read wrongly:
312
+
313
+ - **`cache_read` counts.** Prompt and completion alone are 20× smaller and
314
+ never approach the cap, so a window read that way looks empty while full.
315
+ - **The window is fixed, not sliding.** It runs 24 hours from the first
316
+ request answered after the previous window ended, not from the reset
317
+ instant; a gap between the two means the new window starts later.
318
+ - **`deepseek-v4.1-flash-sg` shares the window.** It is the paid id of the
319
+ same model (`ModelRateLimitCap` in the product config), not a second
320
+ allowance — switching to it continues the same window, at `x0.03`.
321
+ - **1e8 is an estimate within 0.2%.** The largest value seen to still be
322
+ answered is 100,183,315, and the next request is refused, so it cannot be
323
+ probed higher.
324
+ - **A refusal is exact, and the sum is not.** The ledger is this machine's
325
+ only: one window was refused at 92,792,962 because the rest of that
326
+ account's spending happened on another machine, which read as 93%. So a
327
+ 429 whose named reset is still ahead makes the window full — `used: 100`,
328
+ `resetsAt` the reset the vendor named — whatever the rows add up to. The
329
+ sum carries the window only until the first refusal.
330
+ - **The card names both ids**, `deepseek-v4.1-flash` and
331
+ `deepseek-v4.1-flash-sg`: magpie matches a window's models by exact id
332
+ (only an `@size` suffix folds into the bare id), so one alone would leave
333
+ the other unrestricted.
334
+ - **The ledger is read in magpie's own folder** (`appdir.Config()`: the
335
+ portable data folder, `$XDG_CONFIG_HOME/magpie` when that is set (an
336
+ absolute path, as `appdir.rooted` has it), or `~/.config/magpie`), from its
337
+ end backwards — a heavy ledger runs to hundreds of MB.
338
+ - The window is a chain, so where it now began depends on every window
339
+ before it: read a slice of the ledger that starts mid-chain and every
340
+ window after it is shifted (4% on an account two hours from its cap, or
341
+ 100% on one still served). The read therefore goes back until it reaches
342
+ an edge this account's own rows spell out — a refusal whose reset can be
343
+ read, or a gap of a day between two of its answered rows — plus a day
344
+ below that, and with neither it reads to the file's start, where there is
345
+ no earlier window to be shifted by.
346
+ - Nothing bounds that by age, so two things keep a heavy ledger cheap: a
347
+ line's time is read before the line is decoded, so a line under the edge
348
+ (of any provider or account) ends the read at once; and a read past 24h
349
+ with no answered free-model row of this account stops, since the window
350
+ has lapsed and nothing would be reported. Both bounds leave out a line
351
+ a client reported (`"source":"reported"`, #1874): magpie appends those
352
+ dated where the client's call happened, so a backfilled row says nothing
353
+ about where the file has got to.
354
+ - `providerAccount` is the account's label. Two accounts sharing a label
355
+ get its slot added (`pluginLabels`), the label no longer matches what
356
+ their rows were written under, and the card reports no free window.
357
+ - **The offset comes from the refusal's words** (`UTC+8`, `GMT-5`,
358
+ `UTC+05:30`). A reset whose offset cannot be read is no edge at all,
359
+ rather than +08:00 guessed.
360
+
361
+ A window that has lapsed reports nothing rather than 0%: nothing is
362
+ exhausted, and the next request opens a fresh one. The China build is not
363
+ covered; its limit has not been measured.
364
+
292
365
  ## When free models work but a credit model does not
293
366
 
294
367
  Check the failing account and model separately before concluding that the
@@ -324,11 +397,30 @@ entry. An already signed-in browser can simply renew another saved account.
324
397
 
325
398
  ## Not here
326
399
 
327
- magpie's built-in WorkBuddy does more than this package:
328
- - the daily check-in;
329
- - the credits meter (`/billing/meter/get-user-resource-summary`);
330
- - several accounts under one provider.
400
+ magpie's built-in WorkBuddy does more than this package: several accounts
401
+ under one provider.
331
402
 
332
403
  OpenCode's plugin API has no place for these.
333
404
 
334
405
  Credit multipliers accept the international catalog's `x0.34 credits` text as well as `x0.34`. An empty credits value (for example Auto) stays unknown, never free.
406
+
407
+ ## Daily check-in
408
+
409
+ The account's daily credits are claimed through the billing meter. magpie
410
+ can run it once a day per account when its WorkBuddy check-in switch is on;
411
+ Settings → Usage → Plugins has that switch and a *Check in now* button.
412
+ The activity status is read first: inactive is `inactive`, a day already
413
+ checked in is `done`, and only an active unchecked day is claimed. A claim
414
+ that WorkBuddy answers 1001, 1002 or 1003 is `done`, `ineligible` or
415
+ `inactive`. A claim turned away 401 while the account's credits still read
416
+ is `ineligible`: that check-in takes WorkBuddy's own app. A transient
417
+ billing failure is retried twice before the card shows the failure.
418
+ Only WorkBuddy (China) checks in: the AI build has no claim endpoint, and
419
+ its plugin carries no check-in hook.
420
+ A request that gets no answer at all leaves the hook as a thrown error, so
421
+ magpie retries it on its 1-minute offline backoff rather than the 30-minute
422
+ one it gives a failed answer (#149).
423
+
424
+ An `inactive` answer is kept for the whole Beijing day: the plugin host
425
+ gives it no recheck, and a manual refresh reads the kept one (tracked in
426
+ [yetone/magpie#2696](https://github.com/yetone/magpie/issues/2696)).
package/index.mjs CHANGED
@@ -10,9 +10,9 @@
10
10
  // those of its current desktop app (see SITES' app, cli and appName).
11
11
 
12
12
  import { createHash, randomBytes } from "node:crypto"
13
- import { readFileSync } from "node:fs"
13
+ import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs"
14
14
  import { homedir } from "node:os"
15
- import { join } from "node:path"
15
+ import { isAbsolute, join } from "node:path"
16
16
  import { gzipSync } from "node:zlib"
17
17
 
18
18
  const APP_VERSION = "2.0.0" // the version WorkBuddy's sign-in page is opened with
@@ -1868,23 +1868,99 @@ function signedOut(site, a, why, expired, note) {
1868
1868
  // the same: WorkBuddy's message, else its code, else the HTTP status.
1869
1869
  async function meter(site, a, path, body = {}) {
1870
1870
  const res = await meterAsked(site, a, path, body)
1871
+ const raw = await res.text()
1871
1872
  let env = {}
1873
+ let parsed = true
1872
1874
  try {
1873
- env = JSON.parse(await res.text()) ?? {}
1874
- } catch {}
1875
+ env = JSON.parse(raw) ?? {}
1876
+ } catch {
1877
+ parsed = false
1878
+ }
1879
+ if (res.ok && !parsed) {
1880
+ const unreadable = raw.trim().slice(0, 120) || "(empty)"
1881
+ throw Object.assign(new Error(`unreadable reply ${unreadable}`), { status: res.status, code: 0, unreadable: true })
1882
+ }
1875
1883
  const code = Number.parseInt(env.code, 10) || 0
1884
+ // the status, the code and whether the token itself was refused go with
1885
+ // it, for usage's one renewal on a 401 (reissued) and check-in's outcomes
1886
+ const said = { status: res.status, code }
1887
+ if (!res.ok) said.invalidToken = invalidToken(res)
1888
+ const err = (message) => Object.assign(new Error(message), said)
1876
1889
  if (!res.ok) {
1877
- // the status and whether the token itself was refused go with it, for
1878
- // usage's one renewal on a 401 (reissued)
1879
- const said = { status: res.status, invalidToken: invalidToken(res) }
1880
- if (code) throw Object.assign(new Error(env.msg || `error ${code}`), said)
1881
- if (env.msg) throw Object.assign(new Error(env.msg), said)
1882
- throw Object.assign(new Error(statusText(res.status)), said)
1883
- }
1884
- if (code) throw new Error(env.msg || `error ${code}`)
1890
+ if (code) throw err(env.msg || `error ${code}`)
1891
+ if (env.msg) throw err(env.msg)
1892
+ throw err(statusText(res.status))
1893
+ }
1894
+ if (code) throw err(env.msg || `error ${code}`)
1885
1895
  return env.data ?? null
1886
1896
  }
1887
1897
 
1898
+ // checkinOf claims the account's daily credits. China has the /v2 meter;
1899
+ // after the activity status names an active, unchecked day. Transient
1900
+ // upstream failures are asked again twice (the bounded retry quota-hub
1901
+ // observed on the billing endpoint; callers may shorten the waits for a
1902
+ // test).
1903
+ async function checkinOf(site, a, { waits = [0, 0] } = {}) {
1904
+ let status
1905
+ try {
1906
+ status = await meter(site, a, "/v2/billing/meter/checkin-activity-status")
1907
+ } catch (e) {
1908
+ if (e?.code === 1001) return { outcome: "done" }
1909
+ // a reply WorkBuddy sent is the day's failed answer; only a request
1910
+ // that got no answer leaves as a throw, for magpie's offline backoff
1911
+ if (e?.status !== undefined || e?.code !== undefined) return { outcome: "failed", message: e.message }
1912
+ throw e
1913
+ }
1914
+ if (!status?.active) return { outcome: "inactive" }
1915
+ if (status.today_checked_in ?? status.todayCheckedIn) {
1916
+ // today_credit is what today actually gave, the built-in's word for a
1917
+ // day already in; daily_credit is the day's grant
1918
+ const credit = Number(status.today_credit ?? status.daily_credit)
1919
+ return { outcome: "done", ...(Number.isFinite(credit) && credit > 0 ? { credit } : {}) }
1920
+ }
1921
+ const paths = ["/v2/billing/meter/daily-checkin"]
1922
+ for (let attempt = 0; ; attempt++) {
1923
+ let i = 0
1924
+ while (i < paths.length) {
1925
+ try {
1926
+ const got = await meter(site, a, paths[i++])
1927
+ // the claim's own answer, as the built-in hands it on: the credit
1928
+ // and streak it granted and what it said, for the card
1929
+ const credit = Number(got?.credit)
1930
+ const streak = Number(got?.streak_days)
1931
+ return {
1932
+ outcome: "claimed",
1933
+ ...(Number.isFinite(credit) && credit > 0 ? { credit } : {}),
1934
+ ...(Number.isFinite(streak) && streak > 0 ? { streak } : {}),
1935
+ ...(got?.message ? { message: String(got.message) } : {}),
1936
+ }
1937
+ } catch (e) {
1938
+ if (e?.code === 1001) return { outcome: "done" }
1939
+ if (e?.code === 1002) return { outcome: "ineligible", message: e.message }
1940
+ if (e?.code === 1003) return { outcome: "inactive", message: e.message }
1941
+ if (e?.status === 401) {
1942
+ try {
1943
+ await meter(site, a, "/billing/meter/get-user-resource-summary")
1944
+ } catch {
1945
+ throw e
1946
+ }
1947
+ return { outcome: "ineligible", message: "WorkBuddy's check-in takes WorkBuddy's own app" }
1948
+ }
1949
+ if (e?.unreadable) return { outcome: "failed", message: `check-in: ${e.message}` }
1950
+ const transient = e?.status >= 500 || e?.code === 10000
1951
+ if (transient && attempt < waits.length) {
1952
+ await new Promise((r) => setTimeout(r, waits[attempt]))
1953
+ break
1954
+ }
1955
+ // a reply WorkBuddy sent is the day's failed answer; only a request
1956
+ // that got no answer leaves as a throw, for magpie's offline backoff
1957
+ if (e?.status !== undefined || e?.code !== undefined) return { outcome: "failed", message: e.message }
1958
+ throw e
1959
+ }
1960
+ }
1961
+ }
1962
+ }
1963
+
1888
1964
  // capacity is a capacity the billing API sends, as a string ("438.88")
1889
1965
  // or now and then a number; empty is 0.
1890
1966
  function capacity(v) {
@@ -1899,6 +1975,556 @@ function capacity(v) {
1899
1975
  // compact is a count as magpie says one: whole, else to two places.
1900
1976
  const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
1901
1977
 
1978
+ // The window WorkBuddy AI rate limits its free models by, which its credit
1979
+ // meter does not show.
1980
+ //
1981
+ // A free account's usage() reports only the credits window, so a plan with
1982
+ // 160 credits untouched reads 0% while chats to deepseek-v4.1-flash are
1983
+ // already refused: the model has a token allowance of its own, counted
1984
+ // apart from credits, and nothing the vendor answers with reports it. The
1985
+ // refusal is the only place it appears, and only as a time:
1986
+ //
1987
+ // 429 usage exceeds frequency limit, but don't worry, your usage will
1988
+ // reset at 2026-10-20 19:47:41 UTC+8, alternatively, you can switch to
1989
+ // the other models to continue using it.
1990
+ //
1991
+ // Measured on a free plan over ten days (one account, deepseek-v4.1-flash
1992
+ // only), counting in + cache_read + out:
1993
+ //
1994
+ // 10-15 15:40 → 100,183,315 429 10-18 19:52 → 92,792,962 429
1995
+ // 10-16 16:48 → 99,971,831 429 10-19 19:47 → 98,201,537 429
1996
+ // 10-14 15:38 → 71,687,717 lapsed
1997
+ //
1998
+ // so the cap is per account per 24 hours at about 1e8 tokens, the largest
1999
+ // value that still answered being 100,183,315. Three things about it are
2000
+ // easy to get wrong and were all checked:
2001
+ //
2002
+ // - cache_read counts. In + out alone is 20× smaller (0.75M–3.9M a
2003
+ // window) and never comes near the cap.
2004
+ // - the window is fixed, not sliding: peak trailing-24h over the same
2005
+ // data is 172.85M, which a sliding window would have held to 1e8.
2006
+ // - the reset a refusal names is the window's start + 24h exactly, and
2007
+ // the start is the first request answered after the previous window
2008
+ // ended — not the reset instant. Gaps of 6, 68, 80 and 182 minutes
2009
+ // were seen between a reset and the next request, each opening the new
2010
+ // window.
2011
+ //
2012
+ // deepseek-v4.1-flash-sg is the same model under its paid id (the product
2013
+ // config's ModelRateLimitCap.lines[0] names it as paidId to this freeId,
2014
+ // allowPaidSwitch: true) and draws on the same window: it was refused in
2015
+ // the same windows, to the second, with about 0.01M tokens of its own.
2016
+ // Both ids are named in models: magpie matches a plugin window's models
2017
+ // against a model id exactly, folding only an @size suffix
2018
+ // (internal/provider/plugin_usage.go, idSetOf), so one id alone leaves the
2019
+ // other unrestricted.
2020
+ //
2021
+ // The window is read two ways, and a refusal wins:
2022
+ //
2023
+ // - the sum (in + cache_read + out) of this machine's rows, which holds
2024
+ // until the account is first refused. The ledger is this machine's
2025
+ // only, so an account also used on another machine or through another
2026
+ // client reads short: 92.8M read as "93%" on one refused at 92,792,962,
2027
+ // the rest of its spending being elsewhere.
2028
+ // - a refusal whose named reset is still ahead, which makes the window
2029
+ // full whatever the sum says. That is the one exact signal there is,
2030
+ // and routing must not pass over it either.
2031
+ //
2032
+ // The ledger is magpie's own, in magpie's config folder (appdir.Config()):
2033
+ // the folder the plugin host runs in, which is the portable data folder
2034
+ // when magpie runs portable, $XDG_CONFIG_HOME/magpie when that is set, and
2035
+ // ~/.config/magpie otherwise. No endpoint reports the window; a ledger that
2036
+ // cannot be read yields no window rather than a made-up one.
2037
+
2038
+ const FREE_WINDOW_CAP = 1e8
2039
+ const FREE_WINDOW_SPAN = 24 * 3600 * 1000
2040
+ // The lead: how far back of an edge the read goes on. A chain read from a row
2041
+ // that opens a window needs the window the span makes of the rows above it,
2042
+ // so the rows kept reach a day past the edge — the window opening at the
2043
+ // request after it, minutes to hours into that day, whatever the first row
2044
+ // after the edge falls on.
2045
+ const FREE_WINDOW_LEAD = 86400 * 1000
2046
+ // A gap this long between two of this account's answered rows ends every
2047
+ // window before it, so the later row opens one: the window holding the
2048
+ // earlier row began at most 24h before it, and so ended 24h after it at the
2049
+ // latest. This is one of the two edges the ledger itself spells out.
2050
+ const FREE_WINDOW_GAP = FREE_WINDOW_SPAN
2051
+ // the model the window is counted for; -sg shares it
2052
+ const FREE_WINDOW_MODEL = "deepseek-v4.1-flash"
2053
+ // both ids, so magpie's exact match finds the -sg one too
2054
+ const FREE_WINDOW_MODELS = ["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"]
2055
+ // the provider whose rows count
2056
+ const FREE_WINDOW_PROVIDER = "workbuddy-ai"
2057
+
2058
+ // windowOffset is the UTC offset a refusal spells out, in minutes, from
2059
+ // "UTC+8", "UTC+08:00", "UTC-5" or "GMT+8". null when it spells out none,
2060
+ // which is not the same as +08:00: an offset guessed wrong puts the reset
2061
+ // hours from where it is, so a reset that cannot be placed is not used as
2062
+ // an edge at all.
2063
+ function windowOffset(text) {
2064
+ const m = /\b(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?\b/i.exec(String(text ?? ""))
2065
+ if (!m) return null
2066
+ const min = Number(m[2]) * 60 + Number(m[3] ?? 0)
2067
+ if (min > 14 * 60) return null
2068
+ return m[1] === "-" ? -min : min
2069
+ }
2070
+
2071
+ // windowReset is the instant a refusal names, or NaN when it names none or
2072
+ // names it in an offset that cannot be read.
2073
+ function windowReset(text) {
2074
+ const s = String(text ?? "")
2075
+ const at = /reset at (\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s)
2076
+ if (!at) return NaN
2077
+ const off = windowOffset(s)
2078
+ if (off === null) return NaN
2079
+ return Date.UTC(+at[1], +at[2] - 1, +at[3], +at[4], +at[5], +at[6]) - off * 60000
2080
+ }
2081
+
2082
+ // freeWindowOf is the window account is in now, from rows (magpie's usage
2083
+ // records), or null when it has lapsed — the next request opens a new one.
2084
+ // now is passed in so the reading is testable.
2085
+ //
2086
+ // The window is a chain: it begins at the first request answered after the
2087
+ // previous one ended, so where this window began depends on every window
2088
+ // before it. A window ends at a refusal's reset when the ledger holds one,
2089
+ // and otherwise a day after it began. readLedgerTail (which see) stops on
2090
+ // both, so that these rows read the same as the whole ledger.
2091
+ function freeWindowOf(rows, account, now = Date.now()) {
2092
+ const mine = (Array.isArray(rows) ? rows : []).filter(
2093
+ (r) =>
2094
+ r?.provider === FREE_WINDOW_PROVIDER &&
2095
+ r.providerAccount === account &&
2096
+ typeof r.model === "string" &&
2097
+ r.model.startsWith(FREE_WINDOW_MODEL) &&
2098
+ r.t,
2099
+ )
2100
+ if (!mine.length) return null
2101
+
2102
+ const at = (r) => {
2103
+ const t = Date.parse(r.t)
2104
+ return Number.isFinite(t) ? t : 0
2105
+ }
2106
+ const counted = (r) => (r.in ?? 0) + (r.cache_read ?? 0) + (r.out ?? 0)
2107
+ // two rows a day apart: the first of them opened a window that has ended by
2108
+ // the second. Written as a function so that no minifier folds the
2109
+ // subtraction into the comparison.
2110
+ const apart = (a, b) => a - b >= FREE_WINDOW_SPAN
2111
+
2112
+ // a refusal names the end of the window it refused in
2113
+ const edges = []
2114
+ for (const r of mine) {
2115
+ if (Number(r.status) !== 429) continue
2116
+ const t = windowReset(r.err)
2117
+ if (Number.isFinite(t)) edges.push(t)
2118
+ }
2119
+ edges.sort((a, b) => a - b)
2120
+
2121
+ const answered = mine.filter((r) => Number(r.status) === 200 && at(r) > 0).sort((a, b) => at(a) - at(b))
2122
+ if (!answered.length) return null
2123
+
2124
+ const head = (from) => {
2125
+ let start = null
2126
+ let used = 0
2127
+ let edge = 0 // the refusals already read
2128
+ for (let i = from; i < answered.length; i++) {
2129
+ const r = answered[i]
2130
+ const t = at(r)
2131
+ // every refusal this row's window ended at closes it
2132
+ while (edge < edges.length && edges[edge] <= t) {
2133
+ start = null
2134
+ used = 0
2135
+ edge++
2136
+ }
2137
+ if (start === null) start = t
2138
+ else if (apart(t, start)) {
2139
+ start = t
2140
+ used = 0
2141
+ }
2142
+ used += counted(r)
2143
+ }
2144
+ return start === null ? null : { start, used }
2145
+ }
2146
+
2147
+ // The rows may begin before the window now: readLedgerTail goes a day past
2148
+ // the edge it stops at, so the run of rows that opens a window can start a
2149
+ // day after the first row kept. That window opens at a row with no answered
2150
+ // row of this account's within a day before it — where the chain is read
2151
+ // from. Read from the first row kept, the chain would begin shifted, a day
2152
+ // early: every window after it, the card with it.
2153
+ let w = null
2154
+ for (let i = 0; i < answered.length; i++) {
2155
+ if (i && !apart(at(answered[i]), at(answered[i - 1]))) continue
2156
+ const h = head(i)
2157
+ if (h === null) continue
2158
+ if (h.start + FREE_WINDOW_SPAN <= now) continue // lapsed before now
2159
+ w = h
2160
+ break
2161
+ }
2162
+ const start = w?.start
2163
+ const used = w?.used
2164
+ if (!(start > 0)) return null
2165
+
2166
+ const end = start + FREE_WINDOW_SPAN
2167
+ if (now >= end) return null // lapsed: nothing is exhausted, a new window opens on the next request
2168
+
2169
+ const card = (amount, resetsAt, refused) => ({
2170
+ name: "Free model tokens",
2171
+ used: Math.min(100, (100 * amount) / FREE_WINDOW_CAP),
2172
+ amount,
2173
+ limit: FREE_WINDOW_CAP,
2174
+ unit: "tokens",
2175
+ display: `${tokensShort(amount)} / 100M`,
2176
+ resetsAt: new Date(resetsAt).toISOString(),
2177
+ span: FREE_WINDOW_SPAN / 1000,
2178
+ models: FREE_WINDOW_MODELS.slice(),
2179
+ ...(refused ? { refused: true } : {}),
2180
+ })
2181
+
2182
+ // A refusal still in force is exact: the account is spending this window
2183
+ // from elsewhere too, so the sum undercounts it, and the refusal is the
2184
+ // answer the vendor would give again. Its reset is the window's end,
2185
+ // named by the vendor rather than derived from start.
2186
+ let live = null
2187
+ for (const r of mine) {
2188
+ if (Number(r.status) !== 429) continue
2189
+ const t = windowReset(r.err)
2190
+ if (!Number.isFinite(t) || t <= now || t <= start) continue
2191
+ if (live === null || t > live) live = t
2192
+ }
2193
+ if (live !== null) return card(FREE_WINDOW_CAP, live, true)
2194
+
2195
+ return card(used, end, false)
2196
+ }
2197
+
2198
+ // tokensShort says a token count small: 11.9M, 812k. The credits card's
2199
+ // compact() (2 decimals, credited to credits) reads a token count badly.
2200
+ function tokensShort(n) {
2201
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
2202
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`
2203
+ return String(Math.round(n))
2204
+ }
2205
+
2206
+ // ledgerParsed is the rows read last and what they were read for: the file's
2207
+ // fingerprint, the account and the instant. A file that has not changed hands
2208
+ // back the rows already parsed rather than reading and decoding them again.
2209
+ //
2210
+ // Reading the ledger whole was 48 ms on 2026-10-21 (10.3 MiB, 17925 lines:
2211
+ // 6.4 ms to read, 32.8 ms to JSON.parse, 8 ms to find the window), spent
2212
+ // synchronously on the plugin host's thread every time a panel opened or a
2213
+ // card was asked about, which is what made the free-token window feel slow
2214
+ // beside the credits, whose reading is a network call and so blocks nothing.
2215
+ // A heavy ledger runs to hundreds of MiB, so the read starts at the end.
2216
+ //
2217
+ // The key carries the account because the rows are this account's: one
2218
+ // account's read must not answer for another's. A read for an earlier instant
2219
+ // keeps all the rows a read for a later one would — the bounds are only
2220
+ // further back, and an edge is the same time in either — so the rows held
2221
+ // over answer for the later one too: freeWindowOf is what decides with its
2222
+ // own now.
2223
+ let ledgerParsed = null
2224
+
2225
+ // ledgerBad is the number of whole lines the ledger last had that could
2226
+ // not be decoded, and the file it was read from, so that a ledger read
2227
+ // once and read again is complained about once, not on every panel open.
2228
+ let ledgerBad = { key: "", count: 0 }
2229
+
2230
+ // freeWindowLedger is the ledgers looked for, in magpie's own folder order
2231
+ // (appdir.Config()): the plugin host runs in that folder — the portable
2232
+ // data folder when magpie runs portable, $XDG_CONFIG_HOME/magpie when that
2233
+ // is set, ~/.config/magpie otherwise — so its cwd answers first; the other
2234
+ // two are named as well, for a plugin asked outside the host. The first
2235
+ // readable ledger wins: a stale copy in a folder magpie does not use must
2236
+ // not hide the live one.
2237
+ //
2238
+ // $XDG_CONFIG_HOME counts only when it is absolute, as appdir.rooted has it:
2239
+ // an empty or relative value is not where magpie keeps its ledger.
2240
+ function freeWindowLedger(cwd = process.cwd(), env = process.env, home = homedir()) {
2241
+ const dirs = [cwd]
2242
+ if (env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) dirs.push(join(env.XDG_CONFIG_HOME, "magpie"))
2243
+ if (home) dirs.push(join(home, ".config", "magpie"))
2244
+ return dirs.map((d) => join(d, "usage.jsonl"))
2245
+ }
2246
+
2247
+ // freeWindowRows reads magpie's usage ledger for account, oldest row first.
2248
+ // null when no ledger can be read: the window is then left unknown rather
2249
+ // than guessed.
2250
+ //
2251
+ // The ledger is appended to a line at a time, so a read that lands while a
2252
+ // call is being written can catch that line half-written. Only the last
2253
+ // line can be torn that way (everything before it was written whole), and
2254
+ // only it is passed over in silence. A whole line in the middle that won't
2255
+ // decode is a real fault — a truncated file, a writer that lost a record —
2256
+ // and is said aloud, because passing it over quietly is how a window goes
2257
+ // missing with nothing to point at: on 2026-10-21 a restart caught the
2258
+ // ledger mid-write, the one row holding the free window's start was
2259
+ // dropped without a word, and the panel showed no window at all until the
2260
+ // next read happened to succeed.
2261
+ function freeWindowRows(account, now = Date.now(), cwd = process.cwd(), env = process.env, home = homedir()) {
2262
+ if (!account) return null
2263
+ let found = null
2264
+ for (const path of freeWindowLedger(cwd, env, home)) {
2265
+ try {
2266
+ const st = statSync(path)
2267
+ if (!st.size) continue
2268
+ found = { path, st }
2269
+ break
2270
+ } catch {}
2271
+ }
2272
+ if (!found) return null
2273
+ const { path, st } = found
2274
+ // size and mtime both move when a call is appended, which is the only
2275
+ // way this ledger is written; one rewritten in place (a rotation or a
2276
+ // compaction) moves mtime even when its size lands the same.
2277
+ const file = path + "|" + st.mtimeMs + ":" + st.size
2278
+ const key = file + "|" + account
2279
+ if (ledgerParsed?.key === key) return ledgerParsed.rows
2280
+ let got
2281
+ try {
2282
+ got = readLedgerTail(path, st, now, undefined, account)
2283
+ } catch {
2284
+ return null
2285
+ }
2286
+ if (got.bad && ledgerBad.key !== file) {
2287
+ ledgerBad = { key: file, count: got.bad }
2288
+ console.error(
2289
+ `workbuddy: ${got.bad} whole line${got.bad === 1 ? "" : "s"} of ${path} could not be decoded ` +
2290
+ `(not the last line, so not a write in progress); the free-token window may read short`,
2291
+ )
2292
+ }
2293
+ ledgerParsed = { key, rows: got.rows }
2294
+ return got.rows
2295
+ }
2296
+
2297
+ // LEDGER_T is where a row's time sits: "t" is among the first keys
2298
+ // usage.Record writes, within this many bytes of the line's start, so every
2299
+ // line's time is read without decoding the line — most of a ledger's lines
2300
+ // belong to another provider, or to this account and out of reach.
2301
+ const LEDGER_T = /"t"\s*:\s*"([^"]{10,64})"/
2302
+
2303
+ // LEDGER_ACCOUNT finds the account a line was written under, before the line
2304
+ // is decoded. usage.Record writes providerAccount second.
2305
+ const LEDGER_ACCOUNT = /"providerAccount"\s*:\s*"([^"]{0,256})"/
2306
+
2307
+ // rowTime is the instant a line is written at, from that "t", or NaN.
2308
+ function rowTime(line) {
2309
+ const m = LEDGER_T.exec(line)
2310
+ return m ? Date.parse(m[1]) : NaN
2311
+ }
2312
+
2313
+ // LEDGER_REPORTED finds what a client reported: POST /v1/magpie/usage/requests
2314
+ // (#1874) appends a client's own calls to the same ledger, with the time the
2315
+ // client gives them, and a past time is kept on purpose so history can be
2316
+ // backfilled (docs/integrating.md). Such a row is written at the end of the
2317
+ // file and dated anywhere, so it says nothing about where the file's own
2318
+ // records have got to — magpie's index leaves it out of firstOwn for the same
2319
+ // reason (log_index.go). It never carries providerAccount, being not in
2320
+ // ingestFields, so it is never a row of this account either.
2321
+ const LEDGER_REPORTED = /"source"\s*:\s*"reported"/
2322
+
2323
+ // rowReported is whether a line is one a client reported rather than one
2324
+ // magpie wrote when it happened.
2325
+ function rowReported(line) {
2326
+ return LEDGER_REPORTED.test(line)
2327
+ }
2328
+
2329
+ // rowAccount is the account a line was written under, or null when the line
2330
+ // names none (another record, or one cut short).
2331
+ function rowAccount(line) {
2332
+ const m = LEDGER_ACCOUNT.exec(line)
2333
+ if (!m) return null
2334
+ try {
2335
+ return JSON.parse(`"${m[1]}"`)
2336
+ } catch {
2337
+ return null
2338
+ }
2339
+ }
2340
+
2341
+ // ofAccount is whether a row belongs to the chain read for account: this
2342
+ // account's own row of the free model, either id. A refusal of another
2343
+ // account's, or of a paid model, says nothing about where this chain opens.
2344
+ function ofAccount(row, account) {
2345
+ return (
2346
+ row?.provider === FREE_WINDOW_PROVIDER &&
2347
+ row.providerAccount === account &&
2348
+ typeof row.model === "string" &&
2349
+ row.model.startsWith(FREE_WINDOW_MODEL)
2350
+ )
2351
+ }
2352
+
2353
+ // readLedgerTail decodes the ledger from its end backwards, and stops where
2354
+ // the chain of account's own rows can still be read from. chunks is a test's
2355
+ // way of counting reads; nothing else passes it.
2356
+ //
2357
+ // Why it cannot stop at 48h: the window is a chain — each one starts at the
2358
+ // first request answered after the previous one ended — so where the window
2359
+ // now began depends on every window before it. A first row that is not an
2360
+ // edge opens the window in the middle of a real one and every window after
2361
+ // it is shifted: 4% on an account two hours from its cap, or 100% on one
2362
+ // still served, which routing would then pass over. So the read goes back
2363
+ // until the rows it keeps read the window now as the whole ledger does:
2364
+ //
2365
+ // - a refusal of this account's, whose reset can be read: the window it
2366
+ // named ended there, and the next request opened another;
2367
+ // - a gap of 24h or more between two of this account's answered rows: the
2368
+ // window holding the earlier one has ended by then, and the later one
2369
+ // opens one;
2370
+ // - and with neither, it reads to the file's start, where the chain has no
2371
+ // earlier window to be shifted by.
2372
+ //
2373
+ // Each edge is passed by a day (FREE_WINDOW_LEAD), the window opening at the
2374
+ // request after it, minutes to hours into the day.
2375
+ //
2376
+ // Two bounds keep that from being a whole file on every poll, and neither
2377
+ // depends on how busy the account is:
2378
+ //
2379
+ // - the ledger is appended in time order, so a line whose time is under the
2380
+ // edge — of whatever provider, of whatever account — ends the read;
2381
+ // - a read that is past now - 24h with no answered free-model row of this
2382
+ // account has nothing to report: the window has lapsed, and freeWindowOf
2383
+ // says null. It stops there rather than going on to the edges.
2384
+ //
2385
+ // Every line's time is read from its "t" before the line is decoded, so a
2386
+ // line of another provider or account costs one match and no JSON.parse.
2387
+ //
2388
+ // What is not covered: magpie's providerAccount is the account's label, and a
2389
+ // label two accounts share gets its slot added to it (pluginLabels,
2390
+ // plugin_accounts.go). The label then no longer equals the accountId rows are
2391
+ // written under, no row is found for this account at all, and the card
2392
+ // reports no free window.
2393
+ function readLedgerTail(path, st, now, chunks, account) {
2394
+ const CHUNK = 1 << 16
2395
+ const lapsed = now - FREE_WINDOW_SPAN // a read past this holds no window now
2396
+ const fd = openSync(path, "r")
2397
+ try {
2398
+ // a ledger whose last byte ends a line has no line being written
2399
+ let tornEnd = false
2400
+ if (st.size > 0) {
2401
+ const one = Buffer.allocUnsafe(1)
2402
+ readSync(fd, one, 0, 1, st.size - 1)
2403
+ tornEnd = one[0] !== 0x0a
2404
+ }
2405
+ const rev = [] // the rows kept, latest first
2406
+ let bad = 0
2407
+ let pos = st.size
2408
+ let carry = ""
2409
+ let firstPass = true
2410
+ let stop = false
2411
+ let chain = [] // this account's own rows read, latest first
2412
+ let reach = 0 // the row below which nothing is kept: 0 until an edge is found
2413
+ let answered = false // whether an answered free-model row of it is read
2414
+
2415
+ while (pos > 0 && !stop) {
2416
+ const len = Math.min(CHUNK, pos)
2417
+ pos -= len
2418
+ const buf = Buffer.allocUnsafe(len)
2419
+ readSync(fd, buf, 0, len, pos)
2420
+ if (chunks) chunks(1)
2421
+ const lines = (buf.toString("utf8") + carry).split("\n")
2422
+ carry = lines.shift() // its first line is begun in an earlier chunk
2423
+ for (let i = lines.length - 1; i >= 0 && !stop; i--) {
2424
+ const line = lines[i]
2425
+ if (!line.trim()) continue
2426
+ const t = rowTime(line)
2427
+ // Under an edge or the bound, by time alone: nothing below it can be
2428
+ // kept, whatever wrote it — but a line a client reported is dated
2429
+ // where the client's call happened, not where the file has got to.
2430
+ const dated = Number.isFinite(t) && !rowReported(line)
2431
+ if (dated && reach && t < reach) {
2432
+ stop = true
2433
+ break
2434
+ }
2435
+ // A day back with nothing of this account's answered and no edge in
2436
+ // reach: no window is being spent now, and nothing below can open one
2437
+ if (dated && !reach && !answered && t < lapsed) {
2438
+ stop = true
2439
+ break
2440
+ }
2441
+ // Another provider's or another account's line: nothing else to see
2442
+ if (!line.includes(account) || rowAccount(line) !== account) continue
2443
+ let row
2444
+ try {
2445
+ row = JSON.parse(line)
2446
+ } catch {
2447
+ // the last line may be a call still being written, and is passed
2448
+ // over; anything before it is a fault worth saying
2449
+ if (!(firstPass && tornEnd && i === lines.length - 1)) bad++
2450
+ continue
2451
+ }
2452
+ if (!ofAccount(row, account)) continue
2453
+ rev.push(row)
2454
+ chain.push(row)
2455
+ if (Number(row.status) === 200) answered = true
2456
+ if (!reach && Number(row.status) !== 429) reach = pinned(chain, account)
2457
+ }
2458
+ firstPass = false
2459
+ }
2460
+ // the file's first line, with no newline before it: kept only when the
2461
+ // read ran to the file's start
2462
+ if (!stop && pos === 0 && carry.trim() && carry.includes(account) && rowAccount(carry) === account) {
2463
+ const t = rowTime(carry)
2464
+ if (!reach || !Number.isFinite(t) || t >= reach) {
2465
+ try {
2466
+ const row = JSON.parse(carry)
2467
+ if (ofAccount(row, account)) rev.push(row)
2468
+ } catch {
2469
+ bad++
2470
+ }
2471
+ }
2472
+ }
2473
+ return { rows: rev.reverse(), bad }
2474
+ } finally {
2475
+ closeSync(fd)
2476
+ }
2477
+ }
2478
+
2479
+ // pinned is where the chain of account's own rows (latest first) is read
2480
+ // from: the time below which nothing is kept. 0 while the rows say nothing
2481
+ // about it yet, and the read then goes to the file's start.
2482
+ //
2483
+ // A row whose time cannot be read is passed over rather than ending the
2484
+ // chain there: one corrupt line used to cut it wherever it sat.
2485
+ function pinned(rows, account) {
2486
+ const at = (r) => Date.parse(r?.t)
2487
+ const apart = (a, b) => a - b >= FREE_WINDOW_GAP
2488
+ // A refusal names where a window ended, and the next request opened
2489
+ // another. The loop walks oldest to newest and overwrites `denied`, so the
2490
+ // one kept is the *newest* readable refusal in the chain, not the earliest:
2491
+ // a later refusal is nearer to now, and its reset is the tighter bound on
2492
+ // where the chain can be read from. A refusal whose reset has already been
2493
+ // passed by a newer one therefore loses, which is what we want — the old
2494
+ // `reset < newest` test that used to guard this is gone, the overwrite does
2495
+ // the same job and keeps the latest.
2496
+ let denied = null
2497
+ for (let i = rows.length - 1; i >= 0; i--) {
2498
+ const reset = Number(rows[i]?.status) === 429 ? windowReset(rows[i].err) : NaN
2499
+ if (Number.isFinite(reset)) denied = reset
2500
+ }
2501
+ if (denied !== null) return denied - FREE_WINDOW_LEAD
2502
+ // A day's gap between two answered rows ended every window before it, and
2503
+ // the day before the later row is where the span's reading of the rows
2504
+ // above it begins. The rows come latest first, so each is compared with the
2505
+ // one read before it — the row after it in time, `later`.
2506
+ let later = null
2507
+ for (let i = rows.length - 1; i >= 0; i--) {
2508
+ const r = rows[i]
2509
+ const t = at(r)
2510
+ if (!Number.isFinite(t)) continue
2511
+ if (Number(r.status) !== 200) continue
2512
+ if (later === null) {
2513
+ later = t
2514
+ continue
2515
+ }
2516
+ if (apart(t, later)) return later - FREE_WINDOW_LEAD
2517
+ later = t
2518
+ }
2519
+ // no edge among these rows: the read goes on to the file's start, which is
2520
+ // the whole ledger — the only place a chain needs nothing before it
2521
+ return 0
2522
+ }
2523
+
2524
+ // _internal is where these are handed to a test: magpie's host calls every
2525
+ // exported function of a plugin as one of its own factories (host.js,
2526
+ // servers()), so freeWindowRows et al. must not be exports of the plugin.
2527
+
1902
2528
  // usageOf is the account's credits, from its resource summary: the plan's
1903
2529
  // credits used against what the cycle grants, as magpie's built-in said —
1904
2530
  // the count itself too (amount of limit, in credits), for magpie to say it
@@ -2486,6 +3112,25 @@ function makePlugin(site) {
2486
3112
  // and is renewed as before.
2487
3113
  refreshLead: LEAD_MS,
2488
3114
  refresh: (auth) => refreshed(site, auth),
3115
+ // the account's daily check-in: magpie can run it once a day per
3116
+ // account, when its WorkBuddy check-in switch is on; the button in
3117
+ // Settings asks right away. China's meter only: the AI build has no
3118
+ // check-in (#47), so the hook stays off its plugin. Errors leave as
3119
+ // throws, not as failed answers: host.js answers a throwing hook to
3120
+ // magpie as an error, whose wbCheckinFailed keeps the offline words
3121
+ // and magpie's 1-minute backoff, where a returned failed waits the
3122
+ // full 30 minutes (magpie-community/plugins#149). checkinOf returns
3123
+ // an outcome only for answers it read and classified.
3124
+ ...(site.ai ? {} : {
3125
+ async checkin(getAuth) {
3126
+ const auth = await getAuth()
3127
+ // an API key reads no credits (see usage), so it has no day to claim
3128
+ if (auth?.type === "api") return { outcome: "ineligible", message: "check-in needs a signed-in account, not an API key" }
3129
+ if (auth?.type !== "oauth" || !(auth.access || auth.source)) return { outcome: "failed", message: "not signed in" }
3130
+ const a = await current(site, client, auth)
3131
+ return await checkinOf(site, a, { waits: [2000, 4000] })
3132
+ },
3133
+ }),
2489
3134
  options: [
2490
3135
  {
2491
3136
  key: FREE_ONLY,
@@ -2718,6 +3363,16 @@ function makePlugin(site) {
2718
3363
  } catch {}
2719
3364
  }
2720
3365
  await spareFree(site, a, out.windows)
3366
+ // the free model's own window, which the credit meter above does
3367
+ // not show: what actually refuses a chat long before 160 credits
3368
+ // are spent. WorkBuddy AI alone; the China build has no such cap
3369
+ // measured (see freeWindowOf)
3370
+ if (site.ai) {
3371
+ const account = a.accountId || a.user || a.uid || ""
3372
+ const rows = freeWindowRows(account)
3373
+ const w = rows && freeWindowOf(rows, account)
3374
+ if (w) out.windows.push(w)
3375
+ }
2721
3376
  return { ...out, signIn: said }
2722
3377
  } catch (e) {
2723
3378
  return { error: e?.message ?? String(e), signIn: "kept" }
@@ -2849,4 +3504,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2849
3504
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2850
3505
 
2851
3506
  // for tests
2852
- export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
3507
+ export const _internal = { idFor, replyAfterToolsOn, withDefaultEffort, defaultEffortOf, gzipped, FREE_ONLY, PRICE_TTL, keyAccount, remoteConfig, cycleOf, typeUnions, systemAsUser, systemKey, DEFAULT_SYSTEM, EDGE_READS, fitShape, replyAfterTools, AFTER_TOOLS_REFUSED, enterpriseUsageOf, notDesktop, textOnly, runsOutOf, packagesOf, endOf, enterpriseHeaders, enterpriseOf, liveModels, browserSignIn, withSystem, asReasoning, normalizeToolChoice, fitTools, fitted, renamed, unflagged, usageOf, desktopHeld, explained, REFUSED_HINT, RESTRICTED_HINT, EXHAUSTED_HINT, freeCredits, creditsOf, priceOf, fresh, SITES, renewing, renewed, SESSION, attend, scopeOf, turnKey, conversationKey, signatureOf, isMessages, withoutUnsigned, withoutSignedThinking, prepared, answeredBefore, nativeOf, modelOf, configModels, freeWindowOf, tokensShort, freeWindowRows, freeWindowLedger, readLedgerTail, pinned, rowTime, rowAccount, ofAccount, windowOffset, windowReset, FREE_WINDOW_MODELS, FREE_WINDOW_SPAN, FREE_WINDOW_GAP, FREE_WINDOW_LEAD, checkinOf, API_MESSAGES: NPM_MESSAGES, API_CHAT: NPM }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-workbuddy-auth",
3
- "version": "0.1.47",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode provider plugins for WorkBuddy's plans: WorkBuddy (CodeBuddy, copilot.tencent.com) and WorkBuddy AI (www.workbuddy.ai)",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",