dsh-skill-browser 1.8.7 → 1.9.9

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/index.js CHANGED
@@ -11,15 +11,42 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync, rea
11
11
  import { join, dirname } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { spawn } from "node:child_process";
14
- import { scanSkills, categoriesMeta, readSkillBody, parseLedgerTable, parseLedgerDetail, findLedgerFile, readLedgerAtPath, ledgerDefaultPath, setSkillDisabled } from "./scan.js";
15
- import {
16
- createLedgerState, setLedgerRoot, setLedgerPath, recordSkillResult, reportFailure,
17
- flushNow, initializeLedger, ledgerFilePath, autoLogFilePath, readAutoLog
18
- } from "./ledger-writer.js";
19
- import { ZH, UI_TEXT } from "./zh.js";
14
+
15
+ // ── v1.9.4 热加载:子模块走「带版本查询串的动态 import」 ───────────────
16
+ // 首次加载 RELOAD_V 为空串(等价静态 import);/reload 用 ?v=<ts> 重新导入本
17
+ // 模块时,RELOAD_V 跟着变成 ?v=<ts>,五个子模块也一并换新,而不是命中 ESM
18
+ // 缓存里的旧代码。解构出来的仍是原来的裸标识符,下游调用点一个都不用改。
19
+ var RELOAD_V = new URL(import.meta.url).search || "";
20
+ var scanMod = await import("./scan.js" + RELOAD_V);
21
+ var lwMod = await import("./ledger-writer.js" + RELOAD_V);
22
+ var lsMod = await import("./ledger-stats.js" + RELOAD_V);
23
+ var zhMod = await import("./zh.js" + RELOAD_V);
24
+ var grMod = await import("./groups.js" + RELOAD_V);
25
+ var kwMod = await import("./keyword-groups.js" + RELOAD_V);
26
+
27
+ var scanSkills = scanMod.scanSkills, categoriesMeta = scanMod.categoriesMeta,
28
+ readSkillBody = scanMod.readSkillBody, parseLedgerTable = scanMod.parseLedgerTable,
29
+ parseLedgerDetail = scanMod.parseLedgerDetail, findLedgerFile = scanMod.findLedgerFile,
30
+ readLedgerAtPath = scanMod.readLedgerAtPath, ledgerDefaultPath = scanMod.ledgerDefaultPath,
31
+ setSkillDisabled = scanMod.setSkillDisabled;
32
+ var createLedgerState = lwMod.createLedgerState, setLedgerRoot = lwMod.setLedgerRoot,
33
+ setLedgerPath = lwMod.setLedgerPath, recordSkillResult = lwMod.recordSkillResult,
34
+ reportFailure = lwMod.reportFailure, flushNow = lwMod.flushNow,
35
+ initializeLedger = lwMod.initializeLedger, ledgerFilePath = lwMod.ledgerFilePath,
36
+ autoLogFilePath = lwMod.autoLogFilePath, readAutoLog = lwMod.readAutoLog,
37
+ autoLogStat = lwMod.autoLogStat, readAutoLogTail = lwMod.readAutoLogTail;
38
+ var buildStats = lsMod.buildStats;
39
+ var ZH = zhMod.ZH, UI_TEXT = zhMod.UI_TEXT;
40
+ var listGroups = grMod.listGroups, createGroup = grMod.createGroup,
41
+ renameGroup = grMod.renameGroup, deleteGroup = grMod.deleteGroup,
42
+ moveGroup = grMod.moveGroup, assignSkill = grMod.assignSkill,
43
+ assignSkills = grMod.assignSkills, aiClassify = grMod.aiClassify,
44
+ applyPlan = grMod.applyPlan, aiCover = grMod.aiCover, storePath = grMod.storePath,
45
+ describeModelRoute = grMod.describeModelRoute;
46
+ var keywordClassify = kwMod.keywordClassify;
20
47
 
21
48
  var PREFIX = "/dsh-skill-browser";
22
- var VERSION = "1.8.5";
49
+ export var VERSION = "1.9.9";
23
50
  var CACHE_TTL_MS = 30 * 1000;
24
51
  var DIAG_FILE = join(homedir(), ".dsh", "data", "dsh-skill-browser", "diag.log");
25
52
  var CONFIG_FILE = join(homedir(), ".dsh", "data", "dsh-skill-browser", "config.json");
@@ -28,6 +55,11 @@ var CONFIG_FILE = join(homedir(), ".dsh", "data", "dsh-skill-browser", "config.j
28
55
  var BATCH_MAX = 2000;
29
56
 
30
57
  export var name = "dsh-skill-browser";
58
+ // v1.9.0: "llm" is required for the two AI features (auto-classify into
59
+ // groups / one-click group cover art). Without it those endpoints return a
60
+ // clear "宿主未提供 llm 服务" error instead of silently doing nothing.
61
+ // llm is an OPTIONAL capability: NOT declared here, so the panel still loads
62
+ // on a host/profile without it. groups.js reads it through ctx.get("llm").
31
63
  export var inject = ["webServer"];
32
64
 
33
65
  function isLoopback(req) {
@@ -154,7 +186,54 @@ function pickDirViaPowerShell() {
154
186
  });
155
187
  }
156
188
 
189
+ // ── v1.9.4 热加载:当前生效的 API 实例(挂在 globalThis 上) ──────────
190
+ // 必须跨模块实例共享:热重载会造出新的模块实例,如果 LIVE 是模块级变量,
191
+ // 新实例的 handler 会读到自己那份空 LIVE,第二次重载就断链。
192
+ var LIVE = globalThis.__DSB_LIVE__ || (globalThis.__DSB_LIVE__ = { api: null, gen: 0 });
193
+
194
+ /** 用 ?v=<ts> 重新导入自身模块,换掉正在服务的 handler(host 半热重载)。 */
195
+ async function selfReload(ctx, config) {
196
+ var base = new URL(import.meta.url);
197
+ base.search = "";
198
+ var fresh = await import(base.href + "?v=" + Date.now());
199
+ if (!fresh || typeof fresh.createApi !== "function") throw new Error("重新导入的模块没有 createApi()");
200
+ var api = fresh.createApi(ctx, config);
201
+ var old = LIVE.api;
202
+ LIVE.api = api;
203
+ LIVE.gen += 1;
204
+ if (old && typeof old.dispose === "function") { try { old.dispose(); } catch (e) {} }
205
+ // client 半:让 client-modules 重新从磁盘读 client.js,浏览器刷新即新界面
206
+ try {
207
+ var cm = typeof ctx.get === "function" ? ctx.get("clientModules") : null;
208
+ if (cm && typeof cm.rebuilt === "function") cm.rebuilt("dsh-skill-browser");
209
+ } catch (e) { /* client 半刷新失败不影响 host 半 */ }
210
+ return { version: fresh.VERSION || VERSION, gen: LIVE.gen };
211
+ }
212
+
157
213
  export function apply(ctx, config) {
214
+ ctx.effect(function () {
215
+ var api = createApi(ctx, config);
216
+ LIVE.api = api;
217
+ var dispose = ctx.webServer.register({
218
+ kind: "prefix",
219
+ path: PREFIX,
220
+ handler: function (req, res) {
221
+ var cur = LIVE.api;
222
+ if (!cur) { res.writeHead(503); res.end(); return; }
223
+ return cur.handler(req, res);
224
+ }
225
+ });
226
+ return function () {
227
+ try { dispose(); } catch (e) {}
228
+ // 释放「当前生效」的那一份(热重载后已换新,不能只放最初那个实例)
229
+ try { if (LIVE.api && LIVE.api.dispose) LIVE.api.dispose(); } catch (e) {}
230
+ try { if (api.dispose) api.dispose(); } catch (e) {}
231
+ };
232
+ }, "dsh-skill-browser: api routes");
233
+ }
234
+
235
+ /** 构造一套完整 API(配置/根/台账/监听/全部路由)。热重载靠它换新。 */
236
+ export function createApi(ctx, config) {
158
237
  var runtime = loadRuntimeConfig();
159
238
  var detected = detectRoot(config, runtime);
160
239
  var root = detected.root;
@@ -186,8 +265,9 @@ export function apply(ctx, config) {
186
265
  return ledgerFile;
187
266
  }
188
267
  if (root) resolveLedger(false);
268
+ var offTools = null;
189
269
  try {
190
- ctx.on("tools/result", function (exec, result) {
270
+ offTools = ctx.on("tools/result", function (exec, result) {
191
271
  try {
192
272
  if (runtime.ledgerEnabled === false) return;
193
273
  recordSkillResult(ledger, exec, result);
@@ -241,7 +321,46 @@ export function apply(ctx, config) {
241
321
  return cache.payload;
242
322
  }
243
323
 
244
- function getLedgerPayload(force) {
324
+ // 查询串取值(query 是去掉前导 ? 的串)
325
+ function qp(query, key) {
326
+ var re = new RegExp("(^|&)" + key + "=([^&]*)");
327
+ var m = re.exec(String(query || ""));
328
+ if (!m) return "";
329
+ try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
330
+ }
331
+
332
+ // 调用台账统计(带 30s 缓存,键含全部参数)
333
+ var cacheStats = { key: "", ts: 0, payload: null };
334
+ function getLedgerStats(opts) {
335
+ var o = opts || {};
336
+ var now = Date.now();
337
+ // v1.9.6 性能:把 jsonl 的 size+mtime 拼进缓存键 —— 文件没变就直接命中,
338
+ // 不再因为"切粒度/改排序"而重读整个 jsonl(原实现只按参数缓存,跨参数必重读)。
339
+ var probe = { exists: false, size: 0, mtimeMs: 0 };
340
+ try { probe = autoLogStat(root); } catch (e0) {}
341
+ var key = [o.from || "", o.to || "", o.gran || "", o.sort || "", o.top || "",
342
+ probe.exists ? probe.size : -1, probe.exists ? Math.round(probe.mtimeMs) : -1].join("|");
343
+ if (cacheStats.payload && cacheStats.key === key && now - cacheStats.ts < CACHE_TTL_MS) {
344
+ return cacheStats.payload;
345
+ }
346
+ // 同一份 jsonl 只解析一次:文件未变时,不同参数复用同一次解析结果
347
+ var records;
348
+ if (cacheStats.records && cacheStats.recSize === probe.size && cacheStats.recMtime === Math.round(probe.mtimeMs)) {
349
+ records = cacheStats.records;
350
+ } else {
351
+ try { records = readAutoLog(root); } catch (e) { records = []; }
352
+ cacheStats.records = records;
353
+ cacheStats.recSize = probe.size;
354
+ cacheStats.recMtime = Math.round(probe.mtimeMs);
355
+ }
356
+ var out = buildStats(records, {
357
+ from: o.from, to: o.to, gran: o.gran, sort: o.sort, top: o.top
358
+ });
359
+ out.autoLogPath = root ? autoLogFilePath(root) : null;
360
+ cacheStats.key = key; cacheStats.ts = now; cacheStats.payload = out;
361
+ return out;
362
+ }
363
+ function getLedgerPayload(force) {
245
364
  var now = Date.now();
246
365
  if (!force && cacheLedger.payload && now - cacheLedger.ts < CACHE_TTL_MS) return cacheLedger.payload;
247
366
  if (!ledgerFile && root) resolveLedger(force);
@@ -251,9 +370,16 @@ export function apply(ctx, config) {
251
370
  for (var z = 0; z < entries.length; z++) {
252
371
  if (entries[z].fails > 0) { failCount++; totalFail += entries[z].fails; }
253
372
  }
254
- var allAuto = readAutoLog(root);
255
- var autoTotal = allAuto.length, autoFailTotal = 0;
256
- for (var a = 0; a < allAuto.length; a++) { if (!allAuto[a].ok) autoFailTotal++; }
373
+ // v1.9.6 性能:统计总数用轻量计数(不保留整表),最近 30 条只从文件尾部读
374
+ var autoTotal = 0, autoFailTotal = 0;
375
+ try {
376
+ var allAuto = readAutoLog(root);
377
+ autoTotal = allAuto.length;
378
+ for (var a = 0; a < allAuto.length; a++) { if (!allAuto[a].ok) autoFailTotal++; }
379
+ allAuto = null;
380
+ } catch (e) {}
381
+ var autoRecent = [];
382
+ try { autoRecent = readAutoLogTail(root, 30).slice().reverse(); } catch (e) {}
257
383
  cacheLedger.payload = {
258
384
  ok: true,
259
385
  ledgerPath: ledgerFile || (root ? ledgerDefaultPath(root) : null),
@@ -264,7 +390,7 @@ export function apply(ctx, config) {
264
390
  entries: entries,
265
391
  autoTotal: autoTotal,
266
392
  autoFailTotal: autoFailTotal,
267
- autoRecent: allAuto.slice(-30).reverse()
393
+ autoRecent: autoRecent
268
394
  };
269
395
  cacheLedger.ts = now;
270
396
  return cacheLedger.payload;
@@ -355,8 +481,180 @@ export function apply(ctx, config) {
355
481
  });
356
482
  }
357
483
 
358
- ctx.effect(function () {
359
- var handler = function (req, res) {
484
+ // ── v1.9.0 视图分组(groups)──────────────────────────────
485
+ // 分组只是"视图层归档":写入 ~/.dsh/data/dsh-skill-browser/groups.json,
486
+ // 技能目录本身既不移动也不改名,绝不触碰任何 SKILL.md。
487
+ function readBody(req, done) {
488
+ var chunks = [];
489
+ req.on("data", function (c) { chunks.push(c); });
490
+ req.on("end", function () {
491
+ try { done(JSON.parse(Buffer.concat(chunks).toString("utf8")) || {}); }
492
+ catch (e) { done({}); }
493
+ });
494
+ }
495
+
496
+ function handleGroupsList(res) {
497
+ var validDirs = [];
498
+ try {
499
+ var sk = scanSkills(root, ZH);
500
+ for (var i = 0; i < sk.length; i++) validDirs.push(sk[i].dir);
501
+ } catch (e) { /* root 不可用时不清空,只返回已有分组 */ }
502
+ var out = listGroups(validDirs.length ? validDirs : null);
503
+ out.storePath = storePath();
504
+ sendJson(res, 200, out);
505
+ }
506
+
507
+ // POST /groups {action, ...}
508
+ // create {name, emoji?, color?, parent?} parent 为空 = 大类
509
+ // rename {id, name?, emoji?, color?}
510
+ // move {id, parent} 两级结构里调整归属(""=升为大类)
511
+ // delete {id} 删大类会连带删掉它的子类
512
+ // assign {dir, gid} gid 为空字符串 = 移出分组
513
+ // assign-batch {dirs:[...], gid}
514
+ // ai-classify {mode?} AI 出「大类→子类」两级方案,**不落盘**
515
+ // ai-apply {plan, mode?} 用户确认后落盘(replace 覆盖旧 AI 组 / merge 只追加)
516
+ // ai-cover {id} AI 一键生成该分组封面图
517
+ // Skill dirs must be plain single-segment names AND actually exist in the
518
+ // current library. A group assignment is view-only, so an unknown dir is
519
+ // always a client bug - reject it instead of polluting groups.json.
520
+ var SAFE_SKILL_DIR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
521
+ function knownSkillDirs() {
522
+ var out = {};
523
+ try {
524
+ var list = scanSkills(root, ZH) || [];
525
+ for (var i = 0; i < list.length; i++) out[list[i].dir] = true;
526
+ } catch (e) { /* an unreadable root yields an empty set -> all dirs rejected */ }
527
+ return out;
528
+ }
529
+ function validateDirs(dirs) {
530
+ var known = knownSkillDirs();
531
+ var good = [], bad = [];
532
+ for (var i = 0; i < dirs.length; i++) {
533
+ var d = String(dirs[i] || "");
534
+ if (!d || !SAFE_SKILL_DIR_RE.test(d) || d === "." || d === "..") { bad.push(d); continue; }
535
+ if (!known[d]) { bad.push(d); continue; }
536
+ good.push(d);
537
+ }
538
+ return { good: good, bad: bad };
539
+ }
540
+
541
+ function handleGroupsPost(req, res) {
542
+ readBody(req, function (body) {
543
+ var action = String(body.action || "");
544
+ try {
545
+ if (action === "create") {
546
+ var cg = createGroup(body.name, body.emoji, body.color, body.parent);
547
+ sendJson(res, cg.ok ? 200 : 400, cg);
548
+ return;
549
+ }
550
+ if (action === "rename") {
551
+ var rg = renameGroup(String(body.id || ""), body.name, body.emoji, body.color);
552
+ sendJson(res, rg.ok ? 200 : 400, rg);
553
+ return;
554
+ }
555
+ if (action === "move") {
556
+ var mg = moveGroup(String(body.id || ""), body.parent);
557
+ sendJson(res, mg.ok ? 200 : 400, mg);
558
+ return;
559
+ }
560
+ if (action === "delete") {
561
+ var dg = deleteGroup(String(body.id || ""));
562
+ sendJson(res, dg.ok ? 200 : 400, dg);
563
+ return;
564
+ }
565
+ if (action === "assign") {
566
+ var one = validateDirs([body.dir]);
567
+ if (!one.good.length) { sendJson(res, 400, { ok: false, error: "无效的技能目录名:" + String(body.dir || "") }); return; }
568
+ var as = assignSkill(one.good[0], body.gid);
569
+ sendJson(res, as.ok ? 200 : 400, as);
570
+ return;
571
+ }
572
+ // Kept for bulk moves from the client (not wired to a button yet).
573
+ if (action === "assign-batch") {
574
+ var dirs = Object.prototype.toString.call(body.dirs) === "[object Array]" ? body.dirs : [];
575
+ var v = validateDirs(dirs.slice(0, BATCH_MAX));
576
+ if (!v.good.length) { sendJson(res, 400, { ok: false, error: "没有有效的技能目录名" }); return; }
577
+ var bulk = assignSkills(v.good, body.gid);
578
+ bulk.rejected = v.bad;
579
+ sendJson(res, 200, bulk);
580
+ return;
581
+ }
582
+ // v1.9.9 模型发现探针:告诉面板「AI 分组会用哪个模型」,
583
+ // 让别人装完插件零配置就知道能不能用、用的是哪个。
584
+ // 不触发任何模型调用,只解析路由。
585
+ if (action === "model-info") {
586
+ describeModelRoute(ctx, runtime).then(function (r) {
587
+ sendJson(res, 200, r);
588
+ }).catch(function (e) {
589
+ sendJson(res, 200, { ok: false, provider: "", model: "", source: "", error: String((e && e.message) || e) });
590
+ });
591
+ return;
592
+ }
593
+ // v1.9.7 关键词快速分组:零 token、毫秒级、完全确定。
594
+ // 与 ai-classify 一样只出方案不落盘,用户确认后仍走 ai-apply。
595
+ if (action === "kw-classify") {
596
+ var kwSkills = scanSkills(root, ZH);
597
+ if (!kwSkills.length) { sendJson(res, 400, { ok: false, error: "技能库为空或未找到技能" }); return; }
598
+ try {
599
+ sendJson(res, 200, keywordClassify(kwSkills));
600
+ } catch (e) {
601
+ ctx.logger.warn("dsh-skill-browser: kw-classify failed: " + (e && e.message));
602
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
603
+ }
604
+ return;
605
+ }
606
+ if (action === "ai-classify") {
607
+ var skills = scanSkills(root, ZH);
608
+ if (!skills.length) { sendJson(res, 400, { ok: false, error: "技能库为空或未找到技能" }); return; }
609
+ // Build a PLAN only - nothing is written until the user confirms via
610
+ // ai-apply. Replacing existing groups must be an explicit choice.
611
+ aiClassify(ctx, runtime, skills, { mode: body.mode }).then(function (r) {
612
+ sendJson(res, r && r.ok ? 200 : 500, r);
613
+ }).catch(function (e) {
614
+ ctx.logger.warn("dsh-skill-browser: ai-classify failed: " + (e && e.message));
615
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
616
+ });
617
+ return;
618
+ }
619
+ if (action === "ai-apply") {
620
+ var plan = body.plan;
621
+ if (!plan || typeof plan !== "object") { sendJson(res, 400, { ok: false, error: "缺少分组方案 plan" }); return; }
622
+ var skillsForPlan = scanSkills(root, ZH);
623
+ if (!skillsForPlan.length) { sendJson(res, 400, { ok: false, error: "技能库为空或未找到技能" }); return; }
624
+ var applied = applyPlan(plan, skillsForPlan, body.mode);
625
+ sendJson(res, applied.ok ? 200 : 400, applied);
626
+ return;
627
+ }
628
+ if (action === "ai-cover") {
629
+ var gid = String(body.id || "");
630
+ var listed = listGroups(null);
631
+ var g = null;
632
+ for (var i = 0; i < listed.groups.length; i++) {
633
+ if (listed.groups[i].id === gid) { g = listed.groups[i]; break; }
634
+ }
635
+ if (!g) { sendJson(res, 404, { ok: false, error: "分组不存在:" + gid }); return; }
636
+ var sample = [];
637
+ for (var j = 0; j < g.members.length && sample.length < 12; j++) {
638
+ var d = g.members[j];
639
+ sample.push(ZH[d] ? d + "(" + ZH[d] + ")" : d);
640
+ }
641
+ aiCover(ctx, runtime, g, sample).then(function (r) {
642
+ sendJson(res, r && r.ok ? 200 : 500, r);
643
+ }).catch(function (e) {
644
+ ctx.logger.warn("dsh-skill-browser: ai-cover failed: " + (e && e.message));
645
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
646
+ });
647
+ return;
648
+ }
649
+ sendJson(res, 400, { ok: false, error: "unknown action: " + action });
650
+ } catch (e) {
651
+ ctx.logger.warn("dsh-skill-browser: groups action failed: " + (e && e.message));
652
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
653
+ }
654
+ });
655
+ }
656
+
657
+ var handler = function (req, res) {
360
658
  if (!isLoopback(req)) { res.writeHead(403); res.end(); return; }
361
659
  var url = req.url || "/";
362
660
  var path = url.slice(0, url.indexOf("?") >= 0 ? url.indexOf("?") : url.length);
@@ -375,7 +673,7 @@ export function apply(ctx, config) {
375
673
  return;
376
674
  }
377
675
  if (path === PREFIX + "/health") {
378
- sendJson(res, 200, { ok: true, plugin: "dsh-skill-browser", version: VERSION, root: root, source: detected.source });
676
+ sendJson(res, 200, { ok: true, plugin: "dsh-skill-browser", version: VERSION, gen: LIVE.gen, root: root, source: detected.source });
379
677
  return;
380
678
  }
381
679
 
@@ -458,6 +756,34 @@ export function apply(ctx, config) {
458
756
  return;
459
757
  }
460
758
 
759
+ if (path === PREFIX + "/groups" && req.method === "POST") {
760
+ handleGroupsPost(req, res);
761
+ return;
762
+ }
763
+ if (path === PREFIX + "/groups") {
764
+ try { handleGroupsList(res); }
765
+ catch (e) { sendJson(res, 500, { ok: false, error: String((e && e.message) || e) }); }
766
+ return;
767
+ }
768
+
769
+ // 调用台账统计:GET /ledger/stats?from=&to=&gran=day|week|month|year&sort=&top=
770
+ // 服务端聚合,只回聚合结果(不把上千条原始流水塞给前端)
771
+ if (path === PREFIX + "/ledger/stats") {
772
+ try {
773
+ var stats = getLedgerStats({
774
+ from: qp(query, "from"),
775
+ to: qp(query, "to"),
776
+ gran: qp(query, "gran"),
777
+ sort: qp(query, "sort"),
778
+ top: qp(query, "top")
779
+ });
780
+ sendJson(res, 200, stats);
781
+ } catch (e) {
782
+ ctx.logger.warn("dsh-skill-browser: ledger stats failed " + (e && e.message));
783
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
784
+ }
785
+ return;
786
+ }
461
787
  if (path === PREFIX + "/ledger") {
462
788
  try {
463
789
  sendJson(res, 200, getLedgerPayload(/(^|&)refresh=1(&|$)/.test(query)));
@@ -497,8 +823,16 @@ export function apply(ctx, config) {
497
823
  if (!ledgerFile && root) resolveLedger(false);
498
824
  var text2 = ledgerFile ? readLedgerAtPath(ledgerFile) : null;
499
825
  var detail = text2 == null ? "" : (parseLedgerDetail(text2, skill) || "");
500
- var autoEntries = readAutoLog(root, skill);
501
- sendJson(res, 200, { ok: true, skill: skill, detail: detail, autoEntries: autoEntries });
826
+ // v1.9.6 性能:详情页默认只回最近 200 条(含总数),避免单技能上千条记录一次性塞给前端
827
+ var m3 = /(^|&)limit=([^&]*)/.exec(query);
828
+ var lim = m3 ? parseInt(decodeURIComponent(m3[2]), 10) : 200;
829
+ if (!(lim > 0)) lim = 200;
830
+ var allEntries = readAutoLog(root, skill);
831
+ var autoEntries = allEntries.length > lim ? allEntries.slice(allEntries.length - lim) : allEntries;
832
+ sendJson(res, 200, {
833
+ ok: true, skill: skill, detail: detail,
834
+ autoEntries: autoEntries, autoTotal: allEntries.length, autoLimit: lim
835
+ });
502
836
  return;
503
837
  }
504
838
 
@@ -546,10 +880,31 @@ export function apply(ctx, config) {
546
880
  return;
547
881
  }
548
882
 
883
+ // ── v1.9.4 热加载:POST /reload ──────────────────────────
884
+ // 必须先回包再重载:重载会 dispose 当前实例,回包前做会断连接。
885
+ if (path === PREFIX + "/reload" && req.method === "POST") {
886
+ sendJson(res, 200, { ok: true, version: VERSION, gen: LIVE.gen, reloading: true });
887
+ setTimeout(function () {
888
+ selfReload(ctx, config).then(function (r) {
889
+ ctx.logger.info("dsh-skill-browser: hot-reloaded (host half) -> " + r.version + " gen " + r.gen);
890
+ }).catch(function (e) {
891
+ ctx.logger.warn("dsh-skill-browser: hot reload failed: " + (e && e.message ? e.message : e));
892
+ });
893
+ }, 120);
894
+ return;
895
+ }
896
+
897
+ // ── v1.9.4 热加载探针:GET /rev(gen 变了 = 宿主半已换新)──
898
+ if (path === PREFIX + "/rev") {
899
+ sendJson(res, 200, { ok: true, version: VERSION, gen: LIVE.gen, ts: Date.now() });
900
+ return;
901
+ }
902
+
549
903
  sendJson(res, 404, { ok: false, error: "unknown path" });
550
904
  };
551
905
 
552
- var dispose = ctx.webServer.register({ kind: "prefix", path: PREFIX, handler: handler });
553
- return function () { try { dispose(); } catch (e) {} };
554
- }, "dsh-skill-browser: api routes");
555
- }
906
+ return {
907
+ handler: handler,
908
+ dispose: function () { try { if (offTools) offTools(); } catch (e) {} }
909
+ };
910
+ }