mingdao-harness 0.4.4 → 0.4.6
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/ARCHITECTURE.md +1 -1
- package/docs/AUDIT-v0.4.6.md +179 -0
- package/docs/PACK-API.md +250 -0
- package/docs/SAVINGS-BENCHMARK.md +12 -5
- package/docs/STRATEGY-0.5.md +262 -0
- package/docs/STRATEGY-NEXT.md +1 -1
- package/package.json +2 -1
- package/skills-lib/bash/SKILL.md +26 -0
- package/skills-lib/changelog/SKILL.md +24 -0
- package/skills-lib/ci-cd/SKILL.md +25 -0
- package/skills-lib/data-analysis/SKILL.md +25 -0
- package/skills-lib/database-design/SKILL.md +25 -0
- package/skills-lib/docs/SKILL.md +25 -0
- package/skills-lib/email/SKILL.md +25 -0
- package/skills-lib/file-organize/SKILL.md +27 -0
- package/skills-lib/git-workflow/SKILL.md +26 -0
- package/skills-lib/i18n/SKILL.md +24 -0
- package/skills-lib/markdown/SKILL.md +26 -0
- package/skills-lib/meeting-notes/SKILL.md +25 -0
- package/skills-lib/nodejs/SKILL.md +26 -0
- package/skills-lib/performance/SKILL.md +25 -0
- package/skills-lib/python/SKILL.md +26 -0
- package/skills-lib/readme/SKILL.md +25 -0
- package/skills-lib/regex/SKILL.md +25 -0
- package/skills-lib/report/SKILL.md +25 -0
- package/skills-lib/resume/SKILL.md +24 -0
- package/skills-lib/security-audit/SKILL.md +28 -0
- package/skills-lib/sql/SKILL.md +25 -0
- package/skills-lib/translation/SKILL.md +25 -0
- package/src/agent.js +95 -9
- package/src/atomic-write.js +9 -0
- package/src/autostart.js +37 -3
- package/src/batch.js +9 -1
- package/src/cachestats.js +32 -8
- package/src/cli.js +29 -14
- package/src/commands/repl.js +5 -3
- package/src/commands/update.js +1 -1
- package/src/compact.js +16 -1
- package/src/context.js +8 -0
- package/src/cost-guard.js +26 -6
- package/src/hooks.js +20 -4
- package/src/log-writer.js +19 -7
- package/src/mcp.js +4 -0
- package/src/memory.js +6 -5
- package/src/model-caps.js +48 -4
- package/src/permissions.js +10 -4
- package/src/presets.js +13 -3
- package/src/pricing.js +78 -18
- package/src/providers/index.js +43 -13
- package/src/providers/openai-compatible.js +42 -1
- package/src/redact.js +5 -0
- package/src/schedule.js +62 -30
- package/src/session.js +14 -1
- package/src/skill-lib.js +23 -4
- package/src/skills.js +2 -2
- package/src/sync-server.js +29 -1
- package/src/sync.js +6 -3
- package/src/tasks/worker.js +4 -1
- package/src/tasks.js +4 -1
- package/src/tokenizer.js +48 -11
- package/src/tools/bash.js +9 -2
- package/src/tools/fetch.js +97 -8
- package/src/tools/fs-tools.js +48 -3
- package/src/tools/git.js +17 -1
- package/src/tools/index.js +12 -5
- package/src/web/constants.js +10 -0
- package/src/web/routes/api.js +3 -1
- package/src/web/routes/domains/misc.js +6 -0
- package/src/web/server.js +41 -31
package/src/pricing.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { modelPreset } from './models.js';
|
|
10
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
10
11
|
import { mingdaoHome } from './config.js';
|
|
11
12
|
|
|
12
13
|
// 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
|
|
@@ -63,7 +64,7 @@ export async function refreshPricingFromSource(cfg) {
|
|
|
63
64
|
}
|
|
64
65
|
const file = pricingFilePath();
|
|
65
66
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
-
|
|
67
|
+
atomicWriteFileSync(file, JSON.stringify({ fetchedAt: Date.now(), source: src, models }, null, 2));
|
|
67
68
|
extCache = { mtime: -1, ttlDays: 7, data: null, stale: false };
|
|
68
69
|
const names = Object.keys(models).join('、');
|
|
69
70
|
return { ok: true, lines: ['✓ 价格表已刷新(' + names + '),TTL 内费用估算/护栏/避峰自动跟随'] };
|
|
@@ -114,15 +115,37 @@ export function isPeakHour(date = new Date()) {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
// ——
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// —— 计价时区墙钟工具(避峰调度/费用护栏按天统计用,零依赖 Intl) ——
|
|
119
|
+
// v0.4.6:抽出「生效时区」解析(坏配置回退 Asia/Shanghai,绝不让计费/护栏崩溃)。
|
|
120
|
+
function activeTimezone() {
|
|
120
121
|
try {
|
|
121
|
-
tz = peakCfg().timezone;
|
|
122
|
+
const tz = peakCfg().timezone;
|
|
122
123
|
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(); // 非法时区在此抛错
|
|
124
|
+
return tz;
|
|
123
125
|
} catch {
|
|
124
|
-
|
|
126
|
+
return 'Asia/Shanghai';
|
|
125
127
|
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {Date} date @param {string} tz 该时刻在 tz 的墙钟与 UTC 的偏移(毫秒) */
|
|
131
|
+
function tzOffsetMs(date, tz) {
|
|
132
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
133
|
+
timeZone: tz,
|
|
134
|
+
year: 'numeric',
|
|
135
|
+
month: '2-digit',
|
|
136
|
+
day: '2-digit',
|
|
137
|
+
hour: '2-digit',
|
|
138
|
+
minute: '2-digit',
|
|
139
|
+
second: '2-digit',
|
|
140
|
+
hourCycle: 'h23',
|
|
141
|
+
}).formatToParts(date);
|
|
142
|
+
const g = (/** @type {any} */ t) => Number(parts.find((/** @type {any} */ p) => p.type === t)?.value);
|
|
143
|
+
const asUtc = Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second'));
|
|
144
|
+
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function beijingParts(date = new Date()) {
|
|
148
|
+
const tz = activeTimezone();
|
|
126
149
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
127
150
|
timeZone: tz,
|
|
128
151
|
year: 'numeric',
|
|
@@ -137,12 +160,24 @@ export function beijingParts(date = new Date()) {
|
|
|
137
160
|
return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute'), second: g('second') };
|
|
138
161
|
}
|
|
139
162
|
|
|
140
|
-
//
|
|
163
|
+
// 时区墙钟 → Date。
|
|
164
|
+
// v0.4.6 P3 修复:此前硬编码 `- 8h`(只管 Asia/Shanghai),而 beijingParts() 用的是可配置的
|
|
165
|
+
// `pricing.timezone`——用户按文档覆盖时区后,日界(费用护栏按自然日累计)与 --offpeak 顺延
|
|
166
|
+
// 会同时算错(美东实测错 12 小时:日界落在当地中午、避峰时刻整体偏移)。
|
|
167
|
+
// 现按目标时区真实偏移换算,用「两遍法」处理含夏令时的时区。
|
|
141
168
|
/**
|
|
142
169
|
* @param {any} parts
|
|
143
170
|
*/
|
|
144
171
|
export function beijingToDate(parts) {
|
|
145
|
-
|
|
172
|
+
const tz = activeTimezone();
|
|
173
|
+
const guessMs = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
174
|
+
let ts = guessMs;
|
|
175
|
+
for (let i = 0; i < 3; i++) {
|
|
176
|
+
const next = guessMs - tzOffsetMs(new Date(ts), tz);
|
|
177
|
+
if (next === ts) break;
|
|
178
|
+
ts = next;
|
|
179
|
+
}
|
|
180
|
+
return new Date(ts);
|
|
146
181
|
}
|
|
147
182
|
|
|
148
183
|
// 避峰顺延:当前处于高峰 → 顺延到该高峰段的结束整点(即最近一个闲时起点)
|
|
@@ -180,7 +215,10 @@ export const BATCH_DISCOUNT = 0.5;
|
|
|
180
215
|
*/
|
|
181
216
|
export function estimateBatchCost(modelName, promptTokens, completionTokens) {
|
|
182
217
|
const pricing = effectivePricing(modelName);
|
|
183
|
-
|
|
218
|
+
// P0-4 同口径(v0.4.6):无价模型返 null 而非 0。Batch 路径此前恒返 0,导致
|
|
219
|
+
// ① `--max-cost` 预算拦截对「无内置/外部/覆盖价格」的模型静默失效(0 > maxCost 恒 false),
|
|
220
|
+
// ② /cost 把未知费用显示成 ≈¥0.00000 冒充免费。与 estimateCost 的语义保持一致。
|
|
221
|
+
if (!pricing) return null;
|
|
184
222
|
return ((promptTokens * pricing.offpeak.input + completionTokens * pricing.offpeak.output) / 1e6) * BATCH_DISCOUNT;
|
|
185
223
|
}
|
|
186
224
|
|
|
@@ -214,13 +252,32 @@ function pricingOverrides() {
|
|
|
214
252
|
/**
|
|
215
253
|
* @param {any} modelName
|
|
216
254
|
*/
|
|
255
|
+
// P0-4(v0.4.5):判断模型是否有价格数据(内置定价或外部定价或 overrides)——
|
|
256
|
+
// 无价模型 estimateCost 恒 0,费用护栏/仪表盘/分账静默失真,必须显式暴露给调用方告警。
|
|
257
|
+
export function hasPricing(/** @type {any} */ modelName) {
|
|
258
|
+
return Boolean(modelName && effectivePricing(modelName));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** @param {any} modelName */
|
|
217
262
|
function effectivePricing(modelName) {
|
|
218
263
|
const preset = modelPreset(modelName);
|
|
219
264
|
const ext = externalPricing().data?.models?.[modelName] || null;
|
|
220
|
-
if (!preset?.pricing && !ext) return null;
|
|
221
|
-
const base = ext || preset?.pricing || null;
|
|
222
|
-
if (!base) return null;
|
|
223
265
|
const over = pricingOverrides()[modelName] || {}; // 审计 Q3:mtime 缓存替代每轮读盘
|
|
266
|
+
const base = ext || preset?.pricing || null;
|
|
267
|
+
// P0 修复(v0.4.6):overrides 本身就是一条价格来源,必须与 ext/preset 等价对待。
|
|
268
|
+
// 此前「先判 base 是否存在」就 return null,导致用户按护栏提示给 gpt-5 / 本地模型配的
|
|
269
|
+
// config.pricing.overrides 完全不生效:hasPricing=false → estimateCost=null →
|
|
270
|
+
// cache-stats 记 cost=null(当 0 累计)→ todayCost 看不到这些消费 → 护栏永不拦截。
|
|
271
|
+
// 内置 12 个模型里只有 3 个 DeepSeek 带 pricing,即「每日上限防超支」对另外 9 个
|
|
272
|
+
// 以及全部动态发现/自定义模型形同虚设,而护栏给出的补救办法恰是这条不生效的路径。
|
|
273
|
+
const hasOver = Object.keys(over).length > 0;
|
|
274
|
+
if (!base && !hasOver) return null;
|
|
275
|
+
// 仅靠 overrides 供价时,要求 input 与 output 都是有限正数:半张价格表会把缺失的一侧
|
|
276
|
+
// 静默当成 0(输出免费 / 命中免费),比「未知(null)」更危险——宁可报无价并告警。
|
|
277
|
+
if (!base) {
|
|
278
|
+
const ok = (/** @type {any} */ v) => Number.isFinite(Number(v)) && Number(v) > 0;
|
|
279
|
+
if (!ok(over.input) || !ok(over.output)) return null;
|
|
280
|
+
}
|
|
224
281
|
/** @type {(b: any, o?: any) => { input: number, output: number, cacheHit: number }} */
|
|
225
282
|
const merge = (b, o = {}) => ({
|
|
226
283
|
input: Number(o.input ?? b?.input ?? 0),
|
|
@@ -236,8 +293,8 @@ function effectivePricing(modelName) {
|
|
|
236
293
|
cacheHit: over.peak?.cacheHit ?? over.cacheHit,
|
|
237
294
|
};
|
|
238
295
|
return {
|
|
239
|
-
offpeak: merge(base
|
|
240
|
-
peak: merge(base
|
|
296
|
+
offpeak: merge(base?.offpeak || base || {}, over),
|
|
297
|
+
peak: merge(base?.peak || base?.offpeak || base || {}, peakOver),
|
|
241
298
|
};
|
|
242
299
|
}
|
|
243
300
|
|
|
@@ -250,7 +307,9 @@ function effectivePricing(modelName) {
|
|
|
250
307
|
*/
|
|
251
308
|
export function estimateCost(modelName, promptTokens, completionTokens, cache = null, date = new Date()) {
|
|
252
309
|
const pricing = effectivePricing(modelName);
|
|
253
|
-
|
|
310
|
+
// P0-4(v0.4.5):无价返 null 而非 0——0 与「未知」语义完全混淆(0 被当作「免费/没花钱」),
|
|
311
|
+
// 下游护栏/分账/仪表盘据此静默失真。返回 null 强制调用方显式区分「未知」与「免费(0)」(技术评估 P0-4)。
|
|
312
|
+
if (!pricing) return null;
|
|
254
313
|
const price = isPeakHour(date) ? pricing.peak : pricing.offpeak;
|
|
255
314
|
if (cache && Number.isFinite(cache.hit) && Number.isFinite(cache.miss)) {
|
|
256
315
|
const hitPrice = price.cacheHit ?? 0;
|
|
@@ -268,10 +327,11 @@ export function estimateCost(modelName, promptTokens, completionTokens, cache =
|
|
|
268
327
|
* @param {any} [usage]
|
|
269
328
|
*/
|
|
270
329
|
export function estimateCostLabel(modelName, promptTokens, completionTokens, usage = null) {
|
|
271
|
-
const pricing = effectivePricing(modelName);
|
|
272
|
-
if (!pricing) return '';
|
|
273
330
|
const cache = cacheSplit(usage);
|
|
274
|
-
const
|
|
331
|
+
const c = estimateCost(modelName, promptTokens, completionTokens, cache);
|
|
332
|
+
// P0-4(v0.4.5):无价返 null → 标签置空(不显示「≈¥0.0000」冒充免费)
|
|
333
|
+
if (c == null) return '';
|
|
334
|
+
const yuan = c.toFixed(5);
|
|
275
335
|
const hitPart =
|
|
276
336
|
cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
|
|
277
337
|
return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})${pricingDataStale() ? ' · ⚠ 价格表过期,运行 mingdao update --pricing 刷新' : ''}`;
|
package/src/providers/index.js
CHANGED
|
@@ -14,21 +14,42 @@ import { pathToFileURL } from 'node:url';
|
|
|
14
14
|
import { chat as openaiChat } from './openai-compatible.js';
|
|
15
15
|
import { modelPreset, providerPreset } from '../models.js';
|
|
16
16
|
import { mingdaoHome } from '../config.js';
|
|
17
|
-
import { resolveApiKey } from '../credentials.js';
|
|
17
|
+
import { resolveApiKey, getStoredKey } from '../credentials.js';
|
|
18
18
|
import { isLocalBaseUrl } from '../model-caps.js';
|
|
19
19
|
|
|
20
20
|
export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
21
21
|
// 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
|
|
22
22
|
const cm = cfg?.customModels?.[modelName];
|
|
23
23
|
if (cm) {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// P1 修复(v0.4.6):自定义模型的密钥解析不得成为「任意环境变量外泄」通道。
|
|
25
|
+
// 此前 envKeys = [cm.envKey, 'MINGDAO_API_KEY'],而 envKey 与 baseUrl 都完全由调用方指定
|
|
26
|
+
// (WebUI 的 addCustom 接受任意输入)——于是任何能加一个自定义模型的人,都能把宿主环境里的
|
|
27
|
+
// 任意变量(AWS_SECRET_ACCESS_KEY / GITHUB_TOKEN / …)当作 Bearer 发到自己的端点;
|
|
28
|
+
// 即使不填 envKey 也会回落 MINGDAO_API_KEY,把用户的主密钥送出去。两条路一起封:
|
|
29
|
+
// ① envKey 必须是「像模型密钥」的名字(大写、以 _API_KEY/_KEY 结尾、不含 SECRET/TOKEN/…);
|
|
30
|
+
// ② 未声明(或被拒)时不读任何环境变量,只认凭证库——环境变量必须被显式点名才可用。
|
|
31
|
+
const rawEnvKey = String(cm.envKey || '').trim();
|
|
32
|
+
const safeEnvKey =
|
|
33
|
+
/^[A-Z][A-Z0-9_]{2,63}$/.test(rawEnvKey) &&
|
|
34
|
+
/(_API_KEY|_KEY)$/.test(rawEnvKey) &&
|
|
35
|
+
!/(SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE|PASSWD)/.test(rawEnvKey)
|
|
36
|
+
? rawEnvKey
|
|
37
|
+
: '';
|
|
38
|
+
if (rawEnvKey && !safeEnvKey) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[MingDao] 自定义模型 ${modelName} 的 envKey「${rawEnvKey}」不是合法的模型密钥变量名,已忽略。` +
|
|
41
|
+
`如需用环境变量提供密钥,请用形如 MY_GATEWAY_API_KEY 的名字;否则请用 mingdao key set custom:${modelName} 存入凭证库。`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const envVal = safeEnvKey ? process.env[safeEnvKey] || '' : '';
|
|
45
|
+
const storedKey = getStoredKey(`custom:${modelName}`) || getStoredKey('custom') || '';
|
|
46
|
+
const apiKey = envVal || storedKey || '';
|
|
26
47
|
return {
|
|
27
48
|
name: `custom:${modelName}`,
|
|
28
49
|
kind: 'openai-compatible',
|
|
29
50
|
baseUrl: cm.baseUrl || cfg?.baseUrl || '',
|
|
30
51
|
apiKey,
|
|
31
|
-
envHint:
|
|
52
|
+
envHint: safeEnvKey || `custom:${modelName}`,
|
|
32
53
|
isCustom: true,
|
|
33
54
|
modelName,
|
|
34
55
|
};
|
|
@@ -92,14 +113,20 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
92
113
|
config: pc,
|
|
93
114
|
async chat(/** @type {any} */ opts) {
|
|
94
115
|
let attempt = 0;
|
|
116
|
+
// P1-7(v0.4.5):总量护栏提到重试循环外层——此前每 attempt 重建 totalTimer,重试序列最坏
|
|
117
|
+
// 放大 retries+1 倍(本地 3×30min=90min)。总时长覆盖整个重试序列,单次首 token/流式空闲仍按 attempt 计。
|
|
118
|
+
let totalExpired = false;
|
|
119
|
+
let currentAc = /** @type {AbortController | null} */ (null);
|
|
120
|
+
const totalTimer = setTimeout(() => {
|
|
121
|
+
totalExpired = true;
|
|
122
|
+
try {
|
|
123
|
+
currentAc?.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
124
|
+
} catch {}
|
|
125
|
+
}, totalMs);
|
|
95
126
|
for (;;) {
|
|
96
127
|
const ac = new AbortController();
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
const totalTimer = setTimeout(() => {
|
|
100
|
-
timedOut = true;
|
|
101
|
-
ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
102
|
-
}, totalMs);
|
|
128
|
+
currentAc = ac;
|
|
129
|
+
let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时(首 token/流式空闲)
|
|
103
130
|
// 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
|
|
104
131
|
let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
|
|
105
132
|
timedOut = true;
|
|
@@ -125,6 +152,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
125
152
|
const onUserAbort = () => ac.abort(opts.signal?.reason);
|
|
126
153
|
if (opts.signal?.aborted) onUserAbort();
|
|
127
154
|
else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
|
|
155
|
+
let leaving = true; // 本次 attempt 是否退出(成功/不再重试);将重试则置 false,总量计时器保留覆盖下一 attempt
|
|
128
156
|
try {
|
|
129
157
|
return await openaiChat({
|
|
130
158
|
...opts,
|
|
@@ -135,9 +163,11 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
135
163
|
onActivity,
|
|
136
164
|
});
|
|
137
165
|
} catch (err) {
|
|
138
|
-
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C
|
|
139
|
-
|
|
166
|
+
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试。
|
|
167
|
+
// 总量超时(totalExpired)覆盖整个序列,超了直接抛不再重试。
|
|
168
|
+
const transient = !totalExpired && ((timedOut && !opts.signal?.aborted) || isTransient(err));
|
|
140
169
|
if (!transient || attempt >= retries) throw err;
|
|
170
|
+
leaving = false; // 将重试:本次不退出,总量计时器继续覆盖下一 attempt
|
|
141
171
|
attempt += 1;
|
|
142
172
|
// 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
|
|
143
173
|
// 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
|
|
@@ -147,7 +177,7 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
147
177
|
backoff = Math.min(backoff, 30000);
|
|
148
178
|
await sleep(backoff);
|
|
149
179
|
} finally {
|
|
150
|
-
clearTimeout(totalTimer);
|
|
180
|
+
if (leaving) clearTimeout(totalTimer); // 成功/最终失败即清——避免悬挂 totalMs 计时器(每请求一个)
|
|
151
181
|
if (firstTokenTimer) clearTimeout(firstTokenTimer);
|
|
152
182
|
if (idleTimer) clearTimeout(idleTimer);
|
|
153
183
|
opts.signal?.removeEventListener('abort', onUserAbort);
|
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// 部分网关/本地推理框架在拒绝请求(如 507 memory_refusal)时不回非 2xx,而是回
|
|
10
|
+
// 200 + {"error":{"code":507,"message":"memory_refusal"}},或 SSE 首帧夹带 error 对象。
|
|
11
|
+
// 若不识别,会被当作「空输出」吞掉,误报成「模型本轮没有输出正文。」(MacBook 本地 mtplx 507 根因)。
|
|
12
|
+
function extractStreamError(/** @type {any} */ json) {
|
|
13
|
+
const err = json?.error;
|
|
14
|
+
if (!err) return null;
|
|
15
|
+
const code = Number(err?.code || err?.status_code || err?.status || 0);
|
|
16
|
+
const msg = String(err?.message || err?.type || '模型返回错误');
|
|
17
|
+
/** @type {ApiError} */
|
|
18
|
+
const e = new Error(`[流式响应错误${code ? ' ' + code : ''}] ${msg}`);
|
|
19
|
+
if (code) e.status = code;
|
|
20
|
+
return e;
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
24
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
25
|
const payload = /** @type {Record<string, any>} */ ({ model, messages });
|
|
@@ -68,6 +82,8 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
|
|
85
|
+
const se = extractStreamError(json);
|
|
86
|
+
if (se) throw se;
|
|
71
87
|
const choice = json?.choices?.[0];
|
|
72
88
|
const msg = choice?.message ?? {};
|
|
73
89
|
if (msg.content) onDelta?.({ text: msg.content });
|
|
@@ -113,6 +129,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
113
129
|
} catch {
|
|
114
130
|
return;
|
|
115
131
|
}
|
|
132
|
+
// 200 流里夹带 error 帧(本地内存拒绝等):立即上抛,不当作空输出吞掉
|
|
133
|
+
const se = extractStreamError(json);
|
|
134
|
+
if (se) throw se;
|
|
116
135
|
// 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
|
|
117
136
|
if (json.usage) usage = json.usage;
|
|
118
137
|
const choice = json?.choices?.[0];
|
|
@@ -147,9 +166,31 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
147
166
|
if (choice.finish_reason) finish = choice.finish_reason;
|
|
148
167
|
};
|
|
149
168
|
|
|
169
|
+
// P1-6(v0.4.5):[DONE] 后不再无限等待网关关流——此前外层 for 继续 read 至流关闭,
|
|
170
|
+
// 部分网关/代理在 [DONE] 后不主动断开会挂到 streamIdleMs(120s)。但部分网关在 [DONE] 之后
|
|
171
|
+
// 才发 usage-only 终包(且末帧常不带换行),直接退出会丢 usage。因此进入「有界排空」:
|
|
172
|
+
// 继续读残余帧,每次 read 用短超时兜底,超时或流关闭即停止——usage 尾帧仍被捕获,
|
|
173
|
+
// 正常流在 [DONE] 后立即关流,Promise.race 随即以 done 返回,不增加延迟。
|
|
174
|
+
const DRAIN_TIMEOUT_MS = 500;
|
|
175
|
+
const readDrain = async () => {
|
|
176
|
+
let t;
|
|
177
|
+
const timer = new Promise((resolve) => {
|
|
178
|
+
t = setTimeout(() => resolve({ done: false, value: null, timedOut: true }), DRAIN_TIMEOUT_MS);
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
return await Promise.race([reader.read(), timer]);
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(t);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
150
186
|
for (;;) {
|
|
151
|
-
const { done, value } = await reader.read();
|
|
187
|
+
const { done, value } = await (doneFlag ? readDrain() : reader.read());
|
|
152
188
|
if (done) break;
|
|
189
|
+
if (value == null) {
|
|
190
|
+
// 排空超时:不再等,取消 reader 释放连接(防火墙/网关持流不关的场景)
|
|
191
|
+
reader.cancel().catch(() => {});
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
153
194
|
buf += decoder.decode(value, { stream: true });
|
|
154
195
|
let nl;
|
|
155
196
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
package/src/redact.js
CHANGED
|
@@ -13,6 +13,11 @@ export function redactSecrets(/** @type {any} */ text) {
|
|
|
13
13
|
s = s.replace(/(Authorization\s*:\s*Bearer\s+)[^\s"',}]+/gi, '$1***');
|
|
14
14
|
s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
|
|
15
15
|
s = s.replace(/([?&](?:key|token|secret|api_key|access_token)=)[^&\s"']+/gi, '$1***');
|
|
16
|
+
// P2 修复(v0.4.6):URL 内嵌凭据(scheme://user:pass@host)——本文件头注释一直声称覆盖,
|
|
17
|
+
// 但规则里从来没有这一条:`git clone https://oauth2:glpat-xxx@host/repo`、
|
|
18
|
+
// `postgres://user:pw@host/db` 这类命令行会**原样**落入 ~/.mingdao/audit.jsonl
|
|
19
|
+
// (bash/fetch 的完整参数写审计)与 diagnose 诊断包。只掩码密码段,保留用户名与主机便于排查。
|
|
20
|
+
s = s.replace(/(:\/\/[^/\s:@]{1,64}:)[^/\s@]{1,256}@/g, '$1***@');
|
|
16
21
|
return s;
|
|
17
22
|
}
|
|
18
23
|
|
package/src/schedule.js
CHANGED
|
@@ -171,7 +171,10 @@ export function removeSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
// 正在跑的 worker 同步停止,避免成孤儿继续执行
|
|
174
|
-
|
|
174
|
+
// P0-2(v0.4.5):killTask 失败不应阻断「删除」这一更强用户意图——包 try/catch 保证删除照常
|
|
175
|
+
try {
|
|
176
|
+
if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
|
|
177
|
+
} catch {}
|
|
175
178
|
try {
|
|
176
179
|
fs.unlinkSync(path.join(scheduleDir(home), id + '.json'));
|
|
177
180
|
} catch {}
|
|
@@ -192,7 +195,10 @@ export function pauseSchedule(/** @type {any} */ home, /** @type {any} */ id) {
|
|
|
192
195
|
} catch {}
|
|
193
196
|
}
|
|
194
197
|
}
|
|
195
|
-
|
|
198
|
+
// P0-2(v0.4.5):killTask 失败不应阻断「暂停」的状态写
|
|
199
|
+
try {
|
|
200
|
+
if (job.lastTaskId && isRunningTask(home, job.lastTaskId)) killTask(home, job.lastTaskId);
|
|
201
|
+
} catch {}
|
|
196
202
|
writeSchedule(home, { ...job, status: 'paused', pid: null, lastTaskId: null });
|
|
197
203
|
return true;
|
|
198
204
|
});
|
|
@@ -354,6 +360,15 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
354
360
|
|
|
355
361
|
const wait = (/** @type {any} */ ms) => new Promise((r) => setTimeout(r, ms));
|
|
356
362
|
|
|
363
|
+
// P1-15(v0.4.5):标记 running 前加锁并复查 paused——此前锁外 writeSchedule({...cur,'running'})
|
|
364
|
+
// 与 pauseSchedule 锁内写 paused 交错时,running 会覆盖 pause(任务继续触发,pause 语义失效)。
|
|
365
|
+
const markRunning = () => withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
366
|
+
const c = readSchedule(home, id);
|
|
367
|
+
if (!c || c.status === 'paused') return false; // 已删除或已暂停:不覆盖、不再触发
|
|
368
|
+
writeSchedule(home, { ...c, status: 'running' });
|
|
369
|
+
return true;
|
|
370
|
+
});
|
|
371
|
+
|
|
357
372
|
// 依赖检查:支持两种依赖——调度任务 id(chain 编排)或后台任务 id(mingdao run 输出)
|
|
358
373
|
async function depsSatisfied(/** @type {any} */ deps) {
|
|
359
374
|
for (const dep of deps) {
|
|
@@ -376,8 +391,11 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
376
391
|
// 顺延到最近闲时起点(12:00 / 18:00)执行,输入价省 50%
|
|
377
392
|
if (job.offpeak && isPeakHour(new Date())) {
|
|
378
393
|
const defer = deferToOffpeak(new Date());
|
|
379
|
-
|
|
380
|
-
|
|
394
|
+
// P2-4(v0.4.5):note 更新同样加锁 + 复查 paused(避免「读 curN 到写回之间 pause」被覆盖)
|
|
395
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
396
|
+
const curN = readSchedule(home, id);
|
|
397
|
+
if (curN && curN.status !== 'paused') writeSchedule(home, { ...curN, note: `避峰等待至北京时间 ${defer.toISOString().slice(11, 16)}(闲时起执行)` });
|
|
398
|
+
});
|
|
381
399
|
await wait(defer.getTime() - Date.now() + 2000);
|
|
382
400
|
}
|
|
383
401
|
if (job.after?.length) {
|
|
@@ -406,26 +424,32 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
406
424
|
killTask(home, task.id);
|
|
407
425
|
t = { ...t, status: 'timedout' };
|
|
408
426
|
}
|
|
409
|
-
const cur0 = readSchedule(home, id);
|
|
410
|
-
if (!cur0) return 'failed'; // 任务已被删除:停止后续写入
|
|
411
|
-
const history = [...(cur0?.history || [])];
|
|
412
|
-
history.push({
|
|
413
|
-
taskId: task.id,
|
|
414
|
-
status: t?.status || 'unknown',
|
|
415
|
-
at: Date.now(),
|
|
416
|
-
durationMs: t?.durationMs ?? null,
|
|
417
|
-
text: (t?.text || t?.error || '').slice(0, 200),
|
|
418
|
-
});
|
|
419
|
-
if (history.length > 50) history.shift();
|
|
420
427
|
const result = t?.status === 'done' ? 'done' : t?.status === 'timedout' ? 'timedout' : 'failed';
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
history
|
|
427
|
-
|
|
428
|
+
// 审计(H3 + 自检 P2):runOnce 收尾元数据读-改-写加锁——与 pause/remove(锁内写)跨进程互斥,
|
|
429
|
+
// 杜绝「读 cur0 到写回之间用户 pause」被覆盖(毫秒级窗口,彻底起见与终态写同锁、同复查)。
|
|
430
|
+
const wrote = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
431
|
+
const curL = readSchedule(home, id);
|
|
432
|
+
if (!curL) return false; // 已删除:停止后续写入
|
|
433
|
+
const historyL = [...(curL?.history || [])];
|
|
434
|
+
historyL.push({
|
|
435
|
+
taskId: task.id,
|
|
436
|
+
status: t?.status || 'unknown',
|
|
437
|
+
at: Date.now(),
|
|
438
|
+
durationMs: t?.durationMs ?? null,
|
|
439
|
+
text: (t?.text || t?.error || '').slice(0, 200),
|
|
440
|
+
});
|
|
441
|
+
if (historyL.length > 50) historyL.shift();
|
|
442
|
+
writeSchedule(home, {
|
|
443
|
+
...curL,
|
|
444
|
+
lastRunAt: Date.now(),
|
|
445
|
+
lastTaskId: task.id,
|
|
446
|
+
runs: (curL?.runs || 0) + 1,
|
|
447
|
+
history: historyL,
|
|
448
|
+
consecutiveFailures: result === 'done' ? 0 : prevFails + 1,
|
|
449
|
+
});
|
|
450
|
+
return true;
|
|
428
451
|
});
|
|
452
|
+
if (!wrote) return 'failed'; // 任务已被删除
|
|
429
453
|
return result;
|
|
430
454
|
};
|
|
431
455
|
|
|
@@ -438,7 +462,7 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
438
462
|
await wait(Math.min(Math.max((cur.nextRunAt || now) - now, 1000), 60000));
|
|
439
463
|
continue;
|
|
440
464
|
}
|
|
441
|
-
|
|
465
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused(被暂停则直接退出)
|
|
442
466
|
const result = await runOnce();
|
|
443
467
|
// 质检 H3:状态读-改-写加锁(与 pause/remove 互斥,防丢更新)
|
|
444
468
|
const nextState = withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
@@ -456,12 +480,14 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
456
480
|
await wait(Math.min(cur.nextRunAt - now, 60000));
|
|
457
481
|
continue;
|
|
458
482
|
}
|
|
459
|
-
|
|
483
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
460
484
|
const result = await runOnce();
|
|
461
|
-
// 评估 6.4(v0.4.3):once
|
|
485
|
+
// 评估 6.4(v0.4.3)+ P2-4(v0.4.5):once 最终状态读-改-写加锁,且 paused 不覆盖——
|
|
486
|
+
// 用户执行期间 pause(cur2.status==='paused')绝不被 done/failed 覆盖(every 经 postRunStatus 有防护,此处补齐)
|
|
462
487
|
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
463
488
|
const cur2 = readSchedule(home, id);
|
|
464
|
-
if (cur2
|
|
489
|
+
if (!cur2 || cur2.status === 'paused') return;
|
|
490
|
+
writeSchedule(home, { ...cur2, status: result });
|
|
465
491
|
});
|
|
466
492
|
return;
|
|
467
493
|
} else {
|
|
@@ -472,15 +498,21 @@ export async function runSleeper(/** @type {any} */ home, /** @type {any} */ id)
|
|
|
472
498
|
continue;
|
|
473
499
|
}
|
|
474
500
|
if (st === 'failed') {
|
|
475
|
-
|
|
501
|
+
// P2-4(v0.4.5):终态写加锁 + 复查 paused(依赖失败判 skipped 也不得覆盖 pause)
|
|
502
|
+
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
503
|
+
const c2 = readSchedule(home, id);
|
|
504
|
+
if (!c2 || c2.status === 'paused') return;
|
|
505
|
+
writeSchedule(home, { ...c2, status: 'skipped' });
|
|
506
|
+
});
|
|
476
507
|
return;
|
|
477
508
|
}
|
|
478
|
-
|
|
509
|
+
if (!markRunning()) return; // P1-15:标记 running 前复查 paused
|
|
479
510
|
const result = await runOnce();
|
|
480
|
-
// 评估 6.4(v0.4.3):after
|
|
511
|
+
// 评估 6.4(v0.4.3)+ P2-4(v0.4.5):after 最终状态读-改-写加锁,且 paused 不覆盖
|
|
481
512
|
withFileLockSync(path.join(scheduleDir(home), '.lock'), () => {
|
|
482
513
|
const cur2 = readSchedule(home, id);
|
|
483
|
-
if (cur2
|
|
514
|
+
if (!cur2 || cur2.status === 'paused') return;
|
|
515
|
+
writeSchedule(home, { ...cur2, status: result });
|
|
484
516
|
});
|
|
485
517
|
return;
|
|
486
518
|
}
|
package/src/session.js
CHANGED
|
@@ -4,13 +4,26 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
// —— 冲突备份名(单一事实来源,v0.4.6)——
|
|
8
|
+
// 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
|
|
9
|
+
// 教训:producer(sync.js conflictCopyName)带随机后缀,而 consumer 的正则只认 <数字>.jsonl,
|
|
10
|
+
// 导致 listSyncConflicts/resolveSyncConflict 永远匹配不到自己的备份——「冲突三选一」100% 失效;
|
|
11
|
+
// 且这些备份被 listSessions 当成普通会话推出/拉取,在其他设备上变成无法解析的幽灵会话。
|
|
12
|
+
// 现由本模块统一给出「识别 + 排除」,写入 / 冲突面板 / 会话列表与同步三处共用同一判定。
|
|
13
|
+
export const CONFLICT_BACKUP_RE = /^(.+)\.(server|remote)-(\d+)(?:-[a-z0-9]+)?\.jsonl$/;
|
|
14
|
+
/** @param {any} n */
|
|
15
|
+
export function isConflictBackupName(n) {
|
|
16
|
+
return typeof n === 'string' && CONFLICT_BACKUP_RE.test(n);
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
/** @param {any} home */
|
|
8
20
|
export function listSessions(home) {
|
|
9
21
|
const dir = path.join(home, 'sessions');
|
|
10
22
|
try {
|
|
11
23
|
return fs
|
|
12
24
|
.readdirSync(dir)
|
|
13
|
-
|
|
25
|
+
// 冲突备份不是会话:排除后不再出现在会话列表/最近会话/搜索/云同步推送集合中
|
|
26
|
+
.filter((f) => f.endsWith('.jsonl') && !isConflictBackupName(f))
|
|
14
27
|
.map((f) => {
|
|
15
28
|
const file = path.join(dir, f);
|
|
16
29
|
return { file, name: f, mtime: fs.statSync(file).mtimeMs };
|
package/src/skill-lib.js
CHANGED
|
@@ -188,10 +188,26 @@ export function validateSkillMarkdown(text, hint) {
|
|
|
188
188
|
return { ok: true, name, description: desc };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
// P1-5(v0.4.5):检测目录树内是否含符号链接——symlink 可越权读任意本机文件并架空 sha256 指纹检测。
|
|
192
|
+
function containsSymlink(/** @type {string} */ dir) {
|
|
193
|
+
const stack = [dir];
|
|
194
|
+
while (stack.length) {
|
|
195
|
+
const d = /** @type {string} */ (stack.pop());
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
if (e.isSymbolicLink()) return true;
|
|
204
|
+
if (e.isDirectory()) stack.push(path.join(d, e.name));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {any} dir @param {any} hint */
|
|
195
211
|
export function validateSkillDir(dir, hint) {
|
|
196
212
|
const skillMd = path.join(dir, 'SKILL.md');
|
|
197
213
|
let text;
|
|
@@ -200,6 +216,9 @@ export function validateSkillDir(dir, hint) {
|
|
|
200
216
|
} catch {
|
|
201
217
|
return { error: `未找到 SKILL.md:${skillMd}` };
|
|
202
218
|
}
|
|
219
|
+
if (containsSymlink(dir)) {
|
|
220
|
+
return { error: '技能目录含符号链接,已拒绝(防越权读取本机文件/绕过篡改检测)' };
|
|
221
|
+
}
|
|
203
222
|
return validateSkillMarkdown(text, hint);
|
|
204
223
|
}
|
|
205
224
|
|
package/src/skills.js
CHANGED
|
@@ -63,7 +63,7 @@ export function tamperedSkillNames(/** @type {any} */ workingDir) {
|
|
|
63
63
|
if (!e.isDirectory()) continue;
|
|
64
64
|
const skillDir = path.join(dir, e.name);
|
|
65
65
|
try {
|
|
66
|
-
if (!fs.
|
|
66
|
+
if (!fs.lstatSync(path.join(skillDir, 'SKILL.md')).isFile()) continue;
|
|
67
67
|
} catch {
|
|
68
68
|
continue;
|
|
69
69
|
}
|
|
@@ -89,7 +89,7 @@ export function listSkills(/** @type {any} */ workingDir) {
|
|
|
89
89
|
if (!e.isDirectory() || seen.has(e.name)) continue;
|
|
90
90
|
const skillMd = path.join(dir, e.name, 'SKILL.md');
|
|
91
91
|
try {
|
|
92
|
-
if (!fs.
|
|
92
|
+
if (!fs.lstatSync(skillMd).isFile()) continue;
|
|
93
93
|
} catch {
|
|
94
94
|
continue;
|
|
95
95
|
}
|