mslxdff 0.1.225 → 0.1.227

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.
@@ -1,120 +1,135 @@
1
- // 保热心跳(用户 2026-10-18 拍板):每 ≤120s 主动访问一次「每个组员 + 每个在 picks 的上游供应商」,
2
- // 保持连接热度,防「久未使用 → 首发 fetch failed / 上游冷启动 26s」。
3
- //
4
- // ⚠ 必须用**与 chat 完全相同的那条连接池**,否则白热(用户 2026-10-18 纠正):
5
- // - 组员 → warmPeerHealth()(getPeerDispatcher,正是 peer 转发复用的池);
6
- // - 供应商 → provider.preheat()(各家 preheat 经各自 agent/extDispatcher 打自己的 models 端点,
7
- // 与 chat 同池:opencode 走 v6/warp 池、generic/custom 走各自 agent)。
8
- // 绝不能裸 compatFetch / directProbe——那热的是全局池的 socket,真流量复用的是另一条,等于没热。
9
- //
10
- // 为什么需要它(现网取证 2026-10-18 16:16,req mvdjwol5-aysc):三个组员同时 `fetch failed`(TCP 都没建立),
11
- // 根因是两端 keep-alive 打架——服务端 Node 默认 5s 掐闲置连接,而组长 undici 池账面 30s 内以为 socket 还活着,
12
- // 复用死 socket 即失败。已把服务端 keepAliveTimeout 提到 65s(server.js)治本;本心跳是**补强**:
13
- // ① 间隔 120s < 家里 NAT/防火墙更短的空闲回收窗(很多路由 60~120s 就丢映射,单靠 65s keep-alive 挡不住);
14
- // ② 顺带让上游对我们出口保持"温热"(opencode 实测固定 session 1.2s、冷机器 26s+)。
15
- //
16
- // 零额度消耗(全部经各家 preheat:GET models / GET /health,不打 chat)。永不抛,只记事件。
17
- import { warmPeerHealth } from "../routes/peers.js";
18
-
19
- export function keepWarmIntervalMs(env = process.env) {
20
- const n = Number(env.MSLXDFF_KEEP_WARM_MS);
21
- return Number.isInteger(n) && n >= 0 ? n : 120_000; // 0 = 关闭
22
- }
23
-
24
- /**
25
- * 从 picks 里算出要保热的「供应商头」集合:裸 id(无 `/`)归 opencode(免费池);带前缀取首段。
26
- * 只保留出现过的供应商,天然覆盖"每个在 picks 的接口"。opencode 恒排第一(免费池是主力、最该保热)。
27
- * @param {string[]} picks
28
- * @returns {string[]} 供应商 id 列表(含 "opencode"),已去重、opencode 置顶
29
- */
30
- export function providerHeadsFromPicks(picks = []) {
31
- const heads = new Set();
32
- for (const id of picks) {
33
- const s = String(id || "").trim();
34
- if (!s) continue;
35
- heads.add(s.includes("/") ? s.slice(0, s.indexOf("/")) : "opencode");
36
- }
37
- const out = [...heads];
38
- if (out.includes("opencode")) return ["opencode", ...out.filter((h) => h !== "opencode")];
39
- return out;
40
- }
41
-
42
- /**
43
- * 单轮保热:组员各 warmPeerHealth、picks 供应商各 preheat。返回汇总(供事件取证)。绝不抛。
44
- * @param {{peers:{url:string}[], picks:string[], byId?:Map<string,{preheat?:Function}>, upstream?:{preheat?:Function},
45
- * warmPeer?:Function, evt?:Function}} deps
46
- */
47
- export async function warmOnce({
48
- peers = [],
49
- picks = [],
50
- byId = null,
51
- upstream = null,
52
- warmPeer = warmPeerHealth,
53
- evt = () => {},
54
- } = {}) {
55
- const staticPeers = peers.filter((p) => p?.url && !String(p.url).startsWith("relay://")); // broadband 走 relay 队列,不直连
56
- let peerOk = 0, peerFail = 0;
57
- for (const p of staticPeers) {
58
- try {
59
- const r = await warmPeer(String(p.url));
60
- if (r?.ok) peerOk += 1; else peerFail += 1;
61
- } catch { peerFail += 1; }
62
- }
63
- const heads = providerHeadsFromPicks(picks);
64
- let provOk = 0, provFail = 0, provSkip = 0;
65
- for (const head of heads) {
66
- try {
67
- // 供应商预热一律走它自己的 preheat(chat 同池)。opencode 特判:**多 provider 装配**下
68
- // providers 数组里含 opencode,直接 byId 取;单 provider 模式下 byId 为空,退回 upstream.preheat。
69
- const inst = byId?.get?.(head);
70
- const preheat = inst?.preheat || (head === "opencode" ? upstream?.preheat : null);
71
- if (typeof preheat !== "function") { provSkip += 1; continue; } // 本地号型/无域名供应商:本就没有可保热的域
72
- const r = await preheat.call(inst || upstream);
73
- if (r?.ok) provOk += 1; else provFail += 1;
74
- } catch { provFail += 1; }
75
- }
76
- evt("keep-warm", { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip });
77
- return { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip };
78
- }
79
-
80
- /**
81
- * daemon 装配:起一个 unref 的定时器,每 keepWarmIntervalMs 跑一轮 warmOnce。
82
- * 关(=0)返回 null。依赖注入以便测试;生产用 setupProviders 的 ctx(providers/upstream/peers)。
83
- */
84
- export function startKeepWarm({
85
- peers,
86
- providers = [],
87
- upstream,
88
- env = process.env,
89
- evt = () => {},
90
- logs,
91
- loadModelPicks,
92
- warmPeer = warmPeerHealth,
93
- } = {}) {
94
- const interval = keepWarmIntervalMs(env);
95
- if (interval === 0) return null;
96
- const byId = new Map((providers || []).map((p) => [p.id, p]));
97
- const emit = (type, data) => {
98
- const entry = { ts: Date.now(), type, ...data };
99
- try { evt(type, data); } catch {}
100
- try { logs?.appendEvent?.(entry); } catch {}
101
- };
102
- const runTick = async () => {
103
- try {
104
- const picks = loadModelPicks ? loadModelPicks() : [];
105
- await warmOnce({
106
- peers: peers?.all?.() || [],
107
- picks: Array.isArray(picks) ? picks : [],
108
- byId,
109
- upstream,
110
- warmPeer,
111
- evt: emit,
112
- });
113
- } catch { /* 保热永不影响主链路 */ }
114
- };
115
- console.log(`[keep-warm] enabled: every ${Math.round(interval / 1000)}s 经 chat 同池访问每个组员(/health) + 每个 picks 供应商(preheat) 保热,设 MSLXDFF_KEEP_WARM_MS=0 关`);
116
- void runTick(); // 启动即热一次,别等第一个间隔
117
- const timer = setInterval(() => { void runTick(); }, interval);
118
- timer.unref?.();
119
- return { stop: () => clearInterval(timer), interval, tick: runTick };
120
- }
1
+ // 保热心跳(用户 2026-10-18 拍板):每 ≤120s 主动访问一次「每个组员 + 每个在 picks 的上游供应商」,
2
+ // 保持连接热度,防「久未使用 → 首发 fetch failed / 上游冷启动 26s」。
3
+ //
4
+ // ⚠ 必须用**与 chat 完全相同的那条连接池**,否则白热(用户 2026-10-18 纠正):
5
+ // - 组员 → warmPeerHealth()(getPeerDispatcher,正是 peer 转发复用的池);
6
+ // - 供应商 → provider.preheat()(各家 preheat 经各自 agent/extDispatcher 打自己的 models 端点,
7
+ // 与 chat 同池:opencode 走 v6/warp 池、generic/custom 走各自 agent)。
8
+ // 绝不能裸 compatFetch / directProbe——那热的是全局池的 socket,真流量复用的是另一条,等于没热。
9
+ //
10
+ // 为什么需要它(现网取证 2026-10-18 16:16,req mvdjwol5-aysc):三个组员同时 `fetch failed`(TCP 都没建立),
11
+ // 根因是两端 keep-alive 打架——服务端 Node 默认 5s 掐闲置连接,而组长 undici 池账面 30s 内以为 socket 还活着,
12
+ // 复用死 socket 即失败。已把服务端 keepAliveTimeout 提到 65s(server.js)治本;本心跳是**补强**:
13
+ // ① 间隔 120s < 家里 NAT/防火墙更短的空闲回收窗(很多路由 60~120s 就丢映射,单靠 65s keep-alive 挡不住);
14
+ // ② 顺带让上游对我们出口保持"温热"(opencode 实测固定 session 1.2s、冷机器 26s+)。
15
+ //
16
+ // 零额度消耗(全部经各家 preheat:GET models / GET /health,不打 chat)。永不抛,只记事件。
17
+ import { warmPeerHealth } from "../routes/peers.js";
18
+
19
+ export function keepWarmIntervalMs(env = process.env) {
20
+ const n = Number(env.MSLXDFF_KEEP_WARM_MS);
21
+ return Number.isInteger(n) && n >= 0 ? n : 120_000; // 0 = 关闭
22
+ }
23
+
24
+ /**
25
+ * 从 picks 里算出要保热的「供应商头」集合:裸 id(无 `/`)归 opencode(免费池);带前缀取首段。
26
+ * 只保留出现过的供应商,天然覆盖"每个在 picks 的接口"。opencode 恒排第一(免费池是主力、最该保热)。
27
+ * @param {string[]} picks
28
+ * @returns {string[]} 供应商 id 列表(含 "opencode"),已去重、opencode 置顶
29
+ */
30
+ export function providerHeadsFromPicks(picks = []) {
31
+ const heads = new Set();
32
+ for (const id of picks) {
33
+ const s = String(id || "").trim();
34
+ if (!s) continue;
35
+ heads.add(s.includes("/") ? s.slice(0, s.indexOf("/")) : "opencode");
36
+ }
37
+ const out = [...heads];
38
+ if (out.includes("opencode")) return ["opencode", ...out.filter((h) => h !== "opencode")];
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * 单轮保热:组员各 warmPeerHealth、picks 供应商各 preheat。返回汇总(供事件取证)。绝不抛。
44
+ * @param {{peers:{url:string}[], picks:string[], byId?:Map<string,{preheat?:Function}>, upstream?:{preheat?:Function},
45
+ * warmPeer?:Function, evt?:Function}} deps
46
+ */
47
+ export async function warmOnce({
48
+ peers = [],
49
+ picks = [],
50
+ byId = null,
51
+ upstream = null,
52
+ warmPeer = warmPeerHealth,
53
+ markProbe = null, // (url, ok) => void:探测结果写进 peer 冷却账(health 探不通 ⇒ 10min 不当候选)
54
+ evt = () => {},
55
+ } = {}) {
56
+ const staticPeers = peers.filter((p) => p?.url && !String(p.url).startsWith("relay://")); // broadband 走 relay 队列,不直连
57
+ const safeMark = (url, ok) => {
58
+ try {
59
+ const p = markProbe?.(url, ok);
60
+ if (p && typeof p.catch === "function") p.catch(() => {});
61
+ } catch { /* 记账失败不影响保热 */ }
62
+ };
63
+ let peerOk = 0, peerFail = 0;
64
+ for (const p of staticPeers) {
65
+ try {
66
+ const r = await warmPeer(String(p.url));
67
+ if (r?.ok) { peerOk += 1; safeMark(String(p.url), true); }
68
+ else { peerFail += 1; safeMark(String(p.url), false); }
69
+ } catch { peerFail += 1; safeMark(String(p.url), false); }
70
+ }
71
+ const heads = providerHeadsFromPicks(picks);
72
+ let provOk = 0, provFail = 0, provSkip = 0;
73
+ for (const head of heads) {
74
+ try {
75
+ // 供应商预热一律走它自己的 preheat(chat 同池)。opencode 特判:**多 provider 装配**下
76
+ // providers 数组里含 opencode,直接 byId 取;单 provider 模式下 byId 为空,退回 upstream.preheat。
77
+ const inst = byId?.get?.(head);
78
+ const preheat = inst?.preheat || (head === "opencode" ? upstream?.preheat : null);
79
+ if (typeof preheat !== "function") { provSkip += 1; continue; } // 本地号型/无域名供应商:本就没有可保热的域
80
+ const r = await preheat.call(inst || upstream);
81
+ if (r?.ok) provOk += 1; else provFail += 1;
82
+ } catch { provFail += 1; }
83
+ }
84
+ evt("keep-warm", { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip });
85
+ return { peers: staticPeers.length, peerOk, peerFail, providers: heads.length, provOk, provFail, provSkip };
86
+ }
87
+
88
+ /**
89
+ * daemon 装配:起一个 unref 的定时器,每 keepWarmIntervalMs 跑一轮 warmOnce。
90
+ * 关(=0)返回 null。依赖注入以便测试;生产用 setupProviders 的 ctx(providers/upstream/peers)。
91
+ */
92
+ export function startKeepWarm({
93
+ peers,
94
+ providers = [],
95
+ upstream,
96
+ env = process.env,
97
+ evt = () => {},
98
+ logs,
99
+ loadModelPicks,
100
+ warmPeer = warmPeerHealth,
101
+ } = {}) {
102
+ const interval = keepWarmIntervalMs(env);
103
+ if (interval === 0) return null;
104
+ const byId = new Map((providers || []).map((p) => [p.id, p]));
105
+ const emit = (type, data) => {
106
+ const entry = { ts: Date.now(), type, ...data };
107
+ try { evt(type, data); } catch {}
108
+ try { logs?.appendEvent?.(entry); } catch {}
109
+ };
110
+ const runTick = async () => {
111
+ try {
112
+ const picks = loadModelPicks ? loadModelPicks() : [];
113
+ await warmOnce({
114
+ peers: peers?.all?.() || [],
115
+ picks: Array.isArray(picks) ? picks : [],
116
+ byId,
117
+ upstream,
118
+ warmPeer,
119
+ // 探测结果写进冷却账:health 连不上 ⇒ 10min 不当候选(防"探到了但选路不知道"——现网 2026-10-20 13:27)
120
+ markProbe: async (url, ok) => {
121
+ try {
122
+ if (ok) await peers?.recordProbeOk?.(url);
123
+ else await peers?.recordProbeDead?.(url);
124
+ } catch { /* 记账失败不影响保热 */ }
125
+ },
126
+ evt: emit,
127
+ });
128
+ } catch { /* 保热永不影响主链路 */ }
129
+ };
130
+ console.log(`[keep-warm] enabled: every ${Math.round(interval / 1000)}s 经 chat 同池访问每个组员(/health) + 每个 picks 供应商(preheat) 保热,设 MSLXDFF_KEEP_WARM_MS=0 关`);
131
+ void runTick(); // 启动即热一次,别等第一个间隔
132
+ const timer = setInterval(() => { void runTick(); }, interval);
133
+ timer.unref?.();
134
+ return { stop: () => clearInterval(timer), interval, tick: runTick };
135
+ }
@@ -46,6 +46,7 @@ export async function setupWorkbuddyGrowth({ bus, logs } = {}) {
46
46
  const stateMod = await import("../state.js");
47
47
  const { runGrowthAll } = await import("../providers/workbuddy/growth.js");
48
48
  const { runCatTravel } = await import("../providers/workbuddy/cat-travel.js");
49
+ const { reportChatActivity, runStreakBonus } = await import("../providers/workbuddy/activity.js");
49
50
  const { createAuthService, decodeJwtExp } = await import("../providers/workbuddy/auth.js");
50
51
 
51
52
  let running = false;
@@ -83,6 +84,23 @@ export async function setupWorkbuddyGrowth({ bus, logs } = {}) {
83
84
  tasks: row.tasks.filter((t) => t.ok && !t.skipped).length, reason,
84
85
  }),
85
86
  });
87
+ // 活跃上报必须单独发:签到不点亮连登(2026-10-20 实测 days 恒 0),
88
+ // 不报就连登档位 7/14/28 天的积分与抽奖次数永远拿不到。
89
+ const activity = [];
90
+ const streak = [];
91
+ for (const a of accounts) {
92
+ const r = await reportChatActivity(a);
93
+ activity.push({ uid: a.uid, ok: r.ok, msg: r.msg });
94
+ emit("workbuddy-activity", { uid: String(a.uid).slice(0, 8), ok: r.ok, msg: r.msg, reason });
95
+ const s = await runStreakBonus({ ...a, onStep: (e) => emit("workbuddy-streak", { uid: String(a.uid).slice(0, 8), ...e }) });
96
+ streak.push({ uid: a.uid, days: s.days, redeemed: s.redeemed, drawn: s.drawn, credit: s.credit, errors: s.errors });
97
+ emit("workbuddy-streak-done", {
98
+ uid: String(a.uid).slice(0, 8), days: s.days,
99
+ redeemed: s.redeemed.map((x) => x.tier).join(",") || "-", drawn: s.drawn, credit: s.credit,
100
+ errors: s.errors.length ? s.errors : undefined, reason,
101
+ });
102
+ await sleep(1000);
103
+ }
86
104
  const travel = [];
87
105
  for (const a of accounts) {
88
106
  try {
@@ -95,17 +113,21 @@ export async function setupWorkbuddyGrowth({ bus, logs } = {}) {
95
113
  await sleep(1000);
96
114
  }
97
115
  const date = todayKey();
98
- const creditTotal = growthRes.creditTotal + travel.reduce((s, r) => s + (r.credits || 0), 0);
116
+ const streakCredit = streak.reduce((s, r) => s + (r.credit || 0), 0);
117
+ const creditTotal = growthRes.creditTotal + travel.reduce((s, r) => s + (r.credits || 0), 0) + streakCredit;
99
118
  try {
100
119
  stateMod.writeStateImmediate(stateMod.defaultStateFile(), {
101
120
  workbuddyGrowth: {
102
121
  date, at: Date.now(), ok: growthRes.ok, credit: creditTotal, accounts: accounts.length,
122
+ streakCredit,
123
+ activity: activity.map((a) => ({ uid: a.uid, ok: a.ok })),
124
+ streak: streak.map((s) => ({ uid: s.uid, days: s.days, drawn: s.drawn, credit: s.credit })),
103
125
  travel: travel.map((t) => ({ uid: t.uid, outcome: t.outcome, credits: t.credits })),
104
126
  },
105
127
  });
106
128
  } catch {}
107
- emit("workbuddy-growth-done", { date, accounts: accounts.length, credit: creditTotal, ok: growthRes.ok, reason });
108
- return { ok: true, creditTotal, growthRes, travel };
129
+ emit("workbuddy-growth-done", { date, accounts: accounts.length, credit: creditTotal, streakCredit, ok: growthRes.ok, reason });
130
+ return { ok: true, creditTotal, streakCredit, growthRes, travel, streak };
109
131
  } catch (e) {
110
132
  emit("workbuddy-growth-failed", { error: String(e?.message || e).slice(0, 200), reason });
111
133
  return { ok: false, error: String(e?.message || e).slice(0, 200) };