magicrew-auth 0.3.32 → 0.3.34

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.
package/src/store.mjs CHANGED
@@ -1,447 +1,625 @@
1
- // 落盘 —— 这个插件自己需要记住的那几样东西
2
- //
3
- // 三份文件,各自一个用途,互不依赖:
4
- // 模型快照 magicrew-models-<site>.json 未登录时卡片显示什么
5
- // 邀请码 magicrew-invite-<site>.json 下一次注册带谁的码(滚动的)
6
- // 登录态 magicrew-session-<site>.json 「自己的邀请码」要用它去换
7
- //
8
- // ⚠ 这不是「状态管理」,是缓存:删掉全部,插件照样能跑(只是要重新登录一次)。
9
-
10
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"
11
- import { homedir } from "node:os"
12
- import { join, dirname } from "node:path"
13
- import { fetchOwnInviteCode } from "./api.mjs"
14
-
15
- function cacheDir() {
16
- return join(homedir(), ".cache", "magpie")
17
- }
18
-
19
- // ═══ 模型:不写死,按账号实时拉 ═══════════════════════════════════════
20
- //
21
- // 权威来源只有一条:
22
- //
23
- // GET <host>/api/v1/flows/models (要网页 JWT + organization-code)
24
- // → { data: { models: [{ value, label, vision, tags, icon, configs }] } }
25
- //
26
- // 实测(2026-10-27):国内 27 个、国际 49 个。两站列表**不一样**,而且会
27
- // 随订阅档位和平台方开关变 —— 所以这个包里没有一张模型清单要维护。
28
- //
29
- // ⚠ 别改成读 /v1/models:那个国内 44、国际 65,混着 VIP 锁定、生图、
30
- // embedding,塞进 magpie 用户会选到根本调不动的模型。
31
- //
32
- // ⚠ 上下文窗口:flows/models 和 /v1/models **都不返回** context_length
33
- // (/v1/models 只有 id / object / created_at / owner_by)。所以一律按
34
- // 256K 保守填 —— 填大了 agent 要等上游报错才知道越界,填小了自己压得早。
35
- //
36
- // 未登录时 config() 用**上次登录成功时落盘的快照**,没有快照就是空表。
37
- // 空表让卡片显示 0 models,但那是诚实的:这会儿确实不知道账号能选什么。
38
-
39
- /** 快照落在 magpie 自己的缓存目录,按站点分开 */
40
- function snapshotPath(siteId) {
41
- return join(cacheDir(), `magicrew-models-${siteId}.json`)
42
- }
43
-
44
- function readSnapshot(siteId) {
45
- try {
46
- const v = JSON.parse(readFileSync(snapshotPath(siteId), "utf8"))
47
- return v && typeof v === "object" && Object.keys(v).length ? v : null
48
- } catch {
49
- return null
50
- }
51
- }
52
-
53
- function writeSnapshot(siteId, models) {
54
- try {
55
- const f = snapshotPath(siteId)
56
- mkdirSync(dirname(f), { recursive: true })
57
- writeFileSync(f, JSON.stringify(models))
58
- } catch {}
59
- }
60
-
61
- // ═══ 邀请码:滚动的,就一条规则 ═══════════════════════════════════════
62
- //
63
- // ① 固定位置有记录 → 用记录里那个
64
- // ② 没有记录 → 用作者码
65
- // ③ 登录成功后 → 查这个账号自己的邀请码 → 覆盖写回
66
- //
67
- // 固定位置:~/.cache/magpie/magicrew-invite-<siteId>.json { code, at }
68
- //
69
- // 效果:第一次用作者的,之后每次都用「上一个登录成功的账号」的码,一路滚下去。
70
- //
71
- // ⚠ ③ 不是每次都能成。/api/v1/invitations/info 只认带 device_id 的 JWT ——
72
- // 同一个账号、同一时刻签的两把,实测差别就在这一个字段:
73
- // 桌面客户端 438 字符 cus.device_id = 64 位 hex → 200 拿到 invite_code
74
- // 网页版 350 字符 cus.device_id = "" → 2155 用户不存在
75
- // 密码登录走 API、会带 device 指纹,签出来的是前者,滚得动;
76
- // 浏览器登录(剪贴板)拿到的就是网页那把,滚不动 —— 那就保留旧值,
77
- // 不报错、不挡登录。
78
- //
79
- // 奖励口径(上游前端 constants,2026-10-27):
80
- // 每次注册 每次订阅 邀请注册封顶 总额封顶
81
- // 国内 1,000 3,000 60,000 300,000
82
- // 国际 200 500 10,000 30,000
83
- const AUTHOR_INVITE = {
84
- // 国内站(2026-10-27 用作者账号 orgcode12345 从 /api/v1/invitations/info 复核)
85
- magicrew: "IVC8BAWYXRY1",
86
- // 国际站
87
- "magicrew-intl": "IV75F0ZKVKXG",
88
- }
89
-
90
- function invitePath(siteId) {
91
- return join(cacheDir(), `magicrew-invite-${siteId}.json`)
92
- }
93
-
94
- /** 固定位置里存的邀请码;没有就是空串 */
95
- function readInvite(siteId) {
96
- try {
97
- const v = JSON.parse(readFileSync(invitePath(siteId), "utf8"))
98
- return typeof v?.code === "string" ? v.code.trim() : ""
99
- } catch {
100
- return ""
101
- }
102
- }
103
-
104
- /** 覆盖写 —— 拿到新的就盖掉旧的 */
105
- function writeInvite(siteId, code) {
106
- const c = String(code || "").trim()
107
- if (!c) return ""
108
- try {
109
- const f = invitePath(siteId)
110
- mkdirSync(dirname(f), { recursive: true })
111
- writeFileSync(f, JSON.stringify({ code: c, at: new Date().toISOString() }))
112
- } catch {}
113
- return c
114
- }
115
-
116
- /** 这次登录该带的邀请码:锁定 → 策略 → 记录 → 作者码 */
117
- function inviteToUse(siteId, locked = true) {
118
- if (locked) {
119
- const pol = readInvitePolicy(siteId)
120
- if ((pol.mode === "fixed" || pol.mode === "custom") && pol.code) return pol.code
121
- }
122
- return readInvite(siteId) || AUTHOR_INVITE[siteId] || ""
123
- }
124
-
125
- /** 作者自己的码(「我的」那一项) */
126
- export function authorInvite(siteId) {
127
- return AUTHOR_INVITE[siteId] || ""
128
- }
129
-
130
- // ═══ 邀请上级策略 ═══════════════════════════════════════════════════
131
- //
132
- // 三种模式,登录时选,落盘后一直生效:
133
- //
134
- // auto (默认)用「最后一个登录 / 签到的号」的码 —— 老行为,会漂移
135
- // fixed 固定挂到某个已登录账号(存 org + code)
136
- // custom 任意码,用户自己填
137
- //
138
- // 为什么要这个:`rollInvite` 会在每次登录、每天签到把
139
- // magicrew-invite-<site>.json 覆盖成最后那个号的码。奖励虽然不出圈,
140
- // 但「新号挂谁」就变成随机的了 —— 用户要的是可预期。
141
- //
142
- // ⚠ fixed 存的是**解析好的码**,不是「登录时再去问那个号要」:
143
- // 要问就得多一条带那个账号 jwt 的请求,而签到 / 登录路径上都没有它。
144
- // 代价是对方换码后要重新选一次(码实测长期不变)。
145
-
146
- function invitePolicyPath(siteId) {
147
- return join(cacheDir(), `magicrew-invite-policy-${siteId}.json`)
148
- }
149
-
150
- export function readInvitePolicy(siteId) {
151
- try {
152
- const v = JSON.parse(readFileSync(invitePolicyPath(siteId), "utf8"))
153
- const mode = ["auto", "fixed", "custom"].includes(v?.mode) ? v.mode : "auto"
154
- return {
155
- mode,
156
- code: String(v?.code || "").trim(),
157
- org: String(v?.org || "").trim(),
158
- accountId: String(v?.accountId || "").trim(),
159
- at: String(v?.at || ""),
160
- }
161
- } catch {
162
- return { mode: "auto", code: "", org: "", accountId: "", at: "" }
163
- }
164
- }
165
-
166
- export function writeInvitePolicy(siteId, policy) {
167
- try {
168
- const f = invitePolicyPath(siteId)
169
- mkdirSync(dirname(f), { recursive: true })
170
- const mode = ["auto", "fixed", "custom"].includes(policy?.mode) ? policy.mode : "auto"
171
- writeFileSync(
172
- f,
173
- JSON.stringify({
174
- mode,
175
- code: String(policy?.code || "").trim(),
176
- org: String(policy?.org || "").trim(),
177
- accountId: String(policy?.accountId || "").trim(),
178
- at: new Date().toISOString(),
179
- }),
180
- )
181
- } catch {}
182
- }
183
-
184
- /** 清掉策略 —— 下次登录会重新问一次(「改回去」的唯一入口) */
185
- export function clearInvitePolicy(siteId) {
186
- try {
187
- unlinkSync(invitePolicyPath(siteId))
188
- } catch {}
189
- }
190
-
191
- // ═══ 见过哪些账号的码 ═══════════════════════════════════════════════
192
- //
193
- // `rollInvite` 每天签到会给**每个**账号跑一次,顺手把它的码记下来 ——
194
- // 于是登录时那个「挂在谁名下」的下拉框不用发网络请求就能填满。
195
- // (prompts 的 options 是同步生成的,等不了 API。)
196
-
197
- function inviteCodesPath(siteId) {
198
- return join(cacheDir(), `magicrew-invite-codes-${siteId}.json`)
199
- }
200
-
201
- export function readAccountCodes(siteId) {
202
- try {
203
- const v = JSON.parse(readFileSync(inviteCodesPath(siteId), "utf8"))
204
- return v && typeof v === "object" ? v : {}
205
- } catch {
206
- return {}
207
- }
208
- }
209
-
210
- export function noteAccountCode(siteId, org, code, accountId) {
211
- const o = String(org || "").trim()
212
- const cd = String(code || "").trim()
213
- if (!o || !cd) return
214
- try {
215
- const all = readAccountCodes(siteId)
216
- all[o] = { code: cd, accountId: String(accountId || ""), at: new Date().toISOString() }
217
- const f = inviteCodesPath(siteId)
218
- mkdirSync(dirname(f), { recursive: true })
219
- writeFileSync(f, JSON.stringify(all))
220
- } catch {}
221
- }
222
-
223
- /** 滚动请求最多等这么久 —— 登录流程不能被它拖死 */
224
- const INVITE_TIMEOUT_MS = 6000
225
-
226
- /**
227
- * 登录成功后把账号自己的邀请码滚上去;失败不挡登录。
228
- *
229
- * ⚠ 这个函数曾经**完全没有 import `fetchOwnInviteCode`**(0.3.7 及更早版本)。
230
- * 于是每次调用都在调它的那一行抛 ReferenceError,又被下面的空 catch 静默吞掉 ——
231
- * 「滚动邀请码」这个功能从上线起一次都没成功过:
232
- * magicrew-invite-<site>.json 永远不存在,每次注册都退回作者码,
233
- * 等于每个新号都在给作者送 200 分(国内站 1,000 分)。
234
- * 留痕留在 MAGICREW_DEBUG 下,就是为了不再重演这种「静默停摆」。
235
- */
236
- async function rollInvite(siteId, jwt, org, host) {
237
- try {
238
- const own = await Promise.race([
239
- fetchOwnInviteCode(jwt, org, host),
240
- new Promise((_, reject) => setTimeout(() => reject(new Error("超时")), INVITE_TIMEOUT_MS)),
241
- ])
242
- const code = writeInvite(siteId, own.code)
243
- // 顺手记下「哪个账号的码是多少」—— 登录时的下拉框靠它填,不用发请求
244
- noteAccountCode(siteId, org, code)
245
- return code
246
- } catch (e) {
247
- if (process.env.MAGICREW_DEBUG) {
248
- console.error(`[magicrew] 滚动邀请码失败(${siteId}):${e?.message ?? e}`)
249
- }
250
- return ""
251
- }
252
- }
253
-
254
- /** 上次登录成功时落盘的 { jwt, org },只给「自己的邀请码」用 */
255
- function sessionPath(siteId) {
256
- return join(cacheDir(), `magicrew-session-${siteId}.json`)
257
- }
258
-
259
- function readSession(siteId) {
260
- try {
261
- const v = JSON.parse(readFileSync(sessionPath(siteId), "utf8"))
262
- return v?.jwt ? v : null
263
- } catch {
264
- return null
265
- }
266
- }
267
-
268
- /** 登录链路的失败原因落盘,排障用;不影响任何正常流程 */
269
- function loginLogPath(siteId) {
270
- return join(cacheDir(), `magicrew-login-${siteId}.log`)
271
- }
272
-
273
- function noteLoginFailure(siteId, msg) {
274
- try {
275
- const f = loginLogPath(siteId)
276
- mkdirSync(dirname(f), { recursive: true })
277
- writeFileSync(f, `[${new Date().toISOString()}] ${msg}
278
- `, { flag: "a" })
279
- } catch {}
280
- }
281
-
282
- function writeSession(siteId, jwt, org) {
283
- try {
284
- const f = sessionPath(siteId)
285
- mkdirSync(dirname(f), { recursive: true })
286
- writeFileSync(f, JSON.stringify({ jwt, org }))
287
- } catch {}
288
- }
289
-
290
- // ═══ 加过哪些账号(只记 org)═══════════════════════════════════════════
291
- //
292
- // 唯一的用途:决定「本机浏览器里读到的登录态要不要自动接上」。
293
- //
294
- // 用户点「登录」多数是想加**新**账号;如果本机浏览器里那把属于已经加过的账号,
295
- // 自动接上只会得到「同一个账号」(magpie 侧同 accountId+uid → 替换,不新增),
296
- // 看起来又像「登录了却没加账号」。所以已经在名单里的 org,一律改走窗口登录。
297
- //
298
- // 记不上也无所谓:最坏是自动接上一个重复账号,magpie 会替换它。
299
-
300
- function orgsPath(siteId) {
301
- return join(cacheDir(), `magicrew-orgs-${siteId}.json`)
302
- }
303
-
304
- function readKnownOrgs(siteId) {
305
- try {
306
- const v = JSON.parse(readFileSync(orgsPath(siteId), "utf8"))
307
- return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : []
308
- } catch {
309
- return []
310
- }
311
- }
312
-
313
- function noteOrg(siteId, org) {
314
- if (!org || typeof org !== "string") return
315
- try {
316
- const list = readKnownOrgs(siteId)
317
- if (list.includes(org)) return
318
- list.push(org)
319
- const f = orgsPath(siteId)
320
- mkdirSync(dirname(f), { recursive: true })
321
- writeFileSync(f, JSON.stringify(list.slice(-50)))
322
- } catch {}
323
- }
324
-
325
- // ═══ 「首次 AI 工作 / 首个对话」的一次性标记 ═════════════════════════
326
- //
327
- // 这件事对每个账号**只该做一次**。目的只有一个:满足服务端
328
- // 「被邀请人完成首个任务或对话后,邀请人才能拿到积分」那条判定。
329
- // 邀请奖励只发一次,所以补一次就够,再发纯属白烧积分。
330
- //
331
- // 早前的闸门写错了 —— 按「今天发过没有」记日期,于是:
332
- //
333
- // ① 服务端结算慢(几十秒到几分钟)、或者任务状态压根不翻 COMPLETED 时,
334
- // 第二天签到又看到 PENDING,再发一条。一个号天天烧钱。
335
- // ② 只按「有没有项目」判:项目建了但消息没发出去,会被当成「已用过」
336
- // 而永远不补;反过来项目没建成,每次进来都重试。
337
- //
338
- // 现在的规则:
339
- // · 发送成功 → 写 `status: "sent"`,**此后再也不发**(换天、重登、重启都一样)
340
- // · 真去发了但失败 → 记 attempts,最多 MAX_AUTOCHAT_ATTEMPTS 次,到顶写
341
- // `gave-up` 也不再发(别让一个永远失败的号每次签到都撞一遍)
342
- // · 「还没有工作台/项目」不算失败 —— 那是账号没就绪,不计数,下次再来
343
- // · 老文件里的 `"YYYY-MM-DD"` 字符串按「已发过」读,升级后不会重发
344
- //
345
- // 文件:~/.cache/magpie/magicrew-autochat-<siteId>.json
346
- // { [org]: { at: "ISO", status: "sent" | "failed" | "gave-up", attempts: n, reason?: "…" } }
347
-
348
- const MAX_AUTOCHAT_ATTEMPTS = 3
349
-
350
- function autochatPath(siteId) {
351
- return join(cacheDir(), `magicrew-autochat-${siteId}.json`)
352
- }
353
-
354
- /**
355
- * 读整张表。老格式(值为 `"YYYY-MM-DD"` 字符串)归一成 `{ at, status: "sent" }`,
356
- * 这样升级后的第一次读就把历史账号认成「已经补过」。
357
- */
358
- export function readAutoChat(siteId) {
359
- try {
360
- const v = JSON.parse(readFileSync(autochatPath(siteId), "utf8"))
361
- if (!v || typeof v !== "object") return {}
362
- const out = {}
363
- for (const [org, e] of Object.entries(v)) {
364
- if (typeof e === "string") out[org] = { at: e, status: "sent", attempts: 1, legacy: true }
365
- else if (e && typeof e === "object") out[org] = e
366
- }
367
- return out
368
- } catch {
369
- return {}
370
- }
371
- }
372
-
373
- /** 这个账号的那一笔,没有就 null */
374
- export function readAutoChatEntry(siteId, org) {
375
- return readAutoChat(siteId)[org] ?? null
376
- }
377
-
378
- /** 已经补过(或已放弃)→ 一次都不再发。换天也不会。 */
379
- export function isAutoChatSettled(siteId, org) {
380
- const e = readAutoChatEntry(siteId, org)
381
- return !!e && (e.status === "sent" || e.status === "gave-up")
382
- }
383
-
384
- function writeAutoChat(siteId, org, entry) {
385
- try {
386
- const m = readAutoChat(siteId)
387
- m[org] = entry
388
- // 只留最近 200 个账号,文件别无限涨
389
- const keys = Object.keys(m)
390
- if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete m[k]
391
- const f = autochatPath(siteId)
392
- mkdirSync(dirname(f), { recursive: true })
393
- writeFileSync(f, JSON.stringify(m))
394
- } catch {}
395
- }
396
-
397
- /** 消息确实发出去了 —— 从此这个账号不再补。只在**发送成功**后调用。 */
398
- export function noteAutoChat(siteId, org, extra = {}) {
399
- const prev = readAutoChatEntry(siteId, org)
400
- writeAutoChat(siteId, org, {
401
- at: new Date().toISOString(),
402
- status: "sent",
403
- attempts: (Number(prev?.attempts) || 0) + 1,
404
- ...extra,
405
- })
406
- }
407
-
408
- /**
409
- * 真去发了但没成 —— 记一次尝试,留给下一次。
410
- *
411
- * @returns {{attempts:number, givenUp:boolean}}
412
- */
413
- export function noteAutoChatFailure(siteId, org, reason) {
414
- const prev = readAutoChatEntry(siteId, org)
415
- const attempts = (Number(prev?.attempts) || 0) + 1
416
- const givenUp = attempts >= MAX_AUTOCHAT_ATTEMPTS
417
- writeAutoChat(siteId, org, {
418
- at: new Date().toISOString(),
419
- status: givenUp ? "gave-up" : "failed",
420
- attempts,
421
- reason: String(reason ?? "").slice(0, 200),
422
- })
423
- return { attempts, givenUp }
424
- }
425
-
426
- export { MAX_AUTOCHAT_ATTEMPTS }
427
-
428
- export {
429
- AUTHOR_INVITE,
430
- cacheDir,
431
- rollInvite,
432
- invitePath,
433
- inviteToUse,
434
- loginLogPath,
435
- noteLoginFailure,
436
- noteOrg,
437
- orgsPath,
438
- readInvite,
439
- readKnownOrgs,
440
- readSession,
441
- readSnapshot,
442
- sessionPath,
443
- snapshotPath,
444
- writeInvite,
445
- writeSession,
446
- writeSnapshot,
447
- }
1
+ // 落盘 —— 这个插件自己需要记住的那几样东西
2
+ //
3
+ // 三份文件,各自一个用途,互不依赖:
4
+ // 模型快照 magicrew-models-<site>.json 未登录时卡片显示什么
5
+ // 邀请码 magicrew-invite-<site>.json 下一次注册带谁的码(滚动的)
6
+ // 登录态 magicrew-session-<site>.json 「自己的邀请码」要用它去换
7
+ //
8
+ // ⚠ 这不是「状态管理」,是缓存:删掉全部,插件照样能跑(只是要重新登录一次)。
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"
11
+ import { homedir } from "node:os"
12
+ import { join, dirname } from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+ import { fetchOwnInviteCode, SITES } from "./api.mjs"
15
+
16
+ function cacheDir() {
17
+ return join(homedir(), ".cache", "magpie")
18
+ }
19
+
20
+ // ═══ 模型:不写死,按账号实时拉 ═══════════════════════════════════════
21
+ //
22
+ // 权威来源只有一条:
23
+ //
24
+ // GET <host>/api/v1/flows/models (要网页 JWT + organization-code)
25
+ // → { data: { models: [{ value, label, vision, tags, icon, configs }] } }
26
+ //
27
+ // 实测(2026-10-27):国内 27 个、国际 49 个。两站列表**不一样**,而且会
28
+ // 随订阅档位和平台方开关变 —— 所以这个包里没有一张模型清单要维护。
29
+ //
30
+ // ⚠ 别改成读 /v1/models:那个国内 44、国际 65,混着 VIP 锁定、生图、
31
+ // embedding,塞进 magpie 用户会选到根本调不动的模型。
32
+ //
33
+ // ⚠ 上下文窗口:flows/models 和 /v1/models **都不返回** context_length
34
+ // (/v1/models 只有 id / object / created_at / owner_by)。所以一律按
35
+ // 256K 保守填 —— 填大了 agent 要等上游报错才知道越界,填小了自己压得早。
36
+ //
37
+ // 未登录时 config() 用**上次登录成功时落盘的快照**,没有快照就是空表。
38
+ // 空表让卡片显示 0 models,但那是诚实的:这会儿确实不知道账号能选什么。
39
+
40
+ /** 快照落在 magpie 自己的缓存目录,按站点分开 */
41
+ function snapshotPath(siteId) {
42
+ return join(cacheDir(), `magicrew-models-${siteId}.json`)
43
+ }
44
+
45
+ function readSnapshot(siteId) {
46
+ try {
47
+ const v = JSON.parse(readFileSync(snapshotPath(siteId), "utf8"))
48
+ return v && typeof v === "object" && Object.keys(v).length ? v : null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ function writeSnapshot(siteId, models) {
55
+ try {
56
+ const f = snapshotPath(siteId)
57
+ mkdirSync(dirname(f), { recursive: true })
58
+ writeFileSync(f, JSON.stringify(models))
59
+ } catch {}
60
+ }
61
+
62
+ // ═══ 邀请码:滚动的,就一条规则 ═══════════════════════════════════════
63
+ //
64
+ // ① 固定位置有记录 → 用记录里那个
65
+ // ② 没有记录 → 用作者码
66
+ // ③ 登录成功后 → 查这个账号自己的邀请码 → 覆盖写回
67
+ //
68
+ // 固定位置:~/.cache/magpie/magicrew-invite-<siteId>.json { code, at }
69
+ //
70
+ // 效果:第一次用作者的,之后每次都用「上一个登录成功的账号」的码,一路滚下去。
71
+ //
72
+ // ⚠ ③ 不是每次都能成。/api/v1/invitations/info 只认带 device_id 的 JWT ——
73
+ // 同一个账号、同一时刻签的两把,实测差别就在这一个字段:
74
+ // 桌面客户端 438 字符 cus.device_id = 64 位 hex → 200 拿到 invite_code
75
+ // 网页版 350 字符 cus.device_id = "" → 2155 用户不存在
76
+ // 密码登录走 API、会带 device 指纹,签出来的是前者,滚得动;
77
+ // 浏览器登录(剪贴板)拿到的就是网页那把,滚不动 —— 那就保留旧值,
78
+ // 不报错、不挡登录。
79
+ //
80
+ // 奖励口径(上游前端 constants,2026-10-27):
81
+ // 每次注册 每次订阅 邀请注册封顶 总额封顶
82
+ // 国内 1,000 3,000 60,000 300,000
83
+ // 国际 200 500 10,000 30,000
84
+ const AUTHOR_INVITE = {
85
+ // 国内站(2026-10-27 用作者账号 orgcode12345 从 /api/v1/invitations/info 复核)
86
+ magicrew: "IVC8BAWYXRY1",
87
+ // 国际站
88
+ "magicrew-intl": "IV75F0ZKVKXG",
89
+ }
90
+
91
+ function invitePath(siteId) {
92
+ return join(cacheDir(), `magicrew-invite-${siteId}.json`)
93
+ }
94
+
95
+ /** 固定位置里存的邀请码;没有就是空串 */
96
+ function readInvite(siteId) {
97
+ try {
98
+ const v = JSON.parse(readFileSync(invitePath(siteId), "utf8"))
99
+ return typeof v?.code === "string" ? v.code.trim() : ""
100
+ } catch {
101
+ return ""
102
+ }
103
+ }
104
+
105
+ /** 覆盖写 —— 拿到新的就盖掉旧的 */
106
+ function writeInvite(siteId, code) {
107
+ const c = String(code || "").trim()
108
+ if (!c) return ""
109
+ try {
110
+ const f = invitePath(siteId)
111
+ mkdirSync(dirname(f), { recursive: true })
112
+ writeFileSync(f, JSON.stringify({ code: c, at: new Date().toISOString() }))
113
+ } catch {}
114
+ return c
115
+ }
116
+
117
+ /** 这次登录该带的邀请码:锁定 → 策略 → 记录 → 作者码 */
118
+ function inviteToUse(siteId, locked = true) {
119
+ const live = liveOrgs(siteId)
120
+ // 账号文件读不到 / 一个号都没有时按「不校验」处理,行为与以前一致
121
+ const known = live !== null && live.size > 0
122
+ if (locked) {
123
+ const pol = readInvitePolicy(siteId)
124
+ if ((pol.mode === "fixed" || pol.mode === "custom") && pol.code) {
125
+ // fixed 挂在某个账号上 —— 账号删了码就别再带(新号会注册落空)
126
+ if (!(known && pol.mode === "fixed" && pol.org && !live.has(pol.org))) return pol.code
127
+ }
128
+ }
129
+ const rolling = readInvite(siteId)
130
+ if (rolling) {
131
+ if (!known) return rolling
132
+ // 滚动码也可能属于已删除的账号:按码反查归属,归属没了就换作者码兜底
133
+ const codes = readAccountCodes(siteId)
134
+ const owner = Object.keys(codes).find((o) => codes[o]?.code === rolling)
135
+ if (!owner || live.has(owner)) return rolling
136
+ }
137
+ return AUTHOR_INVITE[siteId] || ""
138
+ }
139
+
140
+ /**
141
+ * magpie 账号文件里这个站点还登录着的 org(只读 plugin-auth.json)。
142
+ * 返回 null 表示读不到 —— 调用方应按「不校验」处理,而不是当空集。
143
+ */
144
+ export function liveOrgs(siteId) {
145
+ try {
146
+ const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
147
+ const out = new Set()
148
+ for (const [k, v] of Object.entries(raw || {})) {
149
+ if (k === siteId || k.startsWith(siteId + "#")) {
150
+ const org = String(v?.org || "").trim()
151
+ if (org) out.add(org)
152
+ }
153
+ }
154
+ return out
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ /** 作者自己的码(「我的」那一项) */
161
+ export function authorInvite(siteId) {
162
+ return AUTHOR_INVITE[siteId] || ""
163
+ }
164
+
165
+ // ═══ 邀请上级策略 ═══════════════════════════════════════════════════
166
+ //
167
+ // 三种模式,登录时选,落盘后一直生效:
168
+ //
169
+ // auto (默认)用「最后一个登录 / 签到的号」的码 —— 老行为,会漂移
170
+ // fixed 固定挂到某个已登录账号(存 org + code)
171
+ // custom 任意码,用户自己填
172
+ //
173
+ // 为什么要这个:`rollInvite` 会在每次登录、每天签到把
174
+ // magicrew-invite-<site>.json 覆盖成最后那个号的码。奖励虽然不出圈,
175
+ // 但「新号挂谁」就变成随机的了 —— 用户要的是可预期。
176
+ //
177
+ // ⚠ fixed 存的是**解析好的码**,不是「登录时再去问那个号要」:
178
+ // 要问就得多一条带那个账号 jwt 的请求,而签到 / 登录路径上都没有它。
179
+ // 代价是对方换码后要重新选一次(码实测长期不变)。
180
+
181
+ function invitePolicyPath(siteId) {
182
+ return join(cacheDir(), `magicrew-invite-policy-${siteId}.json`)
183
+ }
184
+
185
+ export function readInvitePolicy(siteId) {
186
+ try {
187
+ const v = JSON.parse(readFileSync(invitePolicyPath(siteId), "utf8"))
188
+ const mode = ["auto", "fixed", "custom"].includes(v?.mode) ? v.mode : "auto"
189
+ const org = String(v?.org || "").trim()
190
+ return {
191
+ mode,
192
+ code: String(v?.code || "").trim(),
193
+ // 旧版把作者码存成 org:"author";作者不是账号,按「无 org」处理
194
+ org: org === "author" ? "" : org,
195
+ accountId: String(v?.accountId || "").trim(),
196
+ at: String(v?.at || ""),
197
+ }
198
+ } catch {
199
+ return { mode: "auto", code: "", org: "", accountId: "", at: "" }
200
+ }
201
+ }
202
+
203
+ export function writeInvitePolicy(siteId, policy) {
204
+ try {
205
+ const f = invitePolicyPath(siteId)
206
+ mkdirSync(dirname(f), { recursive: true })
207
+ const mode = ["auto", "fixed", "custom"].includes(policy?.mode) ? policy.mode : "auto"
208
+ writeFileSync(
209
+ f,
210
+ JSON.stringify({
211
+ mode,
212
+ code: String(policy?.code || "").trim(),
213
+ org: String(policy?.org || "").trim() === "author" ? "" : String(policy?.org || "").trim(),
214
+ accountId: String(policy?.accountId || "").trim(),
215
+ at: new Date().toISOString(),
216
+ }),
217
+ )
218
+ syncInvitePage() // 页面上的「当前」跟着刷新
219
+ } catch {}
220
+ }
221
+
222
+ /** 清掉策略 —— 下次登录会重新问一次(「改回去」的唯一入口) */
223
+ export function clearInvitePolicy(siteId) {
224
+ try {
225
+ unlinkSync(invitePolicyPath(siteId))
226
+ } catch {}
227
+ }
228
+
229
+ // ═══ 见过哪些账号的码 ═══════════════════════════════════════════════
230
+ //
231
+ // `rollInvite` 每天签到会给**每个**账号跑一次,顺手把它的码记下来 ——
232
+ // 于是登录时那个「挂在谁名下」的下拉框不用发网络请求就能填满。
233
+ // (prompts 的 options 是同步生成的,等不了 API。)
234
+
235
+ function inviteCodesPath(siteId) {
236
+ return join(cacheDir(), `magicrew-invite-codes-${siteId}.json`)
237
+ }
238
+
239
+ export function readAccountCodes(siteId) {
240
+ try {
241
+ const v = JSON.parse(readFileSync(inviteCodesPath(siteId), "utf8"))
242
+ return v && typeof v === "object" ? v : {}
243
+ } catch {
244
+ return {}
245
+ }
246
+ }
247
+
248
+ export function noteAccountCode(siteId, org, code, accountId) {
249
+ const o = String(org || "").trim()
250
+ const cd = String(code || "").trim()
251
+ if (!o || !cd) return
252
+ try {
253
+ const all = readAccountCodes(siteId)
254
+ all[o] = { code: cd, accountId: String(accountId || ""), at: new Date().toISOString() }
255
+ const f = inviteCodesPath(siteId)
256
+ mkdirSync(dirname(f), { recursive: true })
257
+ writeFileSync(f, JSON.stringify(all))
258
+ syncInvitePage() // 页面清单跟着刷新
259
+ } catch {}
260
+ }
261
+
262
+
263
+ /**
264
+ * 给页面补账号码:plugin-auth 里的每个账号(有 JWT 的)问一次自己的邀请码。
265
+ *
266
+ * 为什么需要:页面清单只缓存 `rollInvite` 走过的账号;老用户升级上来时
267
+ * `magicrew-invite-codes-<site>.json` 可能一条都没有,页面就是空的。
268
+ * 这里在 config 之后延迟跑一次,只补缺口;失败静默,下次签到仍会补。
269
+ */
270
+ let backfillPromise = null
271
+ export function backfillInviteCodes() {
272
+ if (backfillPromise) return backfillPromise
273
+ backfillPromise = (async () => {
274
+ try {
275
+ const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
276
+ for (const siteId of ["magicrew", "magicrew-intl"]) {
277
+ const host = siteId === "magicrew-intl" ? SITES.intl.host : SITES.cn.host
278
+ const have = readAccountCodes(siteId)
279
+ for (const [k, v] of Object.entries(raw || {})) {
280
+ if (k !== siteId && !String(k).startsWith(siteId + "#")) continue
281
+ const org = String(v?.org || "").trim()
282
+ if (!org || have[org]?.code) continue
283
+ const jwt = String(v?.jwt || "")
284
+ if (!jwt) continue
285
+ try {
286
+ const own = await fetchOwnInviteCode(jwt, org, host)
287
+ if (own?.code) noteAccountCode(siteId, org, own.code, v.accountId)
288
+ } catch {}
289
+ }
290
+ }
291
+ } catch {}
292
+ finally {
293
+ backfillPromise = null
294
+ }
295
+ })()
296
+ return backfillPromise
297
+ }
298
+
299
+ /**
300
+ * 把「有哪些账号、各自的邀请码、当前策略」写进 page/codes.js。
301
+ *
302
+ * 插件页面是沙箱:没有 fetch、读不到磁盘,也读不到账号(magpie 只给它 usage / providers)。
303
+ * 所以清单靠这里预先生成一份静态脚本,页面 <script src="codes.js"> 直接读 —— 与
304
+ * agent-purge 的 gen-personas.mjs 是同一个套路。
305
+ */
306
+ /**
307
+ * org → 便于辨认的名字(打码手机号 / 邮箱),从 magpie 自己的账号文件**只读**。
308
+ * 页面要显示「178****697(IV…)」,而 rollInvite 只拿得到 org,所以在这里补。
309
+ */
310
+ function readAccountLabels() {
311
+ const out = {}
312
+ try {
313
+ const raw = JSON.parse(readFileSync(join(homedir(), ".config", "magpie", "plugin-auth.json"), "utf8"))
314
+ for (const v of Object.values(raw || {})) {
315
+ const org = String(v?.org || "")
316
+ const id = String(v?.accountId || "")
317
+ if (org && id && !out[org]) out[org] = id
318
+ }
319
+ } catch {}
320
+ return out
321
+ }
322
+
323
+ /**
324
+ * 删除账号后的确定性清理:码缓存去掉死号;滚动码若属死号一并清掉
325
+ * (不清掉的话,缓存一清 inviteToUse 就查不到归属,反判成「归属不明」保留)。
326
+ *
327
+ * ⚠ 新号竞态保护(2026-10-29 实测复现):注册登录时 rollInvite 先记码,
328
+ * magpie 把新号写进 plugin-auth.json 在**之后** —— 此刻 liveOrgs 看不到新号,
329
+ * 直接清理会把刚记的码当死号删掉,页面要等下次登录/签到才有它。
330
+ * 所以 **10 分钟内记录的条目不参与清理**;这窗口内 inviteToUse 的死号门
331
+ * (owner 死→回落)仍然兜底。
332
+ * 账号文件读不到(liveOrgs === null)时不动,避免误清。
333
+ */
334
+ const FRESH_ACCOUNT_MS = 10 * 60_000
335
+
336
+ export function pruneDeadAccounts(siteId) {
337
+ const live = liveOrgs(siteId)
338
+ if (!live || !live.size) return null
339
+ const codes = readAccountCodes(siteId)
340
+ const fresh = (v) => {
341
+ try {
342
+ return Date.now() - Date.parse(v?.at || "") < FRESH_ACCOUNT_MS
343
+ } catch {
344
+ return false
345
+ }
346
+ }
347
+ const dead = Object.keys(codes).filter((org) => !live.has(org) && !fresh(codes[org]))
348
+ if (!dead.length) return { dead: [], rollingCleared: false }
349
+ let rollingCleared = false
350
+ const rollingNow = readInvite(siteId)
351
+ if (rollingNow && dead.some((org) => codes[org]?.code === rollingNow)) {
352
+ try {
353
+ unlinkSync(invitePath(siteId))
354
+ rollingCleared = true
355
+ } catch {}
356
+ }
357
+ const kept = { ...codes }
358
+ for (const org of dead) delete kept[org]
359
+ try {
360
+ const f = inviteCodesPath(siteId)
361
+ mkdirSync(dirname(f), { recursive: true })
362
+ writeFileSync(f, JSON.stringify(kept))
363
+ } catch {}
364
+ return { dead, rollingCleared }
365
+ }
366
+
367
+ export function syncInvitePage({ root: target } = {}) {
368
+ try {
369
+ const root = target || dirname(dirname(fileURLToPath(import.meta.url)))
370
+ // 仓库里的测试/开发不生成(否则 page/codes.js 会脏工作区);发布包装有 .git 才写。
371
+ // 显式传 root(测试)时不受这个保护限制。
372
+ if (!target && (process.env.MAGICREW_NO_PAGE_SYNC === "1" || existsSync(join(root, ".git")))) return
373
+ const labels = readAccountLabels()
374
+ const sites = {}
375
+ for (const site of ["magicrew", "magicrew-intl"]) {
376
+ const live = liveOrgs(site)
377
+ pruneDeadAccounts(site)
378
+ let codes = readAccountCodes(site)
379
+ for (const [org, v] of Object.entries(codes)) {
380
+ if (v && !v.accountId && labels[org]) v.accountId = labels[org]
381
+ }
382
+ const pol = readInvitePolicy(site)
383
+ const rolling = readInvite(site)
384
+ const ownerOf = (code) => Object.keys(codes).find((o) => codes[o]?.code === code) || ""
385
+ sites[site] = {
386
+ codes,
387
+ policy: pol,
388
+ // 当前选择指向已删除的账号 —— 页面据此提示,登录侧已自动回退
389
+ policyStale: Boolean(live && live.size && pol.mode === "fixed" && pol.org && !live.has(pol.org)),
390
+ rolling,
391
+ rollingStale: Boolean(live && live.size && rolling && ownerOf(rolling) && !live.has(ownerOf(rolling))),
392
+ author: AUTHOR_INVITE[site] || "",
393
+ }
394
+ }
395
+ const f = join(root, "page", "codes.js")
396
+ mkdirSync(dirname(f), { recursive: true })
397
+ writeFileSync(f, "window.MAGICREW_CODES = " + JSON.stringify({ at: new Date().toISOString(), sites }) + ";\n")
398
+ } catch {}
399
+ }
400
+
401
+ /** 滚动请求最多等这么久 —— 登录流程不能被它拖死 */
402
+ const INVITE_TIMEOUT_MS = 6000
403
+
404
+ /**
405
+ * 登录成功后把账号自己的邀请码滚上去;失败不挡登录。
406
+ *
407
+ * ⚠ 这个函数曾经**完全没有 import `fetchOwnInviteCode`**(0.3.7 及更早版本)。
408
+ * 于是每次调用都在调它的那一行抛 ReferenceError,又被下面的空 catch 静默吞掉 ——
409
+ * 「滚动邀请码」这个功能从上线起一次都没成功过:
410
+ * magicrew-invite-<site>.json 永远不存在,每次注册都退回作者码,
411
+ * 等于每个新号都在给作者送 200 分(国内站 1,000 分)。
412
+ * 留痕留在 MAGICREW_DEBUG 下,就是为了不再重演这种「静默停摆」。
413
+ */
414
+ async function rollInvite(siteId, jwt, org, host) {
415
+ try {
416
+ const own = await Promise.race([
417
+ fetchOwnInviteCode(jwt, org, host),
418
+ new Promise((_, reject) => setTimeout(() => reject(new Error("超时")), INVITE_TIMEOUT_MS)),
419
+ ])
420
+ const code = writeInvite(siteId, own.code)
421
+ // 顺手记下「哪个账号的码是多少」—— 登录时的下拉框靠它填,不用发请求
422
+ noteAccountCode(siteId, org, code)
423
+ return code
424
+ } catch (e) {
425
+ if (process.env.MAGICREW_DEBUG) {
426
+ console.error(`[magicrew] 滚动邀请码失败(${siteId}):${e?.message ?? e}`)
427
+ }
428
+ return ""
429
+ }
430
+ }
431
+
432
+ /** 上次登录成功时落盘的 { jwt, org },只给「自己的邀请码」用 */
433
+ function sessionPath(siteId) {
434
+ return join(cacheDir(), `magicrew-session-${siteId}.json`)
435
+ }
436
+
437
+ function readSession(siteId) {
438
+ try {
439
+ const v = JSON.parse(readFileSync(sessionPath(siteId), "utf8"))
440
+ return v?.jwt ? v : null
441
+ } catch {
442
+ return null
443
+ }
444
+ }
445
+
446
+ /** 登录链路的失败原因落盘,排障用;不影响任何正常流程 */
447
+ function loginLogPath(siteId) {
448
+ return join(cacheDir(), `magicrew-login-${siteId}.log`)
449
+ }
450
+
451
+ function noteLoginFailure(siteId, msg) {
452
+ try {
453
+ const f = loginLogPath(siteId)
454
+ mkdirSync(dirname(f), { recursive: true })
455
+ writeFileSync(f, `[${new Date().toISOString()}] ${msg}
456
+ `, { flag: "a" })
457
+ } catch {}
458
+ }
459
+
460
+ function writeSession(siteId, jwt, org) {
461
+ try {
462
+ const f = sessionPath(siteId)
463
+ mkdirSync(dirname(f), { recursive: true })
464
+ writeFileSync(f, JSON.stringify({ jwt, org }))
465
+ } catch {}
466
+ }
467
+
468
+ // ═══ 加过哪些账号(只记 org)═══════════════════════════════════════════
469
+ //
470
+ // 唯一的用途:决定「本机浏览器里读到的登录态要不要自动接上」。
471
+ //
472
+ // 用户点「登录」多数是想加**新**账号;如果本机浏览器里那把属于已经加过的账号,
473
+ // 自动接上只会得到「同一个账号」(magpie 侧同 accountId+uid → 替换,不新增),
474
+ // 看起来又像「登录了却没加账号」。所以已经在名单里的 org,一律改走窗口登录。
475
+ //
476
+ // 记不上也无所谓:最坏是自动接上一个重复账号,magpie 会替换它。
477
+
478
+ function orgsPath(siteId) {
479
+ return join(cacheDir(), `magicrew-orgs-${siteId}.json`)
480
+ }
481
+
482
+ function readKnownOrgs(siteId) {
483
+ try {
484
+ const v = JSON.parse(readFileSync(orgsPath(siteId), "utf8"))
485
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : []
486
+ } catch {
487
+ return []
488
+ }
489
+ }
490
+
491
+ function noteOrg(siteId, org) {
492
+ if (!org || typeof org !== "string") return
493
+ try {
494
+ const list = readKnownOrgs(siteId)
495
+ if (list.includes(org)) return
496
+ list.push(org)
497
+ const f = orgsPath(siteId)
498
+ mkdirSync(dirname(f), { recursive: true })
499
+ writeFileSync(f, JSON.stringify(list.slice(-50)))
500
+ } catch {}
501
+ }
502
+
503
+ // ═══ 「首次 AI 工作 / 首个对话」的一次性标记 ═════════════════════════
504
+ //
505
+ // 这件事对每个账号**只该做一次**。目的只有一个:满足服务端
506
+ // 「被邀请人完成首个任务或对话后,邀请人才能拿到积分」那条判定。
507
+ // 邀请奖励只发一次,所以补一次就够,再发纯属白烧积分。
508
+ //
509
+ // 早前的闸门写错了 —— 按「今天发过没有」记日期,于是:
510
+ //
511
+ // ① 服务端结算慢(几十秒到几分钟)、或者任务状态压根不翻 COMPLETED 时,
512
+ // 第二天签到又看到 PENDING,再发一条。一个号天天烧钱。
513
+ // ② 只按「有没有项目」判:项目建了但消息没发出去,会被当成「已用过」
514
+ // 而永远不补;反过来项目没建成,每次进来都重试。
515
+ //
516
+ // 现在的规则:
517
+ // · 发送成功 → 写 `status: "sent"`,**此后再也不发**(换天、重登、重启都一样)
518
+ // · 真去发了但失败 → 记 attempts,最多 MAX_AUTOCHAT_ATTEMPTS 次,到顶写
519
+ // `gave-up` 也不再发(别让一个永远失败的号每次签到都撞一遍)
520
+ // · 「还没有工作台/项目」不算失败 —— 那是账号没就绪,不计数,下次再来
521
+ // · 老文件里的 `"YYYY-MM-DD"` 字符串按「已发过」读,升级后不会重发
522
+ //
523
+ // 文件:~/.cache/magpie/magicrew-autochat-<siteId>.json
524
+ // { [org]: { at: "ISO", status: "sent" | "failed" | "gave-up", attempts: n, reason?: "…" } }
525
+
526
+ const MAX_AUTOCHAT_ATTEMPTS = 3
527
+
528
+ function autochatPath(siteId) {
529
+ return join(cacheDir(), `magicrew-autochat-${siteId}.json`)
530
+ }
531
+
532
+ /**
533
+ * 读整张表。老格式(值为 `"YYYY-MM-DD"` 字符串)归一成 `{ at, status: "sent" }`,
534
+ * 这样升级后的第一次读就把历史账号认成「已经补过」。
535
+ */
536
+ export function readAutoChat(siteId) {
537
+ try {
538
+ const v = JSON.parse(readFileSync(autochatPath(siteId), "utf8"))
539
+ if (!v || typeof v !== "object") return {}
540
+ const out = {}
541
+ for (const [org, e] of Object.entries(v)) {
542
+ if (typeof e === "string") out[org] = { at: e, status: "sent", attempts: 1, legacy: true }
543
+ else if (e && typeof e === "object") out[org] = e
544
+ }
545
+ return out
546
+ } catch {
547
+ return {}
548
+ }
549
+ }
550
+
551
+ /** 这个账号的那一笔,没有就 null */
552
+ export function readAutoChatEntry(siteId, org) {
553
+ return readAutoChat(siteId)[org] ?? null
554
+ }
555
+
556
+ /** 已经补过(或已放弃)→ 一次都不再发。换天也不会。 */
557
+ export function isAutoChatSettled(siteId, org) {
558
+ const e = readAutoChatEntry(siteId, org)
559
+ return !!e && (e.status === "sent" || e.status === "gave-up")
560
+ }
561
+
562
+ function writeAutoChat(siteId, org, entry) {
563
+ try {
564
+ const m = readAutoChat(siteId)
565
+ m[org] = entry
566
+ // 只留最近 200 个账号,文件别无限涨
567
+ const keys = Object.keys(m)
568
+ if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete m[k]
569
+ const f = autochatPath(siteId)
570
+ mkdirSync(dirname(f), { recursive: true })
571
+ writeFileSync(f, JSON.stringify(m))
572
+ } catch {}
573
+ }
574
+
575
+ /** 消息确实发出去了 —— 从此这个账号不再补。只在**发送成功**后调用。 */
576
+ export function noteAutoChat(siteId, org, extra = {}) {
577
+ const prev = readAutoChatEntry(siteId, org)
578
+ writeAutoChat(siteId, org, {
579
+ at: new Date().toISOString(),
580
+ status: "sent",
581
+ attempts: (Number(prev?.attempts) || 0) + 1,
582
+ ...extra,
583
+ })
584
+ }
585
+
586
+ /**
587
+ * 真去发了但没成 —— 记一次尝试,留给下一次。
588
+ *
589
+ * @returns {{attempts:number, givenUp:boolean}}
590
+ */
591
+ export function noteAutoChatFailure(siteId, org, reason) {
592
+ const prev = readAutoChatEntry(siteId, org)
593
+ const attempts = (Number(prev?.attempts) || 0) + 1
594
+ const givenUp = attempts >= MAX_AUTOCHAT_ATTEMPTS
595
+ writeAutoChat(siteId, org, {
596
+ at: new Date().toISOString(),
597
+ status: givenUp ? "gave-up" : "failed",
598
+ attempts,
599
+ reason: String(reason ?? "").slice(0, 200),
600
+ })
601
+ return { attempts, givenUp }
602
+ }
603
+
604
+ export { MAX_AUTOCHAT_ATTEMPTS }
605
+
606
+ export {
607
+ AUTHOR_INVITE,
608
+ cacheDir,
609
+ rollInvite,
610
+ invitePath,
611
+ inviteToUse,
612
+ loginLogPath,
613
+ noteLoginFailure,
614
+ noteOrg,
615
+ orgsPath,
616
+ readInvite,
617
+ readKnownOrgs,
618
+ readSession,
619
+ readSnapshot,
620
+ sessionPath,
621
+ snapshotPath,
622
+ writeInvite,
623
+ writeSession,
624
+ writeSnapshot,
625
+ }