@spzhongwin/skill-logger-plugin 1.0.9 → 1.0.11

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.
Files changed (54) hide show
  1. package/dist/index.js +73 -5
  2. package/openclaw.plugin.json +50 -50
  3. package/package.json +37 -37
  4. package/src/active-skills.test.ts +32 -32
  5. package/src/active-skills.ts +77 -77
  6. package/src/config-sync.test.ts +165 -165
  7. package/src/config-sync.ts +544 -544
  8. package/src/hooks.test.ts +251 -251
  9. package/src/hooks.ts +517 -517
  10. package/src/http.ts +61 -61
  11. package/src/identity.ts +64 -64
  12. package/src/index.test.ts +53 -53
  13. package/src/index.ts +226 -226
  14. package/src/integration.test.ts +119 -119
  15. package/src/matcher.test.ts +170 -170
  16. package/src/matcher.ts +393 -393
  17. package/src/paths.test.ts +57 -57
  18. package/src/paths.ts +84 -84
  19. package/src/reporter.test.ts +139 -139
  20. package/src/reporter.ts +298 -298
  21. package/src/sample-config.json +72 -72
  22. package/src/semver.test.ts +23 -23
  23. package/src/semver.ts +60 -60
  24. package/src/skill-version.ts +53 -53
  25. package/src/types.ts +198 -198
  26. package/src/updater.test.ts +237 -237
  27. package/src/updater.ts +433 -433
  28. package/src/ws-client.test.ts +37 -37
  29. package/src/ws-client.ts +642 -641
  30. package/test-ws.ts +17 -17
  31. package/tsconfig.json +14 -14
  32. package/dist/active-skills.js +0 -67
  33. package/dist/active-skills.test.js +0 -29
  34. package/dist/config-sync.js +0 -439
  35. package/dist/config-sync.test.js +0 -145
  36. package/dist/hooks.js +0 -337
  37. package/dist/hooks.test.js +0 -123
  38. package/dist/http.js +0 -54
  39. package/dist/identity.js +0 -56
  40. package/dist/index.test.js +0 -39
  41. package/dist/integration.test.js +0 -102
  42. package/dist/matcher.js +0 -362
  43. package/dist/matcher.test.js +0 -139
  44. package/dist/paths.js +0 -62
  45. package/dist/paths.test.js +0 -49
  46. package/dist/reporter.js +0 -267
  47. package/dist/reporter.test.js +0 -128
  48. package/dist/semver.js +0 -64
  49. package/dist/semver.test.js +0 -21
  50. package/dist/skill-version.js +0 -23
  51. package/dist/types.js +0 -9
  52. package/dist/updater.js +0 -352
  53. package/dist/updater.test.js +0 -212
  54. package/dist/ws-client.js +0 -484
package/dist/index.js CHANGED
@@ -369,6 +369,31 @@ var SkillUpdater = class {
369
369
  /**
370
370
  * 手动下发安装/更新指令 (供沙盒 HTTP Proxy 与星型架构中控使用)
371
371
  */
372
+ /**
373
+ * 从 URL 下载 ZIP 并直接解压替换到目标目录(不检查 SKILL.md)
374
+ */
375
+ async installZipFromUrl(url, targetDir) {
376
+ const work = path3.join(this.tmpDir, `slp-zip-${randomUUID()}`);
377
+ await fs3.mkdir(work, { recursive: true });
378
+ try {
379
+ const zipPath = path3.join(work, "pkg.zip");
380
+ const res = await this.fetchImpl(url);
381
+ if (!res.ok) {
382
+ return { success: false, message: `\u4E0B\u8F7D\u5931\u8D25: HTTP ${res.status}` };
383
+ }
384
+ const buf = Buffer.from(await res.arrayBuffer());
385
+ await fs3.writeFile(zipPath, buf);
386
+ const staging = path3.join(work, "staging");
387
+ await this.unzip(zipPath, staging);
388
+ await this.replaceDir(staging, targetDir);
389
+ return { success: true, message: `\u5B89\u88C5\u6210\u529F` };
390
+ } catch (err) {
391
+ return { success: false, message: `\u6267\u884C\u51FA\u9519: ${err.message}` };
392
+ } finally {
393
+ await fs3.rm(work, { recursive: true, force: true }).catch(() => {
394
+ });
395
+ }
396
+ }
372
397
  async manualInstall(options) {
373
398
  const { code, force, targetDir } = options;
374
399
  let { url, version } = options;
@@ -1637,6 +1662,13 @@ var GatewayWsClient = class {
1637
1662
  this.appendLogToFile("INFO", "Heartbeat", "Server heartbeat acknowledged", { type: msg.type, connectionId: msg.connectionId });
1638
1663
  return;
1639
1664
  }
1665
+ if (msg?.type === "BATCH_COMMAND" && Array.isArray(msg.commands)) {
1666
+ const { commands, type, ...shared } = msg;
1667
+ for (const cmd of commands) {
1668
+ await this.handleMessage({ ...shared, userId: cmd.userId, replyId: cmd.replyId });
1669
+ }
1670
+ return;
1671
+ }
1640
1672
  await this.handleMessage(msg);
1641
1673
  } catch (err) {
1642
1674
  console.error(`[skill-logger-plugin][WS] Failed to parse/handle message:`, err);
@@ -1673,9 +1705,7 @@ var GatewayWsClient = class {
1673
1705
  try {
1674
1706
  entries = await fs6.readdir(rootPath);
1675
1707
  } catch (e) {
1676
- this.currentAgentIds = /* @__PURE__ */ new Set();
1677
- this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; reporting empty agent list", e);
1678
- this.sendAgentListReport("AGENT_LIST_REPORT");
1708
+ this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; skipping this scan cycle", e);
1679
1709
  return;
1680
1710
  }
1681
1711
  const newAgentIds = /* @__PURE__ */ new Set();
@@ -1789,7 +1819,8 @@ var GatewayWsClient = class {
1789
1819
  type: "GATEWAY_HELLO",
1790
1820
  gatewayId: this.options.gatewayId,
1791
1821
  agentIds: Array.from(this.currentAgentIds),
1792
- clientTime: Date.now()
1822
+ clientTime: Date.now(),
1823
+ supportsBatch: true
1793
1824
  }, "Heartbeat");
1794
1825
  }
1795
1826
  sendClientHeartbeat() {
@@ -1947,10 +1978,46 @@ var GatewayWsClient = class {
1947
1978
  if (replyId) this.reply(replyId, { success: false, message: e.message, action });
1948
1979
  }
1949
1980
  }, delayMs);
1981
+ } else if (action === "INSTALL_EXPERT") {
1982
+ if (!safeCode) throw new Error("Missing code parameter");
1983
+ const { name, version: version2, downloadUrl, skills } = msg;
1984
+ console.log(`[skill-logger-plugin][WS] Executing INSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
1985
+ this.appendLogToFile("INFO", "Command", `INSTALL_EXPERT received`, { userId, code: safeCode, version: version2, downloadUrl });
1986
+ const userSkillRoot = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user");
1987
+ const expertTarget = path7.join(userSkillRoot, "experts", safeCode);
1988
+ await fs6.mkdir(path7.dirname(expertTarget), { recursive: true });
1989
+ const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
1990
+ if (!expertResult.success) {
1991
+ throw new Error(`\u4E13\u5BB6\u5B89\u88C5\u5931\u8D25: ${expertResult.message}`);
1992
+ }
1993
+ const skillTargetRoot = path7.join(userSkillRoot, "skills");
1994
+ await fs6.mkdir(skillTargetRoot, { recursive: true });
1995
+ const skillResults = [];
1996
+ if (Array.isArray(skills)) {
1997
+ for (const sk of skills) {
1998
+ if (!sk.code || !sk.downloadUrl) {
1999
+ skillResults.push(`${sk.code || "unknown"}: \u7F3A\u5C11\u4E0B\u8F7D\u5730\u5740`);
2000
+ continue;
2001
+ }
2002
+ try {
2003
+ const skTarget = path7.join(skillTargetRoot, sk.code);
2004
+ const result = await this.options.updater.installZipFromUrl(sk.downloadUrl, skTarget);
2005
+ skillResults.push(`${sk.code}: ${result.success ? "\u6210\u529F" : "\u5931\u8D25 - " + result.message}`);
2006
+ } catch (e) {
2007
+ skillResults.push(`${sk.code}: \u5931\u8D25 - ${e.message}`);
2008
+ }
2009
+ }
2010
+ }
2011
+ this.reply(replyId, {
2012
+ success: true,
2013
+ message: `\u4E13\u5BB6 ${safeCode} \u5B89\u88C5\u5B8C\u6210`,
2014
+ action,
2015
+ data: { expertCode: safeCode, skills: skillResults }
2016
+ });
1950
2017
  } else if (action === "GET_EXPERT_REGISTRY") {
1951
2018
  console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
1952
2019
  this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
1953
- const registryFilePath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".openclaw", "userskill", "expert-registry.yaml");
2020
+ const registryFilePath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "expert-registry.yaml");
1954
2021
  let content = "";
1955
2022
  let fileExists = false;
1956
2023
  try {
@@ -1992,6 +2059,7 @@ var GatewayWsClient = class {
1992
2059
  } else {
1993
2060
  console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
1994
2061
  this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
2062
+ this.reply(replyId, { success: false, message: `Unknown action: ${action}`, action });
1995
2063
  }
1996
2064
  } catch (err) {
1997
2065
  this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
@@ -1,50 +1,50 @@
1
- {
2
- "id": "skill-logger-plugin",
3
- "name": "Skill Logger",
4
- "description": "追踪 openclaw skill 内功能点(脚本/命令/工具/HTTP)使用与报错,落本地并批量上报",
5
- "activation": {
6
- "onStartup": true
7
- },
8
- "configSchema": {
9
- "type": "object",
10
- "additionalProperties": true,
11
- "properties": {
12
- "pluginId": {
13
- "type": "string",
14
- "description": "自定义插件 ID"
15
- },
16
- "pluginName": {
17
- "type": "string",
18
- "description": "自定义插件名称"
19
- },
20
- "platformBaseUrl": {
21
- "type": "string",
22
- "description": "拉取 skill 标准配置的服务地址;缺省时使用内置静态桩"
23
- },
24
- "reportBaseUrl": {
25
- "type": "string",
26
- "description": "批量上报的服务地址;缺省时只落本地、不上报"
27
- },
28
- "authToken": {
29
- "type": "string",
30
- "description": "拉取/上报接口的 Authorization 头值(如 'Bearer xxx')"
31
- },
32
- "recordUnattributed": {
33
- "type": "boolean",
34
- "description": "是否记录无法归属到具体功能点的 exec 调用(默认 true)"
35
- },
36
- "enableFileLog": {
37
- "type": "boolean",
38
- "description": "是否开启插件专属文件日志(落盘为 skill-logger.err),用于排查 WS 连接和指令运行情况"
39
- },
40
- "autoUpdateSkills": {
41
- "type": "boolean",
42
- "description": "是否开启 skill 版本自动更新(检测到本地落后于平台最新版本时下载并覆盖各 workspace 下的副本);默认开启,仅显式设为 false 才关闭"
43
- },
44
- "versionCheckIntervalMinutes": {
45
- "type": "number",
46
- "description": "版本检查+自动更新的周期(分钟)。每隔该周期按本地所有已装 skill 向服务端拉最新版本并按需更新;默认 30。本地扫描发现固定 3 分钟,与此独立"
47
- }
48
- }
49
- }
50
- }
1
+ {
2
+ "id": "skill-logger-plugin",
3
+ "name": "Skill Logger",
4
+ "description": "追踪 openclaw skill 内功能点(脚本/命令/工具/HTTP)使用与报错,落本地并批量上报",
5
+ "activation": {
6
+ "onStartup": true
7
+ },
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": true,
11
+ "properties": {
12
+ "pluginId": {
13
+ "type": "string",
14
+ "description": "自定义插件 ID"
15
+ },
16
+ "pluginName": {
17
+ "type": "string",
18
+ "description": "自定义插件名称"
19
+ },
20
+ "platformBaseUrl": {
21
+ "type": "string",
22
+ "description": "拉取 skill 标准配置的服务地址;缺省时使用内置静态桩"
23
+ },
24
+ "reportBaseUrl": {
25
+ "type": "string",
26
+ "description": "批量上报的服务地址;缺省时只落本地、不上报"
27
+ },
28
+ "authToken": {
29
+ "type": "string",
30
+ "description": "拉取/上报接口的 Authorization 头值(如 'Bearer xxx')"
31
+ },
32
+ "recordUnattributed": {
33
+ "type": "boolean",
34
+ "description": "是否记录无法归属到具体功能点的 exec 调用(默认 true)"
35
+ },
36
+ "enableFileLog": {
37
+ "type": "boolean",
38
+ "description": "是否开启插件专属文件日志(落盘为 skill-logger.err),用于排查 WS 连接和指令运行情况"
39
+ },
40
+ "autoUpdateSkills": {
41
+ "type": "boolean",
42
+ "description": "是否开启 skill 版本自动更新(检测到本地落后于平台最新版本时下载并覆盖各 workspace 下的副本);默认开启,仅显式设为 false 才关闭"
43
+ },
44
+ "versionCheckIntervalMinutes": {
45
+ "type": "number",
46
+ "description": "版本检查+自动更新的周期(分钟)。每隔该周期按本地所有已装 skill 向服务端拉最新版本并按需更新;默认 30。本地扫描发现固定 3 分钟,与此独立"
47
+ }
48
+ }
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "@spzhongwin/skill-logger-plugin",
3
- "version": "1.0.9",
4
- "type": "module",
5
- "exports": "./dist/index.js",
6
- "scripts": {
7
- "test": "node --experimental-strip-types --test src/*.test.ts",
8
- "typecheck": "tsc --noEmit",
9
- "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:ws"
10
- },
11
- "openclaw": {
12
- "extensions": [
13
- "./dist/index.js"
14
- ],
15
- "contracts": {
16
- "tools": [
17
- "report_skill_error"
18
- ]
19
- },
20
- "compat": {
21
- "pluginApi": ">=2026.3.28",
22
- "minGatewayVersion": "2026.3.28"
23
- },
24
- "build": {
25
- "openclawVersion": "2026.3.28",
26
- "pluginSdkVersion": "2026.3.28"
27
- }
28
- },
29
- "devDependencies": {
30
- "@types/node": "^22.19.20",
31
- "@types/ws": "^8.18.1",
32
- "typescript": "^5.9.3"
33
- },
34
- "dependencies": {
35
- "ws": "^8.21.0"
36
- }
37
- }
1
+ {
2
+ "name": "@spzhongwin/skill-logger-plugin",
3
+ "version": "1.0.11",
4
+ "type": "module",
5
+ "exports": "./dist/index.js",
6
+ "scripts": {
7
+ "test": "node --experimental-strip-types --test src/*.test.ts",
8
+ "typecheck": "tsc --noEmit",
9
+ "build": "npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:ws"
10
+ },
11
+ "openclaw": {
12
+ "extensions": [
13
+ "./dist/index.js"
14
+ ],
15
+ "contracts": {
16
+ "tools": [
17
+ "report_skill_error"
18
+ ]
19
+ },
20
+ "compat": {
21
+ "pluginApi": ">=2026.3.28",
22
+ "minGatewayVersion": "2026.3.28"
23
+ },
24
+ "build": {
25
+ "openclawVersion": "2026.3.28",
26
+ "pluginSdkVersion": "2026.3.28"
27
+ }
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.19.20",
31
+ "@types/ws": "^8.18.1",
32
+ "typescript": "^5.9.3"
33
+ },
34
+ "dependencies": {
35
+ "ws": "^8.21.0"
36
+ }
37
+ }
@@ -1,32 +1,32 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { ActiveSkills } from "./active-skills.ts";
4
-
5
- describe("ActiveSkills", () => {
6
- it("标记并取回激活 skill", () => {
7
- const a = new ActiveSkills();
8
- a.markActive("s1", "skA");
9
- a.markActive("s1", "skB");
10
- assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
11
- assert.deepEqual([...a.getActive("s2")], []);
12
- });
13
-
14
- it("TTL 过期后不再返回", () => {
15
- let t = 1000;
16
- const a = new ActiveSkills({ ttlMs: 100, now: () => t });
17
- a.markActive("s", "skA");
18
- t = 1050;
19
- assert.deepEqual([...a.getActive("s")], ["skA"]);
20
- t = 2000;
21
- assert.deepEqual([...a.getActive("s")], []);
22
- });
23
-
24
- it("超出 session 上限按 LRU 淘汰最旧", () => {
25
- const a = new ActiveSkills({ maxSessions: 2 });
26
- a.markActive("s1", "x");
27
- a.markActive("s2", "x");
28
- a.markActive("s3", "x"); // 淘汰 s1
29
- assert.deepEqual([...a.getActive("s1")], []);
30
- assert.deepEqual([...a.getActive("s3")], ["x"]);
31
- });
32
- });
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ActiveSkills } from "./active-skills.ts";
4
+
5
+ describe("ActiveSkills", () => {
6
+ it("标记并取回激活 skill", () => {
7
+ const a = new ActiveSkills();
8
+ a.markActive("s1", "skA");
9
+ a.markActive("s1", "skB");
10
+ assert.deepEqual([...a.getActive("s1")].sort(), ["skA", "skB"]);
11
+ assert.deepEqual([...a.getActive("s2")], []);
12
+ });
13
+
14
+ it("TTL 过期后不再返回", () => {
15
+ let t = 1000;
16
+ const a = new ActiveSkills({ ttlMs: 100, now: () => t });
17
+ a.markActive("s", "skA");
18
+ t = 1050;
19
+ assert.deepEqual([...a.getActive("s")], ["skA"]);
20
+ t = 2000;
21
+ assert.deepEqual([...a.getActive("s")], []);
22
+ });
23
+
24
+ it("超出 session 上限按 LRU 淘汰最旧", () => {
25
+ const a = new ActiveSkills({ maxSessions: 2 });
26
+ a.markActive("s1", "x");
27
+ a.markActive("s2", "x");
28
+ a.markActive("s3", "x"); // 淘汰 s1
29
+ assert.deepEqual([...a.getActive("s1")], []);
30
+ assert.deepEqual([...a.getActive("s3")], ["x"]);
31
+ });
32
+ });
@@ -1,77 +1,77 @@
1
- /**
2
- * 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
3
- * 出现多候选时消歧——优先归属到本 session 已激活的 skill。
4
- *
5
- * 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
6
- */
7
-
8
- type SkillEntry = { name: string; ts: number };
9
-
10
- const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
11
- const DEFAULT_MAX_SESSIONS = 500;
12
-
13
- export type ActiveSkillsOptions = {
14
- ttlMs?: number;
15
- maxSessions?: number;
16
- /** 注入时钟,便于测试。 */
17
- now?: () => number;
18
- };
19
-
20
- export class ActiveSkills {
21
- private readonly ttlMs: number;
22
- private readonly maxSessions: number;
23
- private readonly now: () => number;
24
- /** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
25
- private readonly bySession = new Map<string, Map<string, SkillEntry>>();
26
-
27
- constructor(opts: ActiveSkillsOptions = {}) {
28
- this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
29
- this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
30
- this.now = opts.now ?? Date.now;
31
- }
32
-
33
- /** 标记某 session 触发了某 skill。 */
34
- markActive(sessionKey: string | undefined, skillName: string): void {
35
- const key = sessionKey || "__nosession__";
36
- let skills = this.bySession.get(key);
37
- if (!skills) {
38
- skills = new Map();
39
- this.bySession.set(key, skills);
40
- } else {
41
- // 触碰即刷新 LRU 顺序
42
- this.bySession.delete(key);
43
- this.bySession.set(key, skills);
44
- }
45
- skills.set(skillName, { name: skillName, ts: this.now() });
46
- this.evictIfNeeded();
47
- }
48
-
49
- /** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
50
- getActive(sessionKey: string | undefined): Set<string> {
51
- const key = sessionKey || "__nosession__";
52
- const skills = this.bySession.get(key);
53
- const out = new Set<string>();
54
- if (!skills) return out;
55
- const cutoff = this.now() - this.ttlMs;
56
- for (const [name, entry] of skills) {
57
- if (entry.ts >= cutoff) out.add(name);
58
- else skills.delete(name);
59
- }
60
- if (skills.size === 0) this.bySession.delete(key);
61
- return out;
62
- }
63
-
64
- /** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
65
- clearSession(sessionKey: string | undefined): void {
66
- this.bySession.delete(sessionKey || "__nosession__");
67
- }
68
-
69
- /** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
70
- private evictIfNeeded(): void {
71
- while (this.bySession.size > this.maxSessions) {
72
- const oldest = this.bySession.keys().next().value;
73
- if (oldest === undefined) break;
74
- this.bySession.delete(oldest);
75
- }
76
- }
77
- }
1
+ /**
2
+ * 按 session 记录「已触发(激活)的 skill」,供 matcher 在通用命令(curl、共享 CLI)
3
+ * 出现多候选时消歧——优先归属到本 session 已激活的 skill。
4
+ *
5
+ * 内存态,带 TTL + 容量上限,防止长跑 gateway 进程里无限增长。
6
+ */
7
+
8
+ type SkillEntry = { name: string; ts: number };
9
+
10
+ const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 分钟
11
+ const DEFAULT_MAX_SESSIONS = 500;
12
+
13
+ export type ActiveSkillsOptions = {
14
+ ttlMs?: number;
15
+ maxSessions?: number;
16
+ /** 注入时钟,便于测试。 */
17
+ now?: () => number;
18
+ };
19
+
20
+ export class ActiveSkills {
21
+ private readonly ttlMs: number;
22
+ private readonly maxSessions: number;
23
+ private readonly now: () => number;
24
+ /** sessionKey -> (skillName -> entry)。用 Map 保留插入顺序以便 LRU 淘汰。 */
25
+ private readonly bySession = new Map<string, Map<string, SkillEntry>>();
26
+
27
+ constructor(opts: ActiveSkillsOptions = {}) {
28
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
29
+ this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
30
+ this.now = opts.now ?? Date.now;
31
+ }
32
+
33
+ /** 标记某 session 触发了某 skill。 */
34
+ markActive(sessionKey: string | undefined, skillName: string): void {
35
+ const key = sessionKey || "__nosession__";
36
+ let skills = this.bySession.get(key);
37
+ if (!skills) {
38
+ skills = new Map();
39
+ this.bySession.set(key, skills);
40
+ } else {
41
+ // 触碰即刷新 LRU 顺序
42
+ this.bySession.delete(key);
43
+ this.bySession.set(key, skills);
44
+ }
45
+ skills.set(skillName, { name: skillName, ts: this.now() });
46
+ this.evictIfNeeded();
47
+ }
48
+
49
+ /** 取某 session 当前仍在 TTL 内的已激活 skill 集合。 */
50
+ getActive(sessionKey: string | undefined): Set<string> {
51
+ const key = sessionKey || "__nosession__";
52
+ const skills = this.bySession.get(key);
53
+ const out = new Set<string>();
54
+ if (!skills) return out;
55
+ const cutoff = this.now() - this.ttlMs;
56
+ for (const [name, entry] of skills) {
57
+ if (entry.ts >= cutoff) out.add(name);
58
+ else skills.delete(name);
59
+ }
60
+ if (skills.size === 0) this.bySession.delete(key);
61
+ return out;
62
+ }
63
+
64
+ /** session 结束时清掉其激活记录(释放内存 + 避免跨会话误判)。 */
65
+ clearSession(sessionKey: string | undefined): void {
66
+ this.bySession.delete(sessionKey || "__nosession__");
67
+ }
68
+
69
+ /** 超出 session 上限时,按 LRU 淘汰最旧的 session。 */
70
+ private evictIfNeeded(): void {
71
+ while (this.bySession.size > this.maxSessions) {
72
+ const oldest = this.bySession.keys().next().value;
73
+ if (oldest === undefined) break;
74
+ this.bySession.delete(oldest);
75
+ }
76
+ }
77
+ }