@morehao/dsh-skills-console 0.5.4 → 0.5.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/README.en.md +18 -6
- package/README.md +16 -4
- package/client.js +414 -81
- package/lib/core/hash.js +25 -4
- package/lib/core/intro-store.js +5 -2
- package/lib/core/intro.js +112 -18
- package/lib/core/invocation.js +31 -5
- package/lib/core/plugin-update.js +323 -0
- package/lib/core/service.js +82 -1
- package/lib/index.js +29 -1
- package/lib/routes.js +26 -3
- package/package.json +1 -1
package/lib/core/hash.js
CHANGED
|
@@ -22,8 +22,28 @@ function collectFiles(dir, out) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* 排序口径:UTF-16 code unit 序(等价 `Array#sort()` 默认序),**与进程 locale 无关**。
|
|
26
|
+
*
|
|
27
|
+
* 绝不能用 `localeCompare`:它按默认 locale 的 collation 比较,同一个目录在不同
|
|
28
|
+
* LANG / ICU 版本下会排出不同顺序,同一份内容就能算出两个哈希。真实案例:
|
|
29
|
+
* `script.md` 与 `什么是token.html` 在 en 下 script 在前、在 zh 下汉字在前,
|
|
30
|
+
* 于是「安装时 en、读时 zh」的技能被误报成「本地已改」。
|
|
31
|
+
*/
|
|
32
|
+
export function comparePaths(a, b) {
|
|
33
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 旧口径(v0.6.x 之前):进程 / 显式 locale 的 collation。
|
|
38
|
+
* 只用于识别历史基线(`matchesLegacyHash`),不得用于写新基线。
|
|
39
|
+
*/
|
|
40
|
+
export function localeComparator(locale) {
|
|
41
|
+
return (a, b) => a.localeCompare(b, locale);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 目录内容哈希(F6):
|
|
46
|
+
* 只收普通文件 → 跳过 `.git` / `node_modules` → 相对路径 `/` 归一 → code unit 排序
|
|
27
47
|
* → 先 update(路径) 再 update(内容) → sha256。
|
|
28
48
|
*
|
|
29
49
|
* 用途只有一个:进 L1「本地有没有被改过」的基线,绝不与上游 tree SHA 混用。
|
|
@@ -36,12 +56,13 @@ export function contentHash(dir) {
|
|
|
36
56
|
* 同 `contentHash`,但允许按相对路径改写文件内容后再参与哈希。
|
|
37
57
|
* `transform(rel, buffer)` 返回 Buffer;返回 `null` 表示无法归一化(调用方保守处理)。
|
|
38
58
|
* 用于「仅手动」:把插件写入的 frontmatter 行按 state 还原后再与基线比较。
|
|
59
|
+
* `compare` 只给历史基线识别用,正常路径保持默认的 locale 无关排序。
|
|
39
60
|
*/
|
|
40
|
-
export function contentHashWith(dir, transform = null) {
|
|
61
|
+
export function contentHashWith(dir, transform = null, compare = comparePaths) {
|
|
41
62
|
const files = collectFiles(dir, []).map((file) =>
|
|
42
63
|
path.relative(dir, file).split(path.sep).join('/'),
|
|
43
64
|
);
|
|
44
|
-
files.sort(
|
|
65
|
+
files.sort(compare);
|
|
45
66
|
const hash = crypto.createHash('sha256');
|
|
46
67
|
for (const rel of files) {
|
|
47
68
|
const raw = fs.readFileSync(path.join(dir, rel));
|
package/lib/core/intro-store.js
CHANGED
|
@@ -33,6 +33,8 @@ function sanitizeEntry(entry) {
|
|
|
33
33
|
inputBytes: Number.isFinite(entry.inputBytes) ? entry.inputBytes : null,
|
|
34
34
|
truncated: Boolean(entry.truncated),
|
|
35
35
|
sourceBytes: Number.isFinite(entry.sourceBytes) ? entry.sourceBytes : null,
|
|
36
|
+
maxTokens: Number.isFinite(entry.maxTokens) ? entry.maxTokens : null,
|
|
37
|
+
finish: typeof entry.finish === 'string' ? entry.finish : 'stop',
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -125,10 +127,11 @@ export function createIntroStore({ paths, logger = () => {} }) {
|
|
|
125
127
|
return problem;
|
|
126
128
|
},
|
|
127
129
|
load,
|
|
128
|
-
/** 读一条缓存;没有就是 null
|
|
130
|
+
/** 读一条缓存;没有就是 null。只有 `finish: stop` 的完整简介才会被返回。 */
|
|
129
131
|
get(name, lang) {
|
|
130
132
|
const entry = load().items[name]?.[lang];
|
|
131
|
-
|
|
133
|
+
if (!entry || entry.finish !== 'stop') return null;
|
|
134
|
+
return { ...entry };
|
|
132
135
|
},
|
|
133
136
|
/** 写一条缓存;写盘失败不影响返回值(介绍已经拿到了)。 */
|
|
134
137
|
put(name, lang, entry) {
|
package/lib/core/intro.js
CHANGED
|
@@ -19,9 +19,10 @@ import { sha256 } from './hash.js';
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* 简介缓存的格式版本(与 state 的版本无关,坏了直接当空表)。
|
|
22
|
-
*
|
|
22
|
+
* v4 = 只缓存 `finish: stop` 的完整简介;v3 及更早的条目(含 300 token 上限下
|
|
23
|
+
* 产生的截断文本)全部丢弃重建。
|
|
23
24
|
*/
|
|
24
|
-
export const INTRO_STORE_VERSION =
|
|
25
|
+
export const INTRO_STORE_VERSION = 4;
|
|
25
26
|
|
|
26
27
|
/** 介绍语言:跟随界面语言,只支持这两档。 */
|
|
27
28
|
export const INTRO_LANGS = Object.freeze(['zh', 'en']);
|
|
@@ -31,20 +32,30 @@ export const INTRO_LIMITS = Object.freeze({
|
|
|
31
32
|
minInputBytes: 8 * 1024,
|
|
32
33
|
maxInputBytes: 48 * 1024,
|
|
33
34
|
minOutputTokens: 96,
|
|
34
|
-
maxOutputTokens:
|
|
35
|
+
maxOutputTokens: 8_192,
|
|
35
36
|
minTimeoutMs: 5_000,
|
|
36
37
|
maxTimeoutMs: 60_000,
|
|
37
38
|
/** 单条简介的字符上限(提示词要 160 字并含触发句,留出余量后兜底) */
|
|
38
39
|
maxIntroChars: 400,
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* 输出预算的保底值:`maxOutputTokens` 是「防跑飞上限」,不是长度目标。
|
|
44
|
+
* 推理模型的思考 token 与正文共用同一份输出预算,300 这种小上限会被思考吃光;
|
|
45
|
+
* 因此推理模型至少 4096,普通模型至少 1024(400 字符中文也可能超过 300 token)。
|
|
46
|
+
*/
|
|
47
|
+
export const INTRO_OUTPUT_FLOOR = Object.freeze({
|
|
48
|
+
plain: 1_024,
|
|
49
|
+
reasoning: 4_096,
|
|
50
|
+
});
|
|
51
|
+
|
|
41
52
|
export const DEFAULT_INTRO_CONFIG = Object.freeze({
|
|
42
53
|
enabled: true,
|
|
43
54
|
provider: null,
|
|
44
55
|
model: null,
|
|
45
56
|
maxInputBytes: 24 * 1024,
|
|
46
|
-
maxOutputTokens:
|
|
47
|
-
timeoutMs:
|
|
57
|
+
maxOutputTokens: 4_096,
|
|
58
|
+
timeoutMs: 60_000,
|
|
48
59
|
});
|
|
49
60
|
|
|
50
61
|
/** 调用/准备阶段的失败,由 Service 转成带 HTTP 状态的错误。 */
|
|
@@ -326,7 +337,7 @@ function failureFrom(error) {
|
|
|
326
337
|
* @param {{system: string, input: string}} options.prompt
|
|
327
338
|
* @param {number} options.maxOutputTokens
|
|
328
339
|
* @param {AbortSignal} [options.signal]
|
|
329
|
-
* @returns {Promise<{ text: string, usage: object|null, provider: string, model: string }>}
|
|
340
|
+
* @returns {Promise<{ text: string, usage: object|null, provider: string, model: string, maxTokens: number, reasoningEffort: string|null, finish: string }>}
|
|
330
341
|
*/
|
|
331
342
|
export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal }) {
|
|
332
343
|
if (!llm || (typeof llm.prepareCall !== 'function' && typeof llm.stream !== 'function')) {
|
|
@@ -345,16 +356,29 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
|
|
|
345
356
|
},
|
|
346
357
|
];
|
|
347
358
|
|
|
359
|
+
// 输出上限是「防跑飞预算」,不是长度目标:推理 token 也计入这里。
|
|
360
|
+
// 推理模型至少 4096,普通模型至少 1024,避免思考吃光预算后正文为空。
|
|
361
|
+
const requestedMaxTokens = Math.max(
|
|
362
|
+
Number.isFinite(maxOutputTokens) ? maxOutputTokens : DEFAULT_INTRO_CONFIG.maxOutputTokens,
|
|
363
|
+
INTRO_OUTPUT_FLOOR.plain,
|
|
364
|
+
);
|
|
365
|
+
|
|
348
366
|
let call = null;
|
|
349
367
|
try {
|
|
350
368
|
if (typeof llm.prepareCall === 'function') {
|
|
351
369
|
call = await llm.prepareCall(
|
|
352
|
-
{ provider: route.provider, model: route.model, maxTokens:
|
|
370
|
+
{ provider: route.provider, model: route.model, maxTokens: requestedMaxTokens },
|
|
353
371
|
signal,
|
|
354
372
|
(controls, model) => {
|
|
373
|
+
const floor = model?.reasoning ? INTRO_OUTPUT_FLOOR.reasoning : INTRO_OUTPUT_FLOOR.plain;
|
|
374
|
+
const modelCap = Number.isFinite(model?.defaultMaxTokens) ? model.defaultMaxTokens : Infinity;
|
|
355
375
|
// 介绍不需要长思考:取适配器公布的最低推理强度,压低首字延迟与成本。
|
|
356
376
|
const lowest = model?.reasoning?.efforts?.[0]?.id;
|
|
357
|
-
return
|
|
377
|
+
return {
|
|
378
|
+
...controls,
|
|
379
|
+
maxTokens: Math.max(controls.maxTokens ?? 0, Math.min(floor, modelCap)),
|
|
380
|
+
...(lowest ? { reasoningEffort: lowest } : {}),
|
|
381
|
+
};
|
|
358
382
|
},
|
|
359
383
|
);
|
|
360
384
|
}
|
|
@@ -362,42 +386,96 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
|
|
|
362
386
|
throw failureFrom(error);
|
|
363
387
|
}
|
|
364
388
|
|
|
389
|
+
const fallbackMaxTokens = Math.max(requestedMaxTokens, INTRO_OUTPUT_FLOOR.reasoning);
|
|
365
390
|
const options = call
|
|
366
391
|
? { ...call.config, messages, system: prompt.system, signal }
|
|
367
392
|
: {
|
|
368
393
|
provider: route.provider,
|
|
369
394
|
model: route.model,
|
|
370
|
-
maxTokens:
|
|
395
|
+
maxTokens: fallbackMaxTokens,
|
|
371
396
|
messages,
|
|
372
397
|
system: prompt.system,
|
|
373
398
|
signal,
|
|
374
399
|
};
|
|
375
400
|
|
|
376
|
-
let
|
|
401
|
+
let deltaText = '';
|
|
402
|
+
let sawTextDelta = false;
|
|
403
|
+
const textBlocks = new Map();
|
|
404
|
+
let reasoningChars = 0;
|
|
377
405
|
let usage = null;
|
|
378
406
|
let finish = null;
|
|
379
407
|
try {
|
|
380
408
|
const stream = call ? call.stream(options) : llm.stream(options);
|
|
381
409
|
for await (const chunk of stream) {
|
|
382
410
|
if (!chunk || typeof chunk !== 'object') continue;
|
|
383
|
-
if (chunk.type === 'text-delta')
|
|
384
|
-
|
|
385
|
-
|
|
411
|
+
if (chunk.type === 'text-delta') {
|
|
412
|
+
sawTextDelta = true;
|
|
413
|
+
if (typeof chunk.text === 'string') deltaText += chunk.text;
|
|
414
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
415
|
+
if (typeof chunk.text === 'string') reasoningChars += chunk.text.length;
|
|
416
|
+
} else if (chunk.type === 'block-end') {
|
|
417
|
+
const block = chunk.block;
|
|
418
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
419
|
+
textBlocks.set(chunk.index, block.text);
|
|
420
|
+
} else if (block?.type === 'reasoning' && typeof block.text === 'string') {
|
|
421
|
+
reasoningChars = Math.max(reasoningChars, block.text.length);
|
|
422
|
+
}
|
|
423
|
+
} else if (chunk.type === 'usage') {
|
|
424
|
+
usage = chunk.usage || null;
|
|
425
|
+
} else if (chunk.type === 'finish') {
|
|
426
|
+
finish = chunk.reason || null;
|
|
427
|
+
}
|
|
386
428
|
}
|
|
387
429
|
} catch (error) {
|
|
388
430
|
throw failureFrom(error);
|
|
389
431
|
}
|
|
390
432
|
|
|
391
|
-
|
|
392
|
-
|
|
433
|
+
const effectiveMaxTokens = Number.isFinite(options.maxTokens) ? options.maxTokens : requestedMaxTokens;
|
|
434
|
+
const kind = finish?.kind || 'stop';
|
|
435
|
+
|
|
436
|
+
if (kind === 'aborted') {
|
|
437
|
+
throw failureFrom({ failure: finish?.failure || null, code: 'ABORTED' });
|
|
438
|
+
}
|
|
439
|
+
if (kind === 'error') {
|
|
440
|
+
throw failureFrom({ failure: finish?.failure || null, code: null });
|
|
441
|
+
}
|
|
442
|
+
if (kind === 'max-tokens') {
|
|
443
|
+
throw new IntroError(
|
|
444
|
+
'error.skills.intro-truncated',
|
|
445
|
+
'简介达到输出上限被截断,请重试或调大 intro.maxOutputTokens',
|
|
446
|
+
{ finish: kind, maxTokens: effectiveMaxTokens, usage },
|
|
447
|
+
502,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (kind === 'tool-calls') {
|
|
451
|
+
throw new IntroError(
|
|
452
|
+
'error.skills.intro-no-text',
|
|
453
|
+
'模型没有输出文字简介(尝试调用工具),请重试',
|
|
454
|
+
{ finish: kind, maxTokens: effectiveMaxTokens, usage },
|
|
455
|
+
502,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (kind !== 'stop') {
|
|
459
|
+
throw failureFrom({ failure: finish?.failure || null, code: null });
|
|
393
460
|
}
|
|
394
461
|
|
|
462
|
+
// 块式适配器可能不给 text-delta,回退用 block-end 里的 text block。
|
|
463
|
+
const text = sawTextDelta ? deltaText : [...textBlocks.values()].join('');
|
|
395
464
|
const cleaned = cleanIntroText(text);
|
|
396
465
|
if (!cleaned) {
|
|
466
|
+
const produced = reasoningChars > 0 || Number(usage?.outputTokens) > 0;
|
|
467
|
+
if (produced) {
|
|
468
|
+
throw new IntroError(
|
|
469
|
+
'error.skills.intro-reasoning-only',
|
|
470
|
+
'模型只输出了思考过程、没有输出简介正文,请提高输出上限或换一个非推理模型',
|
|
471
|
+
{ finish: kind, maxTokens: effectiveMaxTokens, usage, reasoningChars },
|
|
472
|
+
502,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
397
475
|
throw new IntroError(
|
|
398
476
|
'error.skills.intro-empty',
|
|
399
|
-
'
|
|
400
|
-
{ finish:
|
|
477
|
+
'模型没有返回内容,请重试',
|
|
478
|
+
{ finish: kind, maxTokens: effectiveMaxTokens, usage },
|
|
401
479
|
502,
|
|
402
480
|
);
|
|
403
481
|
}
|
|
@@ -407,6 +485,9 @@ export async function runIntroCall({ llm, route, prompt, maxOutputTokens, signal
|
|
|
407
485
|
usage,
|
|
408
486
|
provider: route.provider,
|
|
409
487
|
model: route.model,
|
|
488
|
+
maxTokens: effectiveMaxTokens,
|
|
489
|
+
reasoningEffort: options.reasoningEffort || null,
|
|
490
|
+
finish: kind,
|
|
410
491
|
};
|
|
411
492
|
}
|
|
412
493
|
|
|
@@ -416,7 +497,18 @@ export function introKey(name, lang) {
|
|
|
416
497
|
}
|
|
417
498
|
|
|
418
499
|
/** 汇总一次生成的元信息(缓存条目与响应共用)。 */
|
|
419
|
-
export function introEntry({
|
|
500
|
+
export function introEntry({
|
|
501
|
+
text,
|
|
502
|
+
hash,
|
|
503
|
+
provider,
|
|
504
|
+
model,
|
|
505
|
+
usage,
|
|
506
|
+
inputBytes,
|
|
507
|
+
truncated,
|
|
508
|
+
sourceBytes,
|
|
509
|
+
maxTokens = null,
|
|
510
|
+
finish = 'stop',
|
|
511
|
+
}) {
|
|
420
512
|
return {
|
|
421
513
|
hash,
|
|
422
514
|
text,
|
|
@@ -427,6 +519,8 @@ export function introEntry({ text, hash, provider, model, usage, inputBytes, tru
|
|
|
427
519
|
inputBytes,
|
|
428
520
|
truncated: Boolean(truncated),
|
|
429
521
|
sourceBytes: sourceBytes ?? inputBytes,
|
|
522
|
+
maxTokens: Number.isFinite(maxTokens) ? maxTokens : null,
|
|
523
|
+
finish,
|
|
430
524
|
};
|
|
431
525
|
}
|
|
432
526
|
|
package/lib/core/invocation.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
import { contentHashWith } from './hash.js';
|
|
5
|
+
import { contentHashWith, localeComparator } from './hash.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 「自动 / 仅手动 / 完全停用」的落盘层。
|
|
@@ -527,7 +527,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
|
|
|
527
527
|
let hashMismatch = false;
|
|
528
528
|
if (marker?.canonicalHash) {
|
|
529
529
|
const current = contentHashWith(dir);
|
|
530
|
-
|
|
530
|
+
// 旧版 marker 里的哈希是按 locale collation 排出来的;换 locale 后同一份内容
|
|
531
|
+
// 也会对不上,先按旧口径复核一次,避免把「排序变了」报成「内容漂移」。
|
|
532
|
+
hashMismatch = current !== null
|
|
533
|
+
&& current !== marker.canonicalHash
|
|
534
|
+
&& !matchesLegacyHash(dir, null, marker.canonicalHash);
|
|
531
535
|
}
|
|
532
536
|
const previousMode = (marker?.previousMode === 'manual' || marker?.previousMode === 'auto')
|
|
533
537
|
? marker.previousMode
|
|
@@ -547,9 +551,11 @@ export function unparkSkill({ dir, name, fallbackMode = 'auto', keep = null }) {
|
|
|
547
551
|
* 归一化哈希:把插件写入的 `disable-model-invocation: true` 按 state 还原后再算目录哈希。
|
|
548
552
|
* 只有 origin='plugin' 才归一化;author 声明属于上游内容,必须原样计入。
|
|
549
553
|
* 解析失败 / 当前值不是 true 时返回 null —— 调用方保守判为「本地有改动」。
|
|
554
|
+
*
|
|
555
|
+
* `compare` 仅供 `matchesLegacyHash` 复核旧口径使用;新基线一律用默认的 locale 无关排序。
|
|
550
556
|
*/
|
|
551
|
-
export function hashWithoutInvocation(dir, manual) {
|
|
552
|
-
if (!manual || manual.origin !== 'plugin') return contentHashWith(dir);
|
|
557
|
+
export function hashWithoutInvocation(dir, manual, compare) {
|
|
558
|
+
if (!manual || manual.origin !== 'plugin') return contentHashWith(dir, null, compare);
|
|
553
559
|
const expected = manual.original;
|
|
554
560
|
if (expected !== 'absent' && expected !== 'false') return null;
|
|
555
561
|
return contentHashWith(dir, (relative, buffer) => {
|
|
@@ -565,7 +571,27 @@ export function hashWithoutInvocation(dir, manual) {
|
|
|
565
571
|
if (expected === 'absent') lines.splice(parsed.keyIndex, 1);
|
|
566
572
|
else lines[parsed.keyIndex] = renderLine(lines[parsed.keyIndex], 'false');
|
|
567
573
|
return Buffer.from(join(parsed, lines), 'utf8');
|
|
568
|
-
});
|
|
574
|
+
}, compare);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 旧基线识别:v0.6.x 之前的内容哈希按进程 locale 的 collation 排序,同一份内容
|
|
579
|
+
* 换 locale / ICU 版本就会得到不同 digest。用几个最常见的 collation 反查,
|
|
580
|
+
* 命中即说明「内容没变、只是排序口径变了」,可以安全地把基线改写成新口径。
|
|
581
|
+
*
|
|
582
|
+
* 只是识别手段:某个旧口径能精确复现记录值,等价于内容逐字节相同。
|
|
583
|
+
*/
|
|
584
|
+
export function matchesLegacyHash(dir, manual, digest) {
|
|
585
|
+
if (typeof digest !== 'string' || !digest) return false;
|
|
586
|
+
const defaultLocale = new Intl.Collator().resolvedOptions().locale;
|
|
587
|
+
// 覆盖最常见的几类 collation:拉丁(en)、中文(zh)、日文(ja)。
|
|
588
|
+
// 都不命中就保守地维持「本地已改」,让用户确认 —— 绝不靠猜去改基线。
|
|
589
|
+
const locales = new Set([defaultLocale, 'en', 'zh', 'ja']);
|
|
590
|
+
for (const locale of locales) {
|
|
591
|
+
const compare = locale === defaultLocale ? undefined : localeComparator(locale);
|
|
592
|
+
if (hashWithoutInvocation(dir, manual, compare) === digest) return true;
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
569
595
|
}
|
|
570
596
|
|
|
571
597
|
/** 供 Host 层判断「这行到底是不是被 park 了」,不读 state。 */
|