mslxdff 0.1.55 → 0.1.57

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 (40) hide show
  1. package/README.md +1 -160
  2. package/bin/mslxdff.js +741 -29
  3. package/package.json +3 -2
  4. package/src/chat/config.js +10 -0
  5. package/src/chat/index.js +6 -0
  6. package/src/chat/prompt.js +63 -0
  7. package/src/chat/repl.js +245 -0
  8. package/src/chat/spinner.js +38 -0
  9. package/src/chat/stats.js +170 -0
  10. package/src/chat/store.js +48 -0
  11. package/src/chat/tools.js +304 -0
  12. package/src/chat/upstream.js +90 -0
  13. package/src/models.js +90 -2
  14. package/src/providers/dispatcher.js +100 -0
  15. package/src/providers/generic.js +221 -0
  16. package/src/providers/index.js +6 -0
  17. package/src/providers/keyring.js +38 -0
  18. package/src/providers/model-id.js +36 -0
  19. package/src/providers/opencode.js +20 -0
  20. package/src/providers/openrouter.js +217 -0
  21. package/src/providers/share-keys.js +53 -0
  22. package/src/providers/workbuddy-balance.js +76 -0
  23. package/src/providers/workbuddy.js +563 -0
  24. package/src/routes/chat/index.js +56 -2
  25. package/src/routes/peers.js +12 -7
  26. package/src/routes/stream.js +9 -0
  27. package/src/state.js +299 -4
  28. package/src/sync-opencode.js +152 -0
  29. package/src/time.js +73 -0
  30. package/src/upstream.js +126 -8
  31. package/docs/adr/0001-reasoning-content-injection.md +0 -14
  32. package/docs/adr/0002-models-free-filter.md +0 -12
  33. package/docs/adr/0003-zero-state-no-auth.md +0 -10
  34. package/docs/adr/0004-bearer-token.md +0 -18
  35. package/docs/adr/0005-peer-mesh.md +0 -53
  36. package/docs/adr/0006-broadband-member.md +0 -103
  37. package/docs/agents/domain.md +0 -51
  38. package/docs/agents/issue-tracker.md +0 -30
  39. package/docs/agents/triage-labels.md +0 -15
  40. package/docs/plugins.md +0 -185
package/src/upstream.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import os from "node:os";
6
6
  import { isFreeModel } from "./models.js";
7
7
  import { defaultStateFile } from "./state.js";
8
+ import { fmtShanghaiYMDHMS } from "./time.js";
8
9
 
9
10
  let UndiciAgent = null;
10
11
  let UndiciFetch = null;
@@ -45,6 +46,7 @@ export function createUpstreamClient({
45
46
  },
46
47
  fetchImpl,
47
48
  hooks,
49
+ keepAlive = true,
48
50
  } = {}) {
49
51
  // 使用 undici 的 fetch 与 Agent 配对,避免 global fetch 与 npm undici Agent 不兼容
50
52
  if (!fetchImpl) fetchImpl = UndiciFetch || fetch;
@@ -91,6 +93,74 @@ export function createUpstreamClient({
91
93
  return true;
92
94
  }
93
95
 
96
+ function isResponsesModel(model) {
97
+ const m = String(model || "").toLowerCase();
98
+ return m.startsWith("muse-spark");
99
+ }
100
+
101
+ function chatToResponsesBody(chatBody) {
102
+ const msgs = Array.isArray(chatBody?.messages) ? chatBody.messages : [];
103
+ const system = msgs.filter((m) => m.role === "system").map((m) => String(m.content || "")).join("\n");
104
+ const nonSystem = msgs.filter((m) => m.role !== "system");
105
+ // 取最后一条 user 内容作为 input,其余拼到 input 前(保留上下文)
106
+ const inputParts = nonSystem.map((m) => {
107
+ const c = m.content;
108
+ if (typeof c === "string") return `${m.role}: ${c}`;
109
+ if (Array.isArray(c)) return `${m.role}: ${c.map((x) => x.text || "").join("")}`;
110
+ return `${m.role}: ${String(c || "")}`;
111
+ });
112
+ const input = inputParts.join("\n\n") || "hi";
113
+ const out = {
114
+ model: chatBody.model,
115
+ input,
116
+ stream: false, // muse-spark 的 chat 流式经 responses 的 SSE 格式不同,先以非流式 200 保证可用
117
+ };
118
+ if (system) out.instructions = system;
119
+ if (chatBody.tools) out.tools = chatBody.tools;
120
+ if (chatBody.tool_choice) out.tool_choice = chatBody.tool_choice;
121
+ if (chatBody.temperature != null) out.temperature = chatBody.temperature;
122
+ if (chatBody.max_tokens != null) out.max_output_tokens = chatBody.max_tokens;
123
+ return out;
124
+ }
125
+
126
+ function responsesToChatJson(respJson) {
127
+ // responses: {id, model, output:[{type:reasoning},{type:message, content:[{type:output_text,text}]}]}
128
+ let text = "";
129
+ for (const item of respJson.output || []) {
130
+ if (item.type === "message" && item.role === "assistant") {
131
+ for (const c of item.content || []) {
132
+ if (c.type === "output_text") text += c.text || "";
133
+ else if (c.type === "text") text += c.text || "";
134
+ }
135
+ }
136
+ }
137
+ if (!text) {
138
+ // 兜底:取 output_text 的第一个
139
+ for (const item of respJson.output || []) {
140
+ if (item.type === "message") {
141
+ const t = item.content?.[0]?.text;
142
+ if (t) { text = t; break; }
143
+ }
144
+ }
145
+ }
146
+ if (!text) text = "";
147
+ const chatJson = {
148
+ id: respJson.id || `resp_${Date.now()}`,
149
+ object: "chat.completion",
150
+ created: Math.floor((respJson.created_at || Date.now() / 1000)),
151
+ model: respJson.model,
152
+ choices: [
153
+ {
154
+ index: 0,
155
+ finish_reason: respJson.status === "completed" ? "stop" : "length",
156
+ message: { role: "assistant", content: text },
157
+ },
158
+ ],
159
+ usage: respJson.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
160
+ };
161
+ return chatJson;
162
+ }
163
+
94
164
  function freeAnonLogFile() {
95
165
  // 按用户要求:放在当前项目目录,方便一眼看到;可用 env 覆盖
96
166
  return process.env.MSLXDFF_FREE_ANON_LOG || join(process.cwd(), "free-anon-extra.txt");
@@ -98,7 +168,7 @@ export function createUpstreamClient({
98
168
 
99
169
  function logFreeAnon({ model, publicStatus, anonStatus, anonAttempts, hit, totalMs }) {
100
170
  const file = freeAnonLogFile();
101
- const line = `${new Date().toISOString()} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
171
+ const line = `${fmtShanghaiYMDHMS(new Date())} model=${model} public=429 anonTries=${anonAttempts} anonStatus=${anonStatus ?? "none"} hit=${hit ? "YES额外额度" : "NO无额外"} totalMs=${totalMs} count=${hit ? "1" : "0"}\n`;
102
172
  try {
103
173
  mkdirSync(dirname(file), { recursive: true });
104
174
  } catch {}
@@ -118,7 +188,7 @@ export function createUpstreamClient({
118
188
  const keepAliveConnections = envInt("MSLXDFF_UPSTREAM_KEEPALIVE_CONNECTIONS", 20);
119
189
  let dispatcher = null;
120
190
  let agent = null;
121
- if (UndiciAgent) {
191
+ if (keepAlive && UndiciAgent) {
122
192
  try {
123
193
  agent = new UndiciAgent({
124
194
  keepAliveTimeout,
@@ -134,13 +204,15 @@ export function createUpstreamClient({
134
204
  }
135
205
 
136
206
  async function chat(body) {
137
- const url = `${baseUrl}/zen/v1/chat/completions`;
207
+ const isResp = isResponsesModel(body?.model);
208
+ const url = isResp ? `${baseUrl}/zen/v1/responses` : `${baseUrl}/zen/v1/chat/completions`;
209
+ const reqBody = isResp ? chatToResponsesBody(body) : body;
138
210
  const t0 = performance.now();
139
211
  const attempts = [];
140
212
  let waitMs = 0;
141
213
  for (let attempt = 0; ; attempt++) {
142
214
  const t = performance.now();
143
- const result = await attemptOnce(url, body);
215
+ const result = await attemptOnce(url, reqBody);
144
216
  attempts.push({
145
217
  attempt,
146
218
  type: result instanceof Error ? "network" : `http${result.status}`,
@@ -177,7 +249,7 @@ export function createUpstreamClient({
177
249
  await sleep(anonDelay);
178
250
  waitMs += anonDelay;
179
251
  const t2 = performance.now();
180
- anonResult = await attemptOnce(url, body, { anonymous: true });
252
+ anonResult = await attemptOnce(url, reqBody, { anonymous: true });
181
253
  attempts.push({
182
254
  attempt: `anon-${i}`,
183
255
  type: anonResult instanceof Error ? "network" : `http${anonResult.status}`,
@@ -185,7 +257,23 @@ export function createUpstreamClient({
185
257
  });
186
258
  if (anonResult instanceof Error) continue;
187
259
  if (anonResult.status !== 429) {
188
- anonResult._t = {
260
+ // responses 模型的 anon 命中也需转回 chat 形状
261
+ let outAnon = anonResult;
262
+ if (isResp && anonResult.ok) {
263
+ try {
264
+ const txt = await anonResult.text();
265
+ const j = JSON.parse(txt);
266
+ if (j && Array.isArray(j.output)) {
267
+ const chatJson = responsesToChatJson(j);
268
+ const newHeaders = new Headers(anonResult.headers);
269
+ newHeaders.set("content-type", "application/json");
270
+ outAnon = new Response(JSON.stringify(chatJson), { status: anonResult.status, headers: newHeaders });
271
+ } else {
272
+ outAnon = new Response(txt, { status: anonResult.status, headers: anonResult.headers });
273
+ }
274
+ } catch { outAnon = anonResult; }
275
+ }
276
+ outAnon._t = {
189
277
  attempts,
190
278
  waitMs,
191
279
  totalMs: Math.round(performance.now() - t0),
@@ -195,7 +283,7 @@ export function createUpstreamClient({
195
283
  hit = true;
196
284
  hitAt = i + 1;
197
285
  consecutiveHits += 1;
198
- logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: anonResult._t.totalMs });
286
+ logFreeAnon({ model: body?.model, publicStatus: 429, anonStatus: anonResult.status, anonAttempts: i + 1, hit: true, totalMs: outAnon._t.totalMs });
199
287
  // 额外在 txt 里强调连续命中
200
288
  try {
201
289
  if (consecutiveHits >= 2) {
@@ -204,7 +292,7 @@ export function createUpstreamClient({
204
292
  }
205
293
  } catch {}
206
294
  if (process.env.MSLXDFF_DEBUG === "1") try { console.log(`[free-anon] ${body?.model} public 429 -> anon ${anonResult.status} after ${i + 1} try HIT`); } catch {}
207
- return anonResult;
295
+ return outAnon;
208
296
  }
209
297
  }
210
298
  // 3 次 anon 仍 429
@@ -222,6 +310,36 @@ export function createUpstreamClient({
222
310
  return anonResult;
223
311
  }
224
312
  }
313
+ // Muse Spark 走 /responses,需把 responses 的 JSON 转回 chat.completions 形状,保持上层无感
314
+ if (isResp && result.ok) {
315
+ try {
316
+ const txt = await result.text();
317
+ const j = JSON.parse(txt);
318
+ // responses 成功态:{output:[...]};chat 态:{choices:[...]}。非 responses 形状则原样回
319
+ if (j && Array.isArray(j.output)) {
320
+ const chatJson = responsesToChatJson(j);
321
+ const newBody = JSON.stringify(chatJson);
322
+ const newHeaders = new Headers(result.headers);
323
+ newHeaders.set("content-type", "application/json");
324
+ const transformed = new Response(newBody, { status: result.status, headers: newHeaders });
325
+ transformed._t = {
326
+ attempts,
327
+ waitMs,
328
+ totalMs: Math.round(performance.now() - t0),
329
+ };
330
+ // 保留原始 timing 供日志
331
+ if (result._t) transformed._t = { ...transformed._t, ...result._t };
332
+ return transformed;
333
+ }
334
+ // 不是 responses 形状,回退:用原文本重建可读 Response
335
+ const fallback = new Response(txt, { status: result.status, headers: result.headers });
336
+ fallback._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
337
+ return fallback;
338
+ } catch {
339
+ result._t = { attempts, waitMs, totalMs: Math.round(performance.now() - t0) };
340
+ return result;
341
+ }
342
+ }
225
343
  result._t = {
226
344
  attempts,
227
345
  waitMs,
@@ -1,14 +0,0 @@
1
- # ADR-0001: Inject a reasoning_content placeholder on outbound assistant messages
2
-
3
- The Zen upstream's thinking-mode models (deepseek-family at minimum) return
4
- `400 "The reasoning_content in the thinking mode must be passed back"` when a
5
- multi-turn request echoes an assistant message without its `reasoning_content`.
6
- Clients speaking plain OpenAI format never send that field, so the proxy writes
7
- a `" "` placeholder into assistant messages before forwarding. Scope is `all`
8
- for deepseek-family models and `tool_calls` for kimi-family models; messages
9
- that already carry non-empty `reasoning_content` are left untouched.
10
-
11
- The alternative — telling clients to manage `reasoning_content` themselves —
12
- would break standard OpenAI-compatible clients, so the proxy eats this
13
- compatibility cost instead. Matches `/root/9router` v0.5.45
14
- `open-sse/utils/reasoningContentInjector.js`.
@@ -1,12 +0,0 @@
1
- # ADR-0002: /v1/models exposes only free models, matched by suffix or whitelist
2
-
3
- The upstream `/zen/v1/models` list contains ~60 models; exposing them all would
4
- pollute clients with paid models this proxy can't serve for free. `/v1/models`
5
- therefore filters to: `id` ending in `-free`, OR the explicit whitelist entry
6
- `big-pickle`. The whitelist exists because `big-pickle` is a free model without
7
- the `-free` suffix, and a suffix-only filter would silently drop it.
8
-
9
- A plain `endsWith("-free")` filter was considered and rejected for exactly that
10
- reason. Matches `/root/9router` v0.5.45
11
- `src/app/api/providers/suggested-models/filters.js`
12
- (`KNOWN_FREE_OPENCODE_MODELS = ["big-pickle"]`).
@@ -1,10 +0,0 @@
1
- # ADR-0003: Zero-state, no-DB, no-account local proxy
2
-
3
- Status: partially superseded by [ADR-0004](./0004-bearer-token.md) — the
4
- no-auth clause below is replaced; the zero-DB / no-account / no-cloud principles
5
- stand.
6
-
7
- By design this proxy holds no database, no token store, no account rotation,
8
- and no cloud sync. A single static bearer token is the only credential, kept
9
- in a 0600 state file (see ADR-0004); everything else is stateless per-process
10
- memory at most.
@@ -1,18 +0,0 @@
1
- # ADR-0004: Single static bearer token, persisted in a 0600 state file
2
-
3
- The proxy requires a bearer token on `/v1/*` so an accidentally-exposed port
4
- isn't an open relay. There is no account system: the token is a random
5
- `crypto` 32-byte value (hex), generated once on first run, persisted to a
6
- state file (default `~/.config/mslxdff/state.json`, `0600`, path overridable
7
- via `MSLXDFF_STATE_FILE`), and printed to stdout on creation. Rotate with
8
- `mslxdff -refresh-token`, which regenerates, rewrites the file, prints the
9
- new token, and exits (does not start the server).
10
-
11
- Auth is enforced with a constant-time string compare on
12
- `Authorization: Bearer <token>`; mismatches get `401` with `WWW-Authenticate`.
13
- `/health` stays public (no token). Tokens never appear in logs.
14
-
15
- Alternatives rejected: a fixed default token (same key on every install),
16
- per-user accounts (needs a DB — that's the 9Router provisioning surface we
17
- rejected in ADR-0003), and unauthenticated local-only binding (fragile;
18
- a proxy relay deserves an explicit secret even on localhost).
@@ -1,53 +0,0 @@
1
- # ADR-0005: Peer mesh for same-model failover across machines
2
-
3
- > **Update (group model):** peers are now configured through named groups, and the
4
- > group name doubles as the join password (supersedes the random join key). CLI:
5
- > `-creategroup <name>` on the leader (no address needed — the first joiner seeds
6
- > the leader's own entry from the address it connects from), `-addtogroup <leader-host> <name>`
7
- > elsewhere. Every member (leader included) re-registers with the leader on a timer
8
- > (`MSLXDFF_GROUP_SYNC_MS`, default 60s) and rebuilds its local peer list from the
9
- > freshest member map, so membership changes propagate to all nodes automatically.
10
- > The `-peer` commands are removed; peers are an internal mechanism. Wrong group
11
- > names/tokens are counted per source IP: `MSLXDFF_BAN_THRESHOLD` (default 5)
12
- > failures ban the IP for `MSLXDFF_BAN_WINDOW_MS` (default 48h); `-resetban [ip]`
13
- > clears bans.
14
-
15
- A single mslxdff instance depends on one upstream quota; when that upstream
16
- starts rate-limiting or failing for a model, the only local fallback is
17
- switching to a *different* model. That changes the model out from under the
18
- client. To keep the requested model working while the local path recovers,
19
- multiple instances can be joined into a group: when the local upstream
20
- fails for model X, the instance forwards the request (still model X) to a
21
- group member that runs its own mslxdff and has its own upstream quota.
22
-
23
- ## Design
24
-
25
- - **Peers are plain mslxdff instances.** Each peer is identified by
26
- `{ url, token }` (its bearer token, per ADR-0004). Configured with
27
- `mslxdff -peer add <token> <url> [name]`, removed with `-peer remove`,
28
- listed with `-peer list`; persisted in the state file. No new protocol —
29
- forwarding is a normal authenticated `POST /v1/chat/completions` to the
30
- peer, reusing the existing API surface.
31
- - **Local-first routing.** A request always tries the local upstream first.
32
- Only on local failure (network error or HTTP ≥ 400) does it iterate peers
33
- for the *same model*, round-robin over currently-available peers.
34
- - **Model lock.** Forwarded requests carry `x-mslxdff-model-lock: <model>`
35
- so the receiving peer uses exactly that model — it must not re-select or
36
- fall back to another model, keeping "same model, different machine" true.
37
- - **Hop bound.** Forwarded requests carry `x-mslxdff-hops` (incremented each
38
- hop). A peer receiving hops ≥ `maxHops` (default 3, `MSLXDFF_MAX_HOPS`)
39
- stops forwarding further, bounding mesh depth and preventing loops.
40
- - **Peer cooldown.** A peer that fails a request enters a cooldown window
41
- (default 30s, `MSLXDFF_PEER_COOLDOWN_MS`), during which it is skipped by
42
- the round-robin, so the mesh rotates to healthy peers instead of
43
- hammering a down one. Mirrors the model cooldown in ADR-0001.
44
-
45
- ## Why not alternatives
46
-
47
- - **Point the whole proxy at another machine** (client-side failover): the
48
- client can't detect per-model upstream failure, and every request pays the
49
- cross-machine latency even when local is healthy.
50
- - **Different model per machine**: violates the requirement of keeping the
51
- same model, and hides the model-change from the client.
52
- - **Central coordinator / service discovery**: overkill for a handful of
53
- private instances; static peer config is zero-config and debuggable.
@@ -1,103 +0,0 @@
1
- # ADR-0006: 宽带动态IP成员(broadband)经 Leader 中继共享配额
2
-
3
- > **状态**:规划完成,待实现(基于 v0.1.33)。用户确认:`--broadband` 为唯一语义,不设 `--relay` 别名;默认 `127.0.0.1` 监听。
4
-
5
- ## 1. 背景与问题
6
-
7
- 现有组模型(ADR-0005)假设组员均为公网 VPS,`A->D` 直连 `http://D公网IP:8989/v1/chat/completions` 用 `D` 的出口 IP 打 `opencode.ai`,实现 `IP级免费池` 分散。家庭宽带 `D` 有公网 IP 但:
8
-
9
- 1. **入站不可达**:无端口映射/CGNAT,`probeHealth GET http://D:8989/health` 恒 `fail`,`peer-race` 直接跳过,`D` 的配额永不被用。
10
- 2. **IP 动态**:`refreshGroupMembers` 透传旧 `myUrl`,IP 变更后 60s 同步期内全组仍用旧 IP。
11
- 3. **语义缺失**:组内无法区分 `static(VPS直连)` 与 `broadband(家庭中继)`,`-group list` 无标识。
12
-
13
- 需求:家庭 `D` 以 `broadband` 类型加入,无需公网入站,经 `Leader` 中继仍用 `D` 的家庭出口打上游,且全程可观测。
14
-
15
- ## 2. 决策
16
-
17
- 新增成员类型 `kind: "broadband"`(默认 `kind: "static"` 兼容老数据),`broadband` 隐含 `relay` 中继:
18
-
19
- * **加入**:`mslxdff -addtogroup <leader-host> <group> --broadband`(唯一旗标,不设 `--relay`)。
20
- * **监听**:`--broadband` 下默认 `listen 127.0.0.1:8989`,仅本机 `WorkBuddy` 可用;不加该旗标的为 `static` 走 `0.0.0.0`。
21
- * **共享**:`D --WS--> Leader` 常驻出站,`A(429) -> Leader -> D -> opencode.ai -> D -> Leader -> A`,上游仍见 `D` 家庭 IP。
22
- * **展示**:`-help` 新增用法行,`-group list / -status / -log` 标注 `[broadband] via leader Xs ago ip=...`。
23
-
24
- ## 3. 设计
25
-
26
- ### 3.1 成员模型
27
-
28
- `state.json groups[name].members[id]` 扩展:
29
-
30
- ```json
31
- "home-D": {
32
- "url": "relay://home-D",
33
- "token": "...",
34
- "kind": "broadband",
35
- "publicIp": "183.14.22.78",
36
- "lastSeen": 1724212345678,
37
- "status": {"upstreamOk": true, "latencyMs": 4200}
38
- }
39
- ```
40
-
41
- * `static`:`url: http://IP:8989`,参与 `probeHealth`。
42
- * `broadband`:`url: relay://id`,不探活,只看 `lastSeen`(`>90s` 标 `cooling`)+ `status`,`rankModels` 同 `slow` 5m 冷却共用。
43
-
44
- ### 3.2 连接与心跳
45
-
46
- **D 端(家庭)**
47
- * 解析 `--broadband`,建 `WS wss://Leader/v1/groups/relay/connect?group=my@mslxd`,`Authorization: Bearer <token>`。
48
- * `hello {group, token, kind:"broadband", version}` → Leader 回 `welcome {memberId}`。
49
- * `heartbeat 30s {status:{upstreamOk, models, load}}`,`publicIp` 由 Leader 的 `clientIp(req)` 填,不靠 `ifconfig.me`。
50
- * 断线指数退避 `1s/2s/4s...` 重连,IP 变更导致 `TCP RST` 自动重建即完成 IP 更新。
51
-
52
- **Leader 端(VPS)**
53
- * `GET /v1/groups/relay/connect` 升级 WS,鉴权 `membersForToken`,存 `relayConns[group][id]=ws`,`ws.remoteIp=clientIp`。
54
- * 每条 `heartbeat` 对比 `remoteIp` vs `members[id].publicIp`,变则 `saveGroups` + `evt relay-ip-change` 入 `events.log`。
55
- * `lastSeen >90s` 或 `WS断开` 立即标 `cooling`,`A` 下次 `candidatesFor` 自动避开。
56
-
57
- ### 3.3 转发路径
58
-
59
- ```
60
- 用户 -> A: POST /v1/chat/completions {model: deepseek, stream:true}
61
- A -> opencode.ai (用A IP) 429
62
- A选 candidatesFor -> [B(static), home-D(broadband via Leader), ...] 按 latency EMA
63
- A -> Leader: POST /v1/groups/relay/forward {target: home-D, body, hops:1, reqId}
64
- Leader -> D (WS): {reqId, body:{model,messages}}
65
- D -> opencode.ai (用D家庭IP) 200 SSE chunk*
66
- D --WS {reqId, data:chunk}--> Leader --HTTP chunk--> A --SSE--> 用户
67
- A断开 -> Leader --WS {abort reqId}--> D controller.abort()
68
- ```
69
-
70
- * `hops` 每跳 +1,`MAX_HOPS=3` 防环;`broadband` 节点自身 `429` 时可直接 `fetch` 到 VPS `static` 节点(出站直连,无需中继)。
71
- * 多请求复用一条 WS,`reqId` 复用现有 `relay` 日志的 `reqId/detail` 串联。
72
-
73
- ### 3.4 可观测
74
-
75
- * `-help`:` -addtogroup <host> <name> [--broadband] 宽带动态IP成员(经Leader中继,无需公网入站,默认127.0.0.1)`
76
- * `-group list`:`3. relay://home-D [broadband] ok via leader 12s ago ip=183.14.22.78`
77
- * `-log [N]`:`relay-ip-change / relay-forward / relay-heartbeat / client-abort` 均带 `reqId/detail`。
78
-
79
- ## 4. 实现清单
80
-
81
- | 文件 | 改动 |
82
- |---|---|
83
- | `bin/mslxdff.js` | `-addtogroup` 解析 `--broadband`,`WS` 客户端+心跳+重连,`listen` 在 `broadband` 下绑 `127.0.0.1`,`printHelp` 新增行,`groupSyncTimer` 对 `broadband` 组 30s |
84
- | `src/groups.js` | 成员结构 `kind/publicIp/lastSeen/status`,`addGroupMember/upsertMember/syncPeersFromMembers` 支持 `broadband`,`refreshGroupMembers` 透传 `kind` |
85
- | `src/peers.js` | 新增 `relayVia` 字段,`add({relayVia, kind}) / isRelay`,`broadband` 不进 `ordered()` 直连池 |
86
- | `src/routes.js` | 新增 `WS /v1/groups/relay/connect` + `POST /v1/groups/relay/forward`,`forwardToPeer` 中继分支,`hops` 处理 |
87
- | `src/state.js` | 持久化 `kind/publicIp/lastSeen`,新增 `load/save` 兼容 |
88
- | `src/logs.js` | `relay-*` 事件类型 |
89
-
90
- 依赖:`ws`(或 Node 22 原生 `WebSocket`,服务端需 `upgrade` 处理)。
91
-
92
- ## 5. 验证
93
-
94
- 1. **IP变更**:`D` 拨号重拨,`WS` 重连,`events.log` 出现 `relay-ip-change old->new`,`-group list` IP 更新,`A` 经 `Leader` 仍命中 `D`。
95
- 2. **配额共享**:`A` 指定 `deepseek 429`,`-log` 显示 `peer-race via=relay-leader target=home-D`,`D` 的 `upstream-done 200` 且 `detail.exitReason:normal`,回包完整。
96
- 3. **本地可用**:`D` 本机 `curl http://127.0.0.1:8989/v1/chat/completions` 200,外网 `curl http://家庭公网IP:8989` 超时(符合预期)。
97
- 4. **`-help` / `-group list`** 均含 `broadband` 标识。
98
-
99
- ## 6. 风险与取舍
100
-
101
- * `Leader` 单点中继增加 `10-30ms` 延迟,可接受;`Leader` 宕则 `broadband` 配额暂不可用(`static` 组员仍直连可用)。
102
- * 多并发复用单 `WS` 需 `reqId` 复用与背压控制,首版可限并发 3(复用 `PEER_RACE_LIMIT`)。
103
- * 不设 `--relay` 别名,术语统一为 `broadband`,内部变量 `relay` 仅作实现名。
@@ -1,51 +0,0 @@
1
- # Domain Docs
2
-
3
- How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
-
5
- ## Before exploring, read these
6
-
7
- - **`CONTEXT.md`** at the repo root, or
8
- - **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
9
- - **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
-
11
- If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
12
-
13
- ## File structure
14
-
15
- Single-context repo (most repos):
16
-
17
- ```
18
- /
19
- ├── CONTEXT.md
20
- ├── docs/adr/
21
- │ ├── 0001-event-sourced-orders.md
22
- │ └── 0002-postgres-for-write-model.md
23
- └── src/
24
- ```
25
-
26
- Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
27
-
28
- ```
29
- /
30
- ├── CONTEXT-MAP.md
31
- ├── docs/adr/ ← system-wide decisions
32
- └── src/
33
- ├── ordering/
34
- │ ├── CONTEXT.md
35
- │ └── docs/adr/ ← context-specific decisions
36
- └── billing/
37
- ├── CONTEXT.md
38
- └── docs/adr/
39
- ```
40
-
41
- ## Use the glossary's vocabulary
42
-
43
- When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
44
-
45
- If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
46
-
47
- ## Flag ADR conflicts
48
-
49
- If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
50
-
51
- > _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
@@ -1,30 +0,0 @@
1
- # Issue tracker: Local Markdown
2
-
3
- Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
4
-
5
- ## Conventions
6
-
7
- - One feature per directory: `.scratch/<feature-slug>/`
8
- - The spec is `.scratch/<feature-slug>/spec.md`
9
- - Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
10
- - Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
11
- - Comments and conversation history append to the bottom of the file under a `## Comments` heading
12
-
13
- ## When a skill says "publish to the issue tracker"
14
-
15
- Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
16
-
17
- ## When a skill says "fetch the relevant ticket"
18
-
19
- Read the file at the referenced path. The user will normally pass the path or the issue number directly.
20
-
21
- ## Wayfinding operations
22
-
23
- Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
24
-
25
- - **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
26
- - **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
27
- - **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
28
- - **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
29
- - **Claim**: set `Status: claimed` and save before any work.
30
- - **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
@@ -1,15 +0,0 @@
1
- # Triage Labels
2
-
3
- The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
-
5
- | Label in mattpocock/skills | Label in our tracker | Meaning |
6
- | -------------------------- | -------------------- | ---------------------------------------- |
7
- | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
- | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
- | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
- | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
- | `wontfix` | `wontfix` | Will not be actioned |
12
-
13
- When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
-
15
- Edit the right-hand column to match whatever vocabulary you actually use.