@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/lib/core/hash.js CHANGED
@@ -22,8 +22,28 @@ function collectFiles(dir, out) {
22
22
  }
23
23
 
24
24
  /**
25
- * 逐字复刻 skills CLI 的内容哈希(F6):
26
- * 只收普通文件 → 跳过 `.git` / `node_modules` → 相对路径 `/` 归一 → localeCompare 排序
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((a, b) => a.localeCompare(b));
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));
@@ -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
- return entry ? { ...entry } : null;
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
- * v3 = 简介必须写清触发方式 / 触发关键词;v2 的旧条目只讲能力,直接丢弃重建。
22
+ * v4 = 只缓存 `finish: stop` 的完整简介;v3 及更早的条目(含 300 token 上限下
23
+ * 产生的截断文本)全部丢弃重建。
23
24
  */
24
- export const INTRO_STORE_VERSION = 3;
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: 1024,
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: 300,
47
- timeoutMs: 30_000,
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: maxOutputTokens },
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 lowest ? { ...controls, reasoningEffort: lowest } : controls;
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: maxOutputTokens,
395
+ maxTokens: fallbackMaxTokens,
371
396
  messages,
372
397
  system: prompt.system,
373
398
  signal,
374
399
  };
375
400
 
376
- let text = '';
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') text += typeof chunk.text === 'string' ? chunk.text : '';
384
- else if (chunk.type === 'usage') usage = chunk.usage || null;
385
- else if (chunk.type === 'finish') finish = chunk.reason || null;
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
- if (finish && finish.kind !== 'stop' && finish.kind !== 'max-tokens') {
392
- throw failureFrom({ failure: finish.failure || null, code: finish.kind === 'aborted' ? 'ABORTED' : null });
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: finish?.kind || null },
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({ text, hash, provider, model, usage, inputBytes, truncated, sourceBytes }) {
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
 
@@ -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
- hashMismatch = current !== null && current !== marker.canonicalHash;
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。 */