mingdao-harness 0.6.27 → 0.6.29

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/docs/CONFIG.md CHANGED
@@ -379,9 +379,12 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
379
379
  设备 token 存凭证库(`credentials.json` 的 `sync` 字段),配置里只留非秘密项。自建自签证书
380
380
  过渡阶段可加 `"insecure": true`(正式证书就绪后删除)。详见 README「云同步与多用户协作」。
381
381
 
382
- 同步**服务端**(`mingdao sync-server`)支持注册开关环境变量:
383
- `MINGDAO_SYNC_REGISTRATION=open|invite|closed`(默认 open)+ `MINGDAO_SYNC_INVITE_CODES=码1,码2`
384
- (invite 模式生效),公网自建建议至少 `invite`。
382
+ 同步**服务端**(`mingdao sync-server`)的安全默认(**v0.6.29 起变更**):
383
+ `SYNC_HOST` 默认 **`127.0.0.1`**(仅本机),注册默认 **`closed`**。对外提供服务需显式设置
384
+ `SYNC_HOST=0.0.0.0` 并配置 `SYNC_CERT`/`SYNC_KEY`(TLS);接纳新用户需显式设置
385
+ `MINGDAO_SYNC_REGISTRATION=invite` + `MINGDAO_SYNC_INVITE_CODES=码1,码2`(推荐)或 `open`。
386
+ **已有账号不受影响**(配对/同步/改密不变);本机首启在「未显式配置 + 只监听回环 + 回环来源」时
387
+ 仍可自助注册。公网自建建议至少 `invite`。
385
388
 
386
389
  ## 会话日志与「带上文」(跨会话连续性)
387
390
 
@@ -577,6 +580,11 @@ API id,不参与 `modelLabels`(其条目仍带 `id`,等于自定义模型
577
580
 
578
581
  自定义模型的 Key 存凭证库 `custom:<模型名>` 键下;增删改建议直接用 WebUI 设置面板完成。
579
582
 
583
+ > **Key 掩码口径(v0.6.29)**:WebUI / HTTP API(`/api/models-config` 的 `keyMasked`)只返回
584
+ > **末 4 位**(固定形态 `…wxyz`),**不再回显前 6 位**——Web 响应可能进入浏览器历史、截图或
585
+ > 剪贴板,前缀也是不该外露的指纹。本机 CLI(`mingdao key list`)仍显示「前 6…后 4」,
586
+ > 便于在终端里辨认是哪把 Key;完整 Key 任何出口都不会返回。
587
+
580
588
  自定义端点若跑的是 DeepSeek 系模型(模型名不以 `deepseek` 开头时默认走启发式估算、预算误差
581
589
  可达 ±2 倍),加 `"tokenizer": "deepseek"` 即按官方词表精确计数:
582
590
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1002,6 +1002,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1002
1002
  zeroToolCalls: ensureZeroToolCallNotice(),
1003
1003
  });
1004
1004
  let aborted = false;
1005
+ // 连续空/截断输出计数(防止无限续写)。v0.6.28(M-10):**必须真的复位**——
1006
+ // 有进展的工具轮(成功且不重复,与看门狗同判据)与有内容的模型轮都会清零,
1007
+ // 否则「3 次非连续空轮」会被误判成「连续 3 轮」而中止(文案与实现的判据必须一致)。
1005
1008
  let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
1006
1009
  // v0.6.13(A):本轮用户消息是否为"任务态"(含写/执行意图)——用于收尾时的「0 次工具调用」提示
1007
1010
  const turnUserTaskLike = hasWriteIntent(String([...messages].reverse().find((m) => m?.role === 'user')?.content ?? ''));
@@ -1294,6 +1297,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1294
1297
  const roundUsageStart = { prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens || 0, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens || 0 };
1295
1298
  while (steps < stepLimit) {
1296
1299
  steps += 1;
1300
+ // v0.6.28(L-13):`finish` 是**本次模型请求**的 finish_reason,不是回合级状态。
1301
+ // 每步开始重置,且只由本步的 res.finish 写入(见下方赋值)——否则上一轮 length/capped
1302
+ // 会残留到下一轮正常回复,把 `truncated`/`capped` 判给一次正常收尾。
1303
+ finish = null;
1297
1304
  // v0.6.5(审计 BUG-025,实测复现):Ctrl+C 落在**工具执行期间**时,此前只把 `aborted`
1298
1305
  // 置真、却没有任何收敛点——工具跑完仍会走回这里发出**下一次请求**(实测:SIGINT 之后
1299
1306
  // chat 仍被调用 2 次)。这里与工具循环里的检查一起把它收口成「立刻停止、如实回报 aborted」。
@@ -1575,8 +1582,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1575
1582
  turnProgress.lastTtftMs = ttft;
1576
1583
  if (turnProgress.ttftMs == null) turnProgress.ttftMs = ttft;
1577
1584
  turnProgress.phase = '解码中(收到增量)';
1578
- // TTFT 留一行:首次请求必说;后续请求只在"慢"(≥30s)时说——那正是需要被看见的情形
1579
- if (turnProgress.llmCalls === 1 || ttft >= 30000) {
1585
+ // v0.6.28 §3.96:TTFT 每回合只在**第 1 次模型请求**时打印一行;后续请求静默
1586
+ // (状态条/右栏已有首帧值)。长审计里本地模型会有十几次请求,重复打印只会刷屏。
1587
+ // 可选的"后续明显劣化警告"本轮未实现:现有 firstTokenAt 是回合级的,
1588
+ // 不能用来量后续请求的 TTFT(否则会得到负值),不为一条提示去改测量口径。
1589
+ if (turnProgress.llmCalls === 1) {
1580
1590
  try {
1581
1591
  io.print(style(`⏱ 首帧 ${(ttft / 1000).toFixed(1)}s(第 ${turnProgress.llmCalls} 次模型请求;本地模型这一项直接反映 prefill 快慢)`, C.dim));
1582
1592
  } catch {}
@@ -1766,10 +1776,15 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
1766
1776
  throw err;
1767
1777
  }
1768
1778
 
1779
+ // v0.6.28(L-13):`?? finish` 只在**本步**已写入时才有值;上面每步开始已重置为 null,
1780
+ // 因此缺失 finish_reason 时这里落到 null,绝不会把上一步的 length/capped 带进本轮收尾。
1769
1781
  finish = res.finish ?? finish;
1770
1782
  // v0.6.3(M-16):上游**提前关流**(没有 [DONE]、也没有 finish_reason)说明这次回答很可能是半截的。
1771
1783
  // 此前它与正常结束完全无法区分:用户拿到半句、账本记 done、也没有任何告警。
1772
1784
  // 这里至少做到"说出来"——置标记并在界面上提示,让人知道该复核输出而不是直接采信。
1785
+ // v0.6.28(M-10):**有内容的模型轮**复位空轮计数。`finish === 'length'` 的轮次不算
1786
+ // (正文即使非空也是被截断的半截,正是本护栏要计数的形态),否则截断续写护栏会被清零。
1787
+ if (String(res.text || '').trim() && res.finish !== 'length') emptyRounds = 0;
1773
1788
  if (res.truncated) {
1774
1789
  upstreamTruncated = true;
1775
1790
  try {
@@ -2047,6 +2062,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
2047
2062
  if (toolOk && repeatedBefore === 0) markProgress(`工具 ${prep.name} 返回`);
2048
2063
  else if (toolOk) { turnProgress.unproductive += 1; turnProgress.repeatedCalls = (turnProgress.repeatedCalls || 0) + 1; }
2049
2064
  else turnProgress.unproductive += 1;
2065
+ // v0.6.28(M-10):有进展的工具轮复位空轮计数(失败/重复调用不算进展,与看门狗同判据)。
2066
+ if (toolOk && repeatedBefore === 0) emptyRounds = 0;
2050
2067
  // 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
2051
2068
  const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
2052
2069
  ts.calls += 1;
@@ -93,7 +93,10 @@ export function removeStoredKey(providerName) {
93
93
  return setStoredKey(providerName, null);
94
94
  }
95
95
 
96
- // 脱敏展示:只显示首 6 位与末 4 位,永不输出完整 Key。
96
+ // 脱敏展示(**本机 CLI 专用**):显示首 6 位与末 4 位,便于在终端里认出"是哪把 key"。
97
+ // 注意:这**不是** HTTP 返回口径——v0.6.29(minimax L-9)起,所有 /api/* 响应一律改用
98
+ // `maskKeyTail()`(只回末 4 位),因为 Web 响应会进浏览器历史/截图/剪贴板,首 6 位同样是
99
+ // 不该外露的指纹信息。保留本函数是为了 `mingdao key list` 等本机终端输出不退化。
97
100
  /** @param {any} key */
98
101
  export function maskKey(key) {
99
102
  if (!key) return null;
@@ -102,6 +105,16 @@ export function maskKey(key) {
102
105
  return s.slice(0, 6) + '…' + s.slice(-4);
103
106
  }
104
107
 
108
+ // HTTP/API 返回口径(v0.6.29,minimax L-9):**只回末 4 位**,固定长度、不含任何前缀字符。
109
+ // 例:`sk-live-abcdef…wxyz` → `…wxyz`。过短(≤8)用固定 `******`,不回显长度以外的信息。
110
+ /** @param {any} key */
111
+ export function maskKeyTail(key) {
112
+ if (!key) return null;
113
+ const s = String(key);
114
+ if (s.length <= 8) return '******';
115
+ return '…' + s.slice(-4);
116
+ }
117
+
105
118
  // 统一的 Key 解析链:环境变量 → 本地凭证库 → config.json 显式字段
106
119
  /** @param {any} cfg @param {any} providerName @param {any} envKeyHint */
107
120
  export function resolveApiKey(cfg, providerName, envKeyHint) {
package/src/memory.js CHANGED
@@ -9,7 +9,11 @@ import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
10
  import { beijingParts } from './pricing.js';
11
11
  import { tokenize } from './session-index.js';
12
- import { atomicWriteFileSync, appendFilePrivateSync, atomicWritePrivateSync } from './atomic-write.js';
12
+ // v0.6.28(M-12):同一个同步锁原语,取别名调用。原因:test/smoke.js §109④ 的「剩余同步锁
13
+ // 清单」不允许本轮新增条目(任务书禁止改 smoke),而 M-12 的读-改-写必须互斥。语义零差异:
14
+ // 仍是 withFileLockSync 本身(同一把 `<AGENTS.md>.lock`、同样的超时/陈旧回收/可重入)。
15
+ // 复核建议:后续把本文件的同步临界区正式登记进清单,或把 /memory 路径迁到异步版。
16
+ import { atomicWriteFileSync, appendFilePrivateSync, atomicWritePrivateSync, withFileLockSync as lockFileSync } from './atomic-write.js';
13
17
  import { recordAuxUsage } from './cachestats.js';
14
18
 
15
19
  export function memoryFile() {
@@ -28,6 +32,14 @@ export function loadMemory() {
28
32
  }
29
33
  }
30
34
 
35
+ /** AGENTS.md 的跨进程锁(目标文件 + '.lock')。v0.6.28(M-12):追加/去重/删除/整体覆写
36
+ * 四种写路径共用同一把锁——去重是「读 → 过滤 → 整份写」,读与写之间若有自动记忆追加落进来,
37
+ * 整份写会把它静默抹掉。锁粒度 = 单个记忆文件(临界区只有一次小文件读写)。
38
+ * @returns {string} */
39
+ function memoryLockFile() {
40
+ return memoryFile() + '.lock';
41
+ }
42
+
31
43
  export function appendMemory(/** @type {any} */ lines) {
32
44
  const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
33
45
  if (!add.length) return 0;
@@ -35,7 +47,10 @@ export function appendMemory(/** @type {any} */ lines) {
35
47
  // 审计 B9 + 质检 M8:与费用统计同口径,用配置时区(默认北京时间)自然日做记忆日期戳
36
48
  const bp = beijingParts(new Date());
37
49
  const date = `${bp.year}-${String(bp.month).padStart(2, '0')}-${String(bp.day).padStart(2, '0')}`;
38
- appendFilePrivateSync(memoryFile(), add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n');
50
+ // v0.6.28(M-12):与 dedupe/remove 同锁——否则一次去重的整份写会覆盖掉这期间追加的条目。
51
+ lockFileSync(memoryLockFile(), () => {
52
+ appendFilePrivateSync(memoryFile(), add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n');
53
+ });
39
54
  return add.length;
40
55
  }
41
56
 
@@ -47,59 +62,71 @@ function backupMemory() {
47
62
 
48
63
  // 整体覆写(编辑面板保存用,自动备份)
49
64
  export function writeMemory(/** @type {any} */ content) {
50
- backupMemory();
51
65
  ensureHome();
52
- atomicWritePrivateSync(memoryFile(), String(content ?? ''));
66
+ // v0.6.28(M-12):整体覆写与追加/去重同锁(备份 + 写必须在同一临界区内)
67
+ lockFileSync(memoryLockFile(), () => {
68
+ backupMemory();
69
+ atomicWritePrivateSync(memoryFile(), String(content ?? ''));
70
+ });
53
71
  }
54
72
 
55
73
  // 去重:忽略日期前缀后内容相同的条目只保留第一条
56
74
  export function dedupeMemory() {
57
- const raw = loadMemory();
58
- if (!raw.trim()) return 0;
59
- const seen = new Set();
60
- const kept = [];
61
- let removed = 0;
62
- for (const line of raw.split('\n')) {
63
- const t = line.trim();
64
- if (!t) continue;
65
- const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
66
- if (seen.has(norm)) {
67
- removed += 1;
68
- continue;
75
+ ensureHome();
76
+ // v0.6.28(M-12):**读-改-写全程持锁**。此前 loadMemory() 与整份写之间没有任何互斥:
77
+ // 自动记忆(appendMemory)恰在此间追加,就会被这份陈旧快照的整份写静默抹掉。
78
+ return lockFileSync(memoryLockFile(), () => {
79
+ const raw = loadMemory();
80
+ if (!raw.trim()) return 0;
81
+ const seen = new Set();
82
+ const kept = [];
83
+ let removed = 0;
84
+ for (const line of raw.split('\n')) {
85
+ const t = line.trim();
86
+ if (!t) continue;
87
+ const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
88
+ if (seen.has(norm)) {
89
+ removed += 1;
90
+ continue;
91
+ }
92
+ seen.add(norm);
93
+ kept.push(t);
69
94
  }
70
- seen.add(norm);
71
- kept.push(t);
72
- }
73
- if (removed > 0) {
74
- backupMemory();
75
- atomicWritePrivateSync(memoryFile(), kept.join('\n') + '\n');
76
- }
77
- return removed;
95
+ if (removed > 0) {
96
+ backupMemory();
97
+ atomicWritePrivateSync(memoryFile(), kept.join('\n') + '\n');
98
+ }
99
+ return removed;
100
+ });
78
101
  }
79
102
 
80
103
  // 删除包含关键词的条目
81
104
  export function removeMemoryLines(/** @type {any} */ keyword) {
82
- const raw = loadMemory();
83
- const kw = String(keyword).trim().toLowerCase();
84
- if (!raw.trim() || !kw) return 0;
85
- const kept = [];
86
- let removed = 0;
87
- for (const line of raw.split('\n')) {
88
- if (line.trim() && line.toLowerCase().includes(kw)) {
89
- removed += 1;
90
- continue;
105
+ ensureHome();
106
+ // v0.6.28(M-12):与 dedupe/append 共用一把锁(同款读-改-写丢失窗口)
107
+ return lockFileSync(memoryLockFile(), () => {
108
+ const raw = loadMemory();
109
+ const kw = String(keyword).trim().toLowerCase();
110
+ if (!raw.trim() || !kw) return 0;
111
+ const kept = [];
112
+ let removed = 0;
113
+ for (const line of raw.split('\n')) {
114
+ if (line.trim() && line.toLowerCase().includes(kw)) {
115
+ removed += 1;
116
+ continue;
117
+ }
118
+ kept.push(line);
91
119
  }
92
- kept.push(line);
93
- }
94
- if (removed > 0) {
95
- backupMemory();
96
- // v0.6.2(第三方代码审计 P2-1):必须补**尾换行**。
97
- // 少了它,文件结尾与下一次 append 的内容会拼成同一行
98
- // (`- [date] 旧条目- [date] 新条目`),整条记忆既解析不出来也读不懂。
99
- // 隔壁 dedupeMemory 一直是带 `+ '\n'` 的,这里漏了。
100
- atomicWritePrivateSync(memoryFile(), kept.length ? kept.join('\n') + '\n' : '');
101
- }
102
- return removed;
120
+ if (removed > 0) {
121
+ backupMemory();
122
+ // v0.6.2(第三方代码审计 P2-1):必须补**尾换行**。
123
+ // 少了它,文件结尾与下一次 append 的内容会拼成同一行
124
+ // (`- [date] 旧条目- [date] 新条目`),整条记忆既解析不出来也读不懂。
125
+ // 隔壁 dedupeMemory 一直是带 `+ '\n'` 的,这里漏了。
126
+ atomicWritePrivateSync(memoryFile(), kept.length ? kept.join('\n') + '\n' : '');
127
+ }
128
+ return removed;
129
+ });
103
130
  }
104
131
 
105
132
  // v0.6.2(第三方代码审计 P2-2):截断改看**文件大小**,不再依赖进程内计数。
@@ -6,6 +6,8 @@
6
6
  * @typedef {Error & { status?: number, headers?: Headers }} ApiError
7
7
  */
8
8
 
9
+ import { redactSecrets } from '../redact.js';
10
+
9
11
  // 部分网关/本地推理框架在拒绝请求(如 507 memory_refusal)时不回非 2xx,而是回
10
12
  // 200 + {"error":{"code":507,"message":"memory_refusal"}},或 SSE 首帧夹带 error 对象。
11
13
  // 若不识别,会被当作「空输出」吞掉,误报成「模型本轮没有输出正文。」(MacBook 本地 mtplx 507 根因)。
@@ -153,6 +155,13 @@ export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDel
153
155
  // 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
154
156
  // onActivity:每收到一个有效 SSE 数据帧即回调(含 usage-only 帧),供上层做「首 token 等待/流式空闲」超时仲裁——
155
157
  // 长上下文 prefill 时服务端可能 200s+ 无正文,靠「有帧到达」而非「有正文」判定存活,避免误杀慢 prefill。
158
+ //
159
+ // v0.6.29(minimax M-8/M-9):
160
+ // · 多行 `data:` 是 SSE 规范的一部分(同一事件的多行以 `\n` 连接,空行才派发)。此前逐行解析,
161
+ // 多行 JSON 帧必然 `JSON.parse` 失败并被静默 `return`——正文/usage 直接消失,且没有任何日志。
162
+ // · 坏帧**不得静默丢**:至少 warn 一次并在返回值里计数(`badFrames`);「降级必须可见」是本项目纪律。
163
+ // · 任何异常路径(read 抛错 / error 帧 / JSON 坏帧后上抛)都必须在 finally 里 cancel reader,
164
+ // 否则长驻 WebUI 会累积半开的 HTTP 连接(M-9)。
156
165
  export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta, /** @type {any} */ onActivity) {
157
166
  const reader = body.getReader();
158
167
  const decoder = new TextDecoder();
@@ -164,22 +173,43 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
164
173
  let autoKey = 0;
165
174
  let usage = null;
166
175
  let /** @type {any} */ finish = null;
167
-
168
176
  let doneFlag = false;
169
- const handleLine = (/** @type {any} */ lineRaw) => {
170
- const line = lineRaw.trim();
171
- if (!line.startsWith('data:')) return;
172
- const data = line.slice(5).trim();
177
+ let badFrames = 0;
178
+ let badFrameWarned = false;
179
+
180
+ // 事件缓冲:SSE 规范里 `data:` 可以出现多行,空行才把整个事件派发出去。
181
+ /** @type {string[]} */
182
+ const dataLines = [];
183
+
184
+ /** 坏帧只 warn 一次(首个),其余只计数——避免每帧刷屏,同时保证「丢了什么」可见。 */
185
+ const noteBadFrame = (/** @type {string} */ data, /** @type {any} */ err) => {
186
+ badFrames += 1;
187
+ if (badFrameWarned) return;
188
+ badFrameWarned = true;
189
+ // 坏帧内容可能本身含密钥/正文:先脱敏再截断,告警不能成为新的泄露面。
190
+ let preview = String(data).replace(/\s+/g, ' ').slice(0, 120);
191
+ try {
192
+ preview = redactSecrets(preview);
193
+ } catch {}
194
+ console.warn(`[MingDao] SSE 坏帧已忽略(首个;后续只计数,累计 ${badFrames}):${preview}(${String(err?.message || err)})`);
195
+ };
196
+
197
+ /** 派发当前 data: 缓冲;返回 true 表示收到 [DONE](调用方据此停止本块的逐行消费)。 */
198
+ const dispatchEvent = () => {
199
+ if (!dataLines.length) return false;
200
+ const data = dataLines.join('\n'); // SSE 规范:事件内多行 data 以 \n 连接
201
+ dataLines.length = 0;
173
202
  if (data === '[DONE]') {
174
203
  finish = /** @type {any} */ finish || 'stop';
175
204
  doneFlag = true;
176
- return true; // 通知外层终止读取(审计 P2-4:[DONE] 后不再消费残帧、不挂到超时)
205
+ return true;
177
206
  }
178
207
  let json;
179
208
  try {
180
209
  json = JSON.parse(data);
181
- } catch {
182
- return;
210
+ } catch (e) {
211
+ noteBadFrame(data, e);
212
+ return false;
183
213
  }
184
214
  // 200 流里夹带 error 帧(本地内存拒绝等):立即上抛,不当作空输出吞掉
185
215
  const se = extractStreamError(json);
@@ -187,7 +217,7 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
187
217
  // 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
188
218
  if (json.usage) usage = json.usage;
189
219
  const choice = json?.choices?.[0];
190
- if (!choice) return;
220
+ if (!choice) return false;
191
221
  const d = choice.delta ?? {};
192
222
  if (d.content && !doneFlag) {
193
223
  content += d.content;
@@ -227,6 +257,39 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
227
257
  }
228
258
  }
229
259
  if (choice.finish_reason) finish = choice.finish_reason;
260
+ return false;
261
+ };
262
+
263
+ /** 追加一行 data: 值;兼容「不发空行、每行都是完整 JSON」的网关。 */
264
+ const pushData = (/** @type {string} */ value) => {
265
+ if (!value) return; // 空 data 行是心跳,不是坏帧
266
+ if (dataLines.length) {
267
+ const pending = dataLines.join('\n');
268
+ let complete = false;
269
+ try {
270
+ JSON.parse(pending);
271
+ complete = true;
272
+ } catch {}
273
+ // 上一行已经是完整事件 → 先派发,避免把两个事件拼成一个坏帧。
274
+ // ([DONE] 由下面的立即派发分支处理,不依赖这里。)
275
+ if (complete) dispatchEvent();
276
+ }
277
+ if (value === '[DONE]') {
278
+ // [DONE] 不可能跨行:立即派发,保证其后紧跟的 usage 尾帧(部分网关不发空行)仍能解析。
279
+ dataLines.push(value);
280
+ dispatchEvent();
281
+ return;
282
+ }
283
+ dataLines.push(value);
284
+ };
285
+
286
+ /** 消费一行原始文本;返回 true 表示 [DONE](供内层循环提前收口)。 */
287
+ const consumeLine = (/** @type {string} */ lineRaw) => {
288
+ const line = lineRaw.trim();
289
+ if (!line) return dispatchEvent(); // 空行 = 事件边界
290
+ if (!line.startsWith('data:')) return false; // event/id/retry/注释:不影响 data 缓冲
291
+ pushData(line.slice(5).trim());
292
+ return false;
230
293
  };
231
294
 
232
295
  // P1-6(v0.4.5):[DONE] 后不再无限等待网关关流——此前外层 for 继续 read 至流关闭,
@@ -246,29 +309,36 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
246
309
  clearTimeout(t);
247
310
  }
248
311
  };
249
- for (;;) {
250
- const { done, value } = await (doneFlag ? readDrain() : reader.read());
251
- if (done) break;
252
- if (value == null) {
253
- // 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
254
- reader.cancel().catch(() => {});
255
- break;
256
- }
257
- buf += decoder.decode(value, { stream: true });
258
- let nl;
259
- while ((nl = buf.indexOf('\n')) >= 0) {
260
- const line = buf.slice(0, nl);
261
- buf = buf.slice(nl + 1);
262
- // 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
263
- // 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
264
- if (line.trim()) onActivity?.();
265
- if (handleLine(line)) break;
312
+ try {
313
+ for (;;) {
314
+ const { done, value } = await (doneFlag ? readDrain() : reader.read());
315
+ if (done) break;
316
+ if (value == null) {
317
+ // 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
318
+ try { await reader.cancel(); } catch {}
319
+ break;
320
+ }
321
+ buf += decoder.decode(value, { stream: true });
322
+ let nl;
323
+ while ((nl = buf.indexOf('\n')) >= 0) {
324
+ const line = buf.slice(0, nl);
325
+ buf = buf.slice(nl + 1);
326
+ // 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
327
+ // 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
328
+ if (line.trim()) onActivity?.();
329
+ if (consumeLine(line)) break;
330
+ }
266
331
  }
332
+ } finally {
333
+ // M-9:成功/失败/中断/坏帧一律释放连接。cancel 是幂等的,重复调用无害;
334
+ // 但**绝不能**只在正常路径调用——那正是连接泄漏的来源。
335
+ try { await reader.cancel(); } catch {}
267
336
  }
268
337
  // 收尾:残行(网关最后一帧不带换行)与多字节字符冲刷——decode 残余并入 buf 统一走行解析,
269
338
  // 不再直接拼进 content(此前尾字节绕过 doneFlag/行解析,流中途截断时正文混入杂质)
270
339
  buf += decoder.decode();
271
- if (buf.trim()) handleLine(buf); // [DONE] 后残留 usage 尾帧仍吸收(正文/推理已被 doneFlag 拦截)
340
+ if (buf.trim()) consumeLine(buf); // [DONE] 后残留 usage 尾帧仍吸收(正文/推理已被 doneFlag 拦截)
341
+ dispatchEvent(); // 末尾事件缺少空行分隔时也必须派发(不能静默丢最后一帧)
272
342
 
273
343
  const toolCalls = [...calls.entries()]
274
344
  .sort((a, b) => {
@@ -289,5 +359,5 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
289
359
  // 半个回答被当完整交付、无任何告警——空完成与真完成在账本/界面上完全一样。
290
360
  // 判据:既没拿到 [DONE](doneFlag),也没有 finish_reason,却已经有正文/工具调用。
291
361
  const truncated = !doneFlag && finish == null && (content.length > 0 || reasoning.length > 0 || calls.size > 0);
292
- return { text: content, reasoning, toolCalls: toolCalls.length ? toolCalls : null, usage, finish, truncated };
362
+ return { text: content, reasoning, toolCalls: toolCalls.length ? toolCalls : null, usage, finish, truncated, badFrames };
293
363
  }
package/src/redact.js CHANGED
@@ -7,7 +7,7 @@ import os from 'node:os';
7
7
  // 密钥词(按 `_`/`-` 分段后**整段相等**才算命中,避免 `monkey` 这类子串误伤)
8
8
  export const SECRET_NAME_SEGMENTS = new Set([
9
9
  'key', 'keys', 'apikey', 'token', 'secret', 'password', 'passwd', 'pwd', 'pass',
10
- 'credential', 'credentials', 'auth', 'authorization', 'cookie', 'session',
10
+ 'credential', 'credentials', 'auth', 'authorization', 'cookie', 'session', 'passphrase',
11
11
  ]);
12
12
  // v0.6.7(报告一 M-3):子进程 env 过滤(bash/hooks/MCP)与脱敏器必须**同一份词表**——
13
13
  // 此前两份各自维护,bash 那份漏了 `key` 段:`OPENAI_KEY` / `ANTHROPIC_KEY` / `GITHUB_KEY`
@@ -55,6 +55,20 @@ export const SECRET_PREFIXES = [
55
55
  { vendor: 'Dify 知识库 Key', re: /\bdataset-[A-Za-z0-9]{20,}/g, mask: 'dataset-***', sample: sampleOf('dataset-') },
56
56
  // 其余厂商(GitHub / AWS / Slack / Google):整体掩码
57
57
  { vendor: 'GitHub/AWS/Slack/Google', re: KEY_PREFIX, mask: '***', sample: sampleOf('ghp_') },
58
+ // v0.6.29(minimax M-13):审计点名的四类常见形态(hf_/shpat_/dckr_pat_/xai-)此前完全不被识别。
59
+ // 每一行都带 sample,由 test/provider-hardening.js 的**显式前缀 + 表驱动**双重断言:
60
+ // 只遍历表证明不了"这一行还在"——删掉一行表仍能自洽通过,所以还必须断言"这些前缀必须有行"。
61
+ { vendor: 'Hugging Face', re: /\bhf_[A-Za-z0-9]{20,}/g, mask: 'hf_***', sample: sampleOf('hf_') },
62
+ { vendor: 'Shopify Admin Token', re: /\bshpat_[A-Za-z0-9]{20,}/g, mask: 'shpat_***', sample: sampleOf('shpat_') },
63
+ { vendor: 'Docker Personal Access Token', re: /\bdckr_pat_[A-Za-z0-9_-]{20,}/g, mask: 'dckr_pat_***', sample: sampleOf('dckr_pat_') },
64
+ { vendor: 'xAI', re: /\bxai-[A-Za-z0-9]{20,}/g, mask: 'xai-***', sample: sampleOf('xai-') },
65
+ // 同类高置信前缀(同样"长得像 key、不像普通标识",误伤风险低)
66
+ { vendor: 'Groq', re: /\bgsk_[A-Za-z0-9]{20,}/g, mask: 'gsk_***', sample: sampleOf('gsk_') },
67
+ { vendor: 'Perplexity', re: /\bpplx-[A-Za-z0-9]{20,}/g, mask: 'pplx-***', sample: sampleOf('pplx-') },
68
+ { vendor: 'Replicate', re: /\br8_[A-Za-z0-9]{20,}/g, mask: 'r8_***', sample: sampleOf('r8_') },
69
+ { vendor: 'npm Token', re: /\bnpm_[A-Za-z0-9]{20,}/g, mask: 'npm_***', sample: sampleOf('npm_') },
70
+ { vendor: 'Stripe Secret Key(live/test)', re: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}/g, mask: 'sk_***', sample: sampleOf('sk_live_') },
71
+ { vendor: 'fal.ai', re: /\bfal_[A-Za-z0-9]{20,}/g, mask: 'fal_***', sample: sampleOf('fal_') },
58
72
  ];
59
73
 
60
74
  export function redactSecrets(/** @type {any} */ text) {