@magpie-community/opencode-workbuddy-auth 0.1.47 → 0.1.48

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 +73 -0
  2. package/index.mjs +563 -3
  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
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
@@ -1899,6 +1899,556 @@ function capacity(v) {
1899
1899
  // compact is a count as magpie says one: whole, else to two places.
1900
1900
  const compact = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""))
1901
1901
 
1902
+ // The window WorkBuddy AI rate limits its free models by, which its credit
1903
+ // meter does not show.
1904
+ //
1905
+ // A free account's usage() reports only the credits window, so a plan with
1906
+ // 160 credits untouched reads 0% while chats to deepseek-v4.1-flash are
1907
+ // already refused: the model has a token allowance of its own, counted
1908
+ // apart from credits, and nothing the vendor answers with reports it. The
1909
+ // refusal is the only place it appears, and only as a time:
1910
+ //
1911
+ // 429 usage exceeds frequency limit, but don't worry, your usage will
1912
+ // reset at 2026-10-20 19:47:41 UTC+8, alternatively, you can switch to
1913
+ // the other models to continue using it.
1914
+ //
1915
+ // Measured on a free plan over ten days (one account, deepseek-v4.1-flash
1916
+ // only), counting in + cache_read + out:
1917
+ //
1918
+ // 10-15 15:40 → 100,183,315 429 10-18 19:52 → 92,792,962 429
1919
+ // 10-16 16:48 → 99,971,831 429 10-19 19:47 → 98,201,537 429
1920
+ // 10-14 15:38 → 71,687,717 lapsed
1921
+ //
1922
+ // so the cap is per account per 24 hours at about 1e8 tokens, the largest
1923
+ // value that still answered being 100,183,315. Three things about it are
1924
+ // easy to get wrong and were all checked:
1925
+ //
1926
+ // - cache_read counts. In + out alone is 20× smaller (0.75M–3.9M a
1927
+ // window) and never comes near the cap.
1928
+ // - the window is fixed, not sliding: peak trailing-24h over the same
1929
+ // data is 172.85M, which a sliding window would have held to 1e8.
1930
+ // - the reset a refusal names is the window's start + 24h exactly, and
1931
+ // the start is the first request answered after the previous window
1932
+ // ended — not the reset instant. Gaps of 6, 68, 80 and 182 minutes
1933
+ // were seen between a reset and the next request, each opening the new
1934
+ // window.
1935
+ //
1936
+ // deepseek-v4.1-flash-sg is the same model under its paid id (the product
1937
+ // config's ModelRateLimitCap.lines[0] names it as paidId to this freeId,
1938
+ // allowPaidSwitch: true) and draws on the same window: it was refused in
1939
+ // the same windows, to the second, with about 0.01M tokens of its own.
1940
+ // Both ids are named in models: magpie matches a plugin window's models
1941
+ // against a model id exactly, folding only an @size suffix
1942
+ // (internal/provider/plugin_usage.go, idSetOf), so one id alone leaves the
1943
+ // other unrestricted.
1944
+ //
1945
+ // The window is read two ways, and a refusal wins:
1946
+ //
1947
+ // - the sum (in + cache_read + out) of this machine's rows, which holds
1948
+ // until the account is first refused. The ledger is this machine's
1949
+ // only, so an account also used on another machine or through another
1950
+ // client reads short: 92.8M read as "93%" on one refused at 92,792,962,
1951
+ // the rest of its spending being elsewhere.
1952
+ // - a refusal whose named reset is still ahead, which makes the window
1953
+ // full whatever the sum says. That is the one exact signal there is,
1954
+ // and routing must not pass over it either.
1955
+ //
1956
+ // The ledger is magpie's own, in magpie's config folder (appdir.Config()):
1957
+ // the folder the plugin host runs in, which is the portable data folder
1958
+ // when magpie runs portable, $XDG_CONFIG_HOME/magpie when that is set, and
1959
+ // ~/.config/magpie otherwise. No endpoint reports the window; a ledger that
1960
+ // cannot be read yields no window rather than a made-up one.
1961
+
1962
+ const FREE_WINDOW_CAP = 1e8
1963
+ const FREE_WINDOW_SPAN = 24 * 3600 * 1000
1964
+ // The lead: how far back of an edge the read goes on. A chain read from a row
1965
+ // that opens a window needs the window the span makes of the rows above it,
1966
+ // so the rows kept reach a day past the edge — the window opening at the
1967
+ // request after it, minutes to hours into that day, whatever the first row
1968
+ // after the edge falls on.
1969
+ const FREE_WINDOW_LEAD = 86400 * 1000
1970
+ // A gap this long between two of this account's answered rows ends every
1971
+ // window before it, so the later row opens one: the window holding the
1972
+ // earlier row began at most 24h before it, and so ended 24h after it at the
1973
+ // latest. This is one of the two edges the ledger itself spells out.
1974
+ const FREE_WINDOW_GAP = FREE_WINDOW_SPAN
1975
+ // the model the window is counted for; -sg shares it
1976
+ const FREE_WINDOW_MODEL = "deepseek-v4.1-flash"
1977
+ // both ids, so magpie's exact match finds the -sg one too
1978
+ const FREE_WINDOW_MODELS = ["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"]
1979
+ // the provider whose rows count
1980
+ const FREE_WINDOW_PROVIDER = "workbuddy-ai"
1981
+
1982
+ // windowOffset is the UTC offset a refusal spells out, in minutes, from
1983
+ // "UTC+8", "UTC+08:00", "UTC-5" or "GMT+8". null when it spells out none,
1984
+ // which is not the same as +08:00: an offset guessed wrong puts the reset
1985
+ // hours from where it is, so a reset that cannot be placed is not used as
1986
+ // an edge at all.
1987
+ function windowOffset(text) {
1988
+ const m = /\b(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?\b/i.exec(String(text ?? ""))
1989
+ if (!m) return null
1990
+ const min = Number(m[2]) * 60 + Number(m[3] ?? 0)
1991
+ if (min > 14 * 60) return null
1992
+ return m[1] === "-" ? -min : min
1993
+ }
1994
+
1995
+ // windowReset is the instant a refusal names, or NaN when it names none or
1996
+ // names it in an offset that cannot be read.
1997
+ function windowReset(text) {
1998
+ const s = String(text ?? "")
1999
+ const at = /reset at (\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(s)
2000
+ if (!at) return NaN
2001
+ const off = windowOffset(s)
2002
+ if (off === null) return NaN
2003
+ return Date.UTC(+at[1], +at[2] - 1, +at[3], +at[4], +at[5], +at[6]) - off * 60000
2004
+ }
2005
+
2006
+ // freeWindowOf is the window account is in now, from rows (magpie's usage
2007
+ // records), or null when it has lapsed — the next request opens a new one.
2008
+ // now is passed in so the reading is testable.
2009
+ //
2010
+ // The window is a chain: it begins at the first request answered after the
2011
+ // previous one ended, so where this window began depends on every window
2012
+ // before it. A window ends at a refusal's reset when the ledger holds one,
2013
+ // and otherwise a day after it began. readLedgerTail (which see) stops on
2014
+ // both, so that these rows read the same as the whole ledger.
2015
+ function freeWindowOf(rows, account, now = Date.now()) {
2016
+ const mine = (Array.isArray(rows) ? rows : []).filter(
2017
+ (r) =>
2018
+ r?.provider === FREE_WINDOW_PROVIDER &&
2019
+ r.providerAccount === account &&
2020
+ typeof r.model === "string" &&
2021
+ r.model.startsWith(FREE_WINDOW_MODEL) &&
2022
+ r.t,
2023
+ )
2024
+ if (!mine.length) return null
2025
+
2026
+ const at = (r) => {
2027
+ const t = Date.parse(r.t)
2028
+ return Number.isFinite(t) ? t : 0
2029
+ }
2030
+ const counted = (r) => (r.in ?? 0) + (r.cache_read ?? 0) + (r.out ?? 0)
2031
+ // two rows a day apart: the first of them opened a window that has ended by
2032
+ // the second. Written as a function so that no minifier folds the
2033
+ // subtraction into the comparison.
2034
+ const apart = (a, b) => a - b >= FREE_WINDOW_SPAN
2035
+
2036
+ // a refusal names the end of the window it refused in
2037
+ const edges = []
2038
+ for (const r of mine) {
2039
+ if (Number(r.status) !== 429) continue
2040
+ const t = windowReset(r.err)
2041
+ if (Number.isFinite(t)) edges.push(t)
2042
+ }
2043
+ edges.sort((a, b) => a - b)
2044
+
2045
+ const answered = mine.filter((r) => Number(r.status) === 200 && at(r) > 0).sort((a, b) => at(a) - at(b))
2046
+ if (!answered.length) return null
2047
+
2048
+ const head = (from) => {
2049
+ let start = null
2050
+ let used = 0
2051
+ let edge = 0 // the refusals already read
2052
+ for (let i = from; i < answered.length; i++) {
2053
+ const r = answered[i]
2054
+ const t = at(r)
2055
+ // every refusal this row's window ended at closes it
2056
+ while (edge < edges.length && edges[edge] <= t) {
2057
+ start = null
2058
+ used = 0
2059
+ edge++
2060
+ }
2061
+ if (start === null) start = t
2062
+ else if (apart(t, start)) {
2063
+ start = t
2064
+ used = 0
2065
+ }
2066
+ used += counted(r)
2067
+ }
2068
+ return start === null ? null : { start, used }
2069
+ }
2070
+
2071
+ // The rows may begin before the window now: readLedgerTail goes a day past
2072
+ // the edge it stops at, so the run of rows that opens a window can start a
2073
+ // day after the first row kept. That window opens at a row with no answered
2074
+ // row of this account's within a day before it — where the chain is read
2075
+ // from. Read from the first row kept, the chain would begin shifted, a day
2076
+ // early: every window after it, the card with it.
2077
+ let w = null
2078
+ for (let i = 0; i < answered.length; i++) {
2079
+ if (i && !apart(at(answered[i]), at(answered[i - 1]))) continue
2080
+ const h = head(i)
2081
+ if (h === null) continue
2082
+ if (h.start + FREE_WINDOW_SPAN <= now) continue // lapsed before now
2083
+ w = h
2084
+ break
2085
+ }
2086
+ const start = w?.start
2087
+ const used = w?.used
2088
+ if (!(start > 0)) return null
2089
+
2090
+ const end = start + FREE_WINDOW_SPAN
2091
+ if (now >= end) return null // lapsed: nothing is exhausted, a new window opens on the next request
2092
+
2093
+ const card = (amount, resetsAt, refused) => ({
2094
+ name: "Free model tokens",
2095
+ used: Math.min(100, (100 * amount) / FREE_WINDOW_CAP),
2096
+ amount,
2097
+ limit: FREE_WINDOW_CAP,
2098
+ unit: "tokens",
2099
+ display: `${tokensShort(amount)} / 100M`,
2100
+ resetsAt: new Date(resetsAt).toISOString(),
2101
+ span: FREE_WINDOW_SPAN / 1000,
2102
+ models: FREE_WINDOW_MODELS.slice(),
2103
+ ...(refused ? { refused: true } : {}),
2104
+ })
2105
+
2106
+ // A refusal still in force is exact: the account is spending this window
2107
+ // from elsewhere too, so the sum undercounts it, and the refusal is the
2108
+ // answer the vendor would give again. Its reset is the window's end,
2109
+ // named by the vendor rather than derived from start.
2110
+ let live = null
2111
+ for (const r of mine) {
2112
+ if (Number(r.status) !== 429) continue
2113
+ const t = windowReset(r.err)
2114
+ if (!Number.isFinite(t) || t <= now || t <= start) continue
2115
+ if (live === null || t > live) live = t
2116
+ }
2117
+ if (live !== null) return card(FREE_WINDOW_CAP, live, true)
2118
+
2119
+ return card(used, end, false)
2120
+ }
2121
+
2122
+ // tokensShort says a token count small: 11.9M, 812k. The credits card's
2123
+ // compact() (2 decimals, credited to credits) reads a token count badly.
2124
+ function tokensShort(n) {
2125
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
2126
+ if (n >= 1e3) return `${Math.round(n / 1e3)}k`
2127
+ return String(Math.round(n))
2128
+ }
2129
+
2130
+ // ledgerParsed is the rows read last and what they were read for: the file's
2131
+ // fingerprint, the account and the instant. A file that has not changed hands
2132
+ // back the rows already parsed rather than reading and decoding them again.
2133
+ //
2134
+ // Reading the ledger whole was 48 ms on 2026-10-21 (10.3 MiB, 17925 lines:
2135
+ // 6.4 ms to read, 32.8 ms to JSON.parse, 8 ms to find the window), spent
2136
+ // synchronously on the plugin host's thread every time a panel opened or a
2137
+ // card was asked about, which is what made the free-token window feel slow
2138
+ // beside the credits, whose reading is a network call and so blocks nothing.
2139
+ // A heavy ledger runs to hundreds of MiB, so the read starts at the end.
2140
+ //
2141
+ // The key carries the account because the rows are this account's: one
2142
+ // account's read must not answer for another's. A read for an earlier instant
2143
+ // keeps all the rows a read for a later one would — the bounds are only
2144
+ // further back, and an edge is the same time in either — so the rows held
2145
+ // over answer for the later one too: freeWindowOf is what decides with its
2146
+ // own now.
2147
+ let ledgerParsed = null
2148
+
2149
+ // ledgerBad is the number of whole lines the ledger last had that could
2150
+ // not be decoded, and the file it was read from, so that a ledger read
2151
+ // once and read again is complained about once, not on every panel open.
2152
+ let ledgerBad = { key: "", count: 0 }
2153
+
2154
+ // freeWindowLedger is the ledgers looked for, in magpie's own folder order
2155
+ // (appdir.Config()): the plugin host runs in that folder — the portable
2156
+ // data folder when magpie runs portable, $XDG_CONFIG_HOME/magpie when that
2157
+ // is set, ~/.config/magpie otherwise — so its cwd answers first; the other
2158
+ // two are named as well, for a plugin asked outside the host. The first
2159
+ // readable ledger wins: a stale copy in a folder magpie does not use must
2160
+ // not hide the live one.
2161
+ //
2162
+ // $XDG_CONFIG_HOME counts only when it is absolute, as appdir.rooted has it:
2163
+ // an empty or relative value is not where magpie keeps its ledger.
2164
+ function freeWindowLedger(cwd = process.cwd(), env = process.env, home = homedir()) {
2165
+ const dirs = [cwd]
2166
+ if (env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)) dirs.push(join(env.XDG_CONFIG_HOME, "magpie"))
2167
+ if (home) dirs.push(join(home, ".config", "magpie"))
2168
+ return dirs.map((d) => join(d, "usage.jsonl"))
2169
+ }
2170
+
2171
+ // freeWindowRows reads magpie's usage ledger for account, oldest row first.
2172
+ // null when no ledger can be read: the window is then left unknown rather
2173
+ // than guessed.
2174
+ //
2175
+ // The ledger is appended to a line at a time, so a read that lands while a
2176
+ // call is being written can catch that line half-written. Only the last
2177
+ // line can be torn that way (everything before it was written whole), and
2178
+ // only it is passed over in silence. A whole line in the middle that won't
2179
+ // decode is a real fault — a truncated file, a writer that lost a record —
2180
+ // and is said aloud, because passing it over quietly is how a window goes
2181
+ // missing with nothing to point at: on 2026-10-21 a restart caught the
2182
+ // ledger mid-write, the one row holding the free window's start was
2183
+ // dropped without a word, and the panel showed no window at all until the
2184
+ // next read happened to succeed.
2185
+ function freeWindowRows(account, now = Date.now(), cwd = process.cwd(), env = process.env, home = homedir()) {
2186
+ if (!account) return null
2187
+ let found = null
2188
+ for (const path of freeWindowLedger(cwd, env, home)) {
2189
+ try {
2190
+ const st = statSync(path)
2191
+ if (!st.size) continue
2192
+ found = { path, st }
2193
+ break
2194
+ } catch {}
2195
+ }
2196
+ if (!found) return null
2197
+ const { path, st } = found
2198
+ // size and mtime both move when a call is appended, which is the only
2199
+ // way this ledger is written; one rewritten in place (a rotation or a
2200
+ // compaction) moves mtime even when its size lands the same.
2201
+ const file = path + "|" + st.mtimeMs + ":" + st.size
2202
+ const key = file + "|" + account
2203
+ if (ledgerParsed?.key === key) return ledgerParsed.rows
2204
+ let got
2205
+ try {
2206
+ got = readLedgerTail(path, st, now, undefined, account)
2207
+ } catch {
2208
+ return null
2209
+ }
2210
+ if (got.bad && ledgerBad.key !== file) {
2211
+ ledgerBad = { key: file, count: got.bad }
2212
+ console.error(
2213
+ `workbuddy: ${got.bad} whole line${got.bad === 1 ? "" : "s"} of ${path} could not be decoded ` +
2214
+ `(not the last line, so not a write in progress); the free-token window may read short`,
2215
+ )
2216
+ }
2217
+ ledgerParsed = { key, rows: got.rows }
2218
+ return got.rows
2219
+ }
2220
+
2221
+ // LEDGER_T is where a row's time sits: "t" is among the first keys
2222
+ // usage.Record writes, within this many bytes of the line's start, so every
2223
+ // line's time is read without decoding the line — most of a ledger's lines
2224
+ // belong to another provider, or to this account and out of reach.
2225
+ const LEDGER_T = /"t"\s*:\s*"([^"]{10,64})"/
2226
+
2227
+ // LEDGER_ACCOUNT finds the account a line was written under, before the line
2228
+ // is decoded. usage.Record writes providerAccount second.
2229
+ const LEDGER_ACCOUNT = /"providerAccount"\s*:\s*"([^"]{0,256})"/
2230
+
2231
+ // rowTime is the instant a line is written at, from that "t", or NaN.
2232
+ function rowTime(line) {
2233
+ const m = LEDGER_T.exec(line)
2234
+ return m ? Date.parse(m[1]) : NaN
2235
+ }
2236
+
2237
+ // LEDGER_REPORTED finds what a client reported: POST /v1/magpie/usage/requests
2238
+ // (#1874) appends a client's own calls to the same ledger, with the time the
2239
+ // client gives them, and a past time is kept on purpose so history can be
2240
+ // backfilled (docs/integrating.md). Such a row is written at the end of the
2241
+ // file and dated anywhere, so it says nothing about where the file's own
2242
+ // records have got to — magpie's index leaves it out of firstOwn for the same
2243
+ // reason (log_index.go). It never carries providerAccount, being not in
2244
+ // ingestFields, so it is never a row of this account either.
2245
+ const LEDGER_REPORTED = /"source"\s*:\s*"reported"/
2246
+
2247
+ // rowReported is whether a line is one a client reported rather than one
2248
+ // magpie wrote when it happened.
2249
+ function rowReported(line) {
2250
+ return LEDGER_REPORTED.test(line)
2251
+ }
2252
+
2253
+ // rowAccount is the account a line was written under, or null when the line
2254
+ // names none (another record, or one cut short).
2255
+ function rowAccount(line) {
2256
+ const m = LEDGER_ACCOUNT.exec(line)
2257
+ if (!m) return null
2258
+ try {
2259
+ return JSON.parse(`"${m[1]}"`)
2260
+ } catch {
2261
+ return null
2262
+ }
2263
+ }
2264
+
2265
+ // ofAccount is whether a row belongs to the chain read for account: this
2266
+ // account's own row of the free model, either id. A refusal of another
2267
+ // account's, or of a paid model, says nothing about where this chain opens.
2268
+ function ofAccount(row, account) {
2269
+ return (
2270
+ row?.provider === FREE_WINDOW_PROVIDER &&
2271
+ row.providerAccount === account &&
2272
+ typeof row.model === "string" &&
2273
+ row.model.startsWith(FREE_WINDOW_MODEL)
2274
+ )
2275
+ }
2276
+
2277
+ // readLedgerTail decodes the ledger from its end backwards, and stops where
2278
+ // the chain of account's own rows can still be read from. chunks is a test's
2279
+ // way of counting reads; nothing else passes it.
2280
+ //
2281
+ // Why it cannot stop at 48h: the window is a chain — each one starts at the
2282
+ // first request answered after the previous one ended — so where the window
2283
+ // now began depends on every window before it. A first row that is not an
2284
+ // edge opens the window in the middle of a real one and every window after
2285
+ // it is shifted: 4% on an account two hours from its cap, or 100% on one
2286
+ // still served, which routing would then pass over. So the read goes back
2287
+ // until the rows it keeps read the window now as the whole ledger does:
2288
+ //
2289
+ // - a refusal of this account's, whose reset can be read: the window it
2290
+ // named ended there, and the next request opened another;
2291
+ // - a gap of 24h or more between two of this account's answered rows: the
2292
+ // window holding the earlier one has ended by then, and the later one
2293
+ // opens one;
2294
+ // - and with neither, it reads to the file's start, where the chain has no
2295
+ // earlier window to be shifted by.
2296
+ //
2297
+ // Each edge is passed by a day (FREE_WINDOW_LEAD), the window opening at the
2298
+ // request after it, minutes to hours into the day.
2299
+ //
2300
+ // Two bounds keep that from being a whole file on every poll, and neither
2301
+ // depends on how busy the account is:
2302
+ //
2303
+ // - the ledger is appended in time order, so a line whose time is under the
2304
+ // edge — of whatever provider, of whatever account — ends the read;
2305
+ // - a read that is past now - 24h with no answered free-model row of this
2306
+ // account has nothing to report: the window has lapsed, and freeWindowOf
2307
+ // says null. It stops there rather than going on to the edges.
2308
+ //
2309
+ // Every line's time is read from its "t" before the line is decoded, so a
2310
+ // line of another provider or account costs one match and no JSON.parse.
2311
+ //
2312
+ // What is not covered: magpie's providerAccount is the account's label, and a
2313
+ // label two accounts share gets its slot added to it (pluginLabels,
2314
+ // plugin_accounts.go). The label then no longer equals the accountId rows are
2315
+ // written under, no row is found for this account at all, and the card
2316
+ // reports no free window.
2317
+ function readLedgerTail(path, st, now, chunks, account) {
2318
+ const CHUNK = 1 << 16
2319
+ const lapsed = now - FREE_WINDOW_SPAN // a read past this holds no window now
2320
+ const fd = openSync(path, "r")
2321
+ try {
2322
+ // a ledger whose last byte ends a line has no line being written
2323
+ let tornEnd = false
2324
+ if (st.size > 0) {
2325
+ const one = Buffer.allocUnsafe(1)
2326
+ readSync(fd, one, 0, 1, st.size - 1)
2327
+ tornEnd = one[0] !== 0x0a
2328
+ }
2329
+ const rev = [] // the rows kept, latest first
2330
+ let bad = 0
2331
+ let pos = st.size
2332
+ let carry = ""
2333
+ let firstPass = true
2334
+ let stop = false
2335
+ let chain = [] // this account's own rows read, latest first
2336
+ let reach = 0 // the row below which nothing is kept: 0 until an edge is found
2337
+ let answered = false // whether an answered free-model row of it is read
2338
+
2339
+ while (pos > 0 && !stop) {
2340
+ const len = Math.min(CHUNK, pos)
2341
+ pos -= len
2342
+ const buf = Buffer.allocUnsafe(len)
2343
+ readSync(fd, buf, 0, len, pos)
2344
+ if (chunks) chunks(1)
2345
+ const lines = (buf.toString("utf8") + carry).split("\n")
2346
+ carry = lines.shift() // its first line is begun in an earlier chunk
2347
+ for (let i = lines.length - 1; i >= 0 && !stop; i--) {
2348
+ const line = lines[i]
2349
+ if (!line.trim()) continue
2350
+ const t = rowTime(line)
2351
+ // Under an edge or the bound, by time alone: nothing below it can be
2352
+ // kept, whatever wrote it — but a line a client reported is dated
2353
+ // where the client's call happened, not where the file has got to.
2354
+ const dated = Number.isFinite(t) && !rowReported(line)
2355
+ if (dated && reach && t < reach) {
2356
+ stop = true
2357
+ break
2358
+ }
2359
+ // A day back with nothing of this account's answered and no edge in
2360
+ // reach: no window is being spent now, and nothing below can open one
2361
+ if (dated && !reach && !answered && t < lapsed) {
2362
+ stop = true
2363
+ break
2364
+ }
2365
+ // Another provider's or another account's line: nothing else to see
2366
+ if (!line.includes(account) || rowAccount(line) !== account) continue
2367
+ let row
2368
+ try {
2369
+ row = JSON.parse(line)
2370
+ } catch {
2371
+ // the last line may be a call still being written, and is passed
2372
+ // over; anything before it is a fault worth saying
2373
+ if (!(firstPass && tornEnd && i === lines.length - 1)) bad++
2374
+ continue
2375
+ }
2376
+ if (!ofAccount(row, account)) continue
2377
+ rev.push(row)
2378
+ chain.push(row)
2379
+ if (Number(row.status) === 200) answered = true
2380
+ if (!reach && Number(row.status) !== 429) reach = pinned(chain, account)
2381
+ }
2382
+ firstPass = false
2383
+ }
2384
+ // the file's first line, with no newline before it: kept only when the
2385
+ // read ran to the file's start
2386
+ if (!stop && pos === 0 && carry.trim() && carry.includes(account) && rowAccount(carry) === account) {
2387
+ const t = rowTime(carry)
2388
+ if (!reach || !Number.isFinite(t) || t >= reach) {
2389
+ try {
2390
+ const row = JSON.parse(carry)
2391
+ if (ofAccount(row, account)) rev.push(row)
2392
+ } catch {
2393
+ bad++
2394
+ }
2395
+ }
2396
+ }
2397
+ return { rows: rev.reverse(), bad }
2398
+ } finally {
2399
+ closeSync(fd)
2400
+ }
2401
+ }
2402
+
2403
+ // pinned is where the chain of account's own rows (latest first) is read
2404
+ // from: the time below which nothing is kept. 0 while the rows say nothing
2405
+ // about it yet, and the read then goes to the file's start.
2406
+ //
2407
+ // A row whose time cannot be read is passed over rather than ending the
2408
+ // chain there: one corrupt line used to cut it wherever it sat.
2409
+ function pinned(rows, account) {
2410
+ const at = (r) => Date.parse(r?.t)
2411
+ const apart = (a, b) => a - b >= FREE_WINDOW_GAP
2412
+ // A refusal names where a window ended, and the next request opened
2413
+ // another. The loop walks oldest to newest and overwrites `denied`, so the
2414
+ // one kept is the *newest* readable refusal in the chain, not the earliest:
2415
+ // a later refusal is nearer to now, and its reset is the tighter bound on
2416
+ // where the chain can be read from. A refusal whose reset has already been
2417
+ // passed by a newer one therefore loses, which is what we want — the old
2418
+ // `reset < newest` test that used to guard this is gone, the overwrite does
2419
+ // the same job and keeps the latest.
2420
+ let denied = null
2421
+ for (let i = rows.length - 1; i >= 0; i--) {
2422
+ const reset = Number(rows[i]?.status) === 429 ? windowReset(rows[i].err) : NaN
2423
+ if (Number.isFinite(reset)) denied = reset
2424
+ }
2425
+ if (denied !== null) return denied - FREE_WINDOW_LEAD
2426
+ // A day's gap between two answered rows ended every window before it, and
2427
+ // the day before the later row is where the span's reading of the rows
2428
+ // above it begins. The rows come latest first, so each is compared with the
2429
+ // one read before it — the row after it in time, `later`.
2430
+ let later = null
2431
+ for (let i = rows.length - 1; i >= 0; i--) {
2432
+ const r = rows[i]
2433
+ const t = at(r)
2434
+ if (!Number.isFinite(t)) continue
2435
+ if (Number(r.status) !== 200) continue
2436
+ if (later === null) {
2437
+ later = t
2438
+ continue
2439
+ }
2440
+ if (apart(t, later)) return later - FREE_WINDOW_LEAD
2441
+ later = t
2442
+ }
2443
+ // no edge among these rows: the read goes on to the file's start, which is
2444
+ // the whole ledger — the only place a chain needs nothing before it
2445
+ return 0
2446
+ }
2447
+
2448
+ // _internal is where these are handed to a test: magpie's host calls every
2449
+ // exported function of a plugin as one of its own factories (host.js,
2450
+ // servers()), so freeWindowRows et al. must not be exports of the plugin.
2451
+
1902
2452
  // usageOf is the account's credits, from its resource summary: the plan's
1903
2453
  // credits used against what the cycle grants, as magpie's built-in said —
1904
2454
  // the count itself too (amount of limit, in credits), for magpie to say it
@@ -2718,6 +3268,16 @@ function makePlugin(site) {
2718
3268
  } catch {}
2719
3269
  }
2720
3270
  await spareFree(site, a, out.windows)
3271
+ // the free model's own window, which the credit meter above does
3272
+ // not show: what actually refuses a chat long before 160 credits
3273
+ // are spent. WorkBuddy AI alone; the China build has no such cap
3274
+ // measured (see freeWindowOf)
3275
+ if (site.ai) {
3276
+ const account = a.accountId || a.user || a.uid || ""
3277
+ const rows = freeWindowRows(account)
3278
+ const w = rows && freeWindowOf(rows, account)
3279
+ if (w) out.windows.push(w)
3280
+ }
2721
3281
  return { ...out, signIn: said }
2722
3282
  } catch (e) {
2723
3283
  return { error: e?.message ?? String(e), signIn: "kept" }
@@ -2849,4 +3409,4 @@ export const WorkBuddyAuthPlugin = makePlugin(SITES.workbuddy)
2849
3409
  export const WorkBuddyAIAuthPlugin = makePlugin(SITES["workbuddy-ai"])
2850
3410
 
2851
3411
  // 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 }
3412
+ 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, 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.1.48",
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",