magpie-plugin-msmoli 0.1.0 → 0.3.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 (5) hide show
  1. package/README.md +586 -331
  2. package/bin/sync.mjs +1569 -148
  3. package/index.mjs +2492 -261
  4. package/package.json +3 -4
  5. package/README.zh-CN.md +0 -340
package/index.mjs CHANGED
@@ -1,33 +1,80 @@
1
- // ModelScope (魔搭) 魔粒 check-in for magpie.
1
+ // ModelScope credits check-in for magpie — one provider that serves both
2
+ // modelscope.cn and modelscope.ai, each account on the site its token is from.
2
3
  //
3
4
  // magpie calls auth.checkin once a Beijing day per signed-in account while the
4
5
  // provider's check-in switch is on, and again later that day after a failure.
5
6
  // Hooks here: checkin, usage.
6
7
  //
7
- // 签到机制:ModelScope 的 OpenAPI 里没有签到端点。访问
8
- // GET /api/v1/users/login/info 会被服务端记成一次登录,随即发放当日
9
- // 「注册并登录 200」与「绑定阿里云账号 50」,当日幂等。
8
+ // How the claim works: ModelScope's OpenAPI has no check-in endpoint. What it
9
+ // has is a side effect — GET /api/v1/users/login/info counts as a website
10
+ // sign-in, and the server grants that day's credits with it (200 for the
11
+ // "register-and-sign-in" daily grant, 50 for a bound Aliyun account), once per day.
12
+ // modelscope.ai is the same backend with the same paths and the same
13
+ // daily_active rule, but separate accounts and separate tokens: measured, an
14
+ // ms- token authenticates only on the site it came from.
10
15
  //
11
- // 本插件不提供任何聊天模型:推理仍走 providers.json 里 API key 版的
12
- // modelscope 预设。这个 provider 只为承载签到所需的登录态而存在。
16
+ // The plugin serves no chat models: inference keeps using the API-key modelscope
17
+ // preset in providers.json. This provider exists only to carry the sign-in the
18
+ // check-in runs as.
13
19
 
14
20
  import { readFileSync } from "node:fs"
15
21
  import { homedir } from "node:os"
16
22
  import { isAbsolute, join } from "node:path"
17
23
 
18
- const SITE = "https://modelscope.cn"
24
+ // One provider serves both sites: a token's site is a fact about the token,
25
+ // not about the card, so the plugin finds it per account instead of asking the
26
+ // user to pick a provider. A profile carries what differs between the sites —
27
+ // the origin, the plan line the card shows, and the word a 401 uses.
28
+ const CN = {
29
+ site: "https://modelscope.cn",
30
+ plan: "魔搭社区",
31
+ where: "国内站",
32
+ }
33
+ const INTL = {
34
+ site: "https://modelscope.ai",
35
+ plan: "魔搭社区(国际版)",
36
+ where: "国际站",
37
+ }
38
+ const SITES = [CN, INTL]
39
+
40
+ // The provider's id and name: one card, whatever mix of accounts it holds.
41
+ const ID = "msmoli"
42
+ const NAME = "ModelScope 魔粒"
43
+
44
+ // sites remembers which site each token signs in to, for the life of the
45
+ // process. Finding it costs one of the reads the hook was about to make anyway
46
+ // — a 401 is the only answer that rules a site out — and asking again on every
47
+ // poll would double every read. Bounded like `names`: a host is long-lived, and
48
+ // tokens come and go.
49
+ const sites = new Map()
50
+ const SITES_MAX = 64
51
+ const rememberSite = (token, profile) => {
52
+ sites.delete(token)
53
+ sites.set(token, profile)
54
+ while (sites.size > SITES_MAX) sites.delete(sites.keys().next().value)
55
+ }
56
+
57
+ // profileForSite maps a value kept on a sign-in back to its site. Anything
58
+ // else — an older host's field, a hand-edited file — is ignored and the token
59
+ // probed again rather than trusted.
60
+ const profileForSite = (v) => (typeof v === "string" ? SITES.find((p) => p.site === v) : undefined)
61
+
19
62
  const LOGIN_INFO = "/api/v1/users/login/info"
20
63
  const BALANCE = "/openapi/v1/magicubes/balance"
21
64
  const TXNS = "/openapi/v1/magicubes/transactions"
22
65
 
66
+ // The sign-in is the site's own web session, so every request carries a
67
+ // browser's UA rather than a bare fetch one — a bare UA is the one shape
68
+ // measured to get a different answer. The string is fixed on purpose (the
69
+ // suite pins it whole): it names a browser version rather than pretending to
70
+ // be one, and nothing here fingerprints beyond it.
23
71
  const UA =
24
72
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
25
73
 
26
- // 每日发放的规则。这类魔粒 24 小时过期,不累积。
74
+ // The daily-grant rules. This kind of credits expires in 24 hours; they do not
75
+ // accumulate.
27
76
  const DAILY_RULES = ["daily_active", "aliyun_bindlogin"]
28
77
 
29
- const ID = "msmoli"
30
-
31
78
  const TIMEOUT_MS = 8000
32
79
  // PAGE_SIZE is one page of the ledger, and one page is all that is read. The
33
80
  // ledger is aggregated — one row per (day, type, rule_key, model_tier), with a
@@ -59,13 +106,22 @@ const USAGE_BUDGET_MS = 18 * 1000
59
106
  // rule, one place — tokenOf and unusableKey must agree on it, or a key one of
60
107
  // them accepts and the other refuses has no single answer to give the user.
61
108
  const SENDABLE = /^[\x21-\x7e]+$/
62
- const rawKey = (auth) => (auth?.type === "api" ? String(auth.key ?? "").trim() : "")
109
+ // A key that is not a string is not a key: String() on an array or a number
110
+ // turns it into text that passes SENDABLE, and the sign-in's key then goes out
111
+ // as a Cookie — measured, `key: [token]` sent the token and `key: 1234…` sent
112
+ // the number. The host writes a string; anything else is a file this plugin
113
+ // could not read, and it is refused the way an unsendable key is, not sent.
114
+ const rawKey = (auth) => (auth?.type === "api" && typeof auth.key === "string" ? auth.key.trim() : "")
63
115
 
64
116
  // tokenOf is the key this sign-in carries, or "" when there is none or it is
65
117
  // not one we can send. The two are told apart by the caller so a key that is
66
118
  // there and unusable does not read as "you never signed in".
67
119
  const unusableKey = (auth) => {
68
- const key = rawKey(auth)
120
+ if (auth?.type !== "api" || auth.key === undefined || auth.key === null) return false
121
+ // a key of the wrong type is a store this plugin cannot read, not an absent
122
+ // one: reported as unusable so the message says the token is unreadable
123
+ if (typeof auth.key !== "string") return true
124
+ const key = auth.key.trim()
69
125
  return Boolean(key) && !SENDABLE.test(key)
70
126
  }
71
127
  const tokenOf = (auth) => {
@@ -109,7 +165,8 @@ const handleOf = (token) => fnv(token).toString(16)
109
165
  // magpie's own fetch, behind a proxy, fails in Go's words — a plain Error
110
166
  // "proxyconnect tcp: dial tcp 127.0.0.1:7890: i/o timeout" (host.js), not a
111
167
  // TypeError — so it is named before the timeout test: its "i/o timeout" is the
112
- // proxy's dial, and "请求超时(8 秒)" would send the user after the network
168
+ // proxy's dial, and the "request timed out (8 s)" wording would send the user
169
+ // after the network
113
170
  // when the proxy is what is down.
114
171
  function saidFailure(e, ctl, budget) {
115
172
  if (e !== ctl.signal.reason && /^proxyconnect\b/i.test(String(e?.message ?? ""))) {
@@ -118,8 +175,10 @@ function saidFailure(e, ctl, budget) {
118
175
  if (e === ctl.signal.reason || /timeout/i.test(String(e?.message ?? ""))) {
119
176
  // Ceiling, not round: the budget can be down to a few hundred
120
177
  // milliseconds when the hook's own deadline is what cut it short, and
121
- // "请求超时(0 秒)" tells the user nothing about which of the two it was.
122
- return new Error(`请求超时(${Math.max(1, Math.ceil(budget / 1000))} 秒)`)
178
+ // "request timed out (0 s)" tells the user nothing about which of the two
179
+ // it was. The flag is how siteRead tells a timeout (the link, which the
180
+ // other site would wait out again) from a refusal (that site's answer).
181
+ return Object.assign(new Error(`请求超时(${Math.max(1, Math.ceil(budget / 1000))} 秒)`), { timeout: true })
123
182
  }
124
183
  if (e?.name === "TypeError" || /fetch failed/i.test(String(e?.message ?? ""))) {
125
184
  return new Error("请求失败:网络不通、被代理拦截,或遇到了重定向")
@@ -127,7 +186,145 @@ function saidFailure(e, ctl, budget) {
127
186
  return e
128
187
  }
129
188
 
130
- async function json(token, path, deadline = Infinity) {
189
+ // siteRead is how the plugin learns a token's site: the remembered site is
190
+ // asked first, then the other one. Only a usable answer — a 200 that parses
191
+ // and does not say it failed — chooses a site and is remembered. A 401 drops
192
+ // the memory (another site may hold the token). Every other failure — a
193
+ // transport error, a proxy's 200 page, a 5xx, a 200 whose body denies the
194
+ // token, a 2xx that is not 200 (a 202 means accepted-and-not-done; gate()
195
+ // refuses it too) — moves the probe on to the other site: the remembered
196
+ // site being wrong (a re-keyed token), unreachable, or broken must not strand
197
+ // the account on it, and the domestic site can be broken on a network where
198
+ // the international one works. An API-level refusal (403 risk control, a 400
199
+ // on the request) is final: this site accepted the token and the answer is
200
+ // what the caller must report. A 429 is not that answer — a rate limit is the
201
+ // site's, not the token's — so the probe moves on and keeps it as the
202
+ // fallback, the way it treats a 5xx. The first failure is the one reported
203
+ // when neither site gives a usable answer, because it is the one the user was
204
+ // closest to (their token most often comes from the domestic site).
205
+ // awaitsSignIn: a 401 is the site refusing the token only when its answer
206
+ // reads as such — a bare "unauthorized" in the words ModelScope's login and
207
+ // API layers use, an empty object (the token read, nothing said), or a
208
+ // status-only answer. Anything else is the path answering: a proxy's
209
+ // "proxy authentication required", a gateway's title, an HTML page, some
210
+ // other object with no such word in it. Measured: a proxy's JSON 401 had the
211
+ // card tell the user the sign-in expired, and a re-login would not have
212
+ // changed the answer. A refusal is the default because it is what the site
213
+ // itself sends; the answer has to excuse it to be read as the path's.
214
+ const pathWords = /proxy|gateway|www-authenticate|re-?authenticate|\bbasic\b/i
215
+ // a path naming itself, as a whole word: in the words of a body or in a field
216
+ // name ("proxy": "upstream"). The 401's words and the 403's envelope both ask.
217
+ // an upstream's "Upstream returned status 401" is not a path naming itself: a
218
+ // site reads the body above it, and what a proxy puts there is its own words
219
+ const PATH_MARK = /(?<![a-z])(?:proxy|gateway|re-?authenticate)(?![a-z])/i
220
+ const signInWords = /unauthori[sz]ed|invalid (?:access )?token|token (?:is )?(?:invalid|expired|revoked)|invalid authentication|authorization header|not (?:logged|signed) in|unauthenticated|auth(?:entication)? (?:failed|required)|\b(?:expired|revoked)\b|\b401\b|登录|未登录|失效|过期|令牌|凭证|鉴权|认证/i
221
+ const awaitsSignIn = (body) => {
222
+ if (body === null || typeof body !== "object") return false
223
+ // an empty object is a refusal: a site that answers 401 with nothing in it
224
+ // has refused the token, and both sites refusing is a dead token (pinned by
225
+ // "a forgotten site is dropped from memory when the other site refuses
226
+ // too"). A proxy answering 401 with `{}` is indistinguishable from that; the
227
+ // cost of reading it as a refusal is a re-login prompt, not the token
228
+ // (a body that only names a path — {"message":"unauthorized","proxy":
229
+ // "upstream"} — is the path's: its names count as its words, and a field
230
+ // called "proxy" said "proxy" whatever its value)
231
+ const keys = Object.keys(body)
232
+ if (keys.length === 0) return true
233
+ if (/proxy|gateway/i.test(keys.join(" "))) return false
234
+ // ModelScope's login layer answers in Pascal case ({"Code":401,"Success":false,
235
+ // "Message":"…"}), the OpenAPI layer in lower case, and some messages are
236
+ // Chinese; all of them are read (a numeric or string code of 401 included)
237
+ // once a body that names a path has been set aside: a proxy's 401 carries a
238
+ // code of its own, and a code is not evidence about who answered
239
+ const said = [...keys.flatMap((k) => (typeof body[k] === "string" ? [k, body[k]] : [k])), body.error_description, body.detail, body.Detail, body.title, body.Title]
240
+ .filter((x) => typeof x === "string")
241
+ .join(" ")
242
+ if (pathWords.test(said) || PATH_MARK.test(said)) return false
243
+ const code = body.code ?? body.Code
244
+ if (String(code ?? "") === "401") return true
245
+ return said !== "" && signInWords.test(said)
246
+ }
247
+ async function siteRead(token, path, deadline = Infinity) {
248
+ const known = sites.get(token)
249
+ const order = known ? [known, ...SITES.filter((p) => p !== known)] : SITES
250
+ // A parsed 4xx/429/5xx that is not an answer is still the site *saying
251
+ // something*, and it beats a transport error from the other site ("risk
252
+ // control" is more useful than "check your proxy"). It is kept as `answer`
253
+ // while the loop goes on: the site that gave it may not be the one holding
254
+ // this token (a stale `site`, a re-keyed sign-in, a middlebox's 404).
255
+ let answer
256
+ // `said` is a parsed reply that is not an answer (429, 5xx, a 200 that says
257
+ // it failed); `down` is a transport error. They are kept apart so the
258
+ // promise above holds in both directions: whatever the site said beats the
259
+ // link failing, whichever order the two arrive in. `first` is then always a
260
+ // parsed reply (or nothing), and the dead link is the last thing reported.
261
+ let said
262
+ let down
263
+ for (const profile of order) {
264
+ let got
265
+ try {
266
+ got = await json(profile.site, token, path, deadline)
267
+ } catch (e) {
268
+ // A timeout is the link, not the site, and the remembered site may be
269
+ // the stale half of a re-keyed sign-in — the same state the 401 branch
270
+ // exists for. The second read is bounded by the hook's own deadline, so
271
+ // a dead network costs at most the budget it would have spent anyway;
272
+ // giving up here left an account on a wrong or unreachable site all day.
273
+ // a parsed answer from the other site outranks this (see below): the
274
+ // link being down says nothing about the account
275
+ down ??= e instanceof Error ? { error: e } : { error: new Error("") }
276
+ continue
277
+ }
278
+ if (got.res.status === 401 && got.parsed && awaitsSignIn(got.body)) {
279
+ // A 401 where the token was accepted before: the sign-in may have been
280
+ // re-keyed with a token of the other site, or the remembered site may be
281
+ // stale (a kept `site` value, a proxy's 200 cached as an answer). A 401
282
+ // that is not ModelScope's own answer is a middlebox's challenge, not
283
+ // the token being refused — it falls through like any other non-answer.
284
+ // Measured: a proxy that answered 401 with its own JSON had the card
285
+ // tell the user the sign-in expired, and a re-login would not have
286
+ // changed the answer.
287
+ // A 401 is not evidence the token is dead by itself: every token answers
288
+ // 401 on the site it does not belong to, so a stale `site` value or a
289
+ // re-keyed sign-in looks exactly like a revoked token. The walk's own
290
+ // end says it (no site answered); this branch only forgets the site and
291
+ // lets the other one answer.
292
+ //
293
+ // A sibling read of the same token may have finished in between: this
294
+ // one captured its order before the other site was known, and its 401
295
+ // arrives after the sibling confirmed a site. Deleting then drops that
296
+ // confirmation — the token reads as unknown for a probe it already paid
297
+ // for, and an expiry decision can land on a live sign-in. The site is
298
+ // forgotten only while it is still the one in memory.
299
+ if (profile === known && sites.get(token) === profile) sites.delete(token)
300
+ continue
301
+ }
302
+ if (got.parsed && got.res.status === 200) {
303
+ if (okOf(got.body)) {
304
+ rememberSite(token, profile)
305
+ return { profile, ...got }
306
+ }
307
+ said ??= { answer: { profile, ...got } }
308
+ continue
309
+ }
310
+ // A 4xx is the site answering — but a 404/405/451 from a middlebox, or a
311
+ // 400/403 from the site that does not hold this token, is no better
312
+ // evidence than a 5xx. Keep it as the best answer and let the other site
313
+ // answer too; a genuine risk-control 403 on the right site still ends up
314
+ // as the report when the other site has nothing better to say.
315
+ if (got.parsed && got.res.status >= 400 && got.res.status < 500 && got.res.status !== 429) {
316
+ answer ??= { profile, ...got }
317
+ continue
318
+ }
319
+ said ??= { answer: { profile, ...got } }
320
+ }
321
+ if (answer) return answer
322
+ if (said) return said.answer
323
+ if (down) throw down.error
324
+ throw deadToken("任一站点")
325
+ }
326
+
327
+ async function json(site, token, path, deadline = Infinity) {
131
328
  const left = deadline - Date.now()
132
329
  if (left <= 0) throw new Error("预算已用完,本次请求未发出")
133
330
  const budget = Math.min(TIMEOUT_MS, left)
@@ -141,18 +338,19 @@ async function json(token, path, deadline = Infinity) {
141
338
  ctl.signal.addEventListener("abort", () => reject(ctl.signal.reason), { once: true })
142
339
  })
143
340
  aborted.catch(() => {})
341
+ let res
144
342
  try {
145
- const res = await Promise.race([
343
+ res = await Promise.race([
146
344
  aborted,
147
- fetch(SITE + path, {
345
+ fetch(site + path, {
148
346
  // every redirect is refused, the site's own included: one that left
149
- // modelscope.cn would carry the session cookie off-site (the runtimes
347
+ // the origin would carry the session cookie off-site (the runtimes
150
348
  // happen not to forward it), and ModelScope's endpoints answer in place
151
349
  redirect: "error",
152
350
  headers: {
153
351
  Cookie: "m_session_id=" + token,
154
352
  "User-Agent": UA,
155
- Referer: SITE + "/",
353
+ Referer: site + "/",
156
354
  Accept: "application/json",
157
355
  },
158
356
  signal: ctl.signal,
@@ -165,8 +363,26 @@ async function json(token, path, deadline = Infinity) {
165
363
  try {
166
364
  body = text ? JSON.parse(text) : {}
167
365
  } catch {}
168
- return { res, body, parsed: body !== null }
366
+ // an empty body is not an answer at all: a bare 401 from a proxy is the
367
+ // path, not the site saying the token is refused, and reading it as "{}"
368
+ // (parsed, an object) signed the account out over a middlebox's refusal
369
+ return { res, body, parsed: body !== null && (text !== "" || res.status === 200) }
169
370
  } catch (e) {
371
+ // An answer we stopped reading is still live: a declared length over the
372
+ // cap is refused before a byte is read, and the socket keeps streaming
373
+ // into a body nobody consumes once the timer is cleared — the plugin has
374
+ // no hold on the transport at all. Cancel the unconsumed body and abort
375
+ // the request, so it ends with this call.
376
+ // The abort does NOT carry e: saidFailure reads "e is the abort reason" as
377
+ // a timeout, and aborting with "响应过大" would relabel the refusal.
378
+ if (res) {
379
+ try {
380
+ await res.body?.cancel?.()
381
+ } catch {}
382
+ try {
383
+ ctl.abort(new Error("read abandoned"))
384
+ } catch {}
385
+ }
170
386
  throw saidFailure(e, ctl, budget)
171
387
  } finally {
172
388
  // the answer is here: the timer has nothing left to guard, and one left
@@ -339,11 +555,13 @@ function cnTime(value) {
339
555
  return Number.isFinite(at) ? new Date(at).toISOString() : ""
340
556
  }
341
557
 
342
- // ── 签到时段 ──────────────────────────────────────────────────────────────
343
- // magpie 在北京日切换时问一次,之后每 30 分钟重试一次失败;插件没有自己的
344
- // 定时器(见 README 的「上游」一节)。所以在还没到今天这个账号的目标时刻
345
- // 时,只能回答 failed——那是宿主唯一会重试的结果——其余结果都会让当天就此
346
- // 了结。夜里这一问一答不发出任何请求,领的动作留给上班时段的那一问。
558
+ // ── Check-in window ───────────────────────────────────────────────────────
559
+ // magpie asks once at the Beijing-day rollover and retries a failure every 30
560
+ // minutes; the plugin has no timer of its own (see the README's "upstream"
561
+ // section). So before today's target moment for this account arrives, the only
562
+ // answer is failed — that is the one outcome the host retries; every other
563
+ // outcome ends the day there and then. That overnight ask-and-answer sends no
564
+ // request; the claim itself is left to the ask during working hours.
347
565
  const WINDOW_DEFAULT = ["08:30", "10:00"]
348
566
 
349
567
  // LAST is the latest minute the window's end is capped at. The host asks
@@ -472,7 +690,12 @@ const names = new Map()
472
690
  const NAMES_MAX = 64
473
691
 
474
692
  function rememberName(token, name) {
475
- if (!name) return
693
+ // An empty name is remembered too. "The server gave no usable name" is a
694
+ // fact as much as a name is: before this, `names` never held it, so the
695
+ // "claimed and the name is known" shortcut never took and every provisional
696
+ // re-ask in a morning touched login/info again — the dozen extra sign-ins
697
+ // the shortcut exists to prevent. A read that failed does not come here, so
698
+ // a later ask still tries once more.
476
699
  names.delete(token)
477
700
  names.set(token, name)
478
701
  while (names.size > NAMES_MAX) names.delete(names.keys().next().value)
@@ -502,7 +725,10 @@ function labelOf(auth, token) {
502
725
  // the user's name keyed to a label nothing carries any more — it would stop
503
726
  // showing, with no sign of why. A file that is mid-write, unreadable, or not
504
727
  // the shape we know must therefore count as "don't write", not as "no alias".
505
- function aliasOf(dir, label) {
728
+ //
729
+ // `id` is the provider's own: both sites keep their accounts in the same
730
+ // providers.json, and an alias is only this plugin's when the row's id says so.
731
+ function aliasOf(id, dir, label) {
506
732
  // no directory is not "the user has no alias": it is having nowhere to look
507
733
  if (!dir || !label) return undefined
508
734
  let raw
@@ -531,7 +757,7 @@ function aliasOf(dir, label) {
531
757
  for (const list of lists) {
532
758
  if (!Array.isArray(list)) continue
533
759
  for (const p of list) {
534
- if (p?.id !== ID) continue
760
+ if (p?.id !== id) continue
535
761
  const names = p.accountNames
536
762
  if (!names || typeof names !== "object") continue
537
763
  const mine = names[key]
@@ -541,10 +767,6 @@ function aliasOf(dir, label) {
541
767
  return ""
542
768
  }
543
769
 
544
- // rememberAccountName tells magpie the account's own name, so its card reads
545
- // "Mison" instead of "API key …a1b2". The sign-in is written as it already
546
- // is — the same key, no second copy of the token — with the name beside it;
547
- // an account that has one already is left alone.
548
770
  // maskToken hides all but the ends of a token, for messages that mention one.
549
771
  // The log is magpie's own, but a token has no business in it whole.
550
772
  const maskToken = (token) =>
@@ -568,23 +790,61 @@ const maskToken = (token) =>
568
790
  // matched is a piece of the real key, not a shape, so a key broken across two
569
791
  // lines is still the key, and masking it whole is the point.
570
792
  //
571
- // The matching runs over the visible characters alone, in one pass. Stepping
572
- // over a character as the scan went — and restarting one position later when
573
- // the run came up short — made a run of them cost its own length at every
574
- // position it contained: a 256 KiB body of newlines (a proxy's page, or a
575
- // server echoing one) took minutes, inside a hook whose whole budget is 25
576
- // seconds, and the plugin host's event loop with it. Collected first, the
793
+ // The matching runs over the alphanumeric characters alone, in one pass, and
794
+ // against the token's own alphanumeric run. Collecting "the characters the
795
+ // token is made of" instead let a character the token genuinely holds — the
796
+ // "-" of every ms- key — be used as a cutter: a server echoing the key with a
797
+ // "-" every 11 characters produced runs that were each under MIN_ECHO, and
798
+ // the whole key reached the card. Punctuation is now a separator whether the
799
+ // token holds it or not, and only its letters and digits say "this is the
800
+ // key". Everything else — whitespace, a zero-width space, a comma, a middle
801
+ // dot, an emoji, or the key's own hyphen — is stepped over and the pieces on
802
+ // either side joined for the comparison. Skipping them is what keeps an echo
803
+ // broken into short chunks whole, and a chunk under MIN_ECHO from surviving
804
+ // on its own. A whitelist of separators cannot do this: the set is open, and
805
+ // the one character it misses is the one the server uses.
806
+ //
807
+ // Stepping over a character as the scan went — and restarting one position
808
+ // later when the run came up short — made a run of them cost its own length
809
+ // at every position it contained: a 256 KiB body of newlines (a proxy's page,
810
+ // or a server echoing one) took minutes, inside a hook whose whole budget is
811
+ // 25 seconds, and the plugin host's event loop with it. Collected first, the
577
812
  // same text is scanned once and the spans are put back where they were.
578
- const MIN_ECHO = 12
813
+ // MIN_ECHO is how much of the key must sit together before the text is taken
814
+ // for an echo. Ten rather than twelve, so that "a piece of at least 11
815
+ // characters of the key" is masked: with the scan starting one character past
816
+ // a short run (see maskEchoes below), a floor of 12 missed a run of 11 that
817
+ // opened a chunked echo. Ten still leaves an ordinary short word ("ms-dos",
818
+ // an 8-character slice) alone. The final review then showed the echo split
819
+ // into 8-character groups joined by one letter: each group is under this
820
+ // floor, so the mask steps over a single filler inside a run instead of
821
+ // restarting, and the floor applies to the joined run.
822
+ const MIN_ECHO = 10
823
+ // fold is the form an echo is compared in: NFKC (a full-width "ms" is the
824
+ // same key typed on another keyboard), lower case (a server that upper-cases
825
+ // its log is still echoing the key), and letters and digits only (everything
826
+ // else is a separator the echo inserted). Case and compatibility are not a
827
+ // change of secret, and comparing byte for byte left the whole key on the
828
+ // card when either changed.
829
+ const fold = (s) => (typeof s === "string" ? s.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]/g, "") : "")
579
830
  const maskEchoes = (s, token) => {
580
- if (typeof token !== "string" || token.length < MIN_ECHO) return s
831
+ if (typeof token !== "string") return s
832
+ const flat = fold(token)
833
+ if (flat.length < MIN_ECHO) return s
834
+ // at/end are the source text's bounds for each folded character. One source
835
+ // code point can fold into more than one ("fi" is "fi"), so the end is kept
836
+ // per entry rather than derived from the folded length.
581
837
  const vis = []
582
838
  const at = []
839
+ const end = []
583
840
  for (let i = 0; i < s.length; ) {
584
841
  const c = String.fromCodePoint(s.codePointAt(i))
585
- if (!invisible(c)) {
586
- vis.push(c)
587
- at.push(i)
842
+ for (const f of c.normalize("NFKC").toLowerCase()) {
843
+ if (f >= "a" && f <= "z" || f >= "0" && f <= "9") {
844
+ vis.push(f)
845
+ at.push(i)
846
+ end.push(i + c.length)
847
+ }
588
848
  }
589
849
  i += c.length
590
850
  }
@@ -593,31 +853,68 @@ const maskEchoes = (s, token) => {
593
853
  for (let k = 0; k < vis.length; ) {
594
854
  let piece = ""
595
855
  let v = k
596
- while (v < vis.length && token.includes(piece + vis[v])) {
597
- piece += vis[v]
598
- v++
856
+ let fillers = 0
857
+ while (v < vis.length) {
858
+ if (flat.includes(piece + vis[v])) {
859
+ piece += vis[v]
860
+ v++
861
+ continue
862
+ }
863
+ // Characters the token does not hold at this point, between two pieces
864
+ // of it: a server that prints "01234567x 89abcde0x …" writes
865
+ // 8-character pieces under MIN_ECHO and a letter between them, and the
866
+ // pieces together are the key. Up to as many characters as the run has
867
+ // already matched are stepped over — an echo is mostly the key — and the
868
+ // run resumes at the first of them that continues it. A filler may only
869
+ // be stepped over *inside* a run: skipping a leading character before
870
+ // anything matched swallowed the word in front of the key ("x ms-…" lost
871
+ // its x). One filler was the first cut; two in a row then went through
872
+ // ("01234567xx 89abcdefxx …" kept the whole body).
873
+ if (piece.length === 0) break
874
+ const stop = Math.min(vis.length, v + 1 + (piece.length - fillers))
875
+ let hit = -1
876
+ for (let j = v + 1; j < stop; j += 1) if (flat.includes(piece + vis[j])) { hit = j; break }
877
+ // one character is stepped over however little has matched (a server's
878
+ // single letter between pieces); more than one only when the run is
879
+ // already long enough to be an echo under the lower floor. Without that
880
+ // second half, "said: aaaa1111-b" matched the last s of "said" and then
881
+ // skipped "id" to join the piece, and a 9-character slice that the floor
882
+ // is meant to leave alone was masked as if it were longer.
883
+ if (hit < 0 || (hit - v > 1 && piece.length < MIN_ECHO - 2)) break
884
+ fillers += hit - v
885
+ v = hit
599
886
  }
887
+ // The floor counts the key's own characters, not the characters the echo
888
+ // wrote them in: a slice carrying its own hyphens ("1-bbbb-2222-") is
889
+ // twelve characters on the card and nine of the key, and nine is under the
890
+ // floor by design — a fragment that short is not the key, and counting
891
+ // punctuation would mask ordinary hyphenated text ("2026-11-08" is eight
892
+ // of them). The README says so.
600
893
  if (piece.length >= MIN_ECHO) {
601
- // the span of the whole run, invisible characters inside it included,
602
- // and the ones between it and the last character written before it
894
+ // The span of the whole run, the characters skipped between its pieces
895
+ // included. What sat between the last character written and the run's
896
+ // first one — whitespace skipped while matching — is written out first,
897
+ // so masking does not join two words that the echo had apart.
898
+ out += s.slice(from, at[k])
603
899
  out += maskToken(token)
604
- from = at[v - 1] + vis[v - 1].length
900
+ from = end[v - 1]
605
901
  k = v
606
902
  } else {
607
- out += s.slice(from, at[k] + vis[k].length)
608
- from = at[k] + vis[k].length
903
+ out += s.slice(from, end[k])
904
+ from = end[k]
609
905
  k++
610
906
  }
611
907
  }
612
908
  return out + s.slice(from)
613
909
  }
614
910
 
615
- // invisible is a control or format character: what a token pasted out of a
616
- // chat client carries, and what an echo may have added inside one. INV is the
617
- // same class for the shape test, less the line breaks: a shape is all that is
618
- // known there, and run on across a line it would mask the next line's words.
619
- const invisible = (c) => /[\p{Cc}\p{Cf}]/u.test(c)
911
+ // INV is the control or format class for the shape test, less the line breaks:
912
+ // a shape is all that is known there, and run on across a line it would mask
913
+ // the next line's words. maskEchoes no longer needs a separate predicate —
914
+ // it keeps the alphanumeric characters and skips everything else.
620
915
  const INV = String.raw`(?:(?![\n\r])[\p{Cc}\p{Cf}])`
916
+ // the one place a token-shaped run is turned into a mask is maskShaped, below:
917
+ // the shape alone found a run; the body it carries is what decides.
621
918
 
622
919
  // A key of another shape with an invisible character inside it reaches the card
623
920
  // as two words once that character is turned into a space, and neither half
@@ -638,7 +935,547 @@ const GLUE = new RegExp(String.raw`(?<=[A-Za-z0-9+\/=_.-])${INV}(?=[A-Za-z0-9+\/
638
935
  // the key, that is known here; the length floor keeps a short "ms-" word
639
936
  // ("ms-dos") out of it. An invisible character between two of its characters
640
937
  // does not end the run, for the reason maskEchoes steps over one.
641
- const tokenShaped = new RegExp(String.raw`m${INV}*s${INV}*-(?:${INV}*[\x21-\x7e]){11,}`, "gu")
938
+ //
939
+ // A key pasted with one space inside it ("ms-aaaa1111 bbbb-2222-…") is the
940
+ // same key, and each half is under every word floor on its own: that is the
941
+ // second arm, six characters, one space, five more. "ms-dos and ms-word" has
942
+ // no such shape (six and five characters either side of its real spaces).
943
+ // The body has to read as key material, not merely be eleven characters:
944
+ // an ordinary word after the front ("The ms-interviewing feature is
945
+ // unavailable" on the card, a nickname of the same) was masked whole and the
946
+ // nickname refused its write-back. A ModelScope key is hex or base64 with a
947
+ // digit in it, nearly always in hyphen-separated groups (aaaa1111-bbbb-2222-
948
+ // cccc-…); a word carries no digit and a version carries its digits as one
949
+ // dotted group ("ms-office 2016", "ms-cli 2026.11.9"). Either spelling does:
950
+ // 11+ that are all hex, or 11+ that carry a digit, and the body's last
951
+ // character joined to a letter or digit is a cut, not the end of a key
952
+ // ("ms-abc12345678abc" is one body, not "…5678" and "abc").
953
+ //
954
+ // A key cut by a space or a line break is still one key, and the two arms
955
+ // above each stopped at the first piece: "ms-vqmjh-75qa-651i -9x8x-2p4r-8t2n-
956
+ // qw7z" was masked to "ms-v…651i" with the rest of the key printed after it,
957
+ // and a key wrapped at a line break ("ms-vqmjh-75qa\n-651i-…") was not seen at
958
+ // all. The third arm reads on past a cut — a run of whitespace (a line break
959
+ // included) or a middot, then a piece that carries a digit — because the pieces
960
+ // of a cut are short and a key's own carry digits. It stops at a word of the
961
+ // text's ("… for provider"), at a version's dotted group ("ms-cli 2026.11.9",
962
+ // "ms-office 2016"), and at anything else that is not a piece. What is masked
963
+ // is still the run maskShaped was handed: the arm widens the shape, and keyBody
964
+ // decides, so text that does not read as a key is returned as written.
965
+ const keyChar = String.raw`(?:${INV}*[\x21-\x7e])`
966
+ const tokenShaped = new RegExp(
967
+ String.raw`m${INV}*s${INV}*-(?:${keyChar}{11,}|${keyChar}{0,}\s+(?=[\x21-\x7e]*\d)(?![\x21-\x7e]*\.)${keyChar}{5,})` +
968
+ String.raw`(?:(?:\s*[·•∙‧・⋅]\s*|\s+)(?!(?:m${INV}*s${INV}*-))(?![\x21-\x7e]*\.)(?=[\x21-\x7e]{5,})(?=[\x21-\x7e]*\d)[\x21-\x7e]+)*`,
969
+ "gu",
970
+ )
971
+ const keyBody = new RegExp(String.raw`(?:[0-9a-f]{11,}(?![0-9a-z])|(?=[\x21-\x7e]{11,})(?!\w)[\x21-\x7e]*\d)`)
972
+ // maskShaped is the replace both passes share: the shape finds a run that
973
+ // begins like a key; keyBody asks whether the run after the front reads as
974
+ // one. A run that does not is ordinary text and is handed back as written,
975
+ // untouched — the caller's own passes then judge it word by word.
976
+ const maskShaped = (m) => (keyBody.test(m) ? maskToken(m) : m)
977
+ // The vendor prefixes whose keys are not distinguishable from a name by its
978
+ // characters alone: the prefix is the evidence ("sk-" is Anthropic and OpenAI's,
979
+ // "sk_live_" Stripe's, "ghp_" GitHub's, "xoxb-" Slack's, "AKIA" AWS'). The body
980
+ // behind it is not asked — a name that starts with one of these is a key first.
981
+ // (a key can be echoed upper-cased — "SK-ANT-API03-…" — and that is the same
982
+ // key; the letters are compared without case)
983
+ const VENDOR = /^(?:sk-|sk_(?:live|test)_|gh[pousr]_|xox[abposr]-|akia|asia|aida|aliyun|ssh-rsa|-----begin)/i
984
+ const keyAt = (word, letters) => isKeyWord(word, letters) || VENDOR.test(word) || /^ms-/i.test(word)
985
+ // maskSpaced is the other reading of a piece of text: a key cut by whitespace
986
+ // is read as one key. Measured without it: a 40-character ghp_ token cut into
987
+ // four went out whole, a foreign ms- key cut the same way, and each piece
988
+ // cleared the word floors on its own. The check gets both readings — maskKeys
989
+ // per word, and this per run — and text that reads as a key under either is
990
+ // masked.
991
+ //
992
+ // Three readings, the same shapes bin/sync.mjs's maskCut reads: a run of words
993
+ // on one line (the body of a key cut by spaces — the key-shaped word at an
994
+ // end of it is where the mask begins, its pieces short or a vendor front); a
995
+ // pair either side of a line break (the break a wrapped copy leaves), judged
996
+ // joined; a narrow run of words that are all short pieces (a key written one
997
+ // character per word). A word already carrying a mask is not judged again.
998
+ const MASKED = "…"
999
+ // the length at which a run is long enough to be a key: the floor every
1000
+ // reading asks for, and all a mask needs to show
1001
+ const RUNCUT = 20
1002
+ // a middot or a bullet, one or two in a row, with at most one space on
1003
+ // either side: a cut's other spelling, and the separator a display draws
1004
+ // (the dot operator is a display's cut too: "1⋅0⋅0" is "100" spaced out)
1005
+ const MIDDOT_GAP = /^(?:[ \t\u00a0\u3000]?[·•∙‧・⋅]){1,2}[ \t\u00a0\u3000]?$/
1006
+ const MIDDOT_ONLY = /^[ \t\u00a0\u3000]?[·•∙‧・⋅][ \t\u00a0\u3000]?$/
1007
+ // a capitalised word ("Qwen3-Max") is a caption, not a piece of a key: two of
1008
+ // them either side of a middot are a list ("Qwen3-Max · Qwen3-Coder")
1009
+ const caption = (w) => /^[A-Z][a-z]{2,}/.test(w)
1010
+ // wordLike: a word a person wrote — letters only, a lower-case letter and a
1011
+ // vowel ("release", "today"). The pieces of a cut usually are not ("AKIA",
1012
+ // "qmjh", "ghp_", "NickName=[AKIA"), and a middot with a word on both sides
1013
+ // is the card's own separator
1014
+ const wordLike = (w) => /^[A-Za-z]+$/.test(w) && /[a-z]/.test(w) && /[aeiouy]/i.test(w)
1015
+ // the letters and digits a word ends with: a key can follow a frame
1016
+ // ("NickName=[AKIAIOSFODNN")
1017
+ const tailOf = (w) => w.match(/[A-Za-z0-9]+$/)?.[0] ?? ""
1018
+ // the same for a key-shaped head: "ms-" begins the key and a key may begin
1019
+ // with its front
1020
+ // the word's own key material: from a vendor front anywhere in it ("(ghp_1234",
1021
+ // "key=sk-ant-…"), or its last letters and digits. A front carries an
1022
+ // underscore or a hyphen, and a bare tail would cut "ghp_1234AbCd" at the
1023
+ // underscore, keeping what the front does not
1024
+ const FRONT_IN = /(?:sk-|sk_(?:live|test)_|gh[pousr]_|xox[abposr]-|ms-|akia|asia|aida)[\x21-\x7e]*$/i
1025
+ const keyFrontOf = (w) => (VENDOR.test(w) || /^ms-/i.test(w) ? w : w.match(FRONT_IN)?.[0] ?? tailOf(w))
1026
+ // a bare vendor stem cut off its punctuation ("ms", "sk", "ghp"): the key's
1027
+ // front, with the rest of the front in the next piece ("ms -vqmjh…")
1028
+ const STEM = /^(?:ms|sk|gh[opusr]|xox[a-z]?|akia|asia|aida)$/i
1029
+ // (a piece of a front — "gh" of "ghp_", "xo" of "xoxb-" — opens a key cut a
1030
+ // piece at a time: a word of prose it is not)
1031
+ const FRONT_PART = /^(?:sk|gh|ghp|gho|ghs|ghu|ghr|ms|xox|xoxa|xoxb|xoxp|xoxo|xoxr|xoxs|aki|akia|asi|asia|aid|aida)$/i
1032
+ const startProse = (w) => /^[a-z]{2,}$/.test(w) && !VENDOR.test(w) && !/^ms-/.test(w) && !STEM.test(w) && !FRONT_PART.test(w)
1033
+ function maskSpaced(s, letters) {
1034
+ if (s.length > 32 * 1024) return s
1035
+ const words = [...s.matchAll(/[\x21-\x7e]+/g)]
1036
+ if (words.length < 2) return s
1037
+ const at = (i) => words[i].index
1038
+ const end = (i) => words[i].index + words[i][0].length
1039
+ const gap = (i) => s.slice(end(i - 1), at(i))
1040
+ // (each gap judged once: a page of "1·1·1…" asked the same gap of three
1041
+ // readings at every word, and read itself to its square. Each reading keeps
1042
+ // its own table: one shared table answered a middot test with the space
1043
+ // test's result at the same index, and a space-joined key read as no run)
1044
+ const memo = (fn) => {
1045
+ const mem = new Map()
1046
+ return (i) => {
1047
+ let v = mem.get(i)
1048
+ if (v === undefined) mem.set(i, (v = fn(i)))
1049
+ return v
1050
+ }
1051
+ }
1052
+ const midOf = memo((i) => MIDDOT_GAP.test(gap(i)))
1053
+ const onlyMid = memo((i) => MIDDOT_ONLY.test(gap(i)))
1054
+ const sp = memo((i) => spaces(gap(i)))
1055
+ const front = (w) => VENDOR.test(w) || /^ms-/i.test(w)
1056
+ // a gap a run is read across: one space, a no-break or ideographic one, a
1057
+ // line break, or a middot or a bullet (one or two, with at most one space
1058
+ // each side). The middot is a cut's other spelling as well as a display's
1059
+ // own separator; which one it is, is decided by the words on either side —
1060
+ // a key's front or a piece that does not read as a word is key material, a
1061
+ // word on both sides is prose ("release · notes"), and the cut still has to
1062
+ // be key-shaped below
1063
+ const spaces = (g) => /^[ \t  \r\n]+$/.test(g) && g.length <= 2
1064
+ // a capitalised word ("Qwen3-Max") is a caption, not a piece of a key: two of
1065
+ // them either side of a middot are a list ("Qwen3-Max · Qwen3-Coder"), which
1066
+ // the card drew as one key before this
1067
+ const partsCut = (k, i) => midOf(k) && (front(words[i][0]) || /^[0-9a-f]{12,}/i.test(words[i][0]) || !wordLike(words[i][0]) || !wordLike(words[k][0]))
1068
+ // (a middot joined to the head's own front is a cut from the first piece:
1069
+ // "Ghp_Abcd1234·Efgh5678" is one key, whatever its pieces would read as
1070
+ // alone; the separator is a cut's for a front, not the card's)
1071
+ const runGap = (k, i) => sp(k) || partsCut(k, i) || (k === i + 1 && midOf(k) && front(words[i][0]))
1072
+ const out = []
1073
+ let from = 0
1074
+ for (let i = 0; i < words.length; i += 1) {
1075
+ if (i > 0 && /^\r?\n$/.test(gap(i)) && end(i) - at(i - 1) >= 20) {
1076
+ const pair = words[i - 1][0] + words[i][0]
1077
+ if (!pair.includes(MASKED) && isKeyWord(pair, letters)) {
1078
+ out.push(s.slice(from, at(i - 1)), maskToken(pair))
1079
+ from = end(i)
1080
+ i += 1
1081
+ continue
1082
+ }
1083
+ }
1084
+ // a character cut: every piece one character ("A K I A …"), or a vendor
1085
+ // front cut in two and then one character at a time ("G H P _ 1 2 …").
1086
+ const w1 = words[i][0]
1087
+ const cutGap = (k) => midOf(k) || sp(k)
1088
+ // (a piece behind a frame — the "(ghp" of "(ghp·_·1·…" — is a piece too)
1089
+ const w1p = w1.replace(/^[^A-Za-z0-9]+/, "")
1090
+ const pieceGap = (k) => (cutGap(k) || onlyMid(k)) && words[k][0].length === 1
1091
+ const pieceHead = /^[A-Za-z_]{1,3}$/.test(w1p) || front(keyFrontOf(w1))
1092
+ if (w1.length === 1 || (pieceHead && i + 1 < words.length && pieceGap(i + 1))) {
1093
+ let k = i
1094
+ while (k + 1 < words.length && k - i <= 96 && pieceGap(k + 1)) k += 1
1095
+ const joined = w1p + words.slice(i + 1, k + 1).map((w) => w[0]).join("")
1096
+ const capJoined = joined.replace(/[^A-Z0-9]/g, "")
1097
+ const rhythmic = /[A-Z][0-9]/.test(capJoined) || /[0-9][A-Z]/.test(capJoined)
1098
+ if (
1099
+ k > i &&
1100
+ // (a front that opens the run is the key's first piece, and a run of
1101
+ // one piece after it is the text's "· 4": the cut has two pieces more)
1102
+ (w1.length === 1 || k - i >= 2 || !front(keyFrontOf(w1))) &&
1103
+ joined.length >= 20 &&
1104
+ (isKeyWord(joined, letters) || (joined.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters))) &&
1105
+ (!/^[A-Z0-9]+$/.test(joined) || rhythmic || /^AKIA[0-9A-Z]{16}$/.test(joined))
1106
+ ) {
1107
+ out.push(s.slice(from, at(i)), maskToken(joined.replace(/[·•∙‧・⋅]/g, "")))
1108
+ from = end(k)
1109
+ i = k
1110
+ continue
1111
+ }
1112
+ }
1113
+ // (a front behind a frame — "key=ghp_…", "(ghp_…" — is a word's start,
1114
+ // not a word of prose: the frame itself is the punctuation before it)
1115
+ const framedFront = /^[^A-Za-z0-9]+(?:sk-|sk_[a-z]+_|gh[pousr]_|xox[abposr]-|ms-|akia|asia|aida)/i.test(words[i][0])
1116
+ if (startProse(words[i][0]) && !framedFront) continue
1117
+ // two key-shaped words are two keys, and each is masked where it stands —
1118
+ // unless the two joined are one key cut once ("ghp_…EfG h9012…")
1119
+ // a ModelScope-fronted head followed by a short piece is not a pair: one
1120
+ // is the other's own body, and the run reads them as one key
1121
+ const msPair = /^ms-/i.test(words[i][0]) && !/^ms-[\x21-\x7e]{11,}$/i.test(words[i][0]) && i + 1 < words.length && words[i + 1][0].length <= 16
1122
+ // (two key-shaped pieces joined by a middot are one key's cut — "9f8e7d6c…
1123
+ // 7d6c·5b4a…" — and only two joined by a space are two keys)
1124
+ const pairOfKeys =
1125
+ i + 1 < words.length &&
1126
+ !msPair &&
1127
+ /^[ \t\u00a0\u3000]+$/.test(gap(i + 1)) &&
1128
+ keyAt(words[i][0], letters) &&
1129
+ keyAt(words[i + 1][0], letters) &&
1130
+ !(front(words[i][0]) && isKeyWord(words[i][0] + words[i + 1][0], letters))
1131
+ let j = i
1132
+ let last = i
1133
+ // the last word the run read into its body: a piece read to keep a key in
1134
+ // view is the key's ("…·789·0" lost its last "0"), while a word the run
1135
+ // stopped at is not
1136
+ let readTo = i
1137
+ let mixedCase = words[i][0].length >= 3 && /[a-z]/.test(words[i][0]) && /[A-Z]/.test(words[i][0])
1138
+ let body = words[i][0]
1139
+ let pieces = [body]
1140
+ let shapedBody = false
1141
+ // (a run of middots reads on to its cap, and whether a key is there is
1142
+ // asked of the body only once its length is a key's: a page of "1·1·1…"
1143
+ // answered it at every piece, and read itself to its square)
1144
+ let askedAt = 0
1145
+ let midKey = false
1146
+ // the run's pieces checked as they are read: every piece short enough or a
1147
+ // front, and none a word of seven or more letters (wordPieces)
1148
+ let okPieces = true
1149
+ let wordPieces = false
1150
+ // (a run of capitals and digits that reads as a key after one of its words
1151
+ // is read whatever it goes on to read: "A A A …" is a page, not a key cut
1152
+ // to its cap, and reading it on was a page of the card's budget)
1153
+ let found = false
1154
+ // a front written the way fronts are ("ghp_", "sk-", "ms-"): an upper-
1155
+ // cased vendor id ("AKIA…") is the key itself, and the words after it are
1156
+ // the text's — the prose rule below reads them as prose
1157
+ // (a middot-joined run is a key cut when the run reads as one whole:
1158
+ // "Ghp_Abcd1234·Efgh5678" is one, "file1 · file2 · …" is a list. A run
1159
+ // that is not one is read piece by piece from its head.)
1160
+ const joinedCut = (() => {
1161
+ let t = words[i][0]
1162
+ for (let q = i + 1; q < words.length && t.length < 64; q += 1) {
1163
+ if (!midOf(q) || words[q][0].length > 16) break
1164
+ t += words[q][0]
1165
+ }
1166
+ return t
1167
+ })()
1168
+ // (only a middot-joined run is asked: a whitespace run's "whole" reading is
1169
+ // the cut loop's own, piece by piece)
1170
+ const wholeRun = i + 1 < words.length && midOf(i + 1) && isKeyWord(joinedCut, letters)
1171
+ const headFront = wholeRun || front(keyFrontOf(words[i][0])) || STEM.test(words[i][0])
1172
+ // (a run built of capitals and digits alone is read on only while it is no
1173
+ // key yet: a page of "A A A …" reads as one key's shape at every length)
1174
+ // (a run of one- and two-character capitals — "A A A …", a page of them —
1175
+ // is read on: there is no word of it to end the run, and reading every
1176
+ // one of them was a page of the card's budget)
1177
+ // (a head of capitals and digits alone, the first word too: a vendor id
1178
+ // is read to its front's end, and a page of short words is not one)
1179
+ // (a head that is a vendor front, the first word too, or that carries a
1180
+ // digit: a key's pieces after a front are its own)
1181
+ let capsy = (headFront || /[0-9]/.test(body)) && /^[A-Z0-9]{1,2}$/.test(body)
1182
+ let tinyCaps = capsy
1183
+ for (let k = i + 1; !pairOfKeys && k < words.length && k - i <= 48 && runGap(k, i); k += 1) {
1184
+ last = k
1185
+ // a run already long enough to be a key is not extended on a guess: 20
1186
+ // characters of key material is the floor every reading below asks for.
1187
+ // Once a cut is found the run does go on, because the key does — the
1188
+ // mask has to cover all of a 32-character digest, not its first 20 —
1189
+ // and it stops at a hard cap so a long run of tiny words stays linear
1190
+ if (body.length >= 96) break
1191
+ // (a run that has already read as a key goes on to its cap only over
1192
+ // the separator it read it with: a space-joined run that read as one key
1193
+ // is read on — "ms-abcd… efgh …" is one key cut by spaces — and a page
1194
+ // of "A A A …" is let go)
1195
+ // (the tiny-capitals cap is the one below, at each piece read: asking it
1196
+ // here as well only made the page of "A A A \u2026" end one piece sooner)
1197
+ if (found && !midOf(k) && !/^[ \t\u00a0\u3000]+$/.test(gap(k))) break
1198
+ const x = words[k][0]
1199
+ // a lone hyphen is a cut of its own ("ms - 0123"), not a break
1200
+ if (!/[A-Za-z0-9]/.test(x) && x !== "-") break
1201
+ const mid = midOf(k)
1202
+ // the card's own separator ("Qwen3-Max · Qwen3-Coder") joins a list, not
1203
+ // pieces of one key: a key cut by middots is a key whose pieces are not
1204
+ // captions. A caption piece is read as its own text.
1205
+ // (a head that carries a key's front behind a frame — "NickName=[ghp_…"
1206
+ // — begins with a capital and a lower-case run like a caption's, and is
1207
+ // not one: the run is the key's, and ending it here left the front and
1208
+ // its first piece on the card)
1209
+ if (
1210
+ mid &&
1211
+ caption(x) &&
1212
+ caption(words[i][0]) &&
1213
+ !front(words[i][0]) &&
1214
+ !front(keyFrontOf(words[i][0])) &&
1215
+ !/^[0-9a-f]{12,}/i.test(words[i][0])
1216
+ )
1217
+ break
1218
+ mixedCase = mixedCase || (x.length >= 3 && /[a-z]/.test(x) && /[A-Z]/.test(x))
1219
+ // the readings below ask for a digit or a mixed-case word in the run, and
1220
+ // the key's own front decides the rest: a word of four or more lower-case
1221
+ // letters is prose ("sk-User joined the team") unless the run starts at a
1222
+ // key's front; a word of one to three is key material only while the run
1223
+ // has not yet read as a key ("ms-0123… for provider" keeps its frame)
1224
+ // a word of the run's own frame ("see … here", "the key … here"): four
1225
+ // letters or more, and nothing else — a word carrying digits or
1226
+ // capitals is key material, or a model's name ("Qwen3-Coder")
1227
+ // the run starts at a key's front written the way fronts are ("ghp_",
1228
+ // "sk-", "ms-"): an upper-cased vendor id ("AKIA…") is the key itself
1229
+ const frontHead = (VENDOR.test(words[i][0]) || /^ms-/i.test(words[i][0])) && /^[a-z]/.test(words[i][0])
1230
+ // (a piece of exactly four is a piece of a key cut into fours when the
1231
+ // run started at a front — "ms-vqmjh 75qa 651i …")
1232
+ // a word carrying a lower-case letter and nothing else is prose, wherever
1233
+ // the run started ("sk-User joined the team", "ms-foreign-… here"); a
1234
+ // piece of a key carries digits or is written in capitals, and a piece
1235
+ // of exactly four is a piece of a key cut into fours ("ms-v qmjh 75qa …")
1236
+ // (four letters that a key piece follows are a piece of that key:
1237
+ // "ms-v qmjh 75qa …" — the piece after it carries a digit or a capital)
1238
+ const moreKey = k + 1 < words.length && /[0-9A-Z]/.test(words[k + 1][0])
1239
+ // (a middot's pieces are read whatever they would read as alone: the
1240
+ // separator is a cut's, and "sk-a·nt-a·pi03" is a key)
1241
+ const prose = !mid && /^[a-z]{4,}$/.test(x) && (x.length > 4 || !frontHead || !moreKey)
1242
+ const fronty = front(keyFrontOf(x)) || STEM.test(x)
1243
+ // a word of the run's own text ends it once a cut has been read ("see
1244
+ // AKIA… here", "ms-foreign-… for provider"), and before that if the run
1245
+ // starts at a key's front
1246
+ if (prose && (headFront || j > i)) break
1247
+ const reads = headFront || fronty
1248
+ if (j > i && !reads && !/[0-9A-Z]/.test(x) && !mid && !(body.length >= 24 && !/^[a-z]{1,3}$/.test(x))) break
1249
+ if (!headFront && prose && (x.length > 4 || !fronty)) break
1250
+ // a key that has already read as one ends at a short word ("ms-0123…
1251
+ // for provider" keeps its frame): the frame is not part of the mask
1252
+ // a short word after a run that already reads as a key ends it ("123e…-…
1253
+ // not found", "ms-0123 for provider")
1254
+ if (/^[a-z]{1,3}$/.test(x) && (j > i || isKeyWord(body, letters))) break
1255
+ // (a middot's pieces are the key's: "9f8e·7d6c·…" is one key whatever
1256
+ // case its pieces are written in, and the run goes on to its end)
1257
+ if (j === i && body.length >= 4 * RUNCUT) break
1258
+ // a word of four or more lower-case letters inside the run is prose
1259
+ // ("sk-User joined the team"), not a piece of a key. A run of exactly
1260
+ // four is a piece as readily as a word ("ms-v qmjh 75qa …" is a key cut
1261
+ // into fours), so it ends the run only when the run does not start at a
1262
+ // key's front
1263
+ if (x.length >= 4 && /^[a-z]{4,}$/.test(x) && !/\d/.test(body) && !mid && (x.length > 4 || !frontHead)) break
1264
+ body += x
1265
+ pieces.push(x)
1266
+ readTo = k
1267
+ if (!(x.length <= 16 || front(x) || (x.length <= 64 && /\d/.test(x) && /[A-Za-z]/.test(x)) || tailOf(x) !== x)) okPieces = false
1268
+ // (a run of capitals and digits alone can be a key at any length, and is
1269
+ // read on: it is the one run that has no digit or case to go on)
1270
+ if (/^[A-Za-z]{7,}$/.test(x) && !/^[A-Z]+$/.test(x)) wordPieces = true
1271
+ // (a word of three or more capitals and digits alone is the key's own
1272
+ // piece: "AKIAIOSFODNN" — a page of "A A A …" has none, and a key
1273
+ // written without spaces keeps them)
1274
+ if (x.length >= 3 && /^[A-Z0-9]+$/.test(x) && /[A-Z][0-9]|[0-9][A-Z]/.test(body + x)) found = true
1275
+ capsy = capsy && /^[A-Z0-9]{1,2}$/.test(x)
1276
+ // (a page of "A A A …" reads as one key at every length, and is let go
1277
+ // after sixteen pieces — no key is cut into that many single capitals)
1278
+ if (capsy) tinyCaps += 1
1279
+ if (capsy && tinyCaps > 16) break
1280
+ // a ModelScope key written down before it was cut: its eleven
1281
+ // characters follow ms- at once, and the cut pieces are then read to
1282
+ // the end ("ms-vqmjh75qa 651i 9x8x …") however long the head is
1283
+ shapedBody = shapedBody || (headFront && letters && /^ms-\w{11}/i.test(body) && !/[\s\u3000]/.test(x))
1284
+ // a piece of seven or more letters on its own is a word, not a piece of
1285
+ // a key, and a run holding one is text ("Rebecca Deborah Bradford" is
1286
+ // mixed case and long enough to read as a key otherwise). A piece of 17
1287
+ // a ModelScope-shaped body ("ms-" and eleven characters) is a key the
1288
+ // moment it reads as one; the platform's own keys are shorter than the
1289
+ // twenty characters the other shapes need
1290
+ shapedBody =
1291
+ shapedBody ||
1292
+ headFront &&
1293
+ letters &&
1294
+ /^ms-/i.test(body) &&
1295
+ /\d/.test(body) &&
1296
+ body.length >= 14 &&
1297
+ !pieces.some((w) => /^[a-z]{1,3}$/.test(w))
1298
+ // (the pieces' join is the body: asked once here, not joined again for
1299
+ // every middot piece, which read a page of tiny pieces to its square)
1300
+ // (each piece's check is made when it is read — "okPieces" — so a body
1301
+ // that is no key of key length costs no second walk over its pieces)
1302
+ const bodyKey = body.length < 40 && isKeyWord(body, letters)
1303
+ const cut =
1304
+ (body.length >= 20 || shapedBody) &&
1305
+ !body.includes(MASKED) &&
1306
+ okPieces &&
1307
+ // a piece of seven or more letters is a word, unless it is written in
1308
+ // capitals: a vendor key's piece is ("AKIAIOSFOD · NN7EXAMPLE")
1309
+ // (a middot's piece is the key's whatever it reads as alone: a list of
1310
+ // words with numerals ("file1 · file2 · …") still has to read as one
1311
+ // key whole once its middots are read as spaces, and does not)
1312
+ !wordPieces &&
1313
+ !(mid && !bodyKey) &&
1314
+ (bodyKey || (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters)))
1315
+ // an all-capitals run follows the same rule as the capitals reading
1316
+ if (cut) {
1317
+ const capRun = body
1318
+ if (!(/^[A-Z0-9]+$/.test(capRun) && !/^AKIA[0-9A-Z]{16}$/.test(capRun) && !/[A-Z][0-9]/.test(capRun) && !/[0-9][A-Z]/.test(capRun))) j = k
1319
+ }
1320
+ // a cut marked by a middot every few characters is a cut, and it goes on
1321
+ // to its cap ("ghp_12·34A·bC…" is a key the display drew a piece per
1322
+ // middot). It is the run's from its first piece when the head is a
1323
+ // key's front ("Ghp_Abcd1234·…": an upper-cased front is one too) — a
1324
+ // piece that does not read as a key on its own is still the key's —
1325
+ // and only from the cut it found otherwise ("…·p789": the pieces before
1326
+ // it are the text's, and the run there reads as one key before the
1327
+ // middot)
1328
+ // (the key begins at the head, as it does for a front, whatever the
1329
+ // middot did with the rest; the run still has to read as one key whole
1330
+ // once its middots are read as spaces — "file1 · file2 · …" is a list)
1331
+ // (a run of middots reads on to its cap. Without a front of its own, it
1332
+ // belongs to the mask once it reads as one key whole once — asked every
1333
+ // few characters, since a page of "1·1·1…" answers at every piece and
1334
+ // read itself to its square — and from there on to its end.)
1335
+ // (a middot every few characters is a cut's spelling, and it goes on to
1336
+ // its cap: five middots in a run is no text's "·" separator)
1337
+ if (mid && k - i >= 4) midKey = true
1338
+ if (mid && body.length >= RUNCUT && !body.includes(MASKED)) {
1339
+ if (front(words[i][0])) j = k
1340
+ else {
1341
+ if (!midKey && (askedAt === 0 || body.length - askedAt >= 4)) {
1342
+ askedAt = body.length
1343
+ midKey = isKeyWord(body, letters) || (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters))
1344
+ }
1345
+ if (midKey) j = k
1346
+ }
1347
+ }
1348
+ }
1349
+ if (j === i) {
1350
+ continue
1351
+ }
1352
+
1353
+ // where the key starts: the run's own head, and the first key-shaped word
1354
+ // of a run that begins in a frame ("the key AKIAIOSFODNN 7EXAMPLE"). A
1355
+ // word key-shaped on its own and longer than 16 is the whole key
1356
+ // ("ghp_…EfG h9012…"), not a front with a second key behind it
1357
+ let head = i
1358
+ // a run starts at its first word unless that word is only the frame the
1359
+ // key was written in ("the ms-…", "no key ghp_"); a word key-shaped on its
1360
+ // own ("sk-ant-api03-Eyai1pkJrF") is where the key begins, not the pieces
1361
+ // after it
1362
+ // (a word in a frame is judged by its own key material: "(ghp_…" is where
1363
+ // the key begins, and reading it as a bare "(" moved the head past it)
1364
+ // a vendor front at the word's own start is the word's start, braces or not
1365
+ const iHead = front(words[i][0]) ? words[i][0] : keyFrontOf(words[i][0])
1366
+ const iFrontAt = keyAt(iHead, letters) && !(letters === false && keyAt(words[i][0], letters))
1367
+ for (let q = i + 1; q <= j && !headFront && !shapedBody && !(iFrontAt || keyAt(words[i][0], letters)); q += 1) {
1368
+ const y = tailOf(words[q][0])
1369
+ if (y.length <= 16 && (keyAt(y, letters) || (y.length >= 8 && /\d/.test(y) && /[A-Z]/.test(y)))) {
1370
+ head = q
1371
+ break
1372
+ }
1373
+ }
1374
+ const hp = words[head][0]
1375
+ // where the key's own text begins in the head word. A word with no frame
1376
+ // punctuation that reads as a key is the whole key — a UUID, an ms- key, a
1377
+ // slashed secret: the separators inside it are the key's ("123e4567-e89b-…"),
1378
+ // and cutting at the last letters-and-digits left most of it in the frame.
1379
+ // A frame's punctuation ("key=…", "(…)") hides the key behind it when the
1380
+ // frame carries no word of its own; a frame that does ("NickName=[") is
1381
+ // read from the word's letters and digits, as before.
1382
+ const frameCut = Math.max(0, ...'=:([{,"\''.split("").map((c) => hp.lastIndexOf(c) + 1))
1383
+ const framed = hp.slice(frameCut)
1384
+ const frameWord = /[A-Za-z0-9]{2,}/.test(hp.slice(0, frameCut))
1385
+ // a piece of a hex key cut by whitespace — "123e4567-e89b", "9f8e7d6c5b4a3928"
1386
+ // — is the key's from its own first character, not from its last
1387
+ // letters-and-digits: reading it as a tail began the run at "e89b" and left
1388
+ // "123e4567-" on the card (measured: twelve characters of a UUID where the
1389
+ // mask allows four). The hex letters are what tells the piece from a date
1390
+ // ("2026-11-10" has digits and hyphens but no a–f) and from a word.
1391
+ const hexPiece = /^[0-9a-f]+(?:-[0-9a-f]+)*$/i.test(hp) && /\d/.test(hp) && /[a-f]/i.test(hp) && hp.length >= 8
1392
+ const ht =
1393
+ frameCut === 0 && (keyAt(hp, letters) || hexPiece)
1394
+ ? hp
1395
+ : hp.match(FRONT_IN)?.[0] ?? (frameCut > 0 && !frameWord && keyAt(framed, letters) ? framed : tailOf(hp) || hp)
1396
+ const hf = hp.slice(0, hp.length - ht.length)
1397
+ // the head is a stem of a front the run goes on with ("ms", "sk", "ms-":
1398
+ // the key begins at the stem), and its hyphen is the front's own
1399
+ // (a word that only starts a front — "(gh", "xa" of "xoxb-" — is one too)
1400
+ const stem = head === i && headFront && !front(hp) && !STEM.test(hp) && /^-*$/.test(hf) && FRONT_PART.test(hf)
1401
+ // the separators the key was cut by stay inside the mask (a digest is one
1402
+ // key, "9f8e·7d6c·…" as much as "9f8e 7d6c …"), but a middot or bullet
1403
+ // that is the card's own separator does not ("Qwen3-Max · 调用返还").
1404
+ // A run that begins in a frame ("ms-a 1A1…") starts at its head
1405
+ let rest = words.slice(head + 1, j + 1).map((w) => w[0])
1406
+ // a word after a middot that the run picked up only to keep reading is the
1407
+ // text's ("AKIA… · 4 次"): the mask stops at the last piece the key needed
1408
+ // (a head that is the text's last word has no word after it to read)
1409
+ // (the trim belongs to the card's own separator, "·" with a space on one
1410
+ // side: a bare middot is a cut's spelling and its pieces are the key's,
1411
+ // "12·3e·45·…·40·00" — trimming a piece dropped "00" from the mask and
1412
+ // left the head covering only the first piece)
1413
+ if (head + 1 < words.length && midOf(head + 1) && /^[ \t\u00a0\u3000]/.test(gap(head + 1))) {
1414
+ const endsText = s.slice(end(j)).trim() === ""
1415
+ // (a caption after a spaced middot is the card's own separator's next
1416
+ // entry — "Qwen3-Max · Qwen3-Coder", "qwen3-max · qwen3-max": a model's
1417
+ // name is no piece of a key, and the run is where it was)
1418
+ const text = (w) => caption(w) || (!endsText && w.length <= 2)
1419
+ // (every piece of the run must be read up to it, and the run still has
1420
+ /* eslint-disable-next-line no-irregular-whitespace */
1421
+ // to read as a key whole: "file1 · file2 · file3 · file4" is a list)
1422
+ while (rest.length > 1 && rest[rest.length - 1].length <= 4 && !isKeyWord([ht, ...rest].join(""), letters) && (text(rest[rest.length - 1]) || !isKeyWord(words.slice(i, j + 1).map((w) => w[0]).join(""), letters))) rest = rest.slice(0, -1)
1423
+ while (!endsText && rest.length > 0 && rest[rest.length - 1].length <= 2) rest = rest.slice(0, -1)
1424
+ }
1425
+ // a hyphen the cut left on the front's end ("ms-" then "0123") is the
1426
+ // front's own, and the key may begin with it
1427
+ if (stem && /^-$/.test(rest[0] ?? "")) rest[0] = ht + "-"
1428
+ const run = stem ? rest : [ht, ...rest]
1429
+ // the frame's closing punctuation is the frame's ("(ghp_…7890)", not
1430
+ // "(ghp_…90)"): the mask's last four characters are the key's own
1431
+ let whole = rest.length === words.slice(head + 1, j + 1).length
1432
+ // the run's last words can be the key's even when they added no shape of
1433
+ // their own ("…·789·0"): what the run read is masked. The mask is the
1434
+ // run's to its end — key-shaped from the head's own material ("ghp_") or
1435
+ // long enough to be a key — and the frame the run read past ("· 4 次") is
1436
+ // trimmed below. (A mask whose first twenty characters read as a key stops
1437
+ // at the cut it found; one that reads only at length is a piece that was
1438
+ // joined, and the pieces after it are the key's.)
1439
+ const head20 = ([ht, ...rest].join("").match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r.length > 20 ? r.slice(0, 20) : r, letters))
1440
+ const long20 = [ht, ...rest].join("").length >= 20
1441
+ if (whole && readTo > j && !(head20 && long20) && isKeyWord([ht, ...rest].join(""), letters) && !/^[a-z]{3,}$/.test(words[readTo][0])) {
1442
+ rest = words.slice(head + 1, readTo + 1).map((w) => w[0])
1443
+ whole = rest.length === words.slice(head + 1, readTo + 1).length
1444
+ }
1445
+ // a middot-joined run of the head's own front is the key through every
1446
+ // piece it read ("Ghp_Abcd1234·Efgh5678·…"): the pieces are the key's, and
1447
+ // none of them is the text's. It still has to read as a key once whole —
1448
+ // seventeen characters of a word and a numeral ("file1·file2·file3",
1449
+ // "AKIA… · 4") is a list, not a key cut, and is read piece by piece.
1450
+ // The run stopped at is kept out of the mask: a word after a middot is the
1451
+ // text's. (A middot without a space is a cut's, and takes the last piece.)
1452
+ // (what the run read, its middots read as spaces: "file1 · file2 · …" is
1453
+ // a list of words with numerals, and together they read as a key)
1454
+ const fronted = headFront && run.length > 1 && midOf(head + 1) && wholeRun
1455
+ if (fronted) whole = readTo === j
1456
+ let tailP = ""
1457
+ if (whole && run.length > 0) {
1458
+ const tail = run[run.length - 1].match(/[^A-Za-z0-9_+/=\-]+$/)?.[0] ?? ""
1459
+ if (tail && tail !== run[run.length - 1]) {
1460
+ tailP = tail
1461
+ run[run.length - 1] = run[run.length - 1].slice(0, -tail.length)
1462
+ }
1463
+ }
1464
+ // (a cut can leave a bare hyphen as a piece, "ms- 0123": dropped)
1465
+ // (a run of the head's own front keeps its cut's middots only when it is
1466
+ // one key whole: a mask over a list of pieces was "…" among the text's)
1467
+ // (a middot-joined run keeps its cut's middots only when it is one key
1468
+ // whole: a mask over a list of pieces was "\u2026" among the text's)
1469
+ const keepSep = !fronted && run.every((w) => /^\w+$/.test(w))
1470
+ const keyText = run.join("").replaceAll(MASKED, "").replace(keepSep ? /[·•∙‧・⋅]/g : /$^/g, "").replace(/^-+/, "")
1471
+ out.push(s.slice(from, at(head)), hf, maskToken(keyText), tailP)
1472
+ // the mask ends at the last piece it kept; a word after a middot that the
1473
+ // run read past is text, and stays as written ("… · 4 次")
1474
+ from = whole ? end(j) : end(head + Math.max(rest.length, 0))
1475
+ i = j
1476
+ }
1477
+ return out.join("") + s.slice(from)
1478
+ }
642
1479
 
643
1480
  // keyShaped catches a key this plugin does not hold. tokenShaped knows only
644
1481
  // the ms- prefix, so a server — or a proxy in front of it — that echoes
@@ -656,15 +1493,18 @@ const tokenShaped = new RegExp(String.raw`m${INV}*s${INV}*-(?:${INV}*[\x21-\x7e]
656
1493
  // floor but which together are — a key written as nUJqxrpd9Bc7-GvG7J--o… or
657
1494
  // AbCdEfGh.IjKlMnOp… (measured on the card before that arm was added: all 34
658
1495
  // of the first, all 35 of the second). A word does not qualify — which is what
659
- // keeps "每日签到", a model tier or a rule_key readable on the card.
1496
+ // keeps "Daily check-in", a model tier or a rule_key readable on the card.
660
1497
  const RUN = /[A-Za-z0-9]+/g
661
1498
  // the characters a key's word can be built from; punctuation outside this set is
662
1499
  // the frame around it ("…", a trailing full stop, a comma)
663
1500
  const IDCH = new Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_.-")
664
- const keyRun = (r) =>
665
- (r.length >= 16 && /[a-z]/.test(r) && /[A-Z]/.test(r)) ||
1501
+ const keyRun = (r, letters = true) =>
1502
+ (letters && r.length >= 16 && /[a-z]/.test(r) && /[A-Z]/.test(r)) ||
666
1503
  (r.length >= 32 && /\d/.test(r) && /[A-Za-z]/.test(r)) ||
667
- (r.length >= 16 && /\d/.test(r) && /[A-Z]/.test(r)) ||
1504
+ // (a nickname is judged without the case-mix arms — WangXiaoming2026 is a
1505
+ // word — but an all-caps id with a digit in it is still a key, and it is the
1506
+ // only shape those arms would have caught that the others do not)
1507
+ (r.length >= 16 && /\d/.test(r) && /[A-Z]/.test(r) && (letters || r === r.toUpperCase())) ||
668
1508
  // 16+ of hex with both a letter and a digit in it: a digest, a session id, a
669
1509
  // fragment of a key. Not a word (abcdefgh… carries letters that are not hex,
670
1510
  // modelscope20261031 carries more than hex), and not a number
@@ -720,10 +1560,45 @@ const base64y = (w) => {
720
1560
  // flipping pieces, is what keeps the vendor key (one flipping piece) from
721
1561
  // being lost
722
1562
  const alternates = (w) => (w.match(/[a-z][A-Z]/g) ?? []).length >= 2
723
- const isKeyWord = (w) => {
1563
+ // A slice of a generated key can be handed back with no known key to compare
1564
+ // against — "no key 5gli9zadx8zg9jez for provider" from a host whose
1565
+ // getAuth failed — and a 12–15-character lowercase run has no case mix, no
1566
+ // digit by shape alone and no length floor to catch it. Its letters do not
1567
+ // read as a word: a run of them has few vowels (usually none), letters and
1568
+ // digits alternate, or digits make up much of it. A real word — "modelscope",
1569
+ // "workbuddy", "trae-global" — carries vowels; a long provider id with a date
1570
+ // in it sits under the length floor. The band is narrow on purpose: 40 and
1571
+ // over is the other shapes' business, and under 12 the fragments a legitimate
1572
+ // message carries (a version, a rule id) are short enough to stay.
1573
+ const VOWELS = /[aeiouy]/g
1574
+ const randomish = (r) => {
1575
+ if (r.length < 12 || r.length > 40 || !/[a-z]/.test(r)) return false
1576
+ if ((r.match(VOWELS) ?? []).length / r.length >= 0.2) return false
1577
+ const digits = (r.match(/\d/g) ?? []).length
1578
+ const transitions = (r.match(/[a-z][0-9]|[0-9][a-z]/g) ?? []).length
1579
+ return transitions >= 2 || digits >= 4
1580
+ }
1581
+ // (a run whose every piece is a word with a numeral — "file1file2file3…" — is
1582
+ // a list: no key's word is a word, and a middot run of them was asked at every
1583
+ // piece)
1584
+ const LISTY = /^(?:[A-Za-z]+\d+)+$/
1585
+ const isKeyWord = (w, letters = true) => {
724
1586
  if (w.length < 12) return false
1587
+ // (a word followed by its numeral — "d8e3a6f0b5c9" — is no match: the
1588
+ // letters there are not a word)
1589
+ if (LISTY.test(w) && /[A-Za-z]{3,}/.test(w)) return false
1590
+ // an upper-cased vendor key is the same key, and its pieces are too short
1591
+ // for the run floors ("SK-ANT-API03-EYAI1PKJRF_KBWT"). A provider id in
1592
+ // lower case ("sk-preset-20261031") is not this arm's
1593
+ // (its letters and digits must alternate, as "SK-ANT-API03-EYAI1PKRF_KBWT"
1594
+ // does; a sentence in capitals has words without digits between them)
1595
+ if (VENDOR.test(w) && w === w.toUpperCase() && /[0-9]/.test(w) && !/[A-Z]{6,}/.test(w.toUpperCase().replace(/^[A-Z]+_[A-Z]+_/, ""))) return true
1596
+ // a run with no word in it (a slash of a key a host printed with no key in
1597
+ // hand) is judged by shape; the caller that knows a name from a key skips
1598
+ // this arm by passing `letters: false` (nameText, when a name is being shown)
1599
+ if (letters && /^[a-z0-9]+$/.test(w) && randomish(w)) return true
725
1600
  if (!/[\\/]/.test(w)) {
726
- if (UUID.test(w) || base64y(w) || (w.match(RUN) ?? []).some(keyRun)) return true
1601
+ if (UUID.test(w) || base64y(w) || (w.match(RUN) ?? []).some((r) => keyRun(r, letters))) return true
727
1602
  // the separators out: a key cut into pieces by - _ . is still one key, and
728
1603
  // the pieces together are long and alternate case where a name's do not.
729
1604
  // A word with no separator is not judged here — the run test above has
@@ -736,14 +1611,85 @@ const isKeyWord = (w) => {
736
1611
  // with a separator it is one key only when it does not start like a path and
737
1612
  // carries two parts of 12+ characters mixing cases: that is an AWS secret
738
1613
  // (wJalr…/K7MDENG/bPxRfi…), and a pair of ordinary folder names is not
739
- const parts = w.split(/[\\/]/).filter((seg) => seg.length >= 12 && /[a-z]/.test(seg) && /[A-Z]/.test(seg))
740
- return !PATHY.test(w) && parts.length >= 2
1614
+ const segs = w.split(/[\\/]/).filter((seg) => seg !== "")
1615
+ const parts = segs.filter((seg) => seg.length >= 12 && /[a-z]/.test(seg) && /[A-Z]/.test(seg))
1616
+ if (!PATHY.test(w) && parts.length >= 2) return true
1617
+ // a key cut into pieces by slashes ("ghp_1234Ab/Cd5678EfGh/9012IjKl34/…"):
1618
+ // many parts, each of them key material rather than a word, and the whole
1619
+ // reads as a key with the slashes taken out. A path's parts carry lower-case
1620
+ // words ("SomeLongDirName" is a name), and a key's parts do not. A slash at
1621
+ // the very start is a path's own, and a key behind a frame starts at its own
1622
+ // front
1623
+ const frontAt = w.search(/ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida/i)
1624
+ const sub = frontAt > 0 ? w.slice(frontAt) : w
1625
+ if (PATHY.test(sub) || /^[\\/]/.test(sub)) return false
1626
+ const core = sub.replace(/[^A-Za-z0-9]+$/, "")
1627
+ const coreSegs = core.split(/[\\/]/).filter((seg) => seg !== "")
1628
+ // the front's own letters are not a word ("ghp_" opens a key)
1629
+ const frontLen = (core.match(/^(?:ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida)/i)?.[0] ?? "").length
1630
+ const wordy = (seg, i) => /[a-z]{2,}/.test(i === 0 ? seg.slice(frontLen) : seg)
1631
+ return (
1632
+ coreSegs.length >= 3 &&
1633
+ coreSegs.every((seg) => /^[A-Za-z0-9_+=.-]+$/.test(seg)) &&
1634
+ !coreSegs.some((seg, i) => wordy(seg, i)) &&
1635
+ isKeyWord(compact(core), letters)
1636
+ )
741
1637
  }
742
1638
 
1639
+ // maskAll is the three readings a card's text gets, in order: the run reading
1640
+ // (maskSpaced), the harder cut (maskCut), and then word by word (maskKeys) —
1641
+ // whatever one of them finds is masked, and what it masked is left alone by
1642
+ // the next two.
1643
+ // maskCaps is the key written without its spaces ("AKIAIOSFODNN 7EXAMPLE"),
1644
+ // after the run readings: a word mixing capitals and digits, or one from a
1645
+ // vendor's front ("AKIA"), joined with the words after it where the join is
1646
+ // all capitals and digits — the shape every vendor key keeps — and long
1647
+ // enough to be one of them. The join only covers the pieces that are the
1648
+ // key's own alphabet; a word outside it ("the key AKIAIOSFODNN7 EXAM PLES
1649
+ // here") is where the join stops, and the joined part is what is masked.
1650
+ const maskCaps = (s, letters) => {
1651
+ // a body with no capital in it has no vendor key written without its
1652
+ // spaces (every one of their shapes is all capitals and digits). A name
1653
+ // keeps its capitals: "CHRISTOPHER LEE 2026" is all-capitals and long
1654
+ // enough to read as a key without its spaces, and it is a name
1655
+ if (!letters || s.length > 32 * 1024 || !/[A-Z]/.test(s)) return s
1656
+ const words = [...s.matchAll(/[\x21-\x7e]+/g)]
1657
+ const at = (i) => words[i].index
1658
+ const end = (i) => words[i].index + words[i][0].length
1659
+ const out = []
1660
+ let from = 0
1661
+ for (let i = 0; i < words.length; i += 1) {
1662
+ const w = words[i][0]
1663
+ if (!VENDOR.test(w) && !(/^[A-Z0-9]{4,}$/.test(w) && /[A-Z]/.test(w))) continue
1664
+ let j = i
1665
+ let body = w
1666
+ while (j + 1 < words.length && s.slice(end(j), at(j + 1)).length <= 2 && /^[A-Z0-9]{2,}$/.test(words[j + 1][0]) && /^[A-Z0-9]+$/.test(body)) {
1667
+ j += 1
1668
+ body += words[j][0]
1669
+ }
1670
+ if (j === i || body.length < 16 || /[a-z]/.test(body)) continue
1671
+ // an upper-cased vendor shape ("AKIAIOSFODNN7 EXAMPLE") carries a piece of
1672
+ // an id's rhythm: a capital, a digit, short pieces. A word written out in
1673
+ // full ("DOCUMENTATION", "CONFIGURED") is prose in capitals
1674
+ const awsy = /^AKIA[0-9A-Z]{16}$/.test(body)
1675
+ if (!awsy && !/[A-Z][0-9]/.test(body) && !/[0-9][A-Z]/.test(body)) continue
1676
+ out.push(s.slice(from, at(i)), maskToken(body))
1677
+ from = end(j)
1678
+ i = j
1679
+ }
1680
+ return out.join("") + s.slice(from)
1681
+ }
1682
+
1683
+ // before maskCut: a middot-joined run of a key's shape is the key's own cut
1684
+ // (maskSpaced reads it whole, maskCut reads each piece alone), and maskCut
1685
+ // draws "·" as a mask's ellipsis when it is on the mask's own edge
1686
+ const maskAll = (s, letters) => maskKeys(maskSpaced(maskCut(maskSpaced(maskCaps(s, letters), letters), letters), letters), letters)
743
1687
  // maskKeys walks the words of a piece of text: one word that is itself a key
744
1688
  // goes, and inside a word that is not, only the key-like runs and UUIDs go.
745
1689
  // Punctuation that cannot be part of a key stays where it was, so a card
746
- // reads "(bonus ghp_16C7…8B4a)" rather than losing its brackets.
1690
+ // reads "(bonus ghp_16C7…8B4a)" rather than losing its brackets. A key cut by
1691
+ // spaces is not this walk's to find — it sees one word at a time, and four
1692
+ // pieces under the floors are four words kept; maskSpaced reads the run.
747
1693
  //
748
1694
  // The lead and tail of punctuation are found by walking in from each end, not
749
1695
  // by an anchored regex. `word.slice(lead).match(/[^…]*$/)` backtracked from
@@ -752,8 +1698,174 @@ const isKeyWord = (w) => {
752
1698
  // one match, and a server controls the ledger title it sits in. (A word of
753
1699
  // nothing but punctuation does not show it — the first start position matches
754
1700
  // cleanly — so the test uses that shape)
755
- const maskKeys = (s) =>
756
- s.replace(/[^\s]+/g, (word) => {
1701
+ // maskCut is the harder cut: a key whose front is itself cut, or whose pieces
1702
+ // are cut in two ("ghp _12 34A …", "sk-a nt-a pi03 -Eya …"). Read from a head
1703
+ // — a word carrying a vendor prefix, or folding the key's own case down to a
1704
+ // capital and a digit, or a run of capitals and digits alone (a key written
1705
+ // without its spaces: "AKIAIOSF ODNN7EXA MPLE") — the run goes on while every
1706
+ // piece is short and none is prose, and the join is a key or the key's pieces
1707
+ // keep flipping case between them. The head is where the mask begins.
1708
+ const conts = (w) => (/[0-9]/.test(w) || /[A-Z]/.test(w)) && !VENDOR.test(w)
1709
+ function maskCut(s, letters) {
1710
+ // a body of many KiB of one-character words costs far more reading than a
1711
+ // card ever shows, and a hook's whole budget is 25 seconds: past 32 KiB the
1712
+ // pass is skipped, and the other two readings (linear in the text) still run
1713
+ if (s.length > 32 * 1024) return s
1714
+ const words = [...s.matchAll(/[\x21-\x7e]+/g)]
1715
+ if (words.length < 3) return s
1716
+ const at = (i) => words[i].index
1717
+ const end = (i) => words[i].index + words[i][0].length
1718
+ const gap = (i) => s.slice(end(i - 1), at(i))
1719
+ const front = (w) => VENDOR.test(w) || /^ms-/i.test(w)
1720
+ // the same gaps as the run reading: a space, a line break, or a middot or a
1721
+ // bullet with at most one space each side
1722
+ const runGap = (k, i) =>
1723
+ /^[ \t\u00a0\u3000\r\n]+$/.test(gap(k)) && gap(k).length <= 2
1724
+ ? true
1725
+ : MIDDOT_GAP.test(gap(k)) && !(caption(words[i][0]) && caption(words[k][0])) && (front(keyFrontOf(words[i][0])) || keyFrontOf(words[i][0]) !== "")
1726
+ // (a middot-joined list of words with numerals — "file1·file2·…", which a
1727
+ // log's separators write — is no key's cut, and its pieces are read on
1728
+ // their own: the join off the head was asked at every head, and a page of
1729
+ // them was read to its square)
1730
+ const listy = (i) => {
1731
+ if (!(i + 1 < words.length && MIDDOT_GAP.test(gap(i + 1)))) return false
1732
+ let t = words[i][0].replace(/\d+/g, "#")
1733
+ for (let q = i + 1; q < words.length && t.length < 64; q += 1) {
1734
+ if (!MIDDOT_GAP.test(gap(q))) break
1735
+ t += words[q][0].replace(/\d+/g, "#")
1736
+ }
1737
+ return /^(?:[A-Za-z]+#)+$/.test(t)
1738
+ }
1739
+ const out = []
1740
+ let from = 0
1741
+ for (let i = 0; i < words.length; i += 1) {
1742
+ const w = words[i][0]
1743
+ // a key written one character per word starts at a piece of one or two
1744
+ // characters inside such a run (five or more one-character words); the
1745
+ // join decides whether the pieces are one key
1746
+ // (the five pieces have to be letters: a page of "1 · 1 · 1 · …" is a
1747
+ // run of ones, not a key typed out, and asking after every one of them
1748
+ // made a 32 KiB body take two seconds)
1749
+ const tiny = w.length <= 2 && /^[A-Za-z]+$/.test(w) && words[i + 4] !== undefined && words.slice(i, i + 5).every((x) => x[0].length <= 2)
1750
+ // (a stem of a front, "ms", "ghp", is a head too: the rest of the front
1751
+ // is in the next piece)
1752
+ const headKey = keyFrontOf(w)
1753
+ // a key's bare front ("sk-") is a head too: its body starts after it
1754
+ const bareFront = /^(?:sk-|gh[pousr]_|xox[abposr]-|sk_)$/i.test(w)
1755
+ if (!VENDOR.test(w) && !conts(headKey) && !tiny && !STEM.test(w) && !bareFront) continue
1756
+ if (!VENDOR.test(w) && listy(i)) continue
1757
+ let j = i
1758
+ let mixedCase = w.length >= 3 && /[a-z]/.test(w) && /[A-Z]/.test(w)
1759
+ let body = w
1760
+ let mixed = false
1761
+ // the reads rule below asked every past piece whether it was a word, at
1762
+ // every start (quadratic on a page of "1 · 1 · …"); one flag says it
1763
+ const readsHead = front(w)
1764
+ let sawWord = false
1765
+ for (let k = i + 1; w.length <= 64 && k < words.length && k - i <= 64; k += 1) {
1766
+ if (!runGap(k, i)) break
1767
+ // the same stop as the run reading: 20 characters of key material is
1768
+ // the floor, and once a cut is found the run goes on to its cap
1769
+ if (body.length >= 96) break
1770
+ const x = words[k][0]
1771
+ mixedCase = mixedCase || (x.length >= 3 && /[a-z]/.test(x) && /[A-Z]/.test(x))
1772
+ if (/^[a-z]{3,}$/.test(x)) sawWord = true
1773
+ const reads = readsHead || (/^[A-Z0-9]{1,2}$/.test(w) && !sawWord)
1774
+ // (a middle piece joined by a middot or a bullet is key material in any
1775
+ // case: "ms-f·orei·gn01·…" is a key cut every four characters)
1776
+ const middled = /^[·•∙‧・⋅]{1,2}$/.test(gap(k))
1777
+ if (j > i && !reads && !/[0-9A-Z]/.test(x) && !middled && !(body.length >= 24 && !/^[a-z]{1,3}$/.test(x))) break
1778
+ // a run with no digit and no mixed-case word can become no key (the
1779
+ // same reading as the run pass), and one that can is read to the cap
1780
+ if (j === i && body.length >= RUNCUT && !/\d/.test(body) && !mixedCase && !middled) break
1781
+ if (j === i && body.length >= 4 * RUNCUT) break
1782
+ // a key cut once into a long piece ("ghp_1234AbCd5678EfG h9012…") keeps
1783
+ // reading when the piece is key material: digits beside letters
1784
+ if (x.length > 16 && !(x.length <= 64 && /\d/.test(x) && /[A-Za-z]/.test(x)) && !VENDOR.test(x)) break
1785
+ // a piece of one capital and lower-case letters right after the head is
1786
+ // a word on its own ("sk-User"), and a word of four or more lower-case
1787
+ // letters inside the run is prose ("the team") — unless the head is a
1788
+ // run of capitals and digits, where every piece is the key's
1789
+ if (/^[A-Z][a-z]+$/.test(x) && k === i + 1 && !/[0-9]/.test(body)) break
1790
+ // a lower-case word after a run of capitals and digits is the text's
1791
+ // ("see AKIAIOSFODNN7EXAMPLE here"): the run is the key, whole
1792
+ if (/^[a-z]{3,}$/.test(x) && /^[A-Z0-9]+$/.test(body)) break
1793
+ // and after a run that already reads as a key ("ms-foreign-… for provider")
1794
+ if (/^[a-z]{4,}$/.test(x) && isKeyWord(body, letters)) break
1795
+ // a short word of the text's ends the run once a cut has been read, or
1796
+ // when the body already reads as a key ("ghp_…Qrst for provider" keeps
1797
+ // its "for"; the "for" of "ms-0123 for provider" too)
1798
+ if (/^[a-z]{1,3}$/.test(x) && (j > i || isKeyWord(body, letters))) break
1799
+ // the same piece-versus-prose split as the run reading: four lower-case
1800
+ // letters end the run unless the run starts at a key's front
1801
+ if (/^[a-z]{4,}$/.test(x) && body.length >= 4 && !/[0-9]/.test(body) && !/^[A-Z0-9]+$/.test(body) && (x.length > 4 || !(VENDOR.test(w) || /^ms-/i.test(w)))) break
1802
+ body += x
1803
+ if (body.length < 20 || body.includes(MASKED)) continue
1804
+ const whole = body.length >= 16 && (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters)) // every keyRun arm floors at 16
1805
+ const flips = body.match(/[a-z][A-Z]/g) ?? []
1806
+ // (only asked once both is on the table; most of a run's words carry one case)
1807
+ let pairMix = -1
1808
+ let shortMix = false
1809
+ if (whole || flips.length >= 2) {
1810
+ const run = words.slice(i, k + 1)
1811
+ // a caption ("Qwen3-Max") is mixed case too, and a list of them is not
1812
+ // a key: a key's words flip case inside themselves ("i1pkJrF")
1813
+ pairMix = run.filter((y) => /[a-z]/.test(y[0]) && /[A-Z]/.test(y[0]) && !/^[A-Z][a-z]{2,}/.test(y[0])).length
1814
+ shortMix = pairMix > 0 && run.some((y) => y[0].length <= 12 && /[a-z]/.test(y[0]) && /[A-Z]/.test(y[0]))
1815
+ }
1816
+ if (whole || flips.length >= 2 || (pairMix >= 2 && shortMix)) {
1817
+ j = k
1818
+ mixed = whole || flips.length >= 2
1819
+ }
1820
+ }
1821
+ if (j === i || !mixed) continue
1822
+ const run = words.slice(i, j + 1)
1823
+ // a run of words that are each prose-shaped ("Rebecca Deborah Bradford")
1824
+ // is two runs of words, not a key
1825
+ if (run.every((x) => /^[a-z]{3,}$/i.test(x[0]))) continue
1826
+ // the mask is the key's characters alone; the separators it was cut by
1827
+ // stay inside it, as in the run reading
1828
+ out.push(s.slice(from, at(i)), maskToken(run.map((x) => x[0]).join("").replace(/[·•∙‧・⋅]/g, "")))
1829
+ from = end(j)
1830
+ i = j
1831
+ }
1832
+ return out.join("") + s.slice(from)
1833
+ }
1834
+
1835
+ const maskKeys = (s, letters = true) => {
1836
+ return s.replace(/[^\s]+/g, (word) => {
1837
+ // The word in the other spellings a key is printed in — full-width
1838
+ // characters, mostly. Nothing below can read it: there is no ASCII body
1839
+ // and no ASCII path prefix, so every pass saw ordinary text. Measured, a
1840
+ // host error carrying "no key ms-… for provider" printed the whole
1841
+ // token, and NFKC put it back together. The folded copy is what the shape
1842
+ // tests judge, and the word as written is what is masked.
1843
+ const folded = word.normalize("NFKC").replace(/\uFF0D/g, "-")
1844
+ // (the word as written is what is masked, and the folded copy is what is
1845
+ // judged \u2014 but only the key's own span of it: a word's prose is the
1846
+ // word's, and masking the whole took four ideographs of it with the key
1847
+ // ("\u4F59\u989D\u67E5\u8BE2\u5931\u8D25\uFF1A\uFF4D\uFF53\uFF0D\u2026" came out "\u4F59\u989D\u67E5\u8BE2\u20263456"). A fold that keeps
1848
+ // the word's length maps back character for character; one that does not
1849
+ // (a ligature, a squared character) has no span to name, and the word is
1850
+ // masked whole as before)
1851
+ if (folded !== word) {
1852
+ const bodyOf = (w) => {
1853
+ let x = 0
1854
+ while (x < w.length && !IDCH.has(w[x])) x += 1
1855
+ let y = w.length
1856
+ while (y > x && !IDCH.has(w[y - 1])) y -= 1
1857
+ return [x, y]
1858
+ }
1859
+ const readsAsKey = (w) => isKeyWord(w, letters) || (w.match(RUN) ?? []).some((r) => keyRun(r, letters)) || UUID.test(w)
1860
+ if (folded.length === word.length) {
1861
+ const [fa, fb] = bodyOf(folded)
1862
+ if (fb > fa && readsAsKey(folded.slice(fa, fb))) {
1863
+ return word.slice(0, fa) + maskToken(word.slice(fa, fb)) + word.slice(fb)
1864
+ }
1865
+ } else if (readsAsKey(folded)) {
1866
+ return maskToken(word)
1867
+ }
1868
+ }
757
1869
  let a = 0
758
1870
  while (a < word.length && !IDCH.has(word[a])) a += 1
759
1871
  let b = word.length
@@ -766,11 +1878,21 @@ const maskKeys = (s) =>
766
1878
  // begins with two backslashes, and \ is not in the identity set — stripped
767
1879
  // as "lead", the rest looked like a relative path with two long camelCase
768
1880
  // folders and was masked whole (the same test on the word sees PATHY)
769
- const masked = isKeyWord(word)
770
- ? maskToken(body)
771
- : body.replace(RUN, (r) => (keyRun(r) ? maskToken(r) : r)).replace(UUID_ANY, (m) => maskToken(m))
1881
+ // a word with a frame round it ("NickName=[ghp_…") is judged by what the
1882
+ // frame encloses too: the key may start after a bracket or a "="
1883
+ const start = body.search(/[A-Za-z0-9]/)
1884
+ // (a leading slash is a path's frame, not a key's)
1885
+ // a frame is punctuation or path-like ("NickName=[…", "余额查询失败:…"); a
1886
+ // run of capitals and digits before the key is the key's own front
1887
+ // ("SK-ANT-API03-EYAI1PKJRF_KBWT" behind a colon), and is judged whole
1888
+ const encl = start > 0 && body.length <= 512 && !/[\\/]/.test(body.slice(0, start)) && !/[A-Za-z0-9]{2,}/.test(body.slice(0, start)) ? body.slice(start) : ""
1889
+ const masked =
1890
+ (PATHY.test(word) ? false : isKeyWord(body, letters)) || isKeyWord(word, letters) || (encl !== "" && isKeyWord(encl, letters))
1891
+ ? maskToken(body)
1892
+ : body.replace(RUN, (r) => (keyRun(r, letters) ? maskToken(r) : r)).replace(UUID_ANY, (m) => maskToken(m))
772
1893
  return lead + masked + tail
773
1894
  })
1895
+ }
774
1896
 
775
1897
  // cardText is what a host error becomes before it reaches the account card or
776
1898
  // the terminal. The host's message is not our text: magpie's own CLI scrubs
@@ -795,7 +1917,20 @@ const maskKeys = (s) =>
795
1917
  const cardText = (value, token, max = 200) => {
796
1918
  let s = String(value ?? "").replace(GLUE, "")
797
1919
  if (typeof token === "string" && token) s = s.split(token).join(maskToken(token))
798
- return text(maskKeys(maskEchoes(s, token).replace(tokenShaped, (m) => maskToken(m))), max)
1920
+ s = maskEchoes(s, token).replace(tokenShaped, maskShaped)
1921
+ return text(maskAll(s, true), max)
1922
+ }
1923
+ // The account's own name is text the user picked, not a server's message: a
1924
+ // long camelCase name ("MisonLiangWithALongName") is key-shaped to maskKeys and
1925
+ // was shown as "Miso…Name" on the card. Names still go through the passes that
1926
+ // matter — the key this process holds, the ms- shape, other vendors' key
1927
+ // shapes, a bare digest — only the "16+ characters with a case mix" arm is off.
1928
+ const nameText = (value, token, max = 200) => {
1929
+ let s = String(value ?? "").replace(GLUE, "")
1930
+ if (typeof token === "string" && token) s = s.split(token).join(maskToken(token))
1931
+ // with no token in hand the mask's shape pass is the only guard there is,
1932
+ // so the case-mix arm has to stay on
1933
+ return text(maskAll(maskEchoes(s, token).replace(tokenShaped, maskShaped), !token), max)
799
1934
  }
800
1935
 
801
1936
  // hostText is cardText for the two exits where there is no token to split on:
@@ -803,7 +1938,7 @@ const cardText = (value, token, max = 200) => {
803
1938
  // The host's message can still carry one — magpie's own wording for a missing
804
1939
  // key is "no key <key> for provider" — and these two lines would print it
805
1940
  // whole. With no key in hand the shape is all there is, which cardText does.
806
- const hostText = (value) => cardText(value, "")
1941
+ const hostText = (value) => nameText(value, "")
807
1942
 
808
1943
  // unusableName is a nickname that must not be written back: empty, the token
809
1944
  // itself or a piece of it (the server would be handed it twice), a key the
@@ -817,60 +1952,354 @@ const hostText = (value) => cardText(value, "")
817
1952
  // key-shaped to the card as well, and it is not a key — a generated key nearly
818
1953
  // always carries a digit or a base64 symbol, and a name nearly never does, so
819
1954
  // nameIsKey asks for one of those beside the card's verdict.
820
- // The piece is looked for before the cut to 40: a token longer than 40, cut
821
- // to 40, is no longer equal to the token, and its front would be written to
822
- // plugin-auth.json as the account's name. A substring of 20+ characters is
823
- // the piece; a nickname of a few characters that happens to sit inside a
824
- // token ("ms", "ab") is a nickname.
1955
+ // tightName is a name with the characters an echo may insert taken out. A
1956
+ // server that echoed the token into a nickname can space it out just as a
1957
+ // message can, and "ms-01234567 89abcdef…" is no longer a substring of the
1958
+ // token — but the tight form is.
1959
+ const tightName = (name) => name.replace(/[\s\p{Cc}\p{Cf}]+/gu, "")
1960
+ // flatName keeps only the characters a token is made of. Any other character
1961
+ // in a name — a space, a comma, a bracket — is something an echo inserted, and
1962
+ // the token's own characters survive in order.
1963
+ const flatName = (name) => name.replace(/[^A-Za-z0-9]/g, "")
1964
+ // insideToken: the name is in the token, or its tight form is, or its flat
1965
+ // form is a run of the token's flat form. Either way every character of it is
1966
+ // already in plugin-auth.json, so writing it back leaks nothing new — which is
1967
+ // what makes nameIsKey skip it. A token that holds the name is the proof; it
1968
+ // does not matter how long the name is.
1969
+ const insideToken = (name, token) => {
1970
+ if (typeof token !== "string" || token === "") return false
1971
+ if (token.includes(name) || token.includes(tightName(name))) return true
1972
+ const flat = flatName(name)
1973
+ const t = fold(token)
1974
+ if (flat.length >= 20 && t.includes(flat)) return true
1975
+ // the same answer for the case and compatibility folds: an upper-cased or
1976
+ // full-width name is the same characters the sign-in already holds
1977
+ const n = fold(name)
1978
+ return n.length >= 20 && t.includes(n)
1979
+ }
1980
+ // echoesToken: the name is the token being handed back — the token itself, or
1981
+ // a piece long enough to be an echo. The threshold is the mask's own (MIN_ECHO,
1982
+ // 10) rather than the 20 it used to be: a nickname of 10–19 characters is not
1983
+ // a name a person picks, and the card's `user` line printed it whole while the
1984
+ // mask said nothing about pieces that short. A name of a few characters that
1985
+ // happens to sit inside a token ("ms", "ab") is still a name.
1986
+ const echoesToken = (name, token) => {
1987
+ if (typeof token !== "string" || !token) return false
1988
+ if (name.includes(token)) return true
1989
+ const n = fold(name)
1990
+ const t = fold(token)
1991
+ if (!n || !t) return false
1992
+ if (n.includes(t)) return true
1993
+ return insideToken(name, token) && tightName(name).length >= MIN_ECHO
1994
+ }
1995
+ // runsThrough: the name's folded characters carry MIN_ECHO of the token's, in
1996
+ // order, even when the echo spaced them out with characters the token does not
1997
+ // hold. maskEchoes stops at a filler ("ms-0123abcd4567x 89ef…" is pieces each
1998
+ // under MIN_ECHO), but the pieces still spell the key once the filler is
1999
+ // dropped, and a nickname is written to plugin-auth.json as a second copy of
2000
+ // it. A name that shares a dozen characters of a 32-character key in order is
2001
+ // not a name a person picks; a short name ("ms", "Mison") cannot reach the
2002
+ // floor.
2003
+ //
2004
+ // The run is measured as the longest run the name shares with the key, not by
2005
+ // one greedy pass: a stray character at the name's start or end used to take
2006
+ // the key's first or last position on that pass and hide the body behind it
2007
+ // ("f 01234567x 89abcde0x …" either way round). Ordinary nicknames share 6 or
2008
+ // 7 characters in a row with a realistic key; a key body spelled out shares
2009
+ // 30+.
2010
+ //
2011
+ // A name can reach a run by luck too: a nickname that is a string of digits
2012
+ // is a run of any key holding them in order ("1234567890" is nine characters
2013
+ // of most of them). A run is taken for the key when it is most of the name (a
2014
+ // dozen characters and half of it, or twenty of it however short the name is),
2015
+ // or when the name's whole folded self is a run of the key that a person's
2016
+ // name could not be — anything but digits, spaces and hyphens ("bbbb 2222"
2017
+ // against a key with those characters in a row).
2018
+ const MANY = MIN_ECHO + 2
2019
+ const BACK_MANY = MANY + MIN_ECHO
2020
+ // The DP below is n × t cells. Neither side is bounded by this plugin: the
2021
+ // name comes from the server's answer (a 256 KiB body is allowed) and the key
2022
+ // from the host's sign-in. Measured before this cap existed: a 128 KiB name
2023
+ // against a 32 Ki-character key spent 19 s of CPU inside a hook whose whole
2024
+ // budget is 25 s, and an ordinary 128 KiB nickname against a realistic key
2025
+ // spent ~1.8 s. No answer needs that: the write-back is cut to 40 characters,
2026
+ // so a longer name is not a name a person picks, and refusing it is the safe
2027
+ // direction. The cell cap keeps the work under ~60 ms at the measured rate and
2028
+ // refuses the name instead of spending the budget on it.
2029
+ const NAME_LCS_MAX = 512
2030
+ const LCS_CELLS = 2_000_000
2031
+ function longestRun(n, t) {
2032
+ let prev = new Int32Array(t.length + 1)
2033
+ let cur = new Int32Array(t.length + 1)
2034
+ for (let i = 1; i <= n.length; i += 1) {
2035
+ for (let j = 1; j <= t.length; j += 1) {
2036
+ cur[j] = n[i - 1] === t[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1])
2037
+ }
2038
+ const swap = prev
2039
+ prev = cur
2040
+ cur = swap
2041
+ cur.fill(0)
2042
+ }
2043
+ return prev[t.length]
2044
+ }
2045
+ const runsThrough = (name, token) => {
2046
+ const n = fold(name)
2047
+ const t = fold(token)
2048
+ if (n.length < MIN_ECHO || t.length < MIN_ECHO) return false
2049
+ if (insideToken(name, token) && n.length < 20) return false
2050
+ // too long a name to be a name, or too much DP work to spend a hook's budget
2051
+ // on: treat it as unusable, which is what the write-back does with any name
2052
+ // it cannot vouch for. The card still shows its usual mask for the shape.
2053
+ if (n.length > NAME_LCS_MAX || t.length > NAME_LCS_MAX || n.length * t.length > LCS_CELLS) return true
2054
+ const run = longestRun(n, t)
2055
+ if (run >= MANY && (run * 2 >= n.length || run >= BACK_MANY)) return true
2056
+ // a nickname that is almost nothing but the key's characters, in the key's
2057
+ // order, is the key written with something between its characters. A string
2058
+ // of digits is not: those are a run of any key that holds them, and a name
2059
+ // made of them says nothing about which.
2060
+ return run >= MIN_ECHO && run * 3 >= n.length * 2 && !/^[\d\s-]+$/.test(tightName(name))
2061
+ }
825
2062
  const nameIsKey = (name, token) =>
826
- !(typeof token === "string" && token.includes(name)) &&
827
- name.split(/\s+/).some((w) => isKeyWord(w) && (/\d/.test(w) || /[+/=]/.test(w)))
2063
+ !insideToken(name, token) &&
2064
+ name.split(/\s+/).some(
2065
+ (w) =>
2066
+ isKeyWord(w) &&
2067
+ ((w.match(/\d/g) ?? []).length >= 6 || /[+/=]/.test(w) || /^[0-9a-f]{16,}$/i.test(w)),
2068
+ )
828
2069
  const unusableName = (name, token) =>
829
2070
  !name ||
830
2071
  name === token ||
831
- (typeof token === "string" && ((name.length >= 20 && token.includes(name)) || name.includes(token))) ||
2072
+ echoesToken(name, token) ||
2073
+ runsThrough(name, token) ||
832
2074
  nameIsKey(name, token) ||
2075
+ nameShape(name) ||
833
2076
  ["__proto__", "constructor", "prototype"].includes(name)
2077
+ // nameShape: the name is a credential of a shape this sign-in does not hold —
2078
+ // another account's key, or this one's written in another spelling. The tests
2079
+ // above look for the token in hand; a server echoing a *different* key
2080
+ // ("ms-…" in full width, a bare 32-character body) matched none of them and
2081
+ // was written back as accountId. Only credential shapes count: an ordinary
2082
+ // name, camelCase or carrying a year (WangXiaoming2026), is a name.
2083
+ //
2084
+ // The ms- arm is the shape the card's own mask uses (tokenShaped), not a
2085
+ // narrower list of characters: a nickname written as ms-foreign_key… carries an
2086
+ // underscore, the card masks it, and the narrow arm passed it through to be
2087
+ // stored as the account's name — the one field the card's mask does not reach.
2088
+ // Every character a header can carry is a key's character, so the question is
2089
+ // the shape, not the spelling.
2090
+ const nameShape = (name) => {
2091
+ // the same invisible-character join the card runs before its shape test:
2092
+ // "ms-foreign​_key…" is the same key to the card, and a nameShape that
2093
+ // read it as "ms-foreign_key…" (with a zero-width space in it) refused it by
2094
+ // luck rather than by rule. The space fold after it is for a name written
2095
+ // with spaces between the pieces — a shape that survived the key comparison
2096
+ // in another spelling.
2097
+ const u = name.replace(GLUE, "").normalize("NFKC")
2098
+ const t = u.toLowerCase().replace(/\s+/g, "")
2099
+ // the ms- arm asks the card's own question of the body (keyBody): a word
2100
+ // after the front is a name ("ms-interviewing" is one, and refusing it left
2101
+ // the account with no name at all). A name that is a key of another vendor —
2102
+ // sk-ant-api03-Eyai1pkJrF_Kbwt has three digits and satisfied none of
2103
+ // nameIsKey's extra requirements — is the shape the vendor's own prefix
2104
+ // marks, and a vendor key's body is what the shape tests already accept.
2105
+ // the card's own two questions of an ms- body: does it begin like a key
2106
+ // (tokenShaped), and does the run after the front read as one (keyBody),
2107
+ // both asked of the name as written — spaces and all. Asking them of the
2108
+ // space-folded name let a version number join a short word into a body, and
2109
+ // "ms-cli 2026.11.9" and "ms-office 2016" — names the card shows untouched
2110
+ // — were refused their write-back. A name with pieces spaced out is still
2111
+ // refused by the flat forms above when it is this token's
2112
+ return /^ms-/.test(t)
2113
+ ? [...u.matchAll(tokenShaped)].some((x) => keyBody.test(x[0]))
2114
+ : VENDOR.test(t) ||
2115
+ /^[0-9a-f]{16,}$/.test(t) ||
2116
+ UUID.test(t)
2117
+ }
834
2118
 
835
- async function rememberAccountName(client, token, name, auth, dir) {
2119
+ // withStreak puts the streak on an answer only when it was read to the end.
2120
+ // An explicit `streak: undefined` is not "said nothing": the host reads the
2121
+ // field with num(), and undefined becomes 0 — a card showing "0 天" for a run
2122
+ // the plugin could not see the end of. The key is left out instead, and the
2123
+ // answer is told about an expiry the walk met on the way (`marks`), which the
2124
+ // walk records because a claim that already landed still has to reach the
2125
+ // host as "the token was refused".
2126
+ const withStreak = (answer, streak, marks) =>
2127
+ ({
2128
+ ...answer,
2129
+ ...(streak === undefined ? {} : { streak }),
2130
+ ...(marks?.expired ? { signIn: "expired" } : {}),
2131
+ })
2132
+ // WRITE_WAIT_MS caps how long a hook waits for the host's write-back. The
2133
+ // lock it takes can block for ~20s (host.js's lockAuth, when another process
2134
+ // holds plugin-auth.json), and magpie runs every provider's check-ins under
2135
+ // one two-minute budget — one contended account must not eat a third of it
2136
+ // for a field the card can live without.
2137
+ const WRITE_WAIT_MS = 3000
2138
+
2139
+ // BOUNDED is what bounded answers when the timer won: the promise's own value
2140
+ // could be undefined, and "the host never answered" must not read as "the host
2141
+ // said nothing".
2142
+ const BOUNDED = Symbol("bounded")
2143
+ const bounded = (p, ms) => {
2144
+ let timer
2145
+ const alarm = new Promise((resolve) => {
2146
+ timer = setTimeout(() => resolve(BOUNDED), Math.max(0, ms))
2147
+ if (timer && typeof timer.unref === "function") timer.unref()
2148
+ })
2149
+ return Promise.race([p, alarm]).finally(() => clearTimeout(timer))
2150
+ }
2151
+
2152
+ // waitFor resolves when `p` settles or after `ms`, whichever comes first; the
2153
+ // loser is left running. The timer is unref'd so it never holds a process open.
2154
+ const waitFor = (p, ms) => {
2155
+ let timer
2156
+ const alarm = new Promise((resolve) => {
2157
+ timer = setTimeout(resolve, ms)
2158
+ if (timer && typeof timer.unref === "function") timer.unref()
2159
+ })
2160
+ return Promise.race([p, alarm]).finally(() => clearTimeout(timer))
2161
+ }
2162
+
2163
+ // rememberAccountFields tells magpie what the plugin learned about this
2164
+ // sign-in: the account's own name (so the card reads "Mison" instead of
2165
+ // "API key …a1b2"), and — for an account on the international site — the site
2166
+ // itself, so a restarted host does not have to hand the token to the domestic
2167
+ // site for a 401 first. A site the probe has since found to be wrong is
2168
+ // removed. The sign-in is written as it already is — the same key, no second
2169
+ // copy of the token — with those fields beside it; what is already there is
2170
+ // left alone. Everything goes in one write: auth.set takes plugin-auth.json's
2171
+ // lock, and two writes would take it twice.
2172
+ async function rememberAccountFields(client, token, learned, auth, dir, budgetEnd = Infinity, getAuth) {
836
2173
  // "can write" is a function, not a truthy value: `{ auth: { set: 1 } }` is a
837
2174
  // client that cannot write, and `!client?.auth?.set` let it through to call
838
- // a number — the TypeError is caught below, but it logs "account name not
2175
+ // a number — the TypeError is caught below, but it logs "account facts not
839
2176
  // written back" on every check-in, a failure report for a client that never
840
2177
  // had a chance to succeed.
841
- if (!name || typeof client?.auth?.set !== "function") return
842
- if (auth?.accountId === name) return
2178
+ if (typeof client?.auth?.set !== "function") return
2179
+ // auth.set replaces the whole sign-in, and the copy the hook read when it
2180
+ // started can be a minute old by the time the requests are done. Written on
2181
+ // top of that copy, an edit made meanwhile is undone: a new key pasted in
2182
+ // while the hook ran was overwritten with the old one, and a field added
2183
+ // was dropped. The host only refuses the write when the account is gone
2184
+ // entirely (host.js's `had && !(key in all)`), so the read is repeated here
2185
+ // and the write lands on the freshest copy. A sign-in that no longer holds
2186
+ // this key is not this key's account any more: the facts learned here are
2187
+ // about a token it no longer carries, so nothing is written.
2188
+ let base = auth
2189
+ if (typeof getAuth === "function") {
2190
+ try {
2191
+ // The read is host I/O like the write: it takes the same store lock and
2192
+ // can block for ~20s. Capped by the same budget, and an unreadable store
2193
+ // means no write at all — falling back to the copy from the start of the
2194
+ // hook is exactly the stale base that overwrote a key pasted meanwhile.
2195
+ const cap = Math.max(0, Math.min(WRITE_WAIT_MS, budgetEnd - Date.now()))
2196
+ // Nothing left of the hook's budget: the read and the write both take
2197
+ // the host's store lock, and starting either at zero would spend the
2198
+ // time the answer was supposed to fit in. The fields are best-effort.
2199
+ if (cap <= 0) return
2200
+ const now = await waitFor(Promise.resolve().then(getAuth), cap)
2201
+ // what the host hands over is not always a plain object (a callable
2202
+ // carrying the fields is a sign-in too); the key is what says it is
2203
+ // still this account's entry, and the spread below skips a non-object
2204
+ if (now === undefined || tokenOf(now) !== token) return
2205
+ base = now
2206
+ } catch {
2207
+ return
2208
+ }
2209
+ }
2210
+ const name = learned?.name ?? ""
2211
+ // Only the international site is worth keeping: a probe tries the domestic
2212
+ // one first, so recording it saves no request and costs a locked write.
2213
+ const site = learned?.profile?.site === INTL.site ? INTL.site : ""
2214
+ const wantSite = !!site && base?.site !== site
2215
+ // A sign-in that names a site but the probe found the account on the
2216
+ // domestic one is stale — the key was replaced under it. Left alone, every
2217
+ // restart would pay a 401 against the site it names, so it is dropped. The
2218
+ // probe has to have actually found the domestic site for this to mean
2219
+ // anything: after a failed read, `learned.profile` is null, the site was
2220
+ // simply not reached, and clearing it would be an own-goal on a blip.
2221
+ const wantClear =
2222
+ !site &&
2223
+ learned?.probeDone &&
2224
+ typeof base?.site === "string" &&
2225
+ base.site !== ""
843
2226
  const at = named.get(token)
844
- if (at !== undefined && Date.now() - at < NAME_AGAIN_MS) return
2227
+ const cooling = at !== undefined && Date.now() - at < NAME_AGAIN_MS
845
2228
  // an account the user has named keeps that name: writing the nickname over
846
2229
  // it would re-key their alias to a label the account no longer has. A
847
2230
  // providers.json that can't be read says the same thing — don't write over
848
2231
  // a state we can't see.
849
- if (aliasOf(dir, labelOf(auth, token)) !== "") return
850
- named.delete(token)
851
- named.set(token, Date.now())
852
- while (named.size > NAMES_MAX) named.delete(named.keys().next().value)
853
- try {
854
- // auth.set replaces the whole sign-in, so what the host handed us is the
855
- // base: a field we do not know about is kept, not dropped
856
- const body = { ...(auth && typeof auth === "object" ? auth : {}), type: "api", key: token, accountId: name }
857
- await client.auth.set({ path: { id: ID }, body })
858
- } catch (e) {
859
- // the card keeps the key's tail; nothing else depends on this. Put the
860
- // token back so the next ask tries the write again, and let the log call
861
- // be truly best-effort: a host whose log is synchronous returns undefined
862
- // where a promise is expected, and that must not throw out of here.
2232
+ const wantName =
2233
+ !!name && base?.accountId !== name && !cooling && aliasOf(ID, dir, labelOf(base, token)) === ""
2234
+ if (!wantName && !wantSite && !wantClear) return
2235
+ if (wantName) {
863
2236
  named.delete(token)
864
- // the message can carry whatever the host put in it; scrub the token out
865
- // of it before it reaches magpie's log
866
- const why = cardText(whyOf(e), token)
2237
+ named.set(token, Date.now())
2238
+ while (named.size > NAMES_MAX) named.delete(named.keys().next().value)
2239
+ }
2240
+ try {
2241
+ // auth.set replaces the whole sign-in, so the freshest copy the host
2242
+ // handed us is the base: a field we do not know about is kept, not dropped
2243
+ const body = {
2244
+ ...(base && typeof base === "object" ? base : {}),
2245
+ type: "api",
2246
+ key: token,
2247
+ ...(wantName ? { accountId: name } : {}),
2248
+ ...(wantSite ? { site } : {}),
2249
+ }
2250
+ if (wantClear) delete body.site
2251
+ // auth.set takes plugin-auth.json's lock and, when another process holds
2252
+ // it, can block for up to ~20s (host.js's lockAuth). The answer is worth
2253
+ // more than this best-effort write, and a check-in round serves every
2254
+ // provider under one two-minute budget, so the wait is capped: a write
2255
+ // still waiting past the cap finishes behind the hook instead of holding
2256
+ // it. A rejection that comes after the cap is handled where it lands —
2257
+ // reported and retried on the next ask — so a locked store does not cost
2258
+ // an hour of the nickname's cooldown with no sign that anything failed.
2259
+ const writing = Promise.resolve(client.auth.set({ path: { id: ID }, body }))
2260
+ // the handler is attached before the race so a rejection that lands after
2261
+ // the cap still finds one; both sides of the cap run the same report
2262
+ // one report per write: the failure reaches both the attached handler and
2263
+ // the race, and a doubled line would read as two failed writes
2264
+ let reported = false
2265
+ const failed = (e) => {
2266
+ if (reported) return
2267
+ reported = true
2268
+ onWriteFailed(e, client, token, wantName)
2269
+ }
2270
+ // The cap is the hook's own budget end as well as WRITE_WAIT_MS: a hook
2271
+ // that spent its reads right up to the budget must not spend three more
2272
+ // seconds waiting for a lock, or the whole hook outlives the number the
2273
+ // README promises. A write still pending then finishes behind the hook.
2274
+ const cap = Math.max(0, Math.min(WRITE_WAIT_MS, budgetEnd - Date.now()))
2275
+ writing.catch(failed)
867
2276
  try {
868
- const r = client?.app?.log?.({ body: { level: "warn", service: "msmoli", message: `account name not written back: ${why}` } })
869
- if (r && typeof r.catch === "function") r.catch(() => {})
870
- } catch {
871
- // a host whose log throws synchronously must not turn a successful
872
- // check-in into a failed one, nor a usage read into a rejection
2277
+ await waitFor(writing, cap)
2278
+ } catch (e) {
2279
+ failed(e)
873
2280
  }
2281
+ } catch (e) {
2282
+ // a synchronous throw from the host's own set(): same report, same retry
2283
+ onWriteFailed(e, client, token, wantName)
2284
+ }
2285
+ }
2286
+
2287
+ // onWriteFailed is the one report for every failed write, whether it failed at
2288
+ // once or after the wait was capped: the name goes back so the next ask tries
2289
+ // again, and the reason is logged without the token in it. Best-effort — a
2290
+ // host whose log is synchronous returns undefined here, and that must not
2291
+ // throw out of the caller.
2292
+ function onWriteFailed(e, client, token, wantName) {
2293
+ if (wantName) named.delete(token)
2294
+ // the message can carry whatever the host put in it; scrub the token out of
2295
+ // it before it reaches magpie's log
2296
+ const why = cardText(whyOf(e), token)
2297
+ try {
2298
+ const r = client?.app?.log?.({ body: { level: "warn", service: ID, message: `account facts not written back: ${why}` } })
2299
+ if (r && typeof r.catch === "function") r.catch(() => {})
2300
+ } catch {
2301
+ // a host whose log throws synchronously must not turn a successful
2302
+ // check-in into a failed one, nor a usage read into a rejection
874
2303
  }
875
2304
  }
876
2305
 
@@ -880,21 +2309,29 @@ const signedOut = (e) => Boolean(e?.signIn === "expired" || e?.expired)
880
2309
  // object without a message, a host's own rejection value — would reach String()
881
2310
  // as "[object Object]", which tells the user nothing and reads like a bug in the
882
2311
  // plugin. The empty string lets the caller say something of its own instead.
883
- const whyOf = (e) =>
884
- typeof e?.message === "string" && e.message ? e.message : typeof e === "string" ? e : ""
2312
+ const whyOf = (e) => {
2313
+ const m = typeof e?.message === "string" && e.message ? e.message : typeof e === "string" ? e : ""
2314
+ // a body nested deep enough to overflow the identity pass is a shape this
2315
+ // plugin does not read, and the card must not carry the runtime's English
2316
+ // sentence for it
2317
+ return m.includes("Maximum call stack size exceeded") ? "账单格式不认识:嵌套过深" : m
2318
+ }
885
2319
 
886
2320
  // EXPIRED is what a 401 says, wherever it is met: the ledger read before the
887
2321
  // touch, the touch itself, or either of usage's two reads. Three wordings
888
- // used to exist for one condition ("令牌失效或不属于国内站", the same plus
889
- // "(HTTP 401)", and a bare "HTTP 401" from the balance/ledger path), and the
2322
+ // used to exist for one condition ("the token is invalid or not from the CN
2323
+ // site", the same plus an HTTP 401 suffix, and a bare "HTTP 401" from the
2324
+ // balance/ledger path), and the
890
2325
  // README's troubleshooting row promises the user will see the status code in
891
- // it — which only two of the three carried.
892
- const EXPIRED = "令牌失效或不属于国内站(HTTP 401)"
2326
+ // it — which only two of the three carried. `where` is the profile's CN site /
2327
+ // INT site, and stays in the one wording so the card says which site refused
2328
+ // the token.
2329
+ const expired = (where) => `令牌失效或不属于${where}(HTTP 401)`
893
2330
 
894
2331
  // deadToken is what every 401 becomes: the token is revoked, and magpie only
895
2332
  // signs the account out when the hook says so. Both call sites guard
896
2333
  // `status === 401` before throwing this, so there is no other status to name.
897
- const deadToken = () => Object.assign(new Error(EXPIRED), { signIn: "expired" })
2334
+ const deadToken = (where) => Object.assign(new Error(expired(where)), { signIn: "expired" })
898
2335
 
899
2336
  // RISK is what every 403 says — ModelScope's risk control, not a bad token:
900
2337
  // the account may be perfectly fine and only the request was refused. The
@@ -905,18 +2342,47 @@ const RISK = "被魔搭风控拒绝(HTTP 403)"
905
2342
  // gate turns a reply that is not an answer into the error it is, so the four
906
2343
  // reads that talk to ModelScope (the ledger, the touch, usage's balance and
907
2344
  // usage's ledger) do not each carry their own copy of the four checks. `what`
908
- // names the read in the message ("账单查询失败" / "余额查询失败" / "登录接口
909
- // 失败"); `risk` is the 403 wording, which the touch alone extends with the
910
- // retry it can promise.
2345
+ // names the read in the message ("ledger query failed" / "balance query
2346
+ // failed" / "login endpoint failed"); `risk` is the 403 wording, which the
2347
+ // touch alone extends with the retry it can promise. `profile` is there for
2348
+ // the one site-specific word a 401 carries (the CN site / the INT site).
911
2349
  //
912
2350
  // The parse check and the status check are separate on purpose: a body that
913
2351
  // does not parse is not JSON; a body that parses and carries a 429/500 is the
914
- // server saying why in its own words (「请求过于频繁」「系统繁忙」). Merged,
915
- // a JSON-carrying 429 was reported as "响应不是 JSON" and sent the user to
916
- // check their proxy.
917
- const gate = (res, body, parsed, token, what, risk = RISK) => {
918
- if (res.status === 401) throw deadToken()
919
- if (res.status === 403) throw new Error(risk)
2352
+ // server saying why in its own words ("too many requests" / "system busy").
2353
+ // Merged, a JSON-carrying 429 was reported as "the response is not JSON" and
2354
+ // sent the user to check their proxy.
2355
+ const gate = (profile, res, body, parsed, token, what, risk = RISK) => {
2356
+ // A 401 is the token being refused only when it is ModelScope's own JSON.
2357
+ // A proxy's HTML 401 ("proxy authentication required") is the path, not the
2358
+ // sign-in, and signing the account out over it costs the user their login:
2359
+ // it falls through to the ordinary error, and the host retries in half an
2360
+ // hour.
2361
+ // the same question at the other two statuses: a 401 or a 403 that does not
2362
+ // read as ModelScope's is the path answering, not the account — a proxy's
2363
+ // challenge JSON said the sign-in expired, and a proxy's HTML 403 was
2364
+ // reported as ModelScope's risk control
2365
+ if (res.status === 401 && parsed && !awaitsSignIn(body)) throw new Error(`${what}:HTTP 401(响应不是魔搭的)`)
2366
+ if (res.status === 401 && parsed) throw deadToken(profile.where)
2367
+ // a 403 is ModelScope's risk control only when it answers in its own shape
2368
+ // (the success envelope, words in it); a proxy's "proxy denied" page or its
2369
+ // "authentication required" JSON is the path, and is reported as a plain
2370
+ // refusal of that status
2371
+ // ModelScope's own envelope is success or Code/data, in either case, and a
2372
+ // success:false is the site's too (a login layer's {"Success":false,
2373
+ // "Message":"RiskControl"}); a plain refusal with words in it and none of
2374
+ // those fields is any gateway's error page
2375
+ // `in` needs a value with fields: a JSON body of 17, true or "busy" parses
2376
+ // fine, and asking it for "success" threw the engine's own English sentence
2377
+ // ("Cannot use 'in' operator to search for 'success' in 17") at the hook,
2378
+ // with the request's name and the HTTP status lost. A non-object body is a
2379
+ // reply this plugin does not understand, and says so below.
2380
+ const shaped = parsed && body !== null && typeof body === "object"
2381
+ const envelope = shaped && !Array.isArray(body) && (("success" in body) || ("Success" in body))
2382
+ const msLogin = shaped && !Array.isArray(body) && (("Code" in body) || ("code" in body) || ("data" in body) || ("Data" in body))
2383
+ const pathSaid = PATH_MARK.test(JSON.stringify(body))
2384
+ if (res.status === 403 && !pathSaid && (envelope || msLogin)) throw new Error(risk)
2385
+ if (res.status === 403) throw new Error(`${what}:响应不是魔搭的(HTTP 403)`)
920
2386
  if (!parsed) throw new Error(`${what}:响应不是 JSON(HTTP ${res.status})`)
921
2387
  if (res.status !== 200 || !okOf(body)) throw new Error(`${what}:${saidOf(body, token) || `HTTP ${res.status}`}`)
922
2388
  }
@@ -925,11 +2391,526 @@ const gate = (res, body, parsed, token, what, risk = RISK) => {
925
2391
  // The day is read at each call, on purpose: a check-in that crosses midnight
926
2392
  // between its reads reports what its touch granted on the new day, which is
927
2393
  // true, where diffing both reads against the old day would answer inactive.
928
- async function todayEarn(token, deadline) {
929
- const { res, body, parsed } = await json(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
930
- gate(res, body, parsed, token, "账单查询失败")
2394
+ async function todayEarn(token, deadline, opts = {}) {
2395
+ const { profile, res, body, parsed } = await siteRead(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
2396
+ gate(profile, res, body, parsed, token, "账单查询失败")
931
2397
  const day = todayCN()
932
- return recordsOf(body).filter((r) => r?.type === "EARN" && onDay(r?.gmt_created, day))
2398
+ const first = recordsOf(body)
2399
+ const { all, pages, complete, scanComplete, last, seenPages, seenRows, total, refused } = await dayRows(profile, token, day, first, deadline, body, opts)
2400
+ return {
2401
+ profile,
2402
+ day,
2403
+ all,
2404
+ pages,
2405
+ complete,
2406
+ scanComplete,
2407
+ refused,
2408
+ last,
2409
+ seenPages,
2410
+ seenRows,
2411
+ // the number the walk finished on, not the first page's: a total that grew
2412
+ // on a later page (or was printed only there) is the one that says whether
2413
+ // the streak's next page is worth reading
2414
+ total,
2415
+ rows: all.filter((r) => r?.type === "EARN" && onDay(r?.gmt_created, day)),
2416
+ }
2417
+ }
2418
+
2419
+ // DAY_PAGES bounds the extra ledger reads a page full of today's rows costs.
2420
+ // Rows come newest first, so today's rows sit at the top and spill onto the
2421
+ // next page only when today alone holds more rows than a page. A page whose
2422
+ // oldest row is already before `day` is where they end.
2423
+ const DAY_PAGES = 4
2424
+
2425
+ // dayRows returns the rows the caller already read plus the pages that can
2426
+ // still hold `day`'s rows, and whether the walk proved it saw the end of
2427
+ // them. A page is followed when it is full and holds nothing older than
2428
+ // today, and — the case a server contradicts itself in — when it has room to
2429
+ // spare but its `total` still promises rows this walk has not seen: a short
2430
+ // page that is not the last one used to end the walk, and a day whose
2431
+ // remaining rows sat behind it was read as a complete ledger.
2432
+ //
2433
+ // `complete` says the end was proved: a page older than `day`, a page with
2434
+ // room to spare whose `total` the rows in hand account for, or an empty page
2435
+ // with no `total`. An extra page that fails (a transport error, a 5xx, a body
2436
+ // that is not JSON), a budget already gone, or a `total` still ahead of the
2437
+ // rows stops the walk with `complete` false — the numbers are a floor, and
2438
+ // the check-in must not read "no grant today" into a ledger it could not
2439
+ // finish. `pages` is the highest page read, so the streak walk resumes after
2440
+ // it instead of asking for a page twice (the second read counted its rows
2441
+ // again and made the ledger look finished one page early).
2442
+ // stable is a value's identity with its object keys sorted: JSON.stringify
2443
+ // keeps insertion order, so the same rows with their fields in another order
2444
+ // read as a different page and were summed twice. `deep` bounds the recursion:
2445
+ // a body nested thousands deep overflowed the stack and answered the card with
2446
+ // the runtime's English sentence; past the bound a value is identified by its
2447
+ // depth alone, which keeps the pass cheap and total (a server that sends such
2448
+ // a row has no identity to lose).
2449
+ const STABLE_DEPTH = 64
2450
+ const stable = (v, deep = 0) =>
2451
+ deep > STABLE_DEPTH
2452
+ ? `"<depth ${deep}>"`
2453
+ : Array.isArray(v)
2454
+ ? `[${v.map((x) => stable(x, deep + 1)).join(",")}]`
2455
+ : v && typeof v === "object"
2456
+ ? `{${Object.keys(v)
2457
+ .sort()
2458
+ .map((k) => `${JSON.stringify(k)}:${stable(v[k], deep + 1)}`)
2459
+ .join(",")}}`
2460
+ : JSON.stringify(v)
2461
+ const rowKey = (r) => stable(r)
2462
+ // idOf is the row's identity as the server names it: `id` when the row has
2463
+ // one, the whole-row key when it does not. The ledger may recount an
2464
+ // aggregate between two page reads — same id, new numbers — and the
2465
+ // whole-row key read that as a second row (12 credits of spend showed as 22).
2466
+ // Rows without an id are not merged: two rows that happen to share a tuple
2467
+ // are still two rows unless the server says they are one.
2468
+ const idOf = (r) =>
2469
+ r?.id !== undefined && r?.id !== null && r.id !== "" ? `id:${String(r.id)}` : ""
2470
+
2471
+ async function dayRows(profile, token, day, first, deadline, body, { expiry = true } = {}) {
2472
+ const all = [...first]
2473
+ let rows = first
2474
+ let pages = 1
2475
+ // A page seen before is the page number being ignored, and a row seen before
2476
+ // is the server shifting its window between reads: the same grant then sits
2477
+ // at the bottom of one page and the top of the next, and summing both
2478
+ // reports it twice.
2479
+ const seenPages = new Set([stable(first)])
2480
+ const seenRows = new Set(first.map(rowKey))
2481
+ // the aggregate behind each row already counted, and where it sits in `all`:
2482
+ // a row that comes back with the same identity but new numbers replaces the
2483
+ // one in hand (the later read is the newer read) instead of being summed
2484
+ // beside it
2485
+ const byId = new Map()
2486
+ first.forEach((r, i) => {
2487
+ const id = idOf(r)
2488
+ if (id && !byId.has(id)) byId.set(id, i)
2489
+ })
2490
+ let { total, contradicted } = totalOf(body, first)
2491
+ let complete = false
2492
+ // Two questions end this walk, and they are not the same one. The day's
2493
+ // numbers are proven complete by a page older than the day, a total the rows
2494
+ // account for, or a page with room to spare. The card's countdown needs
2495
+ // more: a daily grant runs 24 hours, so yesterday's grant can be the soonest
2496
+ // to expire, and only a row older than yesterday proves that none can
2497
+ // follow. Once the numbers are proven the walk keeps reading — bounded by
2498
+ // the page cap and the budget — for that scan alone, and a page that fails
2499
+ // there ends the scan without touching the numbers: a 401 met after the
2500
+ // claim still does not undo the claim.
2501
+ const dayOf = (r) => String(r?.gmt_created ?? "").slice(0, 10)
2502
+ const yesterday = new Date(Date.parse(`${day}T00:00:00Z`) - 86400e3).toISOString().slice(0, 10)
2503
+ let dayDone = false
2504
+ // whether the countdown's scan reached the point where no running grant can
2505
+ // follow (a page holding nothing from yesterday or today). The numbers can
2506
+ // be proven whole while this is still open: the card then shows the
2507
+ // earliest expiry it saw and says the scan was cut short.
2508
+ let scanDone = false
2509
+ // the token was refused on a courtesy page: the numbers stand, and the
2510
+ // account is expired (the caller reports it)
2511
+ let refused = false
2512
+ // the walk ended on a page that reached back past the day: the only ending
2513
+ // where another page can still hold a running grant
2514
+ let endedByOlder = false
2515
+ // no row on the page is older than yesterday: a page that reached back past
2516
+ // the day and holds none of those closes the countdown's window, because a
2517
+ // running grant is at most 24 hours old and the rows are newest first
2518
+ const noOlderThanYesterday = (rs) =>
2519
+ !rs.some((r) => {
2520
+ const s = dayOf(r)
2521
+ return isDay(s) && s < yesterday
2522
+ })
2523
+ for (;;) {
2524
+ const older = rows.some((r) => {
2525
+ const s = dayOf(r)
2526
+ return isDay(s) && s < day
2527
+ })
2528
+ if (!dayDone) {
2529
+ if (older) {
2530
+ dayDone = true
2531
+ endedByOlder = true
2532
+ }
2533
+ // The number the page printed beside its rows is the whole ledger's
2534
+ // count: when the rows in hand already account for it, there is no next
2535
+ // page to read for the day's numbers, whether this page was full or not.
2536
+ else if (!contradicted && total !== null && all.length >= total) dayDone = true
2537
+ }
2538
+ // A full page with no `total` that the walk stopped reading (the page cap,
2539
+ // the budget) is a truncation, not an end: the ledger may continue past
2540
+ // it. It used to be called complete here, and a day with 300 rows answered
2541
+ // 250 with no "未读完" on the card and no failed on the check-in. Proving
2542
+ // the end needs a page older than the day, a page with room to spare, or a
2543
+ // total the rows account for. Once proven, a cut costs only the scan.
2544
+ if (total === null && rows.length >= PAGE_SIZE && (pages > DAY_PAGES || deadline <= Date.now())) {
2545
+ complete = dayDone
2546
+ // the page the cap stopped on can already be the window's end: it
2547
+ // reached past the day and holds a row older than yesterday, which is
2548
+ // the same evidence the walk below asks for. Without this the card
2549
+ // printed "到期时间可能更早" for a window the rows in hand had closed.
2550
+ // A page of nothing but today's rows closes nothing — the cap cut the
2551
+ // walk before yesterday's grants were read, and the scan is a floor —
2552
+ // and neither does one that reached yesterday but stopped short of an
2553
+ // older row: the next page can still carry one that expires sooner.
2554
+ scanDone = !expiry || (endedByOlder && !noOlderThanYesterday(rows))
2555
+ break
2556
+ }
2557
+ // a page with room to spare is the end — unless `total` still promises
2558
+ // rows, in which case the server is contradicting itself and the rest may
2559
+ // be on the next page anyway (bounded by DAY_PAGES below)
2560
+ // a `total` below the rows beside it is the ledger contradicting itself:
2561
+ // it proves neither "this is the end" nor "there is more", so the page is
2562
+ // walked on like a short page with no total at all
2563
+ if (!dayDone && rows.length < PAGE_SIZE && !contradicted && (total === null || all.length >= total)) dayDone = true
2564
+ if (dayDone) {
2565
+ // A near-running grant must have landed within the last 24 hours, so it
2566
+ // sits in yesterday's rows or today's. Another page is worth reading
2567
+ // only when this one reached back into yesterday and holds nothing older
2568
+ // than it: the numbers' own endings (a total the rows account for, a
2569
+ // page with room to spare) mean the ledger was read to its end already
2570
+ // and cost no second read.
2571
+ const reached = endedByOlder && !rows.some((r) => {
2572
+ const s = dayOf(r)
2573
+ return isDay(s) && s < yesterday
2574
+ })
2575
+ if (!expiry || !reached || pages > DAY_PAGES || deadline <= Date.now()) {
2576
+ complete = true
2577
+ scanDone = !expiry || !reached
2578
+ break
2579
+ }
2580
+ } else if (pages > DAY_PAGES || deadline <= Date.now()) {
2581
+ // the page cap cut a walk whose numbers were already proven: the day is
2582
+ // complete (an older page or a total said so), and only the countdown's
2583
+ // scan is cut short — the check-in must not read this as a book it could
2584
+ // not finish
2585
+ if (dayDone) complete = true
2586
+ break
2587
+ }
2588
+ let got
2589
+ try {
2590
+ got = await json(profile.site, token, `${TXNS}?page=${pages + 1}&page_size=${PAGE_SIZE}`, deadline)
2591
+ } catch {
2592
+ if (dayDone) complete = true
2593
+ break
2594
+ }
2595
+ const { res } = got
2596
+ // the same rule gate applies to the first page: only a 200 carrying the
2597
+ // success envelope is an answer. A 202 used to be read as the end of the
2598
+ // ledger, which confirmed a day that was never finished. A 401 is the same
2599
+ // token being refused, and swallowing it here kept a confirmed expiry off
2600
+ // the card and out of the check-in's answer: it goes through the same
2601
+ // dead-token throw the first page's gate uses.
2602
+ if (got.parsed && res.status === 401 && awaitsSignIn(got.body)) {
2603
+ // a refusal on a courtesy page (the numbers already proven) ends the
2604
+ // scan without touching them: the claim it was read for stands, as it
2605
+ // did when the walk stopped before this page at all
2606
+ if (dayDone) {
2607
+ complete = true
2608
+ // a refusal on a courtesy page ends the scan without touching the
2609
+ // numbers — but the scan did not reach its end, and the token *was*
2610
+ // refused: the card says the expiry it shows is a floor, and the
2611
+ // account is reported expired instead of the refusal being swallowed
2612
+ refused = true
2613
+ break
2614
+ }
2615
+ throw deadToken(profile.where)
2616
+ }
2617
+ if (!got.parsed || res.status !== 200 || !okOf(got.body)) {
2618
+ if (dayDone) complete = true
2619
+ break
2620
+ }
2621
+ // a page whose shape this walk cannot read — no data, records not a list —
2622
+ // is a page it failed to read, the same as a 500 on it: the walk stops with
2623
+ // the rows it has and `complete` false, so the card says the number is low
2624
+ // and the check-in retries. Letting recordsOf throw here failed the whole
2625
+ // hook with its sentence and threw away the first page's rows.
2626
+ let raw
2627
+ let pageTotal
2628
+ try {
2629
+ raw = recordsOf(got.body)
2630
+ pages += 1
2631
+ // the page's own `total` is checked the way the first page's was: a page
2632
+ // that prints fewer rows than its number promises is contradicting itself
2633
+ // and cannot end the walk on that number.
2634
+ pageTotal = totalOf(got.body, raw)
2635
+ } catch {
2636
+ if (dayDone) complete = true
2637
+ break
2638
+ }
2639
+ if (pageTotal.contradicted) {
2640
+ if (dayDone) complete = true
2641
+ break
2642
+ }
2643
+ // the page's own `total` is the ledger's count as the server sees it now;
2644
+ // a ledger that grew between the reads printed 51 on the first page and 52
2645
+ // on the second, and the walk ended on the first page's number with one
2646
+ // row still unread. The latest number wins, and a total that has grown
2647
+ // keeps the walk going. Only a number the server *printed* can win: a
2648
+ // short page that omits `total` synthesises one from the rows in hand,
2649
+ // and overwriting the known total with it ended a walk before the page
2650
+ // that held the rest of the day's rows (measured: 52 promised, 5 seen,
2651
+ // page 3 never read).
2652
+ //
2653
+ // A printed total below the rows already read, or below one an earlier page
2654
+ // printed, is the ledger contradicting itself — 50 read, then a page
2655
+ // claiming 52 while the rows in hand pass it — and adopting it would end
2656
+ // the walk on the number it just refuted. The walk stops here, incomplete.
2657
+ if (!dayDone && pageTotal.said !== null) {
2658
+ if (pageTotal.said < all.length || (total !== null && pageTotal.said < total)) break
2659
+ total = pageTotal.said
2660
+ }
2661
+ // an empty page is the end of the ledger, or — with a `total` still
2662
+ // counting rows — the server saying two things at once; no page after it
2663
+ // is worth reading. A page repeating one already read is the page number
2664
+ // being ignored, and summing it again would inflate the day's numbers.
2665
+ const stamp = stable(raw)
2666
+ if (!raw.length || seenPages.has(stamp)) {
2667
+ complete = dayDone || (!raw.length && total === null ? true : total !== null && all.length >= total)
2668
+ // an empty page is the end of the ledger: no running grant can follow it.
2669
+ // A repeated page is the server ignoring the page number — the rows are
2670
+ // the ones already read, and a grant older than them (which runs 24 hours
2671
+ // and can expire sooner) may sit on a page the walk never reached, so the
2672
+ // countdown stays a floor there.
2673
+ scanDone = !raw.length
2674
+ break
2675
+ }
2676
+ seenPages.add(stamp)
2677
+ // a row seen before is skipped; the same aggregate with new numbers is not
2678
+ // "fresh" — it is the row in hand, updated
2679
+ const fresh = []
2680
+ const added = []
2681
+ let changed = false
2682
+ for (const r of raw) {
2683
+ const k = rowKey(r)
2684
+ if (seenRows.has(k)) continue
2685
+ const id = idOf(r)
2686
+ // the same id seen before is the same aggregate: the later read replaces
2687
+ // it. Rows the server gives no id for are never merged — two rows that
2688
+ // share a tuple are still two rows.
2689
+ const at = id && byId.has(id) ? byId.get(id) : undefined
2690
+ if (at !== undefined) {
2691
+ all[at] = r
2692
+ added.push(k)
2693
+ changed = true
2694
+ continue
2695
+ }
2696
+ if (id) byId.set(id, all.length + fresh.length)
2697
+ fresh.push(r)
2698
+ added.push(k)
2699
+ }
2700
+ // the set is updated after the page is read, as before: a page that
2701
+ // repeats a row inside itself is summed as many times as it says (the old
2702
+ // filter compared against a snapshot), and a replaced row's new spelling
2703
+ // joins it for the pages after
2704
+ for (const k of added) seenRows.add(k)
2705
+ if (!fresh.length && !changed) {
2706
+ // every row on this page was already counted: the rows are not growing,
2707
+ // and reading on would not change the numbers. That is a fact about the
2708
+ // numbers, not about the window: another page can still hold a running
2709
+ // grant, so the countdown's scan is cut here unless it was asked not to
2710
+ // scan at all (a cut scan is a floor, and the card says so)
2711
+ complete = dayDone || (total !== null && all.length >= total)
2712
+ break
2713
+ }
2714
+ // the page-end proof asks the page that the server sent, not the rows this
2715
+ // walk counted: one repeat on a full page left 49 counted rows, "less than
2716
+ // 50" read as room to spare, and the rest of the ledger — today's grant
2717
+ // included — was never read. A page of nothing but repeats has its own
2718
+ // ending above (an empty or repeated page is the end of the ledger).
2719
+ rows = raw
2720
+ all.push(...fresh)
2721
+ }
2722
+ // `last` is the page the walk stopped on, for a following page-reader to
2723
+ // recognise as already read. It is handed over only when the walk stopped
2724
+ // *without* proving the end (a failed page, a spent budget): there the next
2725
+ // page is the same one again, and reading it twice is what inflated the day.
2726
+ // A walk that proved the end has nothing after it, and an empty page is the
2727
+ // end whatever `total` said — seeding either would make the ledger's next
2728
+ // ask look like a repeat and silence a streak that was read to the last day.
2729
+ return {
2730
+ all,
2731
+ pages,
2732
+ complete,
2733
+ // whether the countdown's own scan reached its end (see scanDone above);
2734
+ // a walk that was not asked to scan at all never started one
2735
+ scanComplete: !expiry || scanDone,
2736
+ refused,
2737
+ total,
2738
+ last: complete || !rows.length ? null : rows,
2739
+ seenPages,
2740
+ seenRows,
2741
+ }
2742
+ }
2743
+
2744
+ // totalOf is how many rows the ledger holds altogether, from the `total` the
2745
+ // page carries beside its `records`; null when that cannot be known. It is
2746
+ // what tells a full page from a complete one: the page size caps at 50 (the
2747
+ // server answers page_size=200 with 50), so a page that comes back with 50
2748
+ // rows may be the whole ledger or the first sliver of it. A page with room to
2749
+ // spare is complete by definition, so a missing `total` is answered by the
2750
+ // rows in hand there; a full page without one is unknown, and the streak walk
2751
+ // leaves its answer unsaid rather than treat the page as the whole ledger.
2752
+ //
2753
+ // `said` is the distinction the two callers need: the number the server
2754
+ // printed, or null. A short page that prints no `total` synthesises one from
2755
+ // the rows in hand — correct to read as "that is the whole ledger" for the page
2756
+ // it was computed from, and wrong to *overwrite* a number an earlier page
2757
+ // printed. Measured: a ledger whose first page promised 52 rows, whose second
2758
+ // carried 5 and no total, had the known 52 replaced by the 5 it could see, the
2759
+ // walk stopped at 51 rows, and the 300 credits on page 3 were never read.
2760
+ const totalOf = (body, rows) => {
2761
+ const n = dataObjectOf(body, "账单格式不认识:没有 data").total
2762
+ // a total below the rows it is printed beside is the ledger contradicting
2763
+ // itself; neither number can be trusted to say whether the page is whole.
2764
+ // That is not the same as a missing total, which the caller reads as "the
2765
+ // rows in hand are the ledger": a short page of a contradicted ledger must
2766
+ // not be called complete on the strength of the number it just refuted.
2767
+ //
2768
+ // A total of another type ("60", 5.5) is the same contradiction, not a
2769
+ // missing one: the server printed a number it did not stand behind, and
2770
+ // reading the short page beside it as "that is the whole ledger" ends the
2771
+ // walk before the pages that hold the rest of today's rows.
2772
+ if (n !== undefined && n !== null && !Number.isInteger(n)) return { total: null, said: null, contradicted: true }
2773
+ if (Number.isInteger(n)) return n >= rows.length ? { total: n, said: n, contradicted: false } : { total: null, said: null, contradicted: true }
2774
+ return { total: rows.length < PAGE_SIZE ? rows.length : null, said: null, contradicted: false }
2775
+ }
2776
+
2777
+ // prevDay is the Beijing day before `day`. The rows carry date labels with no
2778
+ // zone attached, so subtracting on the UTC date is right: the label is a
2779
+ // calendar day, not an instant.
2780
+ // isDay is a stamp's first ten characters when they name a day that exists.
2781
+ // The shape alone let "2026-00-00" through: it sorts before every real day, so
2782
+ // it read as an old row and ended a walk on the spot.
2783
+ const isDay = (s) => {
2784
+ if (typeof s !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
2785
+ const d = new Date(`${s}T00:00:00Z`)
2786
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s
2787
+ }
2788
+ const prevDay = (day) => {
2789
+ const d = new Date(`${day}T00:00:00Z`)
2790
+ d.setUTCDate(d.getUTCDate() - 1)
2791
+ return d.toISOString().slice(0, 10)
2792
+ }
2793
+
2794
+ // STREAK_PAGES caps how far back the streak walk may read. One page reaches
2795
+ // back a fortnight or so on a live account (3–7 rows a day); a run longer
2796
+ // than that is followed onto the next page, because on the account this was
2797
+ // written for the whole ledger held 16 days of daily grants on page 1 and one
2798
+ // row on page 2 — without the second page the run was silently unknown. Eight
2799
+ // pages is ~100 days of rows: past that the walk stops and the streak is left
2800
+ // unsaid, rather than spend a check-in's budget paging through a year.
2801
+ const STREAK_PAGES = 8
2802
+
2803
+ // daysWithGrant is the set of Beijing days a list of ledger rows grants a
2804
+ // daily rule on, in the "YYYY-MM-DD" shape the rows' stamps start with. A
2805
+ // stamp that is not a date is skipped: it does not say which day it grants,
2806
+ // so it may not stand for one. (The date shape that matters for the walk
2807
+ // itself is checked where stamps are compared as "older than" — see dayRows
2808
+ // and streakOf: an empty stamp sorted before every real day and ended a run.)
2809
+ const daysWithGrant = (rows, into = new Set()) => {
2810
+ for (const r of rows) {
2811
+ const stamp = typeof r?.gmt_created === "string" ? r.gmt_created.slice(0, 10) : ""
2812
+ if (isDay(stamp) && r?.type === "EARN" && DAILY_RULES.includes(r?.rule_key)) into.add(stamp)
2813
+ }
2814
+ return into
2815
+ }
2816
+
2817
+ // streakOf is how many consecutive Beijing days up to `day` carried a daily
2818
+ // grant — the run magpie prints as "N-day streak" beside a check-in. The
2819
+ // first page comes from the check-in's own ledger read; when the run is still
2820
+ // going at the oldest row of a page whose `total` says there is more, the
2821
+ // next page is read (readPage) and the walk carries on. The answer is
2822
+ // undefined when today's grant is not in the rows, and when the walk runs out
2823
+ // of pages first: magpie prints any number it is given, so a run it could not
2824
+ // see the end of is not told at all rather than told short.
2825
+ async function streakOf(records, day, total, readPage, deadline) {
2826
+ const days = daysWithGrant(records)
2827
+ if (!days.has(day)) return undefined
2828
+ let held = records.length
2829
+ let n = 1
2830
+ let d = day
2831
+ for (;;) {
2832
+ const prev = prevDay(d)
2833
+ if (days.has(prev)) {
2834
+ n++
2835
+ d = prev
2836
+ continue
2837
+ }
2838
+ // Nothing grants on `prev`. That is a real break when a row older than it
2839
+ // has been read — the ledger goes on past the gap and still says nothing
2840
+ // that day — or when the rows read are the whole ledger. `total` is null
2841
+ // when a full page did not say how many rows the ledger holds, or said a
2842
+ // number the rows contradict; the next page is the only way to learn
2843
+ // whether that page was the whole ledger, so it is read when there is one.
2844
+ // The date part is compared only when it is a date: an empty or malformed
2845
+ // stamp sorted before every real day and ended the run on the spot.
2846
+ const older = records.some((r) => {
2847
+ const s = typeof r?.gmt_created === "string" ? r.gmt_created.slice(0, 10) : ""
2848
+ return isDay(s) && s < prev
2849
+ })
2850
+ if (older) return n
2851
+ if (total !== null && held >= total) return n
2852
+ if (!readPage || deadline <= Date.now()) return undefined
2853
+ const more = await readPage()
2854
+ if (more === null) return undefined
2855
+ // An empty page settles it: with a known `total` still counting rows it is
2856
+ // the ledger contradicting itself, and nothing after this can be trusted
2857
+ // to be the end of the run; with no `total`, it is the end of the ledger.
2858
+ if (!more.length) return total === null ? n : undefined
2859
+ daysWithGrant(more, days)
2860
+ records = more
2861
+ held += more.length
2862
+ }
2863
+ }
2864
+
2865
+ // ledgerReader hands streakOf one page at a time, past the pages the check-in
2866
+ // already read. `from` is the highest page that read reached: asking for page
2867
+ // 2 again would append its rows a second time, and the doubled count made the
2868
+ // ledger look finished one page early. It gives up — null, which leaves the
2869
+ // streak unsaid — when STREAK_PAGES is spent in all, when a page cannot be
2870
+ // read (a 403, a budget already gone), or when the ledger answers with a page
2871
+ // that is not a list. A page error must not fail the check-in: the claim has
2872
+ // already happened, and the answer it deserves is the one already computed,
2873
+ // not a failure the host retries half an hour later.
2874
+ const ledgerReader = (profile, token, deadline, from = 1, first = null, seenPages = null, seenRows = null, marks = null) => {
2875
+ let page = from
2876
+ let left = Math.max(0, STREAK_PAGES - from)
2877
+ // every page and row the day walk read, not just the page it stopped on: a
2878
+ // server that cycles through its pages repeated one the walk had already
2879
+ // summed, and counting it again filled the ledger's `total` and let the
2880
+ // streak claim an end it never saw.
2881
+ const seen = new Set(seenPages ? [...seenPages] : [])
2882
+ if (first !== null) seen.add(stable(first))
2883
+ const counted = new Set(seenRows ? [...seenRows] : [])
2884
+ if (first !== null) for (const r of first) counted.add(rowKey(r))
2885
+ return async () => {
2886
+ if (left <= 0) return null
2887
+ left -= 1
2888
+ page += 1
2889
+ try {
2890
+ const { res, body, parsed } = await json(profile.site, token, `${TXNS}?page=${page}&page_size=${PAGE_SIZE}`, deadline)
2891
+ gate(profile, res, body, parsed, token, "账单查询失败")
2892
+ const rows = recordsOf(body)
2893
+ // a page already read (the server ignoring the page number, or cycling)
2894
+ // is not the walk's next step: counted again it fills the ledger's
2895
+ // `total` and ends a streak the walk could not see the end of
2896
+ const stamp = stable(rows)
2897
+ if (seen.has(stamp)) return null
2898
+ seen.add(stamp)
2899
+ // an empty page is the end of the ledger, not a repeat: streakOf reads
2900
+ // it as the proof that the run went as far as there is
2901
+ if (!rows.length) return []
2902
+ const fresh = rows.filter((r) => !counted.has(rowKey(r)))
2903
+ for (const r of fresh) counted.add(rowKey(r))
2904
+ if (!fresh.length) return null
2905
+ return fresh
2906
+ } catch (e) {
2907
+ // a 401 the gate confirmed is the token refused; the walk still gives up
2908
+ // softly (the claim already landed), but the host is told about the
2909
+ // expiry through `marks`
2910
+ if (marks && signedOut(e)) marks.expired = true
2911
+ return null
2912
+ }
2913
+ }
933
2914
  }
934
2915
 
935
2916
  // SURE_HOUR is magpie's checkinSureHour (workbuddy_checkin.go): before noon,
@@ -949,7 +2930,7 @@ const deferred = new Map()
949
2930
  // nothing but whitespace. The empty string was already refused at both
950
2931
  // numeric sites; the spaces were not, and `Number(" ")` is 0 — so a blank
951
2932
  // amount read as "a grant of nothing" (the check-in answers `inactive`, which
952
- // settles the day) and a blank balance as "0 魔粒可用". A padded number
2933
+ // settles the day) and a blank balance as "0 credits available". A padded number
953
2934
  // (" 251.5 ") is not blank: it says what it means.
954
2935
  const blankOf = (v) => v === undefined || v === null || (typeof v === "string" && !v.trim())
955
2936
 
@@ -958,7 +2939,7 @@ const blankOf = (v) => v === undefined || v === null || (typeof v === "string" &
958
2939
  // 0: the same rule the whole body gets one level up. A localized "1,200", a
959
2940
  // format change, a null — each would otherwise make a real grant look like
960
2941
  // "nothing arrived": the check-in would answer `inactive` (which settles the
961
- // day) and the card would say 领 0 next to a balance that clearly moved.
2942
+ // day) and the card would say claimed 0 next to a balance that clearly moved.
962
2943
  // Absent is included: a row with no amount cannot be summed either way.
963
2944
  const amountOf = (row) => {
964
2945
  const raw = row?.total_amount
@@ -970,7 +2951,7 @@ const amountOf = (row) => {
970
2951
  const n = Number(raw)
971
2952
  // readable, not just isFinite: a huge finite value ("1e307") survives isFinite
972
2953
  // and only blows up at the card, where yin's ×100 overflows to Infinity — so
973
- // "Infinity 魔粒" would be printed for a body we cannot actually read. The
2954
+ // "Infinity credits" would be printed for a body we cannot actually read. The
974
2955
  // same door the balance read and yin itself use.
975
2956
  if (!readable(n)) throw new Error("账单格式不认识:total_amount 不是数字")
976
2957
  return n
@@ -1009,32 +2990,44 @@ export async function MsmoliPlugin(input) {
1009
2990
  auth: {
1010
2991
  provider: ID,
1011
2992
 
1012
- // 静态令牌签到:粘贴一把 ms- 开头的访问令牌即可。
2993
+ // A static-token check-in: paste an access token that starts with ms-.
1013
2994
  //
1014
- // 不声明 prompts,也不声明 authorize:magpie 对 api 型方法本来就会单独
1015
- // 问一次 key,令牌存为 key 本身。声明 prompts 会让它的答案再被写进
1016
- // metadata,同一把令牌落盘两遍(host.js 的 save());而 authorize 只看
1017
- // 得到 prompts 的答案,看不到那个 key,没法替代它。格式由 placeholder
1018
- // 提示,错了由第一次签到或用量读取报 401。
2995
+ // Neither prompts nor authorize is declared: magpie already asks for the
2996
+ // key on its own for an api-type method, and the token is stored as the
2997
+ // key itself. Declaring prompts would write its answer into metadata as
2998
+ // well, putting the same token on disk twice (host.js's save()); and
2999
+ // authorize sees only the answers to prompts, not that key, so it cannot
3000
+ // stand in for it. The placeholder hints at the format; a wrong one shows
3001
+ // as a 401 on the first check-in or usage read.
1019
3002
  methods: [
1020
3003
  {
1021
3004
  type: "api",
1022
- label: "ModelScope 访问令牌",
3005
+ label: "ModelScope 访问令牌(国内站或国际站)",
1023
3006
  placeholder: "ms-…",
1024
3007
  },
1025
3008
  ],
1026
3009
 
1027
- // 签到:先看今天到账没有,再触达登录,最后回读对账。直接触达无法区分
1028
- // 「本次领取」与「早就领过」,而账单能。
3010
+ // The check-in: first see whether today's credits have landed, then touch
3011
+ // the login, and finally read the ledger back to reconcile. A direct touch
3012
+ // cannot tell "just claimed" from "claimed long ago"; the ledger can.
1029
3013
  //
1030
- // 时段:被问到时若还没到今天这个账号的目标时刻,就在发任何请求之前
1031
- // 回答 failed——唯一会被宿主重试的结果,别的都会让当天不再被问。
1032
- // 代价是这段时间卡片上写着「签到失败;magpie 稍后重试」,那是今天
1033
- // 宿主唯一能表达「还没到点」的说法(上游见 README)。
3014
+ // Timing: when asked, if today's target moment for this account has not
3015
+ // arrived yet, answer failed before sending any request — the one outcome
3016
+ // the host retries; any other one stops the day from being asked again.
3017
+ // The cost is that the card reads "check-in failed; magpie will retry
3018
+ // later" during that stretch, the only way the host has today to say "not
3019
+ // yet time" (see the README's upstream section).
1034
3020
  async checkin(getAuth) {
3021
+ // The whole hook's budget starts here, not after the host hands the
3022
+ // sign-in over: a store read that waits on another process's lock is
3023
+ // this hook's time too, and 18 seconds of it plus a request timeout
3024
+ // put a 25-second hook past its own limit.
3025
+ const deadline = Date.now() + CHECKIN_BUDGET_MS
1035
3026
  let auth, token
1036
3027
  try {
1037
- auth = await getAuth()
3028
+ const got = await bounded(Promise.resolve().then(getAuth), deadline - Date.now())
3029
+ if (got === BOUNDED) return { outcome: "failed", message: "读取登录信息超时,稍后自动重试" }
3030
+ auth = got
1038
3031
  token = tokenOf(auth)
1039
3032
  } catch (e) {
1040
3033
  return { outcome: "failed", message: hostText(whyOf(e)) || "签到失败", ...(signedOut(e) ? { signIn: "expired" } : {}) }
@@ -1046,10 +3039,6 @@ export async function MsmoliPlugin(input) {
1046
3039
  // the user to sign in sends them to the wrong place
1047
3040
  message: unusableKey(auth) ? "令牌内容无法识别(含空格、换行或非 ASCII 字符),请重新登录" : "没有登录信息,请先登录",
1048
3041
  }
1049
- // the clock the requests run against: a hook that does not finish in
1050
- // time has failed, as far as the host is concerned, and it asks again
1051
- const deadline = Date.now() + CHECKIN_BUDGET_MS
1052
-
1053
3042
  const today = todayCN()
1054
3043
  const win = windowOf(configOf(configDir))
1055
3044
  if (win) {
@@ -1064,7 +3053,7 @@ export async function MsmoliPlugin(input) {
1064
3053
  // and it is in the README for whoever wants to change it.
1065
3054
  //
1066
3055
  // No countdown. The host keeps this line on the card until its
1067
- // next ask, half an hour later, so "约 8 分钟后" outlives the
3056
+ // next ask, half an hour later, so "in about 8 minutes" outlives the
1068
3057
  // eight minutes: a real card still read that at 09:47 for a
1069
3058
  // 09:28 target whose credits had already landed. The target and
1070
3059
  // the cadence are both absolute, and the cadence is also why the
@@ -1072,23 +3061,52 @@ export async function MsmoliPlugin(input) {
1072
3061
  // it runs on the host's next ask, not at the moment it named.
1073
3062
  return {
1074
3063
  outcome: "failed",
1075
- message: `${clock(p.target)} 后自动领取(magpie 每半小时问一次,无需操作)`,
3064
+ // the zone is named because only the minute is: the target is
3065
+ // Beijing's whatever zone the host's own clock is in, and the
3066
+ // host shows other times (resets, a window's expiry) in the
3067
+ // machine's zone beside it.
3068
+ message: `${clock(p.target)} 后自动领取(北京时间,magpie 每半小时问一次,无需操作)`,
1076
3069
  }
1077
3070
  }
1078
3071
  }
1079
3072
 
3073
+ // The site the token was found on is kept on the sign-in, so a
3074
+ // restarted host goes straight there instead of handing the token to
3075
+ // the domestic site for a 401 first. A value that is not one of the
3076
+ // two is ignored and the token probed again.
3077
+ const kept = profileForSite(auth?.site)
3078
+ if (kept && !sites.has(token)) rememberSite(token, kept)
3079
+
1080
3080
  // The nickname is written back after the answer is made, not between
1081
3081
  // the touch and the reads that reconcile it: magpie's auth.set takes
1082
3082
  // plugin-auth.json's lock synchronously (host.js's lockAuth, up to
1083
3083
  // twice its 10s stale time when another process holds it), and that
1084
3084
  // wait came out of this hook's 25s — a claim that had landed was then
1085
- // answered "预算已用完" and shown as failed for half an hour.
3085
+ // answered "budget used up" and shown as failed for half an hour.
3086
+ // The write is also capped (WRITE_WAIT_MS) so a locked store cannot
3087
+ // stretch the hook past its budget even from the tail.
1086
3088
  let name = ""
3089
+ // the site the reads found the token on, for the write-back after the
3090
+ // answer; null until the first read has answered
3091
+ let probe = null
3092
+ // a confirmed expiry met on a read that is allowed to fail softly
3093
+ // (the name lookup, the streak walk): the day's answer stands, and the
3094
+ // host still has to hear that the token was refused
3095
+ const marks = { expired: false }
1087
3096
  const answer = await (async () => {
1088
3097
  try {
1089
- const before = await todayEarn(token, deadline)
1090
- const beforeDaily = before.filter((r) => DAILY_RULES.includes(r?.rule_key))
1091
- const claimed = before.some((r) => r?.rule_key === "daily_active")
3098
+ let before = await todayEarn(token, deadline, { expiry: false })
3099
+ let rolls = 0
3100
+ for (;;) {
3101
+ const profile = before.profile
3102
+ probe = profile
3103
+ const beforeDaily = before.rows.filter((r) => DAILY_RULES.includes(r?.rule_key))
3104
+ const claimed = before.rows.some((r) => r?.rule_key === "daily_active")
3105
+ // A baseline that was not read to the end cannot prove the day's
3106
+ // grant absent: the row may sit on the page that failed, and the
3107
+ // touch-then-diff would report a row nobody read as this run's gain.
3108
+ // `failed` is the one outcome the host retries.
3109
+ if (!before.complete && !claimed) return { outcome: "failed", message: "账单读取不完整,稍后自动重试" }
1092
3110
  // What this run granted is the ledger's growth, not a new key: the
1093
3111
  // rows are aggregated per (day, type, rule_key, model_tier), so a
1094
3112
  // second grant of a rule already in today's page grows that row's
@@ -1096,11 +3114,21 @@ export async function MsmoliPlugin(input) {
1096
3114
  // aliyun_bindlogin (from the website, say) and gains nothing from
1097
3115
  // the touch must not report "claimed 50" — and one that gains more
1098
3116
  // of a rule it already had must still report it.
3117
+ // Only the daily-rule rows go into the comparison. The ledger also
3118
+ // holds EARN rows of other rules — promotions, bind bonuses — and a
3119
+ // row of one that carries no amount (a server-side adjustment, a
3120
+ // rule added since) would throw here and answer `failed` for a day
3121
+ // whose claim had already landed. Those rows are not what this
3122
+ // comparison is about, so their amounts are never read. It is the
3123
+ // same rule the usage card follows: only the kinds it sums need an
3124
+ // amount.
1099
3125
  const byRule = (rows) => {
1100
3126
  const m = new Map()
1101
3127
  for (const r of rows) m.set(r?.rule_key, (m.get(r?.rule_key) ?? 0) + amountOf(r))
1102
3128
  return m
1103
3129
  }
3130
+ // `beforeDaily` and every `rows` handed to grew(...) are already
3131
+ // filtered to DAILY_RULES by the caller.
1104
3132
  const beforeByRule = byRule(beforeDaily)
1105
3133
  // The name a rule is shown by: the first row of that key that carries
1106
3134
  // one. Rows aggregate per (day, type, rule_key, model_tier), so a key
@@ -1110,11 +3138,15 @@ export async function MsmoliPlugin(input) {
1110
3138
  // "+50" with nothing in front of it.
1111
3139
  const titleOf = (rows, key) =>
1112
3140
  rows.find((r) => r?.rule_key === key && typeof r.title === "string" && r.title.trim())?.title
1113
- const grew = (rows) => {
3141
+ const grew = (rows, pageDay) => {
1114
3142
  const after = byRule(rows)
3143
+ // The rows of a new Beijing day cannot be diffed against the day
3144
+ // before it: a grant that landed at 00:00:xx on the 50 already in
3145
+ // yesterday's page would net to zero.
3146
+ const base = pageDay === before.day ? beforeByRule : new Map()
1115
3147
  const out = []
1116
3148
  for (const [key, sum] of after) {
1117
- const gain = yin(sum - (beforeByRule.get(key) ?? 0))
3149
+ const gain = yin(sum - (base.get(key) ?? 0))
1118
3150
  if (gain > 0) out.push({ key, gain, title: titleOf(rows, key) })
1119
3151
  }
1120
3152
  return out
@@ -1124,14 +3156,47 @@ export async function MsmoliPlugin(input) {
1124
3156
  // left to do. magpie asks a `done` again every half hour before noon
1125
3157
  // when it has not seen the claim itself (provisional), and each ask
1126
3158
  // touching login/info was one more recorded sign-in — up to a dozen
1127
- // a morning for an account claimed on the website.
1128
- const known = names.get(token) ?? ""
3159
+ // a morning for an account claimed on the website. "Known" includes
3160
+ // a remembered "the server gave no usable name" (`names` holds the
3161
+ // empty string then), or the touch would repeat for exactly the
3162
+ // accounts whose nickname is unusable.
3163
+ const known = names.has(token)
1129
3164
  if (claimed && known) {
1130
- name = known
3165
+ name = names.get(token)
3166
+ } else if (claimed) {
3167
+ // The grant is already in the ledger, so this read is only for the
3168
+ // card's name. A refusal or a broken answer must not turn the day
3169
+ // into `failed`: the host retries a failed day every half hour,
3170
+ // touching login/info each time, for a claim that already landed.
3171
+ name = ""
3172
+ try {
3173
+ const { res, body, parsed } = await json(profile.site, token, LOGIN_INFO, deadline)
3174
+ if (res.status === 401 && parsed && awaitsSignIn(body)) throw deadToken(profile.where)
3175
+ gate(profile, res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
3176
+ const me = dataOf(body)
3177
+ // A name is a string. Anything else — a number, an object, a list —
3178
+ // would reach text() as "12345" or "[object Object]" and be written
3179
+ // back as the account's name, and magpie's card would show that
3180
+ // instead of the key's tail. NickName wins when it is a name; Name
3181
+ // is the fallback it always was.
3182
+ const str = (v) => (typeof v === "string" && v.trim() ? v : "")
3183
+ const raw = str(me.NickName) || str(me.Name)
3184
+ const candidate = text(raw, 40)
3185
+ // judged whole, then cut: the cut can leave a piece of the token
3186
+ // in the name that neither the cut string nor the whole one
3187
+ // answers for alone (see runsThrough)
3188
+ name = unusableName(raw, token) || unusableName(candidate, token) ? "" : candidate
3189
+ rememberName(token, name)
3190
+ } catch (e) {
3191
+ // no name this ask; the day still stands as done — but a 401 the
3192
+ // gate confirmed is the token refused, and the host is told
3193
+ if (signedOut(e)) marks.expired = true
3194
+ }
1131
3195
  } else {
1132
- const { res, body, parsed } = await json(token, LOGIN_INFO, deadline)
1133
- // 403 是风控,不是令牌坏了:账号可能是好的,只是这次请求被拒
1134
- gate(res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
3196
+ const { res, body, parsed } = await json(profile.site, token, LOGIN_INFO, deadline)
3197
+ // A 403 is risk control, not a bad token: the account may be fine and
3198
+ // only this request was refused.
3199
+ gate(profile, res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
1135
3200
 
1136
3201
  const me = dataOf(body)
1137
3202
  // A name is a string. Anything else — a number, an object, a list —
@@ -1140,30 +3205,17 @@ export async function MsmoliPlugin(input) {
1140
3205
  // instead of the key's tail. NickName wins when it is a name; Name
1141
3206
  // is the fallback it always was.
1142
3207
  const str = (v) => (typeof v === "string" && v.trim() ? v : "")
1143
- const candidate = text(str(me.NickName) || str(me.Name), 40)
1144
- name = unusableName(candidate, token) ? "" : candidate
3208
+ const raw = str(me.NickName) || str(me.Name)
3209
+ const candidate = text(raw, 40)
3210
+ // judged whole, then cut: the cut can leave a piece of the token
3211
+ // in the name that neither the cut string nor the whole one
3212
+ // answers for alone (see runsThrough)
3213
+ name = unusableName(raw, token) || unusableName(candidate, token) ? "" : candidate
1145
3214
  rememberName(token, name)
1146
3215
  }
1147
3216
 
1148
- // done 用触发**之前**那一页的合计:今天已经领过,领到的就是那时
1149
- // 账本上的数。用触发之后那一页的话,两次读之间账本一变(行被挤到
1150
- // 下一页、或当日合计被重算),卡片会显示「今日已领取 0 魔粒」。
1151
- if (claimed) {
1152
- const credit = sumOf(beforeDaily)
1153
- const which = beforeDaily.map((r) => cardText(r.title, token, 40)).filter(Boolean).join(" + ")
1154
- // An EARN row can carry a reversal (冲正), and a day whose grants
1155
- // were reversed nets below zero. magpie clamps the numeric credit
1156
- // itself (host.js's num: Math.max(0, v)) but renders `message`
1157
- // verbatim, so "今日已领取 -50 魔粒" would reach the card: a claim of
1158
- // a negative amount is not a thing. The rows are still named — the
1159
- // user can see which ones — but the amount stops at zero, the way
1160
- // the usage card's limit already does.
1161
- const shown = Math.max(0, credit)
1162
- return { outcome: "done", credit: shown, message: `今日已领取 ${shown} 魔粒` + (which ? `(${which})` : "") }
1163
- }
1164
-
1165
- const gained = (rows) => {
1166
- const grew1 = grew(rows)
3217
+ const gained = (rows, pageDay) => {
3218
+ const grew1 = grew(rows, pageDay)
1167
3219
  if (!grew1.length) return null
1168
3220
  const credit = yin(grew1.reduce((n, g) => n + g.gain, 0))
1169
3221
  // A key with no name on any of its rows is shown by its amount
@@ -1178,18 +3230,128 @@ export async function MsmoliPlugin(input) {
1178
3230
  return { credit, which }
1179
3231
  }
1180
3232
 
1181
- const daily = gained((await todayEarn(token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1182
- if (daily) return { outcome: "claimed", credit: daily.credit, message: `领取 ${daily.credit} 魔粒:${daily.which}` }
1183
- // 触达了却没有到账记录:可能是账本还没写出来(发放是异步的),
1184
- // 也可能这个账号本来就不在活动范围。再读一次账本确认,别把刚写
1185
- // 出来的发放丢掉。这一次重读买不到「等一会儿」——发放若真是几秒
1186
- // 后才写出来,紧接着的重读也读不到。北京时间 12 点前补上那一段的
1187
- // 是宿主:还没见到领取的 done/inactive 是暂定答案,每 30 分钟重问
1188
- // (provisional,由 checkinSureHour 决定)。12 点起宿主就当天了结,
1189
- // 所以下午的时段由插件自己用 until 要一次半小时后的重问。
1190
- const later = gained((await todayEarn(token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1191
- if (later) return { outcome: "claimed", credit: later.credit, message: `领取 ${later.credit} 魔粒:${later.which}` }
3233
+ // The name read above can sit across Beijing midnight: the baseline
3234
+ // is then yesterday's, and answering `done` for it would settle the
3235
+ // new day without claiming it. A day that changed while the hook ran
3236
+ // is re-read and the walk starts over for the new day (twice at
3237
+ // most, so a clock that keeps rolling cannot loop the hook).
3238
+ if (todayCN() !== before.day && rolls < 2) {
3239
+ rolls += 1
3240
+ const wasDay = before.day
3241
+ before = await todayEarn(token, deadline, { expiry: false })
3242
+ probe = before.profile
3243
+ // The touch was this run's, and the day changed under it: the
3244
+ // baseline is yesterday's, and `gained` reads an empty one for a
3245
+ // page whose day is not the one it is handed. So the new day's
3246
+ // whole page is the diff — every daily grant on it was written
3247
+ // between the two reads (the page had nothing for the new day when
3248
+ // the baseline was read). A grant the server stamped with
3249
+ // yesterday's date (a late write) is not on this page at all, and
3250
+ // is not claimed here: it belongs to yesterday's answer, which
3251
+ // this run already gave.
3252
+ const bornToday = gained(before.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), wasDay)
3253
+ if (bornToday) {
3254
+ return withStreak({
3255
+ outcome: "claimed",
3256
+ credit: bornToday.credit,
3257
+ message: `领取 ${bornToday.credit} 魔粒:${bornToday.which}` + (before.complete ? "" : ";账单未读完,数字偏低"),
3258
+ ...(marks.expired ? { signIn: "expired" } : {}),
3259
+ }, await streakOf(before.all, before.day, before.total, ledgerReader(profile, token, deadline, before.pages, before.last, before.seenPages, before.seenRows, marks), deadline), marks)
3260
+ }
3261
+ if (!before.complete) {
3262
+ return { outcome: "failed", message: "账单读取不完整,稍后自动重试", ...(marks.expired ? { signIn: "expired" } : {}) }
3263
+ }
3264
+ continue
3265
+ }
3266
+
3267
+ // A done uses the totals from the page **before** the touch: if today
3268
+ // was already claimed, what was claimed is the number the ledger held
3269
+ // then. From the page after the touch, a ledger that changed between
3270
+ // the two reads (rows pushed to the next page, or the day's total
3271
+ // recomputed) would make the card say today's claim is 0 credits.
3272
+ if (claimed) {
3273
+ const credit = sumOf(beforeDaily)
3274
+ // each title is cut and stringified as before, and the join is
3275
+ // masked once at the end: two titles that each carry a piece of the
3276
+ // key under MIN_ECHO spell it together, and a per-title mask never
3277
+ // sees the pair.
3278
+ const joined = beforeDaily
3279
+ .map((r) => cardText(r.title, token, 40))
3280
+ .filter(Boolean)
3281
+ .join(" + ")
3282
+ const which = cardText(joined, token, 200)
3283
+ // An EARN row can carry a reversal, and a day whose grants were
3284
+ // reversed nets below zero. magpie clamps the numeric credit
3285
+ // itself (host.js's num: Math.max(0, v)) but renders `message`
3286
+ // verbatim, so a negative claimed-today line would reach the card:
3287
+ // a claim of a negative amount is not a thing. The rows are still
3288
+ // named — the user can see which ones — but the amount stops at
3289
+ // zero, the way the usage card's limit already does.
3290
+ const shown = Math.max(0, credit)
3291
+ // The streak is read from the page **before** the touch here too:
3292
+ // the touch grants nothing new for an account already claimed, so
3293
+ // the two readings answer the same thing, and this one is the page
3294
+ // the totals above were taken from.
3295
+ return withStreak({
3296
+ outcome: "done",
3297
+ credit: shown,
3298
+ message: `今日已领取 ${shown} 魔粒` + (which ? `(${which})` : "") + (before.complete ? "" : ";账单未读完,数字偏低"),
3299
+ ...(marks.expired ? { signIn: "expired" } : {}),
3300
+ }, await streakOf(before.all, before.day, before.total, ledgerReader(profile, token, deadline, before.pages, before.last, before.seenPages, before.seenRows, marks), deadline), marks)
3301
+ }
3302
+
3303
+ // Both claimed exits answer the same way; the streak is computed in
3304
+ // one place so a change to it cannot land in one branch only.
3305
+ const claimedNow = async (page, gain) => {
3306
+ const streak = await streakOf(page.all, page.day, page.total, ledgerReader(profile, token, deadline, page.pages, page.last, page.seenPages, page.seenRows, marks), deadline)
3307
+ return withStreak({
3308
+ outcome: "claimed",
3309
+ credit: gain.credit,
3310
+ message: `领取 ${gain.credit} 魔粒:${gain.which}` + (page.complete ? "" : ";账单未读完,数字偏低"),
3311
+ ...(marks.expired ? { signIn: "expired" } : {}),
3312
+ }, streak, marks)
3313
+ }
3314
+
3315
+ const fresh = await todayEarn(token, deadline, { expiry: false })
3316
+ probe = fresh.profile
3317
+ const daily = gained(fresh.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), fresh.day)
3318
+ if (daily) return await claimedNow(fresh, daily)
3319
+ // Touched, yet no credit record: the ledger may not be written yet
3320
+ // (grants are asynchronous), or this account may simply not be in the
3321
+ // campaign. Read the ledger once more to confirm, and do not drop a
3322
+ // grant that was just written. This re-read buys no "wait a while" —
3323
+ // if the grant really is written a few seconds later, the immediate
3324
+ // re-read cannot see it either. Before Beijing noon the host covers
3325
+ // that stretch: a done/inactive that has not seen a claim is a
3326
+ // provisional answer, re-asked every 30 minutes (provisional, decided
3327
+ // by checkinSureHour). From noon the host settles the day, so in the
3328
+ // afternoon the plugin asks for one more ask half an hour later itself,
3329
+ // via until.
3330
+ const again = await todayEarn(token, deadline, { expiry: false })
3331
+ probe = again.profile
3332
+ const later = gained(again.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), again.day)
3333
+ if (later) return await claimedNow(again, later)
3334
+ // A ledger this walk could not finish is not evidence that nothing
3335
+ // arrived: the grant may sit on the page that failed, and `inactive`
3336
+ // without `until` settles the day on that. The last read is the one
3337
+ // that decides — an earlier incomplete read is answered by this one.
3338
+ // The touch has happened, so the answer that costs nothing is
3339
+ // `failed` — the one outcome the host retries, which reads the
3340
+ // ledger again half an hour later.
3341
+ if (!again.complete) {
3342
+ return { outcome: "failed", message: "账单读取不完整,稍后自动重试" }
3343
+ }
1192
3344
  const hourCN = Math.floor(minuteCN() / 60)
3345
+ // Before noon the host re-asks a provisional answer every half hour,
3346
+ // so an `inactive` here is safe: a grant that is simply late is read
3347
+ // again, and the repeat past noon gets the `until` below. From noon
3348
+ // the host settles the day, so the first `inactive` from noon on
3349
+ // carries `until`, once per day. A check-in that lands just after
3350
+ // midnight is before SURE_HOUR and already covered by the host's
3351
+ // provisional re-ask — that is the guard, and it is the hour, not a
3352
+ // stale day flag: `deferred` holds the Beijing day the `until` was
3353
+ // given on, and a new day starts with no flag, so the afternoon of
3354
+ // the new day gets its own `until` like any other day.
1193
3355
  if (hourCN >= SURE_HOUR && deferred.get(token) !== today) {
1194
3356
  for (const [k, d] of deferred) if (d !== today) deferred.delete(k)
1195
3357
  deferred.set(token, today)
@@ -1199,7 +3361,15 @@ export async function MsmoliPlugin(input) {
1199
3361
  until: new Date(Date.now() + AGAIN_MS).toISOString(),
1200
3362
  }
1201
3363
  }
3364
+ if (beforeDaily.length) {
3365
+ // no until: this answer settles the day, so the line must not
3366
+ // promise a re-check the host will not make. Today has credits of
3367
+ // other kinds and no check-in reward, which reads as an account
3368
+ // outside the activity rather than a grant still on its way.
3369
+ return { outcome: "inactive", message: "登录已触达,今天有到账但没有签到奖励(账号可能不在活动范围)" }
3370
+ }
1202
3371
  return { outcome: "inactive", message: "登录已触达,但今天没有到账记录(账号可能不在活动范围)" }
3372
+ }
1203
3373
  } catch (e) {
1204
3374
  return { outcome: "failed", message: cardText(whyOf(e), token) || "签到失败", ...(signedOut(e) ? { signIn: "expired" } : {}) }
1205
3375
  }
@@ -1208,37 +3378,50 @@ export async function MsmoliPlugin(input) {
1208
3378
  // catches its own failures, and anything it could still throw (an
1209
3379
  // unreadable providers.json read) must not undo the answer above
1210
3380
  try {
1211
- await rememberAccountName(client, token, name, auth, configDir)
3381
+ await rememberAccountFields(client, token, { name, profile: probe, probeDone: !!probe }, auth, configDir, deadline, getAuth)
1212
3382
  } catch {}
1213
3383
  return answer
1214
3384
  },
1215
3385
 
1216
- // 魔粒余额与今日消耗,给账号卡片看。
3386
+ // The credits balance and today's usage, for the account card.
1217
3387
  async usage(getAuth) {
1218
3388
  // everything the host could get wrong about the hand-over is the
1219
3389
  // card's problem alone: it must come back as {error}, never as a
1220
3390
  // rejection out of the hook
3391
+ const deadline = Date.now() + USAGE_BUDGET_MS
1221
3392
  let auth, token
1222
3393
  try {
1223
- auth = await getAuth()
3394
+ const got = await bounded(Promise.resolve().then(getAuth), deadline - Date.now())
3395
+ if (got === BOUNDED) return { error: "读取登录信息超时,稍后自动重试" }
3396
+ auth = got
1224
3397
  token = tokenOf(auth)
1225
3398
  } catch (e) {
1226
3399
  return { error: hostText(whyOf(e)) || "用量查询失败", signIn: signedOut(e) ? "expired" : "" }
1227
3400
  }
1228
3401
  if (!token) return { error: unusableKey(auth) ? "令牌内容无法识别,请重新登录" : "没有登录信息", signIn: "" }
1229
- const deadline = Date.now() + USAGE_BUDGET_MS
3402
+ // the same seeding as the check-in's: a site kept on the sign-in saves
3403
+ // the domestic-site 401 a restarted host would otherwise pay first
3404
+ const kept = profileForSite(auth?.site)
3405
+ if (kept && !sites.has(token)) rememberSite(token, kept)
1230
3406
  // the name is only known from login/info, which usage must never
1231
- // reach; a check-in in this process leaves it here for the card.
1232
- // Inside the try: the hook promises an {error}, never a rejection,
1233
- // and this path reaches client.auth.set.
3407
+ // reach; a check-in in this process leaves it here for the card, and
3408
+ // the write-back of a name the check-in failed to write is retried
3409
+ // here. That write is the last thing before the answer, not the first
3410
+ // thing before the reads: the host's auth.set can wait out a lock for
3411
+ // ~20s (see the check-in's own note), and a wait spent before the
3412
+ // reads came out of the 18s the two of them share — the card answered
3413
+ // "budget used up" for a name it had just written. Everything here is inside
3414
+ // the try, so the hook still promises an {error} and never a
3415
+ // rejection, whatever that write or the reads do.
1234
3416
  try {
1235
- await rememberAccountName(client, token, names.get(token) ?? "", auth, configDir)
1236
- const bal = await json(token, BALANCE, deadline)
1237
- gate(bal.res, bal.body, bal.parsed, token, "余额查询失败")
1238
- // data 缺失读成 0 会把「没读懂」翻译成「余额为零」——一个确定又
1239
- // 错误的结论,卡片会写「0 魔粒可用」。账本那侧的 recordsOf 同理。
1240
- // 字段这一层也一样:available_balance 缺失、null、或改了名字,
1241
- // 都不是 0 元的余额。
3417
+ const bal = await siteRead(token, BALANCE, deadline)
3418
+ const profile = bal.profile
3419
+ gate(profile, bal.res, bal.body, bal.parsed, token, "余额查询失败")
3420
+ // A missing data read as 0 would turn "did not understand" into a
3421
+ // definite and wrong conclusion, "balance is zero"; the card would
3422
+ // read "0 credits available". recordsOf on the ledger side is the
3423
+ // same. The field layer is too: available_balance absent, null, or
3424
+ // renamed is not a zero balance.
1242
3425
  const balData = dataObjectOf(bal.body, "余额查询失败:响应里没有 data")
1243
3426
  const rawAvailable = balData.available_balance
1244
3427
  if (blankOf(rawAvailable)) {
@@ -1246,27 +3429,28 @@ export async function MsmoliPlugin(input) {
1246
3429
  }
1247
3430
  // A number or a numeric string is a balance. Anything else is not:
1248
3431
  // Number([]) is 0 and Number(true) is 1, both finite, so the plain
1249
- // isFinite check let an empty list through as "0 魔粒可用" — the
3432
+ // isFinite check let an empty list through as "0 credits available" — the
1250
3433
  // definite wrong answer this guard exists to stop — and a boolean
1251
- // through as one 魔粒. A formatted string ("1,234") is a number we
3434
+ // through as one credit. A formatted string ("1,234") is a number we
1252
3435
  // cannot read, not 0, and blankOf covers the field that holds
1253
3436
  // nothing but spaces, which `Number` also reads as 0.
1254
3437
  const kind = typeof rawAvailable
1255
3438
  if (kind !== "number" && kind !== "string") throw new Error("余额查询失败:available_balance 不是数字")
1256
3439
  const availableN = Number(rawAvailable)
1257
3440
  if (!readable(availableN)) throw new Error("余额查询失败:available_balance 不是数字")
1258
- // a balance is not a debt: the server's 冲正 direction can put it
1259
- // below zero, and "-5 魔粒可用" is not a thing the card can show
3441
+ // a balance is not a debt: the server's reversal direction can put it
3442
+ // below zero, and "-5 credits available" is not a thing the card can show
1260
3443
  const available = Math.max(0, yin(availableN))
1261
3444
 
1262
- const tx = await json(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
1263
- gate(tx.res, tx.body, tx.parsed, token, "账单查询失败")
3445
+ const tx = await json(profile.site, token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
3446
+ gate(profile, tx.res, tx.body, tx.parsed, token, "账单查询失败")
1264
3447
 
1265
3448
  const day = todayCN()
3449
+ const walked = await dayRows(profile, token, day, recordsOf(tx.body), deadline, tx.body)
1266
3450
  let earned = 0
1267
3451
  let spent = 0
1268
3452
  let earliest = ""
1269
- for (const r of recordsOf(tx.body)) {
3453
+ for (const r of walked.all) {
1270
3454
  // Only the two kinds that go into the card's numbers need an
1271
3455
  // amount. A row of a kind we do not sum is not counted either
1272
3456
  // way, and failing the whole card over its amount would turn a
@@ -1274,10 +3458,13 @@ export async function MsmoliPlugin(input) {
1274
3458
  // numbers at all" — the opposite of what the rule is for.
1275
3459
  if (onDay(r?.gmt_created, day)) {
1276
3460
  if (r?.type === "EARN") {
1277
- // 「领」是今天入账的全部,促销一类也算:卡片问的是「今天进来
1278
- // 多少、用掉多少」,把促销排除会让余额涨了 500 而卡片写「领 0」。
1279
- // 签到报的 credit 是另一个问题(这次签到领到多少),只算日常
1280
- // 额度,两个数不一样是各自的答案,不是同一个数的两种算法。
3461
+ // "Claimed" means everything credited today, promotions
3462
+ // included: the card asks "how much came in, how much was
3463
+ // used", and excluding promotions would show "claimed 0" while
3464
+ // the balance rose by 500. The credit the check-in reports is a
3465
+ // different question (how much this check-in claimed), counting
3466
+ // only the daily grants; the two numbers differing is each
3467
+ // giving its own answer, not two ways of computing one number.
1281
3468
  earned += amountOf(r)
1282
3469
  } else if (r?.type === "SPEND_CONFIRM") {
1283
3470
  spent += amountOf(r)
@@ -1296,36 +3483,47 @@ export async function MsmoliPlugin(input) {
1296
3483
  }
1297
3484
  }
1298
3485
 
1299
- // 不截断到 100:magpie 把 used > 100 读作「超额」
1300
- // (host.js 的 "past 100 when overspent")。今天没领到任何魔粒
1301
- // 且余额为零,才算用尽。下限夹到 0:账本里的退款是负数的
1302
- // SPEND_CONFIRM,不夹就会在卡片上出现「今日用 -50 / 领 200」和
1303
- // 负百分比——宿主自己会夹,这里再兜一次。
3486
+ // Not truncated at 100: magpie reads used > 100 as "overspent"
3487
+ // (host.js's "past 100 when overspent"). Today's credits being spent
3488
+ // up means nothing was claimed today and the balance is zero. The
3489
+ // lower bound is clamped to 0: refunds in the ledger are negative
3490
+ // SPEND_CONFIRM rows, and without the clamp the card would show a
3491
+ // "used -50 today / claimed 200" combination and a negative
3492
+ // percentage — the host clamps it itself; this is one more safety net.
1304
3493
  // summed in floating point: 0.1 + 0.2 is 0.30000000000000004, and it
1305
3494
  // would go into the card as written
1306
3495
  earned = yin(earned)
1307
3496
  spent = yin(spent)
1308
- // 入账方向也可能出现负数(冲正、回收)。不夹的话卡片会写
1309
- // 「领 -50」,而 limit 是负的更没意义。用掉的方向同理。
3497
+ // The credit direction can also come out negative (reversals,
3498
+ // clawbacks). Without the clamp the card would write "claimed -50",
3499
+ // and a negative limit makes even less sense. Same for the spend
3500
+ // direction.
1310
3501
  const earnedToday = Math.max(0, earned)
1311
3502
  const usedToday = Math.max(0, spent)
1312
- // 没有今天的入账时,百分比的分母换成「今天手里有多少」——用掉的
1313
- // 加上还剩的。分母为 0 时沿用旧口径:余额也空了才算用尽。
1314
- // 否则卡片会出现 used=0、今日用 348.5、领 0 的组合,进度条说没动,
1315
- // 文字说花掉了一堆。
3503
+ // When there is no credit today, the percentage's denominator becomes
3504
+ // "how much is in hand today" — what was used plus what remains. When
3505
+ // that denominator is 0 the old measure stands: only a depleted
3506
+ // balance counts as spent up. Otherwise the card would show the
3507
+ // combination used=0, used 348.5 today, claimed 0 — the progress bar
3508
+ // says nothing moved while the text says a lot was spent.
1316
3509
  //
1317
- // 记账里两个数都过了 readable(),比值却可能大到 ×10000 溢出成
1318
- // Infinity;宿主对非有限的 used 读作 0(host.js 的 num 只认有限数),
1319
- // 于是「几乎全用光」被画成没动过。算不出百分比就交给宿主当「没说」
1320
- // (used 省略 = null),文字那行仍然写着真实的数量。
3510
+ // Both numbers in the ledger passed readable(), yet the ratio can be
3511
+ // large enough to overflow to Infinity at ×10000; the host reads a
3512
+ // non-finite used as 0 (host.js's num accepts only finite numbers),
3513
+ // and so "almost all used up" was drawn as nothing having moved. When
3514
+ // the percentage cannot be computed it is handed to the host as "not
3515
+ // said" (used omitted = null), and the text line still states the
3516
+ // real amounts.
1321
3517
  const pct = (n, d) => {
1322
3518
  const v = Math.round((n / d) * 10000) / 100
1323
3519
  return Number.isFinite(v) ? v : null
1324
3520
  }
1325
- // 用掉的超过今天领的、余额却还有:花的是昨天那份还没过期的魔粒,
1326
- // 不是超额。那时分母同样换成「今天手里有多少」——否则昨天 15:00
1327
- // 领 250、今天领 250、上午用掉 300,卡片会画成 120% 的超额,旁边
1328
- // 写着余额 200。超额只在余额也空了时才成立。
3521
+ // More used than claimed today, yet a balance remains: what was spent
3522
+ // is yesterday's not-yet-expired credits, not an overage. The
3523
+ // denominator switches to "how much is in hand today" then too —
3524
+ // otherwise, 250 claimed yesterday at 15:00, 250 claimed today, 300
3525
+ // used this morning, the card would draw a 120% overage beside a
3526
+ // balance of 200. An overage holds only when the balance is empty too.
1329
3527
  const used =
1330
3528
  earnedToday > 0 && (usedToday <= earnedToday || available === 0)
1331
3529
  ? pct(usedToday, earnedToday)
@@ -1333,21 +3531,49 @@ export async function MsmoliPlugin(input) {
1333
3531
  ? pct(usedToday, usedToday + available)
1334
3532
  : 100
1335
3533
 
1336
- // 今天还没领到时,把时段里的目标时刻写在卡片上,别让「领 0」
1337
- // 看起来像是出了问题。
1338
- const win = windowOf(configOf(configDir))
1339
- const ahead = win && earnedToday === 0 ? plan(win, token, day) : null
1340
- const hint = ahead && !ahead.due ? ` · ${clock(ahead.target)} 领取` : ""
3534
+ // Before today's credits are claimed, put the window's target moment
3535
+ // on the card, so "claimed 0" does not look like something went wrong.
3536
+ //
3537
+ // The config is read only when the card actually needs the target:
3538
+ // after the day's grant has landed there is no hint to compute, and
3539
+ // this file read is the hook's only synchronous I/O — a poll every
3540
+ // few minutes per account should not pay for it to be thrown away.
3541
+ // `win` is null when earnedToday is non-zero, so `ahead` is null
3542
+ // exactly where `win && earnedToday === 0` used to make it so.
3543
+ const win = earnedToday === 0 ? windowOf(configOf(configDir)) : null
3544
+ const ahead = win ? plan(win, token, day) : null
3545
+ // the minute is Beijing's (minuteCN), while the card's reset time is
3546
+ // the machine's zone: say which one this is, or a reader elsewhere
3547
+ // takes it for their own clock.
3548
+ const hint = ahead && !ahead.due ? ` · ${clock(ahead.target)} 领取(北京时间)` : ""
3549
+ // A walk that could not finish leaves the two numbers a floor: the
3550
+ // ledger contradicted itself, or a page failed. Said on the card, so
3551
+ // they are not read as the day's whole truth.
3552
+ // the countdown's own scan can be cut short while the numbers are
3553
+ // whole (the page cap, the budget): then the earliest expiry seen is
3554
+ // a floor, not the answer, and the card says so rather than naming a
3555
+ // later time as if it were settled
3556
+ const part = (walked.complete ? "" : " · 账单未读完,数字偏低") + (walked.scanComplete || !earliest ? "" : " · 到期时间可能更早")
3557
+
3558
+ // last, and awaited: the card must not be answered ahead of the
3559
+ // write (a poll answered before it would race the very retry this
3560
+ // exists for), while the reads above have already had the budget to
3561
+ // themselves.
3562
+ await rememberAccountFields(client, token, { name: names.get(token) ?? "", profile, probeDone: true }, auth, configDir, deadline, getAuth)
1341
3563
 
1342
3564
  return {
1343
- plan: "魔搭社区",
1344
- user: names.get(token) ?? "",
3565
+ plan: profile.plan,
3566
+ user: nameText(names.get(token) ?? "", token),
1345
3567
  balance: `${available} 魔粒可用`,
3568
+ // a token refused on a courtesy page is refused: the numbers that
3569
+ // page was read for stand, and the card says what the next poll
3570
+ // would say anyway — the account needs signing in again
3571
+ ...(walked.refused ? { signIn: "expired" } : {}),
1346
3572
  windows: [
1347
3573
  {
1348
3574
  name: "魔粒",
1349
3575
  used,
1350
- display: `今日用 ${usedToday} / 领 ${earnedToday} · 余额 ${available}${hint}`,
3576
+ display: `今日用 ${usedToday} / 领 ${earnedToday} · 余额 ${available}${hint}${part}`,
1351
3577
  amount: usedToday,
1352
3578
  limit: earnedToday,
1353
3579
  unit: "魔粒",
@@ -1362,32 +3588,37 @@ export async function MsmoliPlugin(input) {
1362
3588
  },
1363
3589
  },
1364
3590
 
1365
- // 让 provider 以名字出现,不注册任何模型:聊天仍走 providers.json 里
1366
- // API key 版的那个 modelscope 预设。
3591
+ // Make the provider show up by name without registering any model: chat
3592
+ // still goes through the API-key modelscope preset in providers.json.
1367
3593
  async config(config) {
1368
- // 宿主是 `{ provider: {}, ...userConfig }` 拼出来的,用户配置里那个
1369
- // provider 会盖掉默认的 {}。它不是对象时(旧版本写的字符串、手改的
1370
- // 数字),下面 `[ID] =` 会抛 TypeError,宿主只把 config 钩子的错误
1371
- // 记进日志——插件照常出现,只是没有名字,看不出哪里错了。`??=` 只挡
1372
- // null/undefined,挡不住 ""、0、false;一个装不下 ID 的容器里没有可
1373
- // 保留的东西,整个换掉。
3594
+ // The host builds `{ provider: {}, ...userConfig }`, and the provider in
3595
+ // the user config overrides the default {}. When that is not an object (a
3596
+ // string an old version wrote, a hand-edited number), the `[profile.id] =`
3597
+ // below throws a TypeError, and the host only logs the config hook's
3598
+ // error — the plugin still appears, just without a name, and there is no
3599
+ // sign of what went wrong. `??=` guards only null/undefined, not "", 0, or
3600
+ // false; a container that cannot hold an id has nothing worth keeping, so
3601
+ // the whole thing is replaced.
1374
3602
  if (!config.provider || typeof config.provider !== "object") config.provider = {}
1375
- // 用户自己改过的名字留着:magpie 把 provider 的名字存在 providers.json
1376
- // 里,而钩子每次启动都跑一遍——强制写回默认值会把 UI 里的改名悄悄撤销,
1377
- // 与账号昵称那条「用户命名过就不动」是同一条规矩。值不是对象时当作没有。
3603
+ // Keep the name the user has changed: magpie stores a provider's name in
3604
+ // providers.json, and this hook runs on every start — forcing the default
3605
+ // back would silently undo a rename made in the UI, the same rule as the
3606
+ // account-nickname one ("a user-named thing is left alone"). A value that
3607
+ // is not an object counts as none.
1378
3608
  const was = config.provider[ID] && typeof config.provider[ID] === "object" ? config.provider[ID] : {}
1379
3609
  config.provider[ID] = {
1380
3610
  ...was,
1381
- name: typeof was.name === "string" && was.name ? was.name : "ModelScope 魔粒",
3611
+ name: typeof was.name === "string" && was.name ? was.name : NAME,
1382
3612
  }
1383
3613
  },
1384
3614
  }
1385
3615
  }
1386
3616
 
3617
+
1387
3618
  // testables is how the pure helpers the tests pin are reached without
1388
3619
  // exporting them as functions: magpie's loader treats every exported function
1389
3620
  // as a plugin entry (see windowOf above), but an object it skips. The tests
1390
3621
  // are the only reader.
1391
- export const testables = { windowOf }
3622
+ export const testables = { windowOf, streakOf, CN, INTL, sites, maskAll, cardText, nameText, hostText, awaitsSignIn, gate }
1392
3623
 
1393
3624
  export default MsmoliPlugin