@spzhongwin/skill-logger-plugin 1.0.4 → 1.0.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/dist/index.js +281 -22
- package/package.json +5 -3
- package/src/config-sync.ts +59 -0
- package/src/hooks.test.ts +95 -0
- package/src/hooks.ts +125 -13
- package/src/updater.ts +10 -2
- package/src/ws-client.test.ts +37 -0
- package/src/ws-client.ts +82 -11
package/dist/index.js
CHANGED
|
@@ -141,6 +141,9 @@ function parseSkillVersion(content) {
|
|
|
141
141
|
// src/updater.ts
|
|
142
142
|
var execFileAsync = promisify(execFile);
|
|
143
143
|
var ATTEMPT_COOLDOWN_MS = 30 * 60 * 1e3;
|
|
144
|
+
function skillIdentityHash(code, version) {
|
|
145
|
+
return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
|
|
146
|
+
}
|
|
144
147
|
async function systemUnzip(zipPath, destDir) {
|
|
145
148
|
await fs3.mkdir(destDir, { recursive: true });
|
|
146
149
|
await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", destDir]);
|
|
@@ -259,6 +262,13 @@ var SkillUpdater = class {
|
|
|
259
262
|
console.warn(`[skill-logger-plugin] ${skillName}@${version} \u4E0B\u8F7D\u5305\u65E0\u7248\u672C\u53F7\uFF0C\u653E\u5F03\u8986\u76D6`);
|
|
260
263
|
return;
|
|
261
264
|
}
|
|
265
|
+
if (dl.sha256) {
|
|
266
|
+
const actualHash = skillIdentityHash(skillName, packageVersion);
|
|
267
|
+
if (actualHash !== dl.sha256) {
|
|
268
|
+
console.warn(`[skill-logger-plugin] ${skillName}@${version} \u8EAB\u4EFD\u54C8\u5E0C\u4E0D\u4E00\u81F4\uFF0C\u653E\u5F03\u8986\u76D6`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
262
272
|
const metaPath = path3.join(srcRoot, ".meta.json");
|
|
263
273
|
if (!await this.exists(metaPath)) {
|
|
264
274
|
this.debug(`[skill-logger-plugin] \u4E3A ${skillName}@${version} \u81EA\u52A8\u751F\u6210 .meta.json`);
|
|
@@ -299,8 +309,9 @@ var SkillUpdater = class {
|
|
|
299
309
|
const data = await res.json();
|
|
300
310
|
const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : void 0;
|
|
301
311
|
const resolvedVersion = typeof data?.version === "string" ? data.version : void 0;
|
|
312
|
+
const sha256 = typeof data?.sha256 === "string" ? data.sha256 : void 0;
|
|
302
313
|
if (!url) return void 0;
|
|
303
|
-
return { url, version: resolvedVersion };
|
|
314
|
+
return { url, version: resolvedVersion, sha256 };
|
|
304
315
|
}
|
|
305
316
|
/** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
|
|
306
317
|
async locateSkillRoot(dir, depth) {
|
|
@@ -774,6 +785,44 @@ function defaultFetch(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
774
785
|
};
|
|
775
786
|
}
|
|
776
787
|
|
|
788
|
+
// src/semver.ts
|
|
789
|
+
function parseCore(v) {
|
|
790
|
+
const core = v.trim().replace(/^[vV]/, "").split(/[-+]/, 1)[0];
|
|
791
|
+
if (!core) return null;
|
|
792
|
+
const parts = core.split(".");
|
|
793
|
+
const nums = [];
|
|
794
|
+
for (const p of parts) {
|
|
795
|
+
if (!/^\d+$/.test(p)) return null;
|
|
796
|
+
nums.push(Number(p));
|
|
797
|
+
}
|
|
798
|
+
return nums.length > 0 ? nums : null;
|
|
799
|
+
}
|
|
800
|
+
function compareVersions(a, b) {
|
|
801
|
+
const na = parseCore(a);
|
|
802
|
+
const nb = parseCore(b);
|
|
803
|
+
if (na && nb) {
|
|
804
|
+
const len = Math.max(na.length, nb.length);
|
|
805
|
+
for (let i = 0; i < len; i++) {
|
|
806
|
+
const x = na[i] ?? 0;
|
|
807
|
+
const y = nb[i] ?? 0;
|
|
808
|
+
if (x < y) return -1;
|
|
809
|
+
if (x > y) return 1;
|
|
810
|
+
}
|
|
811
|
+
return 0;
|
|
812
|
+
}
|
|
813
|
+
const sa = a.trim();
|
|
814
|
+
const sb = b.trim();
|
|
815
|
+
if (sa === sb) return 0;
|
|
816
|
+
return sa < sb ? -1 : 1;
|
|
817
|
+
}
|
|
818
|
+
function isOutdated(local, latest) {
|
|
819
|
+
if (!local || !latest) return false;
|
|
820
|
+
const na = parseCore(local);
|
|
821
|
+
const nb = parseCore(latest);
|
|
822
|
+
if (na && nb) return compareVersions(local, latest) < 0;
|
|
823
|
+
return local.trim() !== latest.trim();
|
|
824
|
+
}
|
|
825
|
+
|
|
777
826
|
// src/config-sync.ts
|
|
778
827
|
var MAX_SCAN_DEPTH = 6;
|
|
779
828
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".cache"]);
|
|
@@ -919,6 +968,60 @@ var ConfigSync = class {
|
|
|
919
968
|
getInstallations() {
|
|
920
969
|
return this.installations;
|
|
921
970
|
}
|
|
971
|
+
/** 基于最近一次版本检查结果,找出所有本地版本落后的安装副本。 */
|
|
972
|
+
detectOutdated() {
|
|
973
|
+
const out = [];
|
|
974
|
+
for (const [skillName, copies] of this.installations) {
|
|
975
|
+
const cfg = this.configs.get(skillName);
|
|
976
|
+
const latestVersion = this.latestVersions.get(skillName) || cfg?.latestVersion || cfg?.version;
|
|
977
|
+
if (!latestVersion) continue;
|
|
978
|
+
for (const copy of copies) {
|
|
979
|
+
if (!isOutdated(copy.version, latestVersion)) continue;
|
|
980
|
+
out.push({
|
|
981
|
+
skillName,
|
|
982
|
+
rootDir: copy.rootDir,
|
|
983
|
+
localVersion: copy.version || "",
|
|
984
|
+
latestVersion
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
/** 扫描本地安装副本,拉取平台最新版本,检测落后副本并按配置触发自动更新。 */
|
|
991
|
+
async checkVersionsAndUpdate() {
|
|
992
|
+
if (this.checkingVersions) return;
|
|
993
|
+
this.checkingVersions = true;
|
|
994
|
+
try {
|
|
995
|
+
const installed = await this.scanInstalledSkills();
|
|
996
|
+
this.scanCache = { ts: Date.now(), skills: installed };
|
|
997
|
+
if (installed.length > 0) {
|
|
998
|
+
const { ok, configs } = await this.pullConfigs(
|
|
999
|
+
installed.map((s) => ({ name: s.name, version: s.version }))
|
|
1000
|
+
);
|
|
1001
|
+
if (ok) {
|
|
1002
|
+
for (const cfg of configs) {
|
|
1003
|
+
const latestVersion = cfg.latestVersion || cfg.version;
|
|
1004
|
+
if (latestVersion) this.latestVersions.set(cfg.skillName, latestVersion);
|
|
1005
|
+
if (cfg.version) {
|
|
1006
|
+
this.configPool.set(`${cfg.skillName}@${cfg.version}`, cfg);
|
|
1007
|
+
if (!this.configs.has(cfg.skillName)) this.configs.set(cfg.skillName, cfg);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const outdated = this.detectOutdated();
|
|
1013
|
+
if (outdated.length > 0 && this.updater) {
|
|
1014
|
+
await this.updater.applyUpdates(outdated);
|
|
1015
|
+
const refreshed = await this.scanInstalledSkills();
|
|
1016
|
+
this.scanCache = { ts: Date.now(), skills: refreshed };
|
|
1017
|
+
}
|
|
1018
|
+
await this.persist();
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
console.warn("[skill-logger-plugin] checkVersionsAndUpdate \u5F02\u5E38", err);
|
|
1021
|
+
} finally {
|
|
1022
|
+
this.checkingVersions = false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
922
1025
|
/** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
|
|
923
1026
|
async scanInstalledSkillsCached() {
|
|
924
1027
|
const now = Date.now();
|
|
@@ -1418,6 +1521,22 @@ import fs6 from "fs/promises";
|
|
|
1418
1521
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1419
1522
|
var HEARTBEAT_ACK_TIMEOUT_MS = 75e3;
|
|
1420
1523
|
var AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1e3;
|
|
1524
|
+
var ASSISTANT_WORKSPACE_PREFIX = "workspace-assistant-";
|
|
1525
|
+
var ASSISTANT_AGENT_PREFIX = "assistant-";
|
|
1526
|
+
var ASSISTANT_WORKSPACE_ID_RE = /^\d{5,}$/;
|
|
1527
|
+
function parseAssistantWorkspaceAgentId(entryName) {
|
|
1528
|
+
if (!entryName.startsWith(ASSISTANT_WORKSPACE_PREFIX)) return void 0;
|
|
1529
|
+
const suffix = entryName.slice(ASSISTANT_WORKSPACE_PREFIX.length);
|
|
1530
|
+
if (!ASSISTANT_WORKSPACE_ID_RE.test(suffix)) return void 0;
|
|
1531
|
+
return `${ASSISTANT_AGENT_PREFIX}${suffix}`;
|
|
1532
|
+
}
|
|
1533
|
+
function normalizeAssistantUserId(userId) {
|
|
1534
|
+
const safeUserId = path7.basename(userId);
|
|
1535
|
+
if (safeUserId !== userId) return void 0;
|
|
1536
|
+
const pureId = safeUserId.startsWith(ASSISTANT_AGENT_PREFIX) ? safeUserId.slice(ASSISTANT_AGENT_PREFIX.length) : safeUserId;
|
|
1537
|
+
if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return void 0;
|
|
1538
|
+
return pureId;
|
|
1539
|
+
}
|
|
1421
1540
|
var GatewayWsClient = class {
|
|
1422
1541
|
ws = null;
|
|
1423
1542
|
options;
|
|
@@ -1543,7 +1662,8 @@ var GatewayWsClient = class {
|
|
|
1543
1662
|
});
|
|
1544
1663
|
}
|
|
1545
1664
|
/**
|
|
1546
|
-
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId}
|
|
1665
|
+
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
|
|
1666
|
+
* userId 必须是至少 5 位数字。
|
|
1547
1667
|
*/
|
|
1548
1668
|
async scanAndReportAgents(isInitialReport) {
|
|
1549
1669
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -1560,12 +1680,8 @@ var GatewayWsClient = class {
|
|
|
1560
1680
|
}
|
|
1561
1681
|
const newAgentIds = /* @__PURE__ */ new Set();
|
|
1562
1682
|
for (const entry of entries) {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
if (suffix && suffix === path7.basename(suffix) && !suffix.startsWith(".")) {
|
|
1566
|
-
newAgentIds.add(`assistant-${suffix}`);
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1683
|
+
const agentId = parseAssistantWorkspaceAgentId(entry);
|
|
1684
|
+
if (agentId) newAgentIds.add(agentId);
|
|
1569
1685
|
}
|
|
1570
1686
|
let changed = false;
|
|
1571
1687
|
if (newAgentIds.size !== this.currentAgentIds.size) {
|
|
@@ -1708,9 +1824,12 @@ var GatewayWsClient = class {
|
|
|
1708
1824
|
this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
|
|
1709
1825
|
return;
|
|
1710
1826
|
}
|
|
1711
|
-
const safeUserId = path7.basename(userId);
|
|
1712
1827
|
const safeCode = code ? path7.basename(code) : void 0;
|
|
1713
|
-
const pureId =
|
|
1828
|
+
const pureId = normalizeAssistantUserId(userId);
|
|
1829
|
+
if (!pureId) {
|
|
1830
|
+
this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1714
1833
|
const targetDir = path7.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
|
|
1715
1834
|
try {
|
|
1716
1835
|
if (action === "INSTALL_SKILL") {
|
|
@@ -1828,6 +1947,48 @@ var GatewayWsClient = class {
|
|
|
1828
1947
|
if (replyId) this.reply(replyId, { success: false, message: e.message, action });
|
|
1829
1948
|
}
|
|
1830
1949
|
}, delayMs);
|
|
1950
|
+
} else if (action === "GET_EXPERT_REGISTRY") {
|
|
1951
|
+
console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
|
|
1952
|
+
this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
|
|
1953
|
+
const registryFilePath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".openclaw", "userskill", "expert-registry.yaml");
|
|
1954
|
+
let content = "";
|
|
1955
|
+
let fileExists = false;
|
|
1956
|
+
try {
|
|
1957
|
+
const stat = await fs6.stat(registryFilePath);
|
|
1958
|
+
fileExists = stat.isFile();
|
|
1959
|
+
} catch (err) {
|
|
1960
|
+
if (err?.code !== "ENOENT") throw err;
|
|
1961
|
+
}
|
|
1962
|
+
if (fileExists) {
|
|
1963
|
+
content = await fs6.readFile(registryFilePath, "utf8");
|
|
1964
|
+
const expertsList = [];
|
|
1965
|
+
const lines = content.split("\n");
|
|
1966
|
+
let currentExpert = null;
|
|
1967
|
+
for (const line of lines) {
|
|
1968
|
+
const trimmed = line.trim();
|
|
1969
|
+
if (trimmed.startsWith("#")) continue;
|
|
1970
|
+
const idMatch = line.match(/^\s*-\s*id:\s*(.+)$/);
|
|
1971
|
+
if (idMatch) {
|
|
1972
|
+
if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
|
|
1973
|
+
currentExpert = { id: idMatch[1].trim() };
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
if (currentExpert) {
|
|
1977
|
+
const nameMatch = line.match(/^\s*name:\s*(.+)$/);
|
|
1978
|
+
if (nameMatch) {
|
|
1979
|
+
currentExpert.name = nameMatch[1].trim();
|
|
1980
|
+
}
|
|
1981
|
+
const descMatch = line.match(/^\s*description:\s*(.+)$/);
|
|
1982
|
+
if (descMatch) {
|
|
1983
|
+
currentExpert.description = descMatch[1].trim();
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
|
|
1988
|
+
this.reply(replyId, { success: true, data: expertsList, action });
|
|
1989
|
+
} else {
|
|
1990
|
+
this.reply(replyId, { success: false, message: `\u672A\u627E\u5230\u7528\u6237\u6280\u80FD\u914D\u7F6E\u6587\u4EF6\uFF0C\u53EF\u80FD\u5C1A\u672A\u6CE8\u518C\u6216\u6587\u4EF6\u5DF2\u4E22\u5931`, action });
|
|
1991
|
+
}
|
|
1831
1992
|
} else {
|
|
1832
1993
|
console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
|
|
1833
1994
|
this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
|
|
@@ -1879,6 +2040,71 @@ function extractAppKey(event, ctx) {
|
|
|
1879
2040
|
return void 0;
|
|
1880
2041
|
}
|
|
1881
2042
|
}
|
|
2043
|
+
function asRecord(value) {
|
|
2044
|
+
return value && typeof value === "object" ? value : void 0;
|
|
2045
|
+
}
|
|
2046
|
+
function stringifyErrorValue(value) {
|
|
2047
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2048
|
+
if (typeof value === "string") return value;
|
|
2049
|
+
if (value instanceof Error) return value.stack || value.message;
|
|
2050
|
+
const obj = asRecord(value);
|
|
2051
|
+
if (obj) {
|
|
2052
|
+
for (const key of ["message", "error", "errorMessage", "error_message", "stderr"]) {
|
|
2053
|
+
const nested = stringifyErrorValue(obj[key]);
|
|
2054
|
+
if (nested) return nested;
|
|
2055
|
+
}
|
|
2056
|
+
try {
|
|
2057
|
+
return JSON.stringify(value);
|
|
2058
|
+
} catch {
|
|
2059
|
+
return String(value);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return String(value);
|
|
2063
|
+
}
|
|
2064
|
+
function hasFailureSignal(event) {
|
|
2065
|
+
const records = [
|
|
2066
|
+
event,
|
|
2067
|
+
asRecord(event.result),
|
|
2068
|
+
asRecord(event.output),
|
|
2069
|
+
asRecord(event.response),
|
|
2070
|
+
asRecord(event.data)
|
|
2071
|
+
].filter(Boolean);
|
|
2072
|
+
for (const record of records) {
|
|
2073
|
+
const status = String(record.status ?? record.state ?? "").toLowerCase();
|
|
2074
|
+
if (["error", "failed", "failure"].includes(status)) return true;
|
|
2075
|
+
if (record.success === false || record.ok === false || record.isError === true) return true;
|
|
2076
|
+
}
|
|
2077
|
+
return false;
|
|
2078
|
+
}
|
|
2079
|
+
function extractToolError(event) {
|
|
2080
|
+
for (const key of ["error", "errorMessage", "error_message"]) {
|
|
2081
|
+
const direct = stringifyErrorValue(event[key]);
|
|
2082
|
+
if (direct) return direct;
|
|
2083
|
+
}
|
|
2084
|
+
for (const key of ["result", "output", "response", "data"]) {
|
|
2085
|
+
const obj = asRecord(event[key]);
|
|
2086
|
+
if (!obj) continue;
|
|
2087
|
+
for (const nestedKey of ["error", "errorMessage", "error_message"]) {
|
|
2088
|
+
const nested = stringifyErrorValue(obj[nestedKey]);
|
|
2089
|
+
if (nested) return nested;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
if (hasFailureSignal(event)) {
|
|
2093
|
+
for (const key of ["message", "stderr", "result", "output", "response", "data"]) {
|
|
2094
|
+
const fallback = stringifyErrorValue(event[key]);
|
|
2095
|
+
if (fallback) return fallback;
|
|
2096
|
+
}
|
|
2097
|
+
return "tool call reported failure";
|
|
2098
|
+
}
|
|
2099
|
+
return void 0;
|
|
2100
|
+
}
|
|
2101
|
+
function extractDurationMs(event) {
|
|
2102
|
+
for (const key of ["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]) {
|
|
2103
|
+
const value = event[key];
|
|
2104
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2105
|
+
}
|
|
2106
|
+
return void 0;
|
|
2107
|
+
}
|
|
1882
2108
|
var Hooks = class {
|
|
1883
2109
|
pending = /* @__PURE__ */ new Map();
|
|
1884
2110
|
sessionAppKeys = /* @__PURE__ */ new Map();
|
|
@@ -1915,14 +2141,14 @@ var Hooks = class {
|
|
|
1915
2141
|
return {
|
|
1916
2142
|
event_id: randomUUID2(),
|
|
1917
2143
|
event_type: "function_call",
|
|
1918
|
-
skill_name: p.
|
|
1919
|
-
skill_version: p.
|
|
1920
|
-
function_id: p.
|
|
1921
|
-
function_name: p.
|
|
1922
|
-
match_type: p.
|
|
2144
|
+
skill_name: p.skillName,
|
|
2145
|
+
skill_version: p.skillVersion,
|
|
2146
|
+
function_id: p.functionId,
|
|
2147
|
+
function_name: p.functionName,
|
|
2148
|
+
match_type: p.matchType,
|
|
1923
2149
|
invoke_tool: p.invokeTool,
|
|
1924
2150
|
command: p.command,
|
|
1925
|
-
args: p.
|
|
2151
|
+
args: p.args,
|
|
1926
2152
|
status,
|
|
1927
2153
|
error_message: error,
|
|
1928
2154
|
duration_ms: durationMs,
|
|
@@ -1969,6 +2195,7 @@ var Hooks = class {
|
|
|
1969
2195
|
this.sweepStalePending();
|
|
1970
2196
|
const toolName = event.toolName;
|
|
1971
2197
|
const params = event.params ?? {};
|
|
2198
|
+
const toolCallId = event.toolCallId;
|
|
1972
2199
|
const sk = this.sessionKeyOf(ctx);
|
|
1973
2200
|
let appKey = void 0;
|
|
1974
2201
|
if (sk) {
|
|
@@ -1985,6 +2212,19 @@ var Hooks = class {
|
|
|
1985
2212
|
if (!skillName) return;
|
|
1986
2213
|
this.debug(`Intercepted explicit 'skill' tool call for: ${skillName}`);
|
|
1987
2214
|
this.recordTrigger(skillName, "tool", "skill", ctx, appKey);
|
|
2215
|
+
if (toolCallId) {
|
|
2216
|
+
this.pending.set(toolCallId, {
|
|
2217
|
+
skillName,
|
|
2218
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
2219
|
+
invokeTool: toolName,
|
|
2220
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
2221
|
+
agentId: ctx.agentId,
|
|
2222
|
+
runId: ctx.runId,
|
|
2223
|
+
appKey,
|
|
2224
|
+
ts: Date.now()
|
|
2225
|
+
});
|
|
2226
|
+
this.capPending();
|
|
2227
|
+
}
|
|
1988
2228
|
return;
|
|
1989
2229
|
}
|
|
1990
2230
|
if (toolName === "read") {
|
|
@@ -2002,14 +2242,18 @@ var Hooks = class {
|
|
|
2002
2242
|
const command = typeof params.command === "string" ? params.command : void 0;
|
|
2003
2243
|
if (!res) {
|
|
2004
2244
|
this.debug(`Tool call '${toolName}' did not match any function config.`);
|
|
2005
|
-
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey);
|
|
2245
|
+
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId);
|
|
2006
2246
|
return;
|
|
2007
2247
|
}
|
|
2008
2248
|
this.debug(`Tool call '${toolName}' matched function ID: ${res.functionId} (${res.skillName}@${res.skillVersion})`);
|
|
2009
|
-
const toolCallId = event.toolCallId;
|
|
2010
2249
|
if (toolCallId) {
|
|
2011
2250
|
this.pending.set(toolCallId, {
|
|
2012
|
-
res,
|
|
2251
|
+
skillName: res.skillName,
|
|
2252
|
+
skillVersion: res.skillVersion,
|
|
2253
|
+
functionId: res.functionId,
|
|
2254
|
+
functionName: res.functionName,
|
|
2255
|
+
matchType: res.matchType,
|
|
2256
|
+
args: res.args,
|
|
2013
2257
|
command,
|
|
2014
2258
|
invokeTool: toolName,
|
|
2015
2259
|
sessionId: this.sessionKeyOf(ctx),
|
|
@@ -2033,8 +2277,8 @@ var Hooks = class {
|
|
|
2033
2277
|
const p = this.pending.get(toolCallId);
|
|
2034
2278
|
if (!p) return;
|
|
2035
2279
|
this.pending.delete(toolCallId);
|
|
2036
|
-
const error = event
|
|
2037
|
-
const durationMs =
|
|
2280
|
+
const error = extractToolError(event);
|
|
2281
|
+
const durationMs = extractDurationMs(event);
|
|
2038
2282
|
const appKey = p.appKey || extractAppKey(event, {});
|
|
2039
2283
|
this.debug(`After tool call [${toolCallId}]: Status ${error ? "error" : "success"}, Duration ${durationMs}ms`);
|
|
2040
2284
|
this.emitPending(p, error ? "error" : "success", error, durationMs, appKey);
|
|
@@ -2113,11 +2357,26 @@ var Hooks = class {
|
|
|
2113
2357
|
};
|
|
2114
2358
|
}
|
|
2115
2359
|
/** 可选:无法归属到功能点时,若开启 recordUnattributed 且恰有一个激活 skill,记一条通用 exec。 */
|
|
2116
|
-
maybeRecordUnattributed(toolName, command, active, ctx, appKey) {
|
|
2360
|
+
maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId) {
|
|
2117
2361
|
if (this.getConfig().recordUnattributed === false) return;
|
|
2118
2362
|
if (toolName !== "exec" || active.size !== 1) return;
|
|
2119
2363
|
const skillName = [...active][0];
|
|
2120
2364
|
this.debug(`Recording unattributed function_call for skill: ${skillName}`);
|
|
2365
|
+
if (toolCallId) {
|
|
2366
|
+
this.pending.set(toolCallId, {
|
|
2367
|
+
skillName,
|
|
2368
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
2369
|
+
invokeTool: toolName,
|
|
2370
|
+
command,
|
|
2371
|
+
appKey,
|
|
2372
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
2373
|
+
agentId: ctx.agentId,
|
|
2374
|
+
runId: ctx.runId,
|
|
2375
|
+
ts: Date.now()
|
|
2376
|
+
});
|
|
2377
|
+
this.capPending();
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2121
2380
|
this.emit({
|
|
2122
2381
|
event_id: randomUUID2(),
|
|
2123
2382
|
event_type: "function_call",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spzhongwin/skill-logger-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
"./dist/index.js"
|
|
14
14
|
],
|
|
15
15
|
"contracts": {
|
|
16
|
-
"tools": [
|
|
16
|
+
"tools": [
|
|
17
|
+
"report_skill_error"
|
|
18
|
+
]
|
|
17
19
|
},
|
|
18
20
|
"compat": {
|
|
19
21
|
"pluginApi": ">=2026.3.28",
|
|
@@ -32,4 +34,4 @@
|
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"ws": "^8.21.0"
|
|
34
36
|
}
|
|
35
|
-
}
|
|
37
|
+
}
|
package/src/config-sync.ts
CHANGED
|
@@ -226,6 +226,65 @@ export class ConfigSync {
|
|
|
226
226
|
return this.installations;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/** 基于最近一次版本检查结果,找出所有本地版本落后的安装副本。 */
|
|
230
|
+
detectOutdated(): OutdatedCopy[] {
|
|
231
|
+
const out: OutdatedCopy[] = [];
|
|
232
|
+
for (const [skillName, copies] of this.installations) {
|
|
233
|
+
const cfg = this.configs.get(skillName);
|
|
234
|
+
const latestVersion = this.latestVersions.get(skillName) || cfg?.latestVersion || cfg?.version;
|
|
235
|
+
if (!latestVersion) continue;
|
|
236
|
+
for (const copy of copies) {
|
|
237
|
+
if (!isOutdated(copy.version, latestVersion)) continue;
|
|
238
|
+
out.push({
|
|
239
|
+
skillName,
|
|
240
|
+
rootDir: copy.rootDir,
|
|
241
|
+
localVersion: copy.version || "",
|
|
242
|
+
latestVersion,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 扫描本地安装副本,拉取平台最新版本,检测落后副本并按配置触发自动更新。 */
|
|
250
|
+
async checkVersionsAndUpdate(): Promise<void> {
|
|
251
|
+
if (this.checkingVersions) return;
|
|
252
|
+
this.checkingVersions = true;
|
|
253
|
+
try {
|
|
254
|
+
const installed = await this.scanInstalledSkills();
|
|
255
|
+
this.scanCache = { ts: Date.now(), skills: installed };
|
|
256
|
+
|
|
257
|
+
if (installed.length > 0) {
|
|
258
|
+
const { ok, configs } = await this.pullConfigs(
|
|
259
|
+
installed.map((s) => ({ name: s.name, version: s.version }))
|
|
260
|
+
);
|
|
261
|
+
if (ok) {
|
|
262
|
+
for (const cfg of configs) {
|
|
263
|
+
const latestVersion = cfg.latestVersion || cfg.version;
|
|
264
|
+
if (latestVersion) this.latestVersions.set(cfg.skillName, latestVersion);
|
|
265
|
+
if (cfg.version) {
|
|
266
|
+
this.configPool.set(`${cfg.skillName}@${cfg.version}`, cfg);
|
|
267
|
+
if (!this.configs.has(cfg.skillName)) this.configs.set(cfg.skillName, cfg);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const outdated = this.detectOutdated();
|
|
274
|
+
if (outdated.length > 0 && this.updater) {
|
|
275
|
+
await this.updater.applyUpdates(outdated);
|
|
276
|
+
const refreshed = await this.scanInstalledSkills();
|
|
277
|
+
this.scanCache = { ts: Date.now(), skills: refreshed };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await this.persist();
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn("[skill-logger-plugin] checkVersionsAndUpdate 异常", err);
|
|
283
|
+
} finally {
|
|
284
|
+
this.checkingVersions = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
229
288
|
/** lazyCheck 专用:命中短 TTL 缓存则跳过整树遍历;reconcile 不走此路径,始终全新扫描。 */
|
|
230
289
|
private async scanInstalledSkillsCached(): Promise<InstalledSkill[]> {
|
|
231
290
|
const now = Date.now();
|
package/src/hooks.test.ts
CHANGED
|
@@ -61,6 +61,26 @@ describe("Hooks 端到端串联", () => {
|
|
|
61
61
|
assert.ok(activeSkills.getActive("s1").has("model-usage"));
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("skill 工具直调有 toolCallId 时 after 报错 → 补记 function_call(error)", async () => {
|
|
65
|
+
const { hooks, captured } = setup();
|
|
66
|
+
hooks.onBeforeToolCall(
|
|
67
|
+
{ toolName: "skill", params: { skill: "model-usage" }, toolCallId: "skill-t1" },
|
|
68
|
+
{ sessionId: "s1" }
|
|
69
|
+
);
|
|
70
|
+
hooks.onAfterToolCall({ toolCallId: "skill-t1", errorMessage: "skill failed", elapsedMs: 9 });
|
|
71
|
+
await tick();
|
|
72
|
+
|
|
73
|
+
assert.equal(captured.length, 2);
|
|
74
|
+
assert.equal(captured[0].event_type, "skill_trigger");
|
|
75
|
+
const call = captured[1];
|
|
76
|
+
assert.equal(call.event_type, "function_call");
|
|
77
|
+
assert.equal(call.skill_name, "model-usage");
|
|
78
|
+
assert.equal(call.invoke_tool, "skill");
|
|
79
|
+
assert.equal(call.status, "error");
|
|
80
|
+
assert.equal(call.error_message, "skill failed");
|
|
81
|
+
assert.equal(call.duration_ms, 9);
|
|
82
|
+
});
|
|
83
|
+
|
|
64
84
|
it("after 未到达:flushAllPending 补记 unknown", async () => {
|
|
65
85
|
const { hooks, captured } = setup();
|
|
66
86
|
hooks.onBeforeToolCall(
|
|
@@ -125,6 +145,81 @@ describe("Hooks 端到端串联", () => {
|
|
|
125
145
|
assert.equal(captured[0].error_message, "boom");
|
|
126
146
|
});
|
|
127
147
|
|
|
148
|
+
it("after 的结构化错误与失败状态也会归为 error", async () => {
|
|
149
|
+
const { hooks, captured } = setup();
|
|
150
|
+
hooks.onBeforeToolCall(
|
|
151
|
+
{ toolName: "exec", params: { command: "python /e/skills/model-usage/scripts/model_usage.py --mode current" }, toolCallId: "t4" },
|
|
152
|
+
{ sessionId: "s1" }
|
|
153
|
+
);
|
|
154
|
+
await tick();
|
|
155
|
+
hooks.onAfterToolCall({ toolCallId: "t4", status: "failed", message: "process failed", duration_ms: 7 });
|
|
156
|
+
await tick();
|
|
157
|
+
assert.equal(captured[0].status, "error");
|
|
158
|
+
assert.equal(captured[0].error_message, "process failed");
|
|
159
|
+
assert.equal(captured[0].duration_ms, 7);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("after 的嵌套 result 失败信号会提取 stderr", async () => {
|
|
163
|
+
const { hooks, captured } = setup();
|
|
164
|
+
hooks.onBeforeToolCall(
|
|
165
|
+
{ toolName: "exec", params: { command: "python /e/skills/model-usage/scripts/model_usage.py --mode current" }, toolCallId: "t6" },
|
|
166
|
+
{ sessionId: "s1" }
|
|
167
|
+
);
|
|
168
|
+
await tick();
|
|
169
|
+
hooks.onAfterToolCall({ toolCallId: "t6", result: { ok: false, stderr: "stderr failed" } });
|
|
170
|
+
await tick();
|
|
171
|
+
assert.equal(captured[0].status, "error");
|
|
172
|
+
assert.equal(captured[0].error_message, "stderr failed");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("未匹配 exec 但会话中只有一个激活 skill → 等 after 补记状态和错误", async () => {
|
|
176
|
+
const { hooks, captured } = setup();
|
|
177
|
+
hooks.onBeforeToolCall({ toolName: "read", params: { path: "/e/skills/model-usage/SKILL.md" } }, { sessionId: "s1" });
|
|
178
|
+
await tick();
|
|
179
|
+
captured.length = 0;
|
|
180
|
+
|
|
181
|
+
hooks.onBeforeToolCall(
|
|
182
|
+
{ toolName: "exec", params: { command: "custom command --bad" }, toolCallId: "t5" },
|
|
183
|
+
{ sessionId: "s1" }
|
|
184
|
+
);
|
|
185
|
+
await tick();
|
|
186
|
+
assert.equal(captured.length, 0);
|
|
187
|
+
|
|
188
|
+
hooks.onAfterToolCall({ toolCallId: "t5", error: { message: "custom failed" }, durationMs: 11 });
|
|
189
|
+
await tick();
|
|
190
|
+
assert.equal(captured.length, 1);
|
|
191
|
+
assert.equal(captured[0].event_type, "function_call");
|
|
192
|
+
assert.equal(captured[0].skill_name, "model-usage");
|
|
193
|
+
assert.equal(captured[0].invoke_tool, "exec");
|
|
194
|
+
assert.equal(captured[0].command, "custom command --bad");
|
|
195
|
+
assert.equal(captured[0].status, "error");
|
|
196
|
+
assert.equal(captured[0].error_message, "custom failed");
|
|
197
|
+
assert.equal(captured[0].duration_ms, 11);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("report_skill_error 手动上报会记录 error 事件并携带会话 appKey", async () => {
|
|
201
|
+
const { hooks, captured } = setup();
|
|
202
|
+
hooks.onMessageReceived({ content: "appKey: abcdef123456" }, { sessionId: "s1" });
|
|
203
|
+
hooks.onManualErrorRecord(
|
|
204
|
+
{
|
|
205
|
+
skill_name: "model-usage",
|
|
206
|
+
tool_name: "usage_current",
|
|
207
|
+
error_message: "manual failure",
|
|
208
|
+
input_args: "--mode current",
|
|
209
|
+
},
|
|
210
|
+
{ sessionId: "s1", agentId: "main", runId: "r1" }
|
|
211
|
+
);
|
|
212
|
+
await tick();
|
|
213
|
+
assert.equal(captured.length, 1);
|
|
214
|
+
assert.equal(captured[0].event_type, "function_call");
|
|
215
|
+
assert.equal(captured[0].skill_name, "model-usage");
|
|
216
|
+
assert.equal(captured[0].invoke_tool, "report_skill_error");
|
|
217
|
+
assert.equal(captured[0].status, "error");
|
|
218
|
+
assert.equal(captured[0].error_message, "manual failure");
|
|
219
|
+
assert.equal(captured[0].app_key, "abcdef123456");
|
|
220
|
+
assert.deepEqual(captured[0].args, { raw_args: "--mode current" });
|
|
221
|
+
});
|
|
222
|
+
|
|
128
223
|
it("未命中且默认配置 → 不记录", async () => {
|
|
129
224
|
const { hooks, captured } = setup();
|
|
130
225
|
hooks.onBeforeToolCall({ toolName: "exec", params: { command: "ls -la" }, toolCallId: "t3" }, { sessionId: "s1" });
|
package/src/hooks.ts
CHANGED
|
@@ -21,7 +21,12 @@ type HookEvent = Record<string, unknown>;
|
|
|
21
21
|
type HookCtx = Record<string, unknown>;
|
|
22
22
|
|
|
23
23
|
type Pending = {
|
|
24
|
-
|
|
24
|
+
skillName: string;
|
|
25
|
+
skillVersion?: string;
|
|
26
|
+
functionId?: string;
|
|
27
|
+
functionName?: string;
|
|
28
|
+
matchType?: MatchResult["matchType"];
|
|
29
|
+
args?: Record<string, unknown>;
|
|
25
30
|
command?: string;
|
|
26
31
|
invokeTool: string;
|
|
27
32
|
sessionId?: string;
|
|
@@ -57,6 +62,79 @@ function extractAppKey(event: HookEvent, ctx: HookCtx): string | undefined {
|
|
|
57
62
|
}
|
|
58
63
|
}
|
|
59
64
|
|
|
65
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
66
|
+
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stringifyErrorValue(value: unknown): string | undefined {
|
|
70
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
71
|
+
if (typeof value === "string") return value;
|
|
72
|
+
if (value instanceof Error) return value.stack || value.message;
|
|
73
|
+
const obj = asRecord(value);
|
|
74
|
+
if (obj) {
|
|
75
|
+
for (const key of ["message", "error", "errorMessage", "error_message", "stderr"]) {
|
|
76
|
+
const nested = stringifyErrorValue(obj[key]);
|
|
77
|
+
if (nested) return nested;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasFailureSignal(event: HookEvent): boolean {
|
|
89
|
+
const records = [
|
|
90
|
+
event,
|
|
91
|
+
asRecord(event.result),
|
|
92
|
+
asRecord(event.output),
|
|
93
|
+
asRecord(event.response),
|
|
94
|
+
asRecord(event.data),
|
|
95
|
+
].filter(Boolean) as Record<string, unknown>[];
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const status = String(record.status ?? record.state ?? "").toLowerCase();
|
|
98
|
+
if (["error", "failed", "failure"].includes(status)) return true;
|
|
99
|
+
if (record.success === false || record.ok === false || record.isError === true) return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractToolError(event: HookEvent): string | undefined {
|
|
105
|
+
for (const key of ["error", "errorMessage", "error_message"]) {
|
|
106
|
+
const direct = stringifyErrorValue(event[key]);
|
|
107
|
+
if (direct) return direct;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const key of ["result", "output", "response", "data"]) {
|
|
111
|
+
const obj = asRecord(event[key]);
|
|
112
|
+
if (!obj) continue;
|
|
113
|
+
for (const nestedKey of ["error", "errorMessage", "error_message"]) {
|
|
114
|
+
const nested = stringifyErrorValue(obj[nestedKey]);
|
|
115
|
+
if (nested) return nested;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (hasFailureSignal(event)) {
|
|
120
|
+
for (const key of ["message", "stderr", "result", "output", "response", "data"]) {
|
|
121
|
+
const fallback = stringifyErrorValue(event[key]);
|
|
122
|
+
if (fallback) return fallback;
|
|
123
|
+
}
|
|
124
|
+
return "tool call reported failure";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractDurationMs(event: HookEvent): number | undefined {
|
|
131
|
+
for (const key of ["durationMs", "duration_ms", "elapsedMs", "elapsed_ms"]) {
|
|
132
|
+
const value = event[key];
|
|
133
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
60
138
|
export class Hooks {
|
|
61
139
|
private readonly pending = new Map<string, Pending>();
|
|
62
140
|
private readonly sessionAppKeys = new Map<string, string>();
|
|
@@ -110,14 +188,14 @@ export class Hooks {
|
|
|
110
188
|
return {
|
|
111
189
|
event_id: randomUUID(),
|
|
112
190
|
event_type: "function_call",
|
|
113
|
-
skill_name: p.
|
|
114
|
-
skill_version: p.
|
|
115
|
-
function_id: p.
|
|
116
|
-
function_name: p.
|
|
117
|
-
match_type: p.
|
|
191
|
+
skill_name: p.skillName,
|
|
192
|
+
skill_version: p.skillVersion,
|
|
193
|
+
function_id: p.functionId,
|
|
194
|
+
function_name: p.functionName,
|
|
195
|
+
match_type: p.matchType,
|
|
118
196
|
invoke_tool: p.invokeTool,
|
|
119
197
|
command: p.command,
|
|
120
|
-
args: p.
|
|
198
|
+
args: p.args,
|
|
121
199
|
status,
|
|
122
200
|
error_message: error,
|
|
123
201
|
duration_ms: durationMs,
|
|
@@ -169,6 +247,7 @@ export class Hooks {
|
|
|
169
247
|
this.sweepStalePending();
|
|
170
248
|
const toolName = event.toolName as string;
|
|
171
249
|
const params = (event.params as Record<string, unknown>) ?? {};
|
|
250
|
+
const toolCallId = event.toolCallId as string | undefined;
|
|
172
251
|
|
|
173
252
|
const sk = this.sessionKeyOf(ctx);
|
|
174
253
|
let appKey: string | undefined = undefined;
|
|
@@ -191,6 +270,19 @@ export class Hooks {
|
|
|
191
270
|
if (!skillName) return;
|
|
192
271
|
this.debug(`Intercepted explicit 'skill' tool call for: ${skillName}`);
|
|
193
272
|
this.recordTrigger(skillName, "tool", "skill", ctx, appKey);
|
|
273
|
+
if (toolCallId) {
|
|
274
|
+
this.pending.set(toolCallId, {
|
|
275
|
+
skillName,
|
|
276
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
277
|
+
invokeTool: toolName,
|
|
278
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
279
|
+
agentId: ctx.agentId as string,
|
|
280
|
+
runId: ctx.runId as string,
|
|
281
|
+
appKey,
|
|
282
|
+
ts: Date.now(),
|
|
283
|
+
});
|
|
284
|
+
this.capPending();
|
|
285
|
+
}
|
|
194
286
|
return;
|
|
195
287
|
}
|
|
196
288
|
|
|
@@ -214,16 +306,20 @@ export class Hooks {
|
|
|
214
306
|
|
|
215
307
|
if (!res) {
|
|
216
308
|
this.debug(`Tool call '${toolName}' did not match any function config.`);
|
|
217
|
-
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey);
|
|
309
|
+
this.maybeRecordUnattributed(toolName, command, active, ctx, appKey, toolCallId);
|
|
218
310
|
return;
|
|
219
311
|
}
|
|
220
312
|
|
|
221
313
|
this.debug(`Tool call '${toolName}' matched function ID: ${res.functionId} (${res.skillName}@${res.skillVersion})`);
|
|
222
314
|
|
|
223
|
-
const toolCallId = event.toolCallId as string | undefined;
|
|
224
315
|
if (toolCallId) {
|
|
225
316
|
this.pending.set(toolCallId, {
|
|
226
|
-
res,
|
|
317
|
+
skillName: res.skillName,
|
|
318
|
+
skillVersion: res.skillVersion,
|
|
319
|
+
functionId: res.functionId,
|
|
320
|
+
functionName: res.functionName,
|
|
321
|
+
matchType: res.matchType,
|
|
322
|
+
args: res.args,
|
|
227
323
|
command,
|
|
228
324
|
invokeTool: toolName,
|
|
229
325
|
sessionId: this.sessionKeyOf(ctx),
|
|
@@ -250,8 +346,8 @@ export class Hooks {
|
|
|
250
346
|
if (!p) return;
|
|
251
347
|
this.pending.delete(toolCallId);
|
|
252
348
|
|
|
253
|
-
const error = event
|
|
254
|
-
const durationMs =
|
|
349
|
+
const error = extractToolError(event);
|
|
350
|
+
const durationMs = extractDurationMs(event);
|
|
255
351
|
|
|
256
352
|
const appKey = p.appKey || extractAppKey(event, {});
|
|
257
353
|
this.debug(`After tool call [${toolCallId}]: Status ${error ? "error" : "success"}, Duration ${durationMs}ms`);
|
|
@@ -360,12 +456,28 @@ export class Hooks {
|
|
|
360
456
|
command: string | undefined,
|
|
361
457
|
active: ReadonlySet<string>,
|
|
362
458
|
ctx: HookCtx,
|
|
363
|
-
appKey?: string
|
|
459
|
+
appKey?: string,
|
|
460
|
+
toolCallId?: string
|
|
364
461
|
): void {
|
|
365
462
|
if (this.getConfig().recordUnattributed === false) return;
|
|
366
463
|
if (toolName !== "exec" || active.size !== 1) return;
|
|
367
464
|
const skillName = [...active][0];
|
|
368
465
|
this.debug(`Recording unattributed function_call for skill: ${skillName}`);
|
|
466
|
+
if (toolCallId) {
|
|
467
|
+
this.pending.set(toolCallId, {
|
|
468
|
+
skillName,
|
|
469
|
+
skillVersion: this.configSync.getVersion(skillName),
|
|
470
|
+
invokeTool: toolName,
|
|
471
|
+
command,
|
|
472
|
+
appKey,
|
|
473
|
+
sessionId: this.sessionKeyOf(ctx),
|
|
474
|
+
agentId: ctx.agentId as string,
|
|
475
|
+
runId: ctx.runId as string,
|
|
476
|
+
ts: Date.now(),
|
|
477
|
+
});
|
|
478
|
+
this.capPending();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
369
481
|
this.emit({
|
|
370
482
|
event_id: randomUUID(),
|
|
371
483
|
event_type: "function_call",
|
package/src/updater.ts
CHANGED
|
@@ -205,6 +205,13 @@ export class SkillUpdater {
|
|
|
205
205
|
console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包无版本号,放弃覆盖`);
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
|
+
if (dl.sha256) {
|
|
209
|
+
const actualHash = skillIdentityHash(skillName, packageVersion);
|
|
210
|
+
if (actualHash !== dl.sha256) {
|
|
211
|
+
console.warn(`[skill-logger-plugin] ${skillName}@${version} 身份哈希不一致,放弃覆盖`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
208
215
|
|
|
209
216
|
// [兼容处理] 缺少 .meta.json 时主动补齐
|
|
210
217
|
const metaPath = path.join(srcRoot, ".meta.json");
|
|
@@ -235,7 +242,7 @@ export class SkillUpdater {
|
|
|
235
242
|
private async fetchDownloadUrl(
|
|
236
243
|
skillName: string,
|
|
237
244
|
version: string
|
|
238
|
-
): Promise<{ url: string; version?: string } | undefined> {
|
|
245
|
+
): Promise<{ url: string; version?: string; sha256?: string } | undefined> {
|
|
239
246
|
const cfg = this.getConfig();
|
|
240
247
|
const endpoint = cfg.platformBaseUrl!.replace(/\/$/, "") + "/skill_package/pull";
|
|
241
248
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
@@ -252,8 +259,9 @@ export class SkillUpdater {
|
|
|
252
259
|
const data = (await res.json()) as { url?: unknown; downloadUrl?: unknown; version?: unknown; sha256?: unknown };
|
|
253
260
|
const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
|
|
254
261
|
const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
|
|
262
|
+
const sha256 = typeof data?.sha256 === "string" ? data.sha256 : undefined;
|
|
255
263
|
if (!url) return undefined;
|
|
256
|
-
return { url, version: resolvedVersion };
|
|
264
|
+
return { url, version: resolvedVersion, sha256 };
|
|
257
265
|
}
|
|
258
266
|
|
|
259
267
|
/** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
normalizeAssistantUserId,
|
|
5
|
+
parseAssistantWorkspaceAgentId,
|
|
6
|
+
} from "./ws-client.ts";
|
|
7
|
+
|
|
8
|
+
describe("assistant workspace naming", () => {
|
|
9
|
+
it("accepts workspace-assistant-* only when suffix is at least 5 digits", () => {
|
|
10
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-12345"), "assistant-12345");
|
|
11
|
+
assert.equal(
|
|
12
|
+
parseAssistantWorkspaceAgentId("workspace-assistant-12342325232333223223"),
|
|
13
|
+
"assistant-12342325232333223223"
|
|
14
|
+
);
|
|
15
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-000001"), "assistant-000001");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("rejects short, non-numeric, and malformed workspace names", () => {
|
|
19
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234"), undefined);
|
|
20
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234a"), undefined);
|
|
21
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-abcde"), undefined);
|
|
22
|
+
assert.equal(parseAssistantWorkspaceAgentId("workspace-coder-12345"), undefined);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("normalizes command userId using the same numeric suffix rule", () => {
|
|
26
|
+
assert.equal(normalizeAssistantUserId("12345"), "12345");
|
|
27
|
+
assert.equal(normalizeAssistantUserId("assistant-12345"), "12345");
|
|
28
|
+
assert.equal(normalizeAssistantUserId("assistant-000001"), "000001");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects unsafe or non-conforming command userId values", () => {
|
|
32
|
+
assert.equal(normalizeAssistantUserId("1234"), undefined);
|
|
33
|
+
assert.equal(normalizeAssistantUserId("assistant-1234"), undefined);
|
|
34
|
+
assert.equal(normalizeAssistantUserId("assistant-1234a"), undefined);
|
|
35
|
+
assert.equal(normalizeAssistantUserId("../assistant-12345"), undefined);
|
|
36
|
+
});
|
|
37
|
+
});
|
package/src/ws-client.ts
CHANGED
|
@@ -8,6 +8,26 @@ import { readSkillVersion } from "./skill-version.ts";
|
|
|
8
8
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
9
9
|
const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
|
|
10
10
|
const AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1000;
|
|
11
|
+
const ASSISTANT_WORKSPACE_PREFIX = "workspace-assistant-";
|
|
12
|
+
const ASSISTANT_AGENT_PREFIX = "assistant-";
|
|
13
|
+
const ASSISTANT_WORKSPACE_ID_RE = /^\d{5,}$/;
|
|
14
|
+
|
|
15
|
+
export function parseAssistantWorkspaceAgentId(entryName: string): string | undefined {
|
|
16
|
+
if (!entryName.startsWith(ASSISTANT_WORKSPACE_PREFIX)) return undefined;
|
|
17
|
+
const suffix = entryName.slice(ASSISTANT_WORKSPACE_PREFIX.length);
|
|
18
|
+
if (!ASSISTANT_WORKSPACE_ID_RE.test(suffix)) return undefined;
|
|
19
|
+
return `${ASSISTANT_AGENT_PREFIX}${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeAssistantUserId(userId: string): string | undefined {
|
|
23
|
+
const safeUserId = path.basename(userId);
|
|
24
|
+
if (safeUserId !== userId) return undefined;
|
|
25
|
+
const pureId = safeUserId.startsWith(ASSISTANT_AGENT_PREFIX)
|
|
26
|
+
? safeUserId.slice(ASSISTANT_AGENT_PREFIX.length)
|
|
27
|
+
: safeUserId;
|
|
28
|
+
if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return undefined;
|
|
29
|
+
return pureId;
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
export interface WsClientOptions {
|
|
13
33
|
serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
|
|
@@ -163,7 +183,8 @@ export class GatewayWsClient {
|
|
|
163
183
|
}
|
|
164
184
|
|
|
165
185
|
/**
|
|
166
|
-
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId}
|
|
186
|
+
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录。
|
|
187
|
+
* userId 必须是至少 5 位数字。
|
|
167
188
|
*/
|
|
168
189
|
private async scanAndReportAgents(isInitialReport: boolean) {
|
|
169
190
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
@@ -182,12 +203,8 @@ export class GatewayWsClient {
|
|
|
182
203
|
|
|
183
204
|
const newAgentIds = new Set<string>();
|
|
184
205
|
for (const entry of entries) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (suffix && suffix === path.basename(suffix) && !suffix.startsWith(".")) {
|
|
188
|
-
newAgentIds.add(`assistant-${suffix}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
206
|
+
const agentId = parseAssistantWorkspaceAgentId(entry);
|
|
207
|
+
if (agentId) newAgentIds.add(agentId);
|
|
191
208
|
}
|
|
192
209
|
|
|
193
210
|
let changed = false;
|
|
@@ -354,12 +371,15 @@ export class GatewayWsClient {
|
|
|
354
371
|
return;
|
|
355
372
|
}
|
|
356
373
|
|
|
357
|
-
//
|
|
358
|
-
const safeUserId = path.basename(userId);
|
|
374
|
+
// 闭环完善:清理 code,并严格校验 userId,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
|
|
359
375
|
const safeCode = code ? path.basename(code) : undefined;
|
|
360
376
|
|
|
361
|
-
// 100%
|
|
362
|
-
const pureId =
|
|
377
|
+
// 100% 确定性安全寻址:userId 只接受纯数字或 assistant-数字,且数字至少 5 位。
|
|
378
|
+
const pureId = normalizeAssistantUserId(userId);
|
|
379
|
+
if (!pureId) {
|
|
380
|
+
this.reply(replyId, { success: false, message: `Invalid userId: ${userId}`, action });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
363
383
|
const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
|
|
364
384
|
|
|
365
385
|
try {
|
|
@@ -485,6 +505,57 @@ export class GatewayWsClient {
|
|
|
485
505
|
}
|
|
486
506
|
}, delayMs);
|
|
487
507
|
|
|
508
|
+
} else if (action === "GET_EXPERT_REGISTRY") {
|
|
509
|
+
console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
|
|
510
|
+
this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
|
|
511
|
+
|
|
512
|
+
const registryFilePath = path.join(openclawHome(), `workspace-assistant-${pureId}`, ".openclaw", "userskill", "expert-registry.yaml");
|
|
513
|
+
let content = "";
|
|
514
|
+
let fileExists = false;
|
|
515
|
+
try {
|
|
516
|
+
const stat = await fs.stat(registryFilePath);
|
|
517
|
+
fileExists = stat.isFile();
|
|
518
|
+
} catch (err: any) {
|
|
519
|
+
if (err?.code !== "ENOENT") throw err;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (fileExists) {
|
|
523
|
+
content = await fs.readFile(registryFilePath, "utf8");
|
|
524
|
+
|
|
525
|
+
// 解析 YAML 提取需要的字段
|
|
526
|
+
const expertsList: any[] = [];
|
|
527
|
+
const lines = content.split('\n');
|
|
528
|
+
let currentExpert: any = null;
|
|
529
|
+
|
|
530
|
+
for (const line of lines) {
|
|
531
|
+
const trimmed = line.trim();
|
|
532
|
+
if (trimmed.startsWith('#')) continue;
|
|
533
|
+
|
|
534
|
+
const idMatch = line.match(/^\s*-\s*id:\s*(.+)$/);
|
|
535
|
+
if (idMatch) {
|
|
536
|
+
if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
|
|
537
|
+
currentExpert = { id: idMatch[1].trim() };
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (currentExpert) {
|
|
542
|
+
const nameMatch = line.match(/^\s*name:\s*(.+)$/);
|
|
543
|
+
if (nameMatch) {
|
|
544
|
+
currentExpert.name = nameMatch[1].trim();
|
|
545
|
+
}
|
|
546
|
+
const descMatch = line.match(/^\s*description:\s*(.+)$/);
|
|
547
|
+
if (descMatch) {
|
|
548
|
+
currentExpert.description = descMatch[1].trim();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
|
|
553
|
+
|
|
554
|
+
this.reply(replyId, { success: true, data: expertsList, action });
|
|
555
|
+
} else {
|
|
556
|
+
this.reply(replyId, { success: false, message: `未找到用户技能配置文件,可能尚未注册或文件已丢失`, action });
|
|
557
|
+
}
|
|
558
|
+
|
|
488
559
|
} else {
|
|
489
560
|
console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
|
|
490
561
|
this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
|