quota-keepalive 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -4,12 +4,22 @@
4
4
  插件不接管任何供应商的登录,也不会把页面伪装成单一供应商专属功能;登录、模型、
5
5
  额度和签到 Hook 仍由各供应商插件维护,本插件负责统一检测、调度、执行和展示。
6
6
 
7
+ > **非官方插件声明**
8
+ >
9
+ > 本项目不是 Magpie 官方插件。签到优先调用供应商插件公开的签到接口;真实对话
10
+ > 保活不是供应商授权或保证支持的功能。保活请求通过本机已登录的供应商签名适配器
11
+ > 直接发往供应商,不经过第三方服务器,也不会导出令牌,因此降低了凭据泄露风险,
12
+ > 但不等于厂商风控风险为零。自动化合成消息仍可能被供应商识别为异常流量,并触发
13
+ > 限流、额度冻结、账号限制或封禁。默认关闭保活,仅在理解风险后按需启用,并由
14
+ > 使用者自行承担相关后果。
15
+
7
16
  ## 功能
8
17
 
9
18
  - 一键检测 Magpie 当前已登录的供应商、账号、模型和额度窗口。
10
19
  - 一键签到:通过各供应商登录插件公开的 `auth.checkin` 接口执行,尊重供应商、
11
20
  账号两级启停;页面会明确显示未适配或未登录的账号,不会静默跳过。
12
21
  - 真实对话保活:通过各供应商自己的登录插件取得签名请求,发送一条真实消息。
22
+ 默认使用“短探活”,只发送一条短消息;需要长上下文时才显式切换“长上下文”模式。
13
23
  - 模型选择:只从该账号实际列出的模型中选择。默认优先近期真实保活成功过的模型,
14
24
  再按成功率、成功时间和失败次数排序;没有历史记录时沿用供应商自己的模型顺序,
15
25
  不再按免费或价格抢先选择。第一个模型不可用时自动换下一档,全部不可用才记失败。
@@ -39,18 +49,50 @@
39
49
 
40
50
  ## 安装
41
51
 
42
- 从 GitHub 安装:
52
+ npm 安装(推荐,支持 Magpie 自动检查更新):
53
+
54
+ ```sh
55
+ magpie plugin add quota-keepalive
56
+ ```
57
+
58
+ 安装后的插件规格会显示为 `quota-keepalive@latest`。Magpie 会在启动后和之后每小时
59
+ 检查一次更新,并在插件页面提示新版本。更新方式:
60
+
61
+ ```sh
62
+ # 更新全部 npm/git 插件
63
+ magpie plugin update
64
+
65
+ # 只更新这个插件
66
+ magpie plugin add quota-keepalive
67
+ ```
68
+
69
+ 页面中的 `Update all` 与 `magpie plugin update` 等价。重新添加同一个 npm 包会替换
70
+ 旧版本,并保留插件 options、启用/停用状态和供应商偏好。已固定版本
71
+ `quota-keepalive@0.1.x` 也会在更新时升级到最新版并解除固定。
72
+
73
+ 从 GitHub 安装(不经过 npm):
43
74
 
44
75
  ```sh
45
76
  magpie plugin add github:sljdxde/quota-keepalive
46
77
  ```
47
78
 
48
- 从本地目录安装:
79
+ 从本地目录安装(仅用于开发调试):
49
80
 
50
81
  ```sh
51
82
  magpie plugin add "$(pwd)"
52
83
  ```
53
84
 
85
+ 目录安装会直接读取当前源码,适合修改后立即调试,但不会进入 npm 的版本检查,也不会
86
+ 被 `magpie plugin update` 更新。开发完成后应切换到 npm 安装。切换前先保存 npm
87
+ 条目中的 options,再停用目录条目:
88
+
89
+ ```sh
90
+ magpie plugin add quota-keepalive
91
+ magpie plugin options quota-keepalive '<原来的 options JSON>'
92
+ magpie plugin off '<目录插件的 spec>'
93
+ magpie plugin rm '<目录插件的 spec>'
94
+ ```
95
+
54
96
  插件不需要接管 provider。供应商登录仍由 Magpie 已安装的对应社区插件提供;本插件
55
97
  直接从 Magpie 的插件目录加载这些签名适配器,因此 Magpie 或供应商插件更新后,
56
98
  只要其公开接口没有变化,本插件仍可继续使用。若供应商包改名为不兼容的接口,页面
@@ -59,12 +101,28 @@ magpie plugin add "$(pwd)"
59
101
  安装后在 Magpie 左侧 `Plugins` 分组打开“额度签到”。如果页面文件刚更新,
60
102
  重新打开一次插件页即可刷新清单,不需要重启 Magpie。
61
103
 
62
- 发布到 npm 后也可以按包名安装:
104
+ ### 发布新版本
105
+
106
+ 仓库包含 `.github/workflows/publish.yml`,使用 npm Trusted Publishing,不需要把
107
+ npm token 写进仓库或 GitHub Secrets:
108
+
109
+ 1. 更新 `package.json` 版本并提交改动。
110
+ 2. 创建同名 Git tag,例如版本 `0.1.2` 对应 `v0.1.2`。
111
+ 3. 推送 `main` 和 tag。
112
+ 4. 在 GitHub 创建并发布对应的 Release。
113
+ 5. Release 发布后,工作流会重新跑测试、语法检查和 `npm pack`,再通过 OIDC
114
+ 发布到 npm。
115
+ 6. 本机等待 Magpie 的更新提示,或执行 `magpie plugin add quota-keepalive` /
116
+ `magpie plugin update`。
117
+
118
+ 本地手动发布时,npm 如果启用了双因素认证,需要附加当前动态码:
63
119
 
64
120
  ```sh
65
- magpie plugin add quota-keepalive
121
+ npm publish --access public --otp=<6 位动态码>
66
122
  ```
67
123
 
124
+ 不要把动态码、npm token 或恢复码写进仓库、README 或聊天记录。
125
+
68
126
  ## 页面操作
69
127
 
70
128
  - `一键检测`:刷新供应商、账号、模型和额度快照。
@@ -76,7 +134,10 @@ magpie plugin add quota-keepalive
76
134
  不会因为快速连续点击触发“每秒只能写入一次”的报错。
77
135
  - 圆形刷新按钮:立即刷新全部启用供应商额度;供应商卡片内的刷新按钮只刷新该供应商。
78
136
  - `保存计划`:设置统一执行间隔和任务类型。计划默认关闭,防止无意间消耗积分。
79
- - 定时计划保存或修改后,从下一个间隔开始执行;需要立即执行时使用页面顶部的手动按钮。
137
+ - `短探活/长上下文`:短探活风险、耗时和额度消耗更低;长上下文会按 `inputTokens`
138
+ 填充请求,适合明确需要维持长上下文窗口的场景。
139
+ - 定时计划保存或修改后,从下一个间隔开始执行;下次执行时间会持久化,Magpie 重启或
140
+ 电脑休眠后会补跑已经到期的任务。需要立即执行时使用页面顶部的手动按钮。
80
141
 
81
142
  页面每 5 秒读取一次本地快照;插件后台每 1 分钟刷新启用中供应商的额度,停用项会
82
143
  跳过。额度刷新仍写入后台诊断日志,但不会出现在页面的签到/保活执行记录中。
@@ -120,6 +181,8 @@ magpie plugin options quota-keepalive '{
120
181
  "codearts": "deepseek-v4.1-flash"
121
182
  },
122
183
  "modelCandidates": 4,
184
+ "probeMode": "short",
185
+ "probeMessage": "Magpie daily connectivity check. Reply with OK.",
123
186
  "inputTokens": 25000,
124
187
  "maxAttempts": 3,
125
188
  "timeoutMs": 90000,
@@ -136,21 +199,25 @@ magpie plugin options quota-keepalive '{
136
199
  WorkBuddy CN 默认优先 `deepseek-v4.1-flash`,这是 dsh 实测较稳定的真实对话模型;
137
200
  其他供应商按近 5 天可用性排序,没有成功记录时按供应商模型顺序选择,不按免费或倍率抢先选择。
138
201
  - `modelCandidates`:每个账号每次最多尝试的模型数,默认 4,范围 1–12。
139
- - `inputTokens`:真实请求的输入规模,默认 25000,范围 1000–25000。
202
+ - `probeMode`:`short`(默认)只发一条短消息;`context` 才使用长上下文填充。
203
+ - `probeMessage`:短探活消息,最多 500 字符;留空时使用默认连通性检查文本。
204
+ - `inputTokens`:仅 `probeMode=context` 时生效,默认 25000,范围 1000–25000。
140
205
  - `maxAttempts`:每个账号每个北京时间日最多保活尝试轮数,默认 3。
141
206
  - `timeoutMs`:单个模型请求的整体截止时间,默认 90 秒,范围 10–180 秒。WorkBuddy
142
207
  CN 的 25k 输入请求可能先返回响应头、再继续流式读取;90 秒比 25 秒更接近 dsh 的
143
208
  probe 容忍度,同时仍避免任务无限挂起。即使供应商
144
209
  登录插件内部忽略中止信号,外层也会结束该候选并继续回退;账号较多时不要调得过大。
145
210
  - `logMaxBytes`:单个日志上限,默认 2 MiB,超出后轮转一份 `.1`。
146
- - `statePath`、`logPath`、`snapshotPath`、`commandPath`:可选绝对路径。
211
+ - `statePath`、`logPath`、`snapshotPath`、`commandPath`、`schedulePath`:可选绝对路径。
147
212
 
148
213
  ## 执行周期与失败
149
214
 
150
215
  Magpie 自己的每日签到调度仍会在启动后约 2 分钟进行首轮,之后约每 30 分钟巡检;
151
216
  失败约 30 分钟后重试。本插件的定时任务是额外的统一调度,默认关闭,适合需要更
152
217
  频繁签到或保活的场景。本插件的定时任务不会在保存后立即执行,也不会在修改后
153
- 重启 Magpie;修改配置会替换运行中的定时器,并从新的间隔重新计时。
218
+ 重启 Magpie;修改配置会替换运行中的定时器,并从新的间隔重新计时。调度状态保存在
219
+ `quota-keepalive-schedule.json`,Magpie 重启后会读取上次执行时间和下次执行时间,
220
+ 已到期的任务会补跑,不会因为反复重启而一直错过执行。
154
221
 
155
222
  保活失败会按 `maxAttempts` 重试;达到上限后当天不再自动发送。网络失败、登录过期、
156
223
  模型不可用和空回复都会进入日志。遇到明确的 HTTP 402/429 额度耗尽或免费额度用尽时,
@@ -180,7 +247,8 @@ node ./bin/daily-keepalive-log.js --json
180
247
  ## 安全
181
248
 
182
249
  - 状态和日志文件以 `0600` 创建,写入使用临时文件和原子 rename。
183
- - `plugin-auth.json` 的更新使用短锁,避免与 Magpie 或另一个任务并发写坏登录。
250
+ - `plugin-auth.json` 的更新使用短锁、文件指纹校验和冲突重试。若 Magpie 或其他
251
+ 供应商插件在读取后改动了凭据文件,本插件会重新读取并合并,不会直接覆盖新内容。
184
252
  - 模型回复不会写入状态或日志。
185
253
  - 日志和页面显示会对令牌、Bearer、API key 和账号标识做脱敏。
186
254
  - 未安装对应供应商插件、未登录或没有可用模型时,会明确记为跳过或失败。
package/index.js CHANGED
@@ -11,6 +11,12 @@ import { snapshotWarning, writeDailySnapshot } from "./src/snapshot.js"
11
11
  import { createProviderCheckin } from "./src/checkin-runner.js"
12
12
  import { createWorkBuddyKeepalive } from "./src/workbuddy.js"
13
13
  import { readCommand } from "./src/commands.js"
14
+ import {
15
+ nextRunAfter,
16
+ readSchedule,
17
+ scheduleSignature,
18
+ writeSchedule,
19
+ } from "./src/schedule.js"
14
20
 
15
21
  const LOG_STATUSES = new Set(["started", "success", "failed", "skipped"])
16
22
  const SCHEDULER = Symbol.for("quota-keepalive.scheduler")
@@ -20,32 +26,85 @@ const REFRESH_INTERVAL_MS = 60_000
20
26
 
21
27
  export function stopScheduler() {
22
28
  const previous = globalThis[SCHEDULER]
23
- if (previous?.timer) clearInterval(previous.timer)
29
+ if (previous?.timer) clearTimeout(previous.timer)
30
+ if (previous) previous.stopped = true
24
31
  if (previous) delete globalThis[SCHEDULER]
25
32
  }
26
33
 
27
- export function startScheduler({ config, checkin, keepalive }) {
34
+ function warnSchedule(action, error) {
35
+ console.warn(`[quota-keepalive] ${action}: ${error?.message || error}`)
36
+ }
37
+
38
+ export async function startScheduler({
39
+ config,
40
+ checkin,
41
+ keepalive,
42
+ now = () => Date.now(),
43
+ read = readSchedule,
44
+ persist = writeSchedule,
45
+ run = runCommand,
46
+ log = appendDailyLog,
47
+ snapshot = writeDailySnapshot,
48
+ }) {
28
49
  // Every behavior setting must participate in the signature. Otherwise a
29
50
  // model or provider/account preference change would leave the running timer
30
51
  // on the previous closures and configuration.
31
- const signature = JSON.stringify(config)
52
+ const signature = scheduleSignature(config)
32
53
  const previous = globalThis[SCHEDULER]
33
54
  if (previous?.signature === signature) return
34
55
  stopScheduler()
35
56
  if (!config.scheduleEnabled) return
36
- let running = false
37
- const timer = setInterval(async () => {
38
- if (running) return
39
- running = true
57
+ const controller = {
58
+ signature,
59
+ timer: null,
60
+ running: false,
61
+ stopped: false,
62
+ nextRunAt: 0,
63
+ }
64
+ globalThis[SCHEDULER] = controller
65
+
66
+ const intervalMs = config.scheduleMinutes * 60_000
67
+ let saved
68
+ try {
69
+ saved = await read(config.schedulePath)
70
+ } catch (error) {
71
+ warnSchedule("cannot read the persisted schedule", error)
72
+ saved = {}
73
+ }
74
+ controller.nextRunAt = nextRunAfter({
75
+ now: now(),
76
+ lastRunAt: saved.lastRunAt,
77
+ nextRunAt: saved.nextRunAt,
78
+ intervalMs,
79
+ storedSignature: saved.signature,
80
+ signature,
81
+ })
82
+ await persist(config.schedulePath, {
83
+ ...saved,
84
+ signature,
85
+ nextRunAt: new Date(controller.nextRunAt).toISOString(),
86
+ }).catch((error) => warnSchedule("cannot save the schedule", error))
87
+
88
+ const arm = () => {
89
+ if (controller.stopped) return
90
+ const delay = Math.max(0, controller.nextRunAt - now())
91
+ controller.timer = setTimeout(tick, delay)
92
+ controller.timer?.unref?.()
93
+ }
94
+
95
+ async function tick() {
96
+ if (controller.stopped || controller.running) return
97
+ controller.running = true
98
+ let result = null
40
99
  try {
41
- const result = await runCommand({
100
+ result = await run({
42
101
  action: config.scheduleAction,
43
102
  config,
44
103
  checkin,
45
104
  keepalive,
46
105
  force: false,
47
106
  })
48
- await appendDailyLog({
107
+ await log({
49
108
  logPath: config.logPath,
50
109
  logMaxBytes: config.logMaxBytes,
51
110
  entry: {
@@ -58,15 +117,15 @@ export function startScheduler({ config, checkin, keepalive }) {
58
117
  outcome: result.status,
59
118
  message: result.summary,
60
119
  },
61
- })
62
- await writeDailySnapshot({
120
+ }).catch((error) => warnSchedule("cannot write the schedule log", error))
121
+ await snapshot({
63
122
  ...(config.configHome ? { configHome: config.configHome } : {}),
64
123
  logPath: config.logPath,
65
124
  snapshotPath: config.snapshotPath,
66
125
  commandPath: config.commandPath,
67
- })
126
+ }).catch((error) => warnSchedule(snapshotWarning(error), error))
68
127
  } catch (error) {
69
- await appendDailyLog({
128
+ await log({
70
129
  logPath: config.logPath,
71
130
  logMaxBytes: config.logMaxBytes,
72
131
  entry: {
@@ -79,13 +138,31 @@ export function startScheduler({ config, checkin, keepalive }) {
79
138
  outcome: "failed",
80
139
  error: error instanceof Error ? error.message : String(error),
81
140
  },
82
- })
141
+ }).catch((logError) => warnSchedule("cannot write the failure log", logError))
83
142
  } finally {
84
- running = false
143
+ controller.running = false
144
+ const finishedAt = now()
145
+ controller.nextRunAt = nextRunAfter({
146
+ now: finishedAt,
147
+ lastRunAt: new Date(finishedAt).toISOString(),
148
+ intervalMs,
149
+ storedSignature: signature,
150
+ signature,
151
+ })
152
+ await persist(config.schedulePath, {
153
+ ...saved,
154
+ signature,
155
+ lastRunAt: new Date(finishedAt).toISOString(),
156
+ nextRunAt: new Date(controller.nextRunAt).toISOString(),
157
+ lastStatus: result?.status || "failed",
158
+ lastSummary: result?.summary || "",
159
+ }).catch((error) => warnSchedule("cannot save the completed schedule", error))
160
+ arm()
85
161
  }
86
- }, config.scheduleMinutes * 60_000)
87
- timer.unref?.()
88
- globalThis[SCHEDULER] = { timer, signature }
162
+ }
163
+
164
+ arm()
165
+ return controller
89
166
  }
90
167
 
91
168
  export function stopOptionsWatcher() {
@@ -205,7 +282,7 @@ export function startOptionsWatcher({
205
282
  const liveConfig = resolveKeepaliveOptions(liveRaw)
206
283
  const checkin = makeCheckin({ input, config: liveConfig })
207
284
  const keepalive = makeKeepalive({ input, config: liveConfig })
208
- start({ config: liveConfig, checkin, keepalive })
285
+ await start({ config: liveConfig, checkin, keepalive })
209
286
  startRefresh({ config: liveConfig })
210
287
  await consume({
211
288
  config: liveConfig,
@@ -303,7 +380,7 @@ export async function QuotaKeepalivePlugin(input, rawOptions = {}) {
303
380
  const config = resolveKeepaliveOptions(rawOptions)
304
381
  const checkin = createProviderCheckin({ input, config })
305
382
  const keepalive = createWorkBuddyKeepalive({ input, config })
306
- startScheduler({ config, checkin, keepalive })
383
+ await startScheduler({ config, checkin, keepalive })
307
384
  startQuotaRefresher({ config })
308
385
  startOptionsWatcher({ input, rawOptions, config })
309
386
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "quota-keepalive",
3
- "version": "0.1.0",
4
- "description": "Multi-provider quota inventory, check-in, keepalive scheduling, history, and quota trends for Magpie",
3
+ "version": "0.1.2",
4
+ "description": "Unofficial multi-provider quota inventory, check-in, keepalive scheduling, history, and quota trends for Magpie",
5
5
  "keywords": [
6
6
  "magpie",
7
7
  "magpie-plugin",
@@ -25,7 +25,7 @@
25
25
  "main": "./index.js",
26
26
  "exports": "./index.js",
27
27
  "bin": {
28
- "daily-keepalive-log": "./bin/daily-keepalive-log.js"
28
+ "daily-keepalive-log": "bin/daily-keepalive-log.js"
29
29
  },
30
30
  "magpie": {
31
31
  "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImJnIiB4MT0iOCIgeTE9IjQiIHgyPSI1NiIgeTI9IjYwIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiMwQjIwMjkiLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMTczQzQ2Ii8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJnb2xkIiB4MT0iMzgiIHkxPSIzOCIgeDI9IjU2IiB5Mj0iNTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iI0ZGRDI3QSIvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNFNzlCMzgiLz4KICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgPC9kZWZzPgogIDxyZWN0IHg9IjMiIHk9IjMiIHdpZHRoPSI1OCIgaGVpZ2h0PSI1OCIgcng9IjE4IiBmaWxsPSJ1cmwoI2JnKSIvPgogIDxjaXJjbGUgY3g9IjMwLjUiIGN5PSIzMSIgcj0iMTcuMiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjRjhGMURFIiBzdHJva2Utd2lkdGg9IjcuNCIKICAgICAgICAgIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWRhc2hhcnJheT0iODggMjEiIHRyYW5zZm9ybT0icm90YXRlKDM4IDMwLjUgMzEpIi8+CiAgPHBhdGggZD0iTTM0LjUgMzYuNWM1LjMgNC40IDEwLjIgOS4yIDE5LjUgMTguMyIKICAgICAgICBmaWxsPSJub25lIiBzdHJva2U9InVybCgjZ29sZCkiIHN0cm9rZS13aWR0aD0iNi4yIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+Cg==",
package/src/config.js CHANGED
@@ -7,16 +7,20 @@ import {
7
7
  MIN_LOG_MAX_BYTES,
8
8
  defaultLogPath,
9
9
  } from "./log.js"
10
+ import { DEFAULT_PROBE_MESSAGE } from "./keepalive.js"
10
11
  import {
11
12
  normalizeAccountPreferences,
12
13
  normalizeProviderPreferences,
13
14
  } from "./preferences.js"
15
+ import { defaultSchedulePath } from "./schedule.js"
14
16
  import { defaultSnapshotPath } from "./snapshot.js"
15
17
 
16
18
  export const DEFAULT_KEEPALIVE = Object.freeze({
17
19
  model: "",
18
20
  modelCandidates: 4,
19
21
  inputTokens: 25_000,
22
+ probeMode: "short",
23
+ probeMessage: DEFAULT_PROBE_MESSAGE,
20
24
  maxAttempts: 3,
21
25
  scheduleEnabled: false,
22
26
  scheduleAction: "checkin",
@@ -54,6 +58,9 @@ function absolutePath(value, fallback) {
54
58
  export function resolveKeepaliveOptions(raw) {
55
59
  const source = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}
56
60
  const model = typeof source.model === "string" ? source.model.trim().slice(0, 200) : ""
61
+ const probeMessage = typeof source.probeMessage === "string"
62
+ ? source.probeMessage.trim().slice(0, 500)
63
+ : ""
57
64
  const scheduleAction = ["checkin", "keepalive", "all"].includes(source.scheduleAction)
58
65
  ? source.scheduleAction
59
66
  : DEFAULT_KEEPALIVE.scheduleAction
@@ -68,6 +75,8 @@ export function resolveKeepaliveOptions(raw) {
68
75
  scheduleMinutes: integerIn(source.scheduleMinutes, DEFAULT_KEEPALIVE.scheduleMinutes, 5, 10_080),
69
76
  configHome: absolutePath(source.configHome, undefined),
70
77
  inputTokens: integerIn(source.inputTokens, DEFAULT_KEEPALIVE.inputTokens, 1_000, 25_000),
78
+ probeMode: source.probeMode === "context" ? "context" : DEFAULT_KEEPALIVE.probeMode,
79
+ probeMessage: probeMessage || DEFAULT_PROBE_MESSAGE,
71
80
  maxAttempts: integerIn(source.maxAttempts, DEFAULT_KEEPALIVE.maxAttempts, 1, 10),
72
81
  timeoutMs: integerIn(source.timeoutMs, DEFAULT_KEEPALIVE.timeoutMs, 10_000, 180_000),
73
82
  lockWaitMs: integerIn(source.lockWaitMs, DEFAULT_KEEPALIVE.lockWaitMs, 0, 10_000),
@@ -82,5 +91,6 @@ export function resolveKeepaliveOptions(raw) {
82
91
  MAX_LOG_MAX_BYTES,
83
92
  ),
84
93
  snapshotPath: absolutePath(source.snapshotPath, defaultSnapshotPath()),
94
+ schedulePath: absolutePath(source.schedulePath, defaultSchedulePath()),
85
95
  }
86
96
  }
package/src/keepalive.js CHANGED
@@ -4,20 +4,36 @@ const ORIGIN = "https://copilot.tencent.com"
4
4
  const CHAT_PATH = "/v2/chat/completions"
5
5
  const FILLER = "The quick brown fox jumps over the lazy dog. "
6
6
  const CHARS_PER_TOKEN = 4.5
7
+ export const DEFAULT_PROBE_MESSAGE = "Magpie daily connectivity check. Reply with OK."
7
8
 
8
- export function buildKeepaliveBody({ model, inputTokens, day }) {
9
- const targetChars = Math.ceil(inputTokens * CHARS_PER_TOKEN)
10
- const filler = FILLER.repeat(Math.ceil(targetChars / FILLER.length))
9
+ function probeMessage(value) {
10
+ return sanitize(value, 500).trim() || DEFAULT_PROBE_MESSAGE
11
+ }
12
+
13
+ export function buildKeepaliveBody({
14
+ model,
15
+ inputTokens,
16
+ day,
17
+ probeMode = "short",
18
+ probeMessage: message = "",
19
+ }) {
20
+ const prompt = `${probeMessage(message)} Today is ${day}.`
21
+ let content = prompt
22
+ if (probeMode === "context") {
23
+ const targetChars = Math.ceil(inputTokens * CHARS_PER_TOKEN)
24
+ const filler = FILLER.repeat(Math.ceil(targetChars / FILLER.length))
25
+ content = `${filler}${prompt}`
26
+ }
11
27
  return {
12
28
  model,
13
29
  messages: [
14
30
  {
15
31
  role: "system",
16
- content: "You are a connectivity check. Reply with a single word.",
32
+ content: "You are a concise connectivity check. Reply with a single word.",
17
33
  },
18
34
  {
19
35
  role: "user",
20
- content: `${filler}Reply with a single word.`,
36
+ content,
21
37
  },
22
38
  ],
23
39
  max_tokens: 1,
@@ -152,6 +168,8 @@ export async function sendKeepalive({
152
168
  origin = ORIGIN,
153
169
  model,
154
170
  inputTokens,
171
+ probeMode,
172
+ probeMessage,
155
173
  timeoutMs,
156
174
  day,
157
175
  key,
@@ -167,7 +185,13 @@ export async function sendKeepalive({
167
185
  accept: "text/event-stream",
168
186
  "x-magpie-workbuddy-session": `workbuddy-keepalive:${key}:${day}`.slice(0, 200),
169
187
  },
170
- body: JSON.stringify(buildKeepaliveBody({ model, inputTokens, day })),
188
+ body: JSON.stringify(buildKeepaliveBody({
189
+ model,
190
+ inputTokens,
191
+ day,
192
+ probeMode,
193
+ probeMessage,
194
+ })),
171
195
  signal: AbortSignal.timeout(timeoutMs),
172
196
  })
173
197
  return { response, raw: await response.text() }
package/src/log.js CHANGED
@@ -227,6 +227,7 @@ export function formatLogRecord(record) {
227
227
  ["outcome", record.outcome],
228
228
  ["attempts", record.attempts],
229
229
  ["model", record.model],
230
+ ["probeMode", record.probeMode],
230
231
  ["inputTokens", record.inputTokens],
231
232
  ["credit", record.credit],
232
233
  ["streak", record.streak],
@@ -1,5 +1,9 @@
1
1
  import { sanitize, terminalQuotaFailure, text, withTimeout } from "./common.js"
2
- import { buildKeepaliveBody, sendKeepalive } from "./keepalive.js"
2
+ import {
3
+ DEFAULT_PROBE_MESSAGE,
4
+ buildKeepaliveBody,
5
+ sendKeepalive,
6
+ } from "./keepalive.js"
3
7
 
4
8
  const FILLER = "The quick brown fox jumps over the lazy dog. "
5
9
  const CHARS_PER_TOKEN = 4.5
@@ -42,15 +46,27 @@ export function endpointFor(baseURL, protocol) {
42
46
  return `${base}${protocol === "anthropic" ? "/messages" : "/chat/completions"}`
43
47
  }
44
48
 
45
- export function anthropicBody({ model, inputTokens, day }) {
46
- const targetChars = Math.ceil(inputTokens * CHARS_PER_TOKEN)
47
- const filler = FILLER.repeat(Math.ceil(targetChars / FILLER.length))
49
+ export function anthropicBody({
50
+ model,
51
+ inputTokens,
52
+ day,
53
+ probeMode = "short",
54
+ probeMessage = "",
55
+ }) {
56
+ const message = sanitize(probeMessage, 500).trim() || DEFAULT_PROBE_MESSAGE
57
+ const prompt = `${message} Today is ${day}.`
58
+ let content = prompt
59
+ if (probeMode === "context") {
60
+ const targetChars = Math.ceil(inputTokens * CHARS_PER_TOKEN)
61
+ const filler = FILLER.repeat(Math.ceil(targetChars / FILLER.length))
62
+ content = `${filler}${prompt}`
63
+ }
48
64
  return {
49
65
  model,
50
- system: "You are a connectivity check. Reply with a short word.",
66
+ system: "You are a concise connectivity check. Reply with a short word.",
51
67
  messages: [{
52
68
  role: "user",
53
- content: `${filler}Today is ${day}. Reply with a short word.`,
69
+ content,
54
70
  }],
55
71
  max_tokens: 8,
56
72
  stream: true,
@@ -126,6 +142,8 @@ export async function sendProviderChat({
126
142
  protocol,
127
143
  loader,
128
144
  inputTokens,
145
+ probeMode,
146
+ probeMessage,
129
147
  timeoutMs,
130
148
  day,
131
149
  key,
@@ -148,6 +166,8 @@ export async function sendProviderChat({
148
166
  origin,
149
167
  model,
150
168
  inputTokens,
169
+ probeMode,
170
+ probeMessage,
151
171
  timeoutMs,
152
172
  day,
153
173
  key,
@@ -169,8 +189,8 @@ export async function sendProviderChat({
169
189
 
170
190
  const url = endpointFor(loader.baseURL, protocol)
171
191
  const body = protocol === "anthropic"
172
- ? anthropicBody({ model, inputTokens, day })
173
- : buildKeepaliveBody({ model, inputTokens, day })
192
+ ? anthropicBody({ model, inputTokens, day, probeMode, probeMessage })
193
+ : buildKeepaliveBody({ model, inputTokens, day, probeMode, probeMessage })
174
194
 
175
195
  let response
176
196
  let raw
@@ -0,0 +1,93 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { errorText, magpieConfigHome, sanitize } from "./common.js"
5
+
6
+ const VERSION = 1
7
+ const FILE_OPS = { chmod, mkdir, open, readFile, rename, unlink }
8
+
9
+ export function defaultSchedulePath() {
10
+ return join(magpieConfigHome(), "quota-keepalive-schedule.json")
11
+ }
12
+
13
+ export function emptySchedule() {
14
+ return {
15
+ version: VERSION,
16
+ signature: "",
17
+ lastRunAt: "",
18
+ nextRunAt: "",
19
+ lastStatus: "",
20
+ lastSummary: "",
21
+ }
22
+ }
23
+
24
+ function validDate(value) {
25
+ return typeof value === "string" && Number.isFinite(Date.parse(value))
26
+ }
27
+
28
+ export async function readSchedule(path, ops = FILE_OPS) {
29
+ let raw
30
+ try {
31
+ raw = await ops.readFile(path, "utf8")
32
+ } catch (error) {
33
+ if (error?.code === "ENOENT") return emptySchedule()
34
+ throw new Error(`cannot read ${path}: ${errorText(error)}`)
35
+ }
36
+ let value
37
+ try {
38
+ value = JSON.parse(raw)
39
+ } catch {
40
+ throw new Error(`${path} is not valid JSON; it was left unchanged`)
41
+ }
42
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== VERSION) {
43
+ throw new Error(`${path} has an unsupported shape; it was left unchanged`)
44
+ }
45
+ return {
46
+ version: VERSION,
47
+ signature: sanitize(value.signature, 2_000),
48
+ lastRunAt: validDate(value.lastRunAt) ? value.lastRunAt : "",
49
+ nextRunAt: validDate(value.nextRunAt) ? value.nextRunAt : "",
50
+ lastStatus: sanitize(value.lastStatus, 24),
51
+ lastSummary: sanitize(value.lastSummary, 480),
52
+ }
53
+ }
54
+
55
+ export async function writeSchedule(path, value, ops = FILE_OPS) {
56
+ await ops.mkdir(dirname(path), { recursive: true, mode: 0o700 })
57
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`
58
+ let handle
59
+ try {
60
+ handle = await ops.open(tmp, "wx", 0o600)
61
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8")
62
+ await handle.sync()
63
+ await handle.close()
64
+ handle = null
65
+ await ops.rename(tmp, path)
66
+ await ops.chmod(path, 0o600)
67
+ } catch (error) {
68
+ if (handle) await handle.close().catch(() => {})
69
+ await ops.unlink(tmp).catch(() => {})
70
+ throw error
71
+ }
72
+ }
73
+
74
+ export function nextRunAfter({
75
+ now = Date.now(),
76
+ lastRunAt = "",
77
+ nextRunAt = "",
78
+ intervalMs,
79
+ storedSignature = "",
80
+ signature = "",
81
+ }) {
82
+ const interval = Math.max(1_000, Number(intervalMs) || 60_000)
83
+ if (storedSignature !== signature) return now + interval
84
+ const saved = Date.parse(nextRunAt)
85
+ const last = Date.parse(lastRunAt)
86
+ if (Number.isFinite(saved)) return Math.max(saved, now)
87
+ if (Number.isFinite(last)) return Math.max(last + interval, now)
88
+ return now + interval
89
+ }
90
+
91
+ export function scheduleSignature(config = {}) {
92
+ return JSON.stringify(config)
93
+ }
package/src/snapshot.js CHANGED
@@ -31,6 +31,7 @@ const FIELD_LIMITS = {
31
31
  status: 12,
32
32
  outcome: 24,
33
33
  model: 160,
34
+ probeMode: 16,
34
35
  account: 24,
35
36
  run: 48,
36
37
  }
@@ -47,7 +48,7 @@ function safeField(record, name) {
47
48
 
48
49
  export function publicRecord(record) {
49
50
  const out = {}
50
- for (const name of ["ts", "day", "provider", "event", "status", "outcome", "model", "account", "run"]) {
51
+ for (const name of ["ts", "day", "provider", "event", "status", "outcome", "model", "probeMode", "account", "run"]) {
51
52
  const value = safeField(record, name)
52
53
  if (value) out[name] = value
53
54
  }
package/src/state.js CHANGED
@@ -232,8 +232,21 @@ export async function runDailyOnce({
232
232
  return { kind: "unavailable", error: errorText(error) }
233
233
  }
234
234
  prune(doc, day)
235
- const previous = newer(doc.accounts[account], inMemory)
235
+ let previous = newer(doc.accounts[account], inMemory)
236
+ let interrupted = false
236
237
  if (previous?.day === day) {
238
+ const startedAt = Date.parse(previous.at || "")
239
+ const staleMs = Math.max(30_000, timeoutMs + 30_000)
240
+ if (previous.status === "running" && Number.isFinite(startedAt) &&
241
+ now.getTime() - startedAt > staleMs) {
242
+ interrupted = true
243
+ previous = {
244
+ ...previous,
245
+ status: "failed",
246
+ at: new Date(startedAt).toISOString(),
247
+ error: "the previous attempt was interrupted",
248
+ }
249
+ }
237
250
  remember(statePath, account, previous)
238
251
  if (previous.status === "ok") {
239
252
  return { kind: "skipped", reason: "done-today", attempts: previous.attempts }
@@ -246,7 +259,7 @@ export async function runDailyOnce({
246
259
  error: previous.error || "",
247
260
  }
248
261
  }
249
- if (previous.attempts >= maxAttempts && !force) {
262
+ if (previous.attempts >= maxAttempts && !force && !interrupted) {
250
263
  return {
251
264
  kind: "skipped",
252
265
  reason: "exhausted",
package/src/workbuddy.js CHANGED
@@ -181,31 +181,83 @@ export async function withAuthLock(authPath, work, waitMs = 2_000, ops = FILE_OP
181
181
  export async function saveAuthEntry(authPath, key, next, ops = FILE_OPS) {
182
182
  if (!text(key) || !object(next)) throw new Error("refusing to save an invalid provider sign-in")
183
183
  return withAuthLock(authPath, async () => {
184
- let all
185
- try {
186
- all = JSON.parse(await ops.readFile(authPath, "utf8"))
187
- } catch (error) {
188
- if (error?.code !== "ENOENT") throw error
189
- all = {}
184
+ for (let attempt = 0; attempt < 4; attempt++) {
185
+ const snapshot = await readAuthSnapshot(authPath, ops)
186
+ const all = snapshot.value
187
+ if (!object(all)) throw new Error("plugin-auth.json isn't an object")
188
+ if (JSON.stringify(all[key]) === JSON.stringify(next)) return true
189
+
190
+ all[key] = next
191
+ const serialized = `${JSON.stringify(all, null, 2)}\n`
192
+ const tmp = `${authPath}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`
193
+ let handle
194
+ try {
195
+ handle = await ops.open(tmp, "wx", 0o600)
196
+ await handle.writeFile(serialized, "utf8")
197
+ await handle.sync()
198
+ await handle.close()
199
+ handle = null
200
+
201
+ // Magpie and provider plugins do not use this lock. Refuse to replace
202
+ // a file that changed after our read, then merge against the new copy.
203
+ const current = await readAuthStamp(authPath, ops)
204
+ if (current !== snapshot.stamp) {
205
+ await ops.unlink(tmp).catch(() => {})
206
+ await new Promise((resolve) => setTimeout(resolve, 20))
207
+ continue
208
+ }
209
+ await ops.rename(tmp, authPath)
210
+ await ops.chmod(authPath, 0o600)
211
+ return true
212
+ } catch (error) {
213
+ if (handle) await handle.close().catch(() => {})
214
+ await ops.unlink(tmp).catch(() => {})
215
+ throw error
216
+ }
190
217
  }
191
- if (!object(all)) throw new Error("plugin-auth.json isn't an object")
192
- all[key] = next
193
- const tmp = `${authPath}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`
194
- let handle
218
+ throw new Error("magpie's sign-in file changed repeatedly; the update was not saved")
219
+ })
220
+ }
221
+
222
+ function authStamp(info) {
223
+ if (!info) return "missing"
224
+ return [
225
+ info.ino ?? "",
226
+ info.size ?? "",
227
+ info.mtimeMs ?? "",
228
+ info.ctimeMs ?? "",
229
+ ].join(":")
230
+ }
231
+
232
+ async function readAuthStamp(authPath, ops) {
233
+ try {
234
+ return authStamp(await ops.stat(authPath))
235
+ } catch (error) {
236
+ if (error?.code === "ENOENT") return "missing"
237
+ throw error
238
+ }
239
+ }
240
+
241
+ async function readAuthSnapshot(authPath, ops) {
242
+ for (let attempt = 0; attempt < 4; attempt++) {
243
+ const before = await readAuthStamp(authPath, ops)
244
+ if (before === "missing") {
245
+ return { value: {}, stamp: before }
246
+ }
247
+ let raw
195
248
  try {
196
- handle = await ops.open(tmp, "wx", 0o600)
197
- await handle.writeFile(`${JSON.stringify(all, null, 2)}\n`, "utf8")
198
- await handle.sync()
199
- await handle.close()
200
- handle = null
201
- await ops.rename(tmp, authPath)
202
- await ops.chmod(authPath, 0o600)
249
+ raw = await ops.readFile(authPath, "utf8")
203
250
  } catch (error) {
204
- if (handle) await handle.close().catch(() => {})
205
- await ops.unlink(tmp).catch(() => {})
251
+ if (error?.code === "ENOENT") return { value: {}, stamp: "missing" }
206
252
  throw error
207
253
  }
208
- })
254
+ const after = await readAuthStamp(authPath, ops)
255
+ if (before === after) {
256
+ return { value: JSON.parse(raw), stamp: after }
257
+ }
258
+ await new Promise((resolve) => setTimeout(resolve, 20))
259
+ }
260
+ throw new Error("magpie's sign-in file kept changing while it was being read")
209
261
  }
210
262
 
211
263
  export function authIdentity(auth, key) {
@@ -215,6 +267,7 @@ export function authIdentity(auth, key) {
215
267
  }
216
268
 
217
269
  export function resultFields(result, config, model = "") {
270
+ const inputTokens = config?.probeMode === "context" ? Number(config.inputTokens) || 0 : 0
218
271
  switch (result?.kind) {
219
272
  case "skipped":
220
273
  if (result.reason === "done-today") {
@@ -271,7 +324,7 @@ export function resultFields(result, config, model = "") {
271
324
  : "exhausted",
272
325
  attempts: result.attempts,
273
326
  model: result.result?.model || model,
274
- inputTokens: config.inputTokens,
327
+ inputTokens,
275
328
  retry: !result.ok && !terminal && result.attempts < config.maxAttempts,
276
329
  persisted: result.persisted !== false,
277
330
  error: result.error,
@@ -364,6 +417,8 @@ export async function sendWithCandidates({
364
417
  protocol: candidate.npm === "@ai-sdk/anthropic" ? "anthropic" : adapter.protocol,
365
418
  loader,
366
419
  inputTokens: config.inputTokens,
420
+ probeMode: config.probeMode,
421
+ probeMessage: config.probeMessage,
367
422
  timeoutMs: config.timeoutMs,
368
423
  day,
369
424
  key: accountTag(identity),
@@ -473,7 +528,8 @@ export function createProviderKeepalive(options = {}) {
473
528
  run,
474
529
  model: candidates[0].id,
475
530
  models: candidates.map((candidate) => candidate.id).join(","),
476
- inputTokens: config.inputTokens,
531
+ inputTokens: config.probeMode === "context" ? config.inputTokens : 0,
532
+ probeMode: config.probeMode,
477
533
  keepalive: true,
478
534
  })
479
535
 
@@ -482,7 +538,8 @@ export function createProviderKeepalive(options = {}) {
482
538
  account: accountTag(`${provider}|${identity}`),
483
539
  day,
484
540
  model: candidates[0].id,
485
- inputTokens: config.inputTokens,
541
+ inputTokens: config.probeMode === "context" ? config.inputTokens : 0,
542
+ probeMode: config.probeMode,
486
543
  maxAttempts: config.maxAttempts,
487
544
  lockWaitMs: config.lockWaitMs,
488
545
  timeoutMs: config.timeoutMs,
package/ui/app.js CHANGED
@@ -996,6 +996,7 @@
996
996
  ["供应商", record?.provider],
997
997
  ["账号", record?.account],
998
998
  ["模型", record?.model],
999
+ ["保活方式", record?.probeMode === "context" ? "长上下文" : record?.probeMode === "short" ? "短探活" : ""],
999
1000
  ["输入", number(record?.inputTokens) ? `${formatNumber(record.inputTokens)} tokens` : ""],
1000
1001
  ["本次积分", number(record?.credit) ? `+${formatNumber(record.credit)}` : ""],
1001
1002
  ["连续签到", number(record?.streak) ? `${formatNumber(record.streak)} 天` : ""],
@@ -1192,7 +1193,8 @@
1192
1193
  keepalive: "真实保活",
1193
1194
  all: "签到 + 真实保活",
1194
1195
  }[options.scheduleAction] || "签到"
1195
- return `每 ${number(options.scheduleMinutes) || 60} 分钟执行一次${action}。`
1196
+ const probe = options.probeMode === "context" ? "长上下文保活" : "短探活"
1197
+ return `每 ${number(options.scheduleMinutes) || 60} 分钟执行一次${action};${probe}。`
1196
1198
  }
1197
1199
 
1198
1200
  async function loadSchedule(showToast = false, refresh = false) {
@@ -1210,6 +1212,7 @@
1210
1212
  $("schedule-action").value = ["checkin", "keepalive", "all"].includes(options.scheduleAction)
1211
1213
  ? options.scheduleAction
1212
1214
  : "checkin"
1215
+ $("probe-mode").value = options.probeMode === "context" ? "context" : "short"
1213
1216
  $("schedule-status").textContent = scheduleDescription(options)
1214
1217
  if (showToast) toast("定时计划已保存")
1215
1218
  } catch (error) {
@@ -1225,6 +1228,7 @@
1225
1228
  scheduleEnabled: $("schedule-enabled").checked,
1226
1229
  scheduleMinutes: minutes,
1227
1230
  scheduleAction: $("schedule-action").value,
1231
+ probeMode: $("probe-mode").value,
1228
1232
  })))
1229
1233
  await loadSchedule(true)
1230
1234
  }
package/ui/index.html CHANGED
@@ -108,6 +108,17 @@
108
108
  <path d="m4 6 4 4 4-4"></path>
109
109
  </svg>
110
110
  </label>
111
+ <label class="select-field probe-field"
112
+ title="短探活只发送一条短消息;长上下文会填充到配置的输入规模">
113
+ <select id="probe-mode" aria-label="保活方式">
114
+ <option value="short">短探活</option>
115
+ <option value="context">长上下文</option>
116
+ </select>
117
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
118
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
119
+ <path d="m4 6 4 4 4-4"></path>
120
+ </svg>
121
+ </label>
111
122
  <button class="button schedule-save" id="schedule-save" type="button">保存计划</button>
112
123
  </div>
113
124
  </section>
package/ui/style.css CHANGED
@@ -384,6 +384,7 @@ h2 { margin-bottom: 2px; font-size: 16px; letter-spacing: -.015em; }
384
384
  appearance: none;
385
385
  -webkit-appearance: none;
386
386
  }
387
+ .probe-field select { min-width: 118px; }
387
388
  .select-field svg {
388
389
  position: absolute;
389
390
  right: 8px;