@openclaw/plugin-inspector 0.0.0 → 0.1.1

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.
@@ -0,0 +1,289 @@
1
+ import path from "node:path";
2
+ import { renderPaddedMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
3
+ import { resolveFromRoot } from "./path-utils.js";
4
+ import { runProfiledProcess } from "./process-profile.js";
5
+ import { assertRunCount, percentile } from "./stats.js";
6
+
7
+ export const defaultRuntimeProfileOptions = {
8
+ generatedAt: "deterministic",
9
+ jsonPath: "reports/plugin-runtime-profile.json",
10
+ markdownPath: "reports/plugin-runtime-profile.md",
11
+ reportTitle: "Plugin Runtime Profile",
12
+ runs: 1,
13
+ };
14
+
15
+ export const defaultRuntimeProfileCommands = [
16
+ {
17
+ id: "node-boot",
18
+ label: "Node boot",
19
+ category: "baseline",
20
+ args: ["-e", "0"],
21
+ openclaw: false,
22
+ },
23
+ ];
24
+
25
+ export async function buildRuntimeProfile(options = {}) {
26
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
27
+ const generatedAt = options.generatedAt ?? defaultRuntimeProfileOptions.generatedAt;
28
+ const runs = options.runs ?? defaultRuntimeProfileOptions.runs;
29
+ const commands = [];
30
+ assertRunCount(runs, 10);
31
+
32
+ for (const command of options.commands ?? defaultRuntimeProfileCommands) {
33
+ const samples = [];
34
+ for (let index = 0; index < runs; index += 1) {
35
+ samples.push(await profileCommand(command, { ...options, rootDir }));
36
+ }
37
+ commands.push(summarizeCommand(command, samples));
38
+ }
39
+
40
+ return {
41
+ generatedAt,
42
+ runs,
43
+ targetOpenClaw: options.targetOpenClaw ?? summarizeTargetOpenClaw(options.report?.targetOpenClaw),
44
+ fixtureInventory: options.fixtureInventory ?? summarizeFixtureInventory(options.report, options.inspection),
45
+ platform: {
46
+ os: process.platform,
47
+ arch: process.arch,
48
+ node: process.version,
49
+ rssSampler: process.platform === "win32" ? "unavailable" : "ps",
50
+ cpuSampler: process.platform === "win32" ? "unavailable" : "ps-percent",
51
+ },
52
+ summary: summarizeProfile(commands),
53
+ groups: summarizeCommandGroups(commands),
54
+ commands,
55
+ };
56
+ }
57
+
58
+ export function validateRuntimeProfile(profile) {
59
+ const errors = [];
60
+ for (const command of profile.commands) {
61
+ if (command.exitCodes.some((code) => code !== 0)) {
62
+ errors.push(`${command.id}: nonzero exit code(s): ${command.exitCodes.join(", ")}`);
63
+ }
64
+ if (command.wallMs.max <= 0) {
65
+ errors.push(`${command.id}: missing wall time`);
66
+ }
67
+ }
68
+ if (profile.platform?.rssSampler !== "unavailable" && profile.commands.every((command) => command.peakRssMb.max <= 0)) {
69
+ errors.push("all commands are missing peak RSS");
70
+ }
71
+ return errors;
72
+ }
73
+
74
+ export async function writeRuntimeProfile(profile, options = {}) {
75
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
76
+ const jsonPath = resolveFromRoot(rootDir, options.jsonPath ?? defaultRuntimeProfileOptions.jsonPath);
77
+ const markdownPath = resolveFromRoot(rootDir, options.markdownPath ?? defaultRuntimeProfileOptions.markdownPath);
78
+ return writeJsonMarkdownArtifacts({
79
+ jsonPath,
80
+ markdownPath,
81
+ json: profile,
82
+ markdown: renderRuntimeProfileMarkdown(profile, options),
83
+ check: options.check,
84
+ });
85
+ }
86
+
87
+ export function renderRuntimeProfileMarkdown(profile, options = {}) {
88
+ const title = options.title ?? options.reportTitle ?? defaultRuntimeProfileOptions.reportTitle;
89
+ return [
90
+ `# ${title}`,
91
+ "",
92
+ `Generated: ${profile.generatedAt}`,
93
+ `Samples per command: ${profile.runs}`,
94
+ "",
95
+ "## Summary",
96
+ "",
97
+ markdownTable(
98
+ [
99
+ ["Commands", profile.summary.commandCount],
100
+ ["P50 wall time", `${profile.summary.p50WallMs} ms`],
101
+ ["P95 wall time", `${profile.summary.p95WallMs} ms`],
102
+ ["Max peak RSS", `${profile.summary.maxPeakRssMb} MB`],
103
+ ["Max RSS delta", `${profile.summary.maxRssDeltaMb} MB`],
104
+ ["Max CPU estimate", `${profile.summary.maxCpuMsEstimate} ms`],
105
+ ["Max harness heap delta", `${profile.summary.maxHarnessHeapDeltaMb} MB`],
106
+ ],
107
+ ["Metric", "Value"],
108
+ ),
109
+ "",
110
+ "## Target OpenClaw Registry Surface",
111
+ "",
112
+ markdownTable(
113
+ Object.entries(profile.targetOpenClaw).map(([key, value]) => [key, value ?? "-"]),
114
+ ["Metric", "Value"],
115
+ ),
116
+ "",
117
+ "## Plugin Fixture Surface",
118
+ "",
119
+ markdownTable(
120
+ Object.entries(profile.fixtureInventory).map(([key, value]) => [key, value]),
121
+ ["Metric", "Value"],
122
+ ),
123
+ "",
124
+ "## Boot And Memory Samples",
125
+ "",
126
+ markdownTable(
127
+ profile.commands.map((command) => [
128
+ command.id,
129
+ command.label,
130
+ `${command.wallMs.median} ms`,
131
+ `${command.wallMs.max} ms`,
132
+ `${command.peakRssMb.max} MB`,
133
+ `${command.rssDeltaMb.max} MB`,
134
+ `${command.cpuMsEstimate.max} ms`,
135
+ `${command.harnessHeapDeltaMb.max} MB`,
136
+ command.exitCodes.join(", "),
137
+ ]),
138
+ ["ID", "Label", "Median wall", "Max wall", "Max peak RSS", "Max RSS delta", "CPU estimate", "Heap delta", "Exit codes"],
139
+ ),
140
+ "",
141
+ "## Category Rollups",
142
+ "",
143
+ markdownTable(
144
+ (profile.groups ?? []).map((group) => [
145
+ group.category,
146
+ group.commandCount,
147
+ `${group.p50WallMs} ms`,
148
+ `${group.p95WallMs} ms`,
149
+ `${group.maxPeakRssMb} MB`,
150
+ `${group.maxCpuMsEstimate} ms`,
151
+ group.commands.join(", "),
152
+ ]),
153
+ ["Category", "Commands", "P50 wall", "P95 wall", "Max peak RSS", "CPU estimate", "Command IDs"],
154
+ ),
155
+ ].join("\n");
156
+ }
157
+
158
+ function summarizeTargetOpenClaw(targetOpenClaw = {}) {
159
+ return {
160
+ status: targetOpenClaw.status ?? "unknown",
161
+ configuredPath: targetOpenClaw.configuredPath ?? null,
162
+ compatRecords: targetOpenClaw.compatRecordCount ?? 0,
163
+ hookNames: targetOpenClaw.hookNameCount ?? 0,
164
+ apiRegistrars: targetOpenClaw.apiRegistrarCount ?? 0,
165
+ capturedRegistrars: targetOpenClaw.capturedRegistrarCount ?? 0,
166
+ sdkExports: targetOpenClaw.sdkExportCount ?? 0,
167
+ manifestFields: targetOpenClaw.manifestFieldCount ?? 0,
168
+ manifestContractFields: targetOpenClaw.manifestContractFieldCount ?? 0,
169
+ };
170
+ }
171
+
172
+ function summarizeFixtureInventory(report = {}, inspection = {}) {
173
+ const fixtures = report.fixtures ?? [];
174
+ const inspections = inspection.inspections ?? [];
175
+ return {
176
+ fixtures: fixtures.length,
177
+ sourceFiles: inspections.reduce((sum, item) => sum + item.sourceFiles.length, 0),
178
+ observedHooks: fixtures.reduce((sum, item) => sum + item.hooks.length, 0),
179
+ observedRegistrations: fixtures.reduce((sum, item) => sum + item.registrations.length, 0),
180
+ observedSdkImports: fixtures.reduce((sum, item) => sum + item.sdkImports.length, 0),
181
+ contractProbes: report.summary?.contractProbeCount ?? 0,
182
+ issueFindings: report.summary?.issueCount ?? 0,
183
+ };
184
+ }
185
+
186
+ function summarizeProfile(commands) {
187
+ const wallTimes = commands.map((command) => command.wallMs.median).sort((left, right) => left - right);
188
+ const maxPeakRssMb = Math.max(0, ...commands.map((command) => command.peakRssMb.max));
189
+ const maxRssDeltaMb = Math.max(0, ...commands.map((command) => command.rssDeltaMb.max));
190
+ const maxCpuMsEstimate = Math.max(0, ...commands.map((command) => command.cpuMsEstimate.max));
191
+ const maxHarnessHeapDeltaMb = Math.max(0, ...commands.map((command) => command.harnessHeapDeltaMb.max));
192
+ return {
193
+ commandCount: commands.length,
194
+ p50WallMs: percentile(wallTimes, 0.5),
195
+ p95WallMs: percentile(wallTimes, 0.95),
196
+ maxPeakRssMb,
197
+ maxRssDeltaMb,
198
+ maxCpuMsEstimate,
199
+ maxHarnessHeapDeltaMb,
200
+ };
201
+ }
202
+
203
+ function summarizeCommand(command, samples) {
204
+ const wallMs = samples.map((sample) => sample.wallMs).sort((left, right) => left - right);
205
+ const peakRssMb = samples.map((sample) => sample.peakRssMb).sort((left, right) => left - right);
206
+ const rssDeltaMb = samples.map((sample) => sample.rssDeltaMb).sort((left, right) => left - right);
207
+ const peakCpuPercent = samples.map((sample) => sample.peakCpuPercent).sort((left, right) => left - right);
208
+ const cpuMsEstimate = samples.map((sample) => sample.cpuMsEstimate).sort((left, right) => left - right);
209
+ const harnessHeapDeltaMb = samples
210
+ .map((sample) => sample.harnessHeapDeltaMb)
211
+ .sort((left, right) => left - right);
212
+ const commandName = command.command ?? process.execPath;
213
+ return {
214
+ id: command.id,
215
+ label: command.label,
216
+ category: command.category,
217
+ command: [commandName, ...(command.args ?? [])].join(" "),
218
+ samples,
219
+ wallMs: summarizeNumbers(wallMs),
220
+ peakRssMb: summarizeNumbers(peakRssMb),
221
+ rssDeltaMb: summarizeNumbers(rssDeltaMb),
222
+ peakCpuPercent: summarizeNumbers(peakCpuPercent),
223
+ cpuMsEstimate: summarizeNumbers(cpuMsEstimate),
224
+ harnessHeapDeltaMb: summarizeNumbers(harnessHeapDeltaMb),
225
+ exitCodes: [...new Set(samples.map((sample) => sample.exitCode))].sort(),
226
+ };
227
+ }
228
+
229
+ function summarizeCommandGroups(commands) {
230
+ const groups = new Map();
231
+ for (const command of commands) {
232
+ const category = command.category ?? "uncategorized";
233
+ const existing = groups.get(category) ?? [];
234
+ existing.push(command);
235
+ groups.set(category, existing);
236
+ }
237
+ return [...groups.entries()].map(([category, categoryCommands]) => {
238
+ const wallTimes = categoryCommands
239
+ .flatMap((command) => command.samples.map((sample) => sample.wallMs))
240
+ .sort((left, right) => left - right);
241
+ const peakRss = categoryCommands
242
+ .flatMap((command) => command.samples.map((sample) => sample.peakRssMb))
243
+ .sort((left, right) => left - right);
244
+ const cpuMs = categoryCommands
245
+ .flatMap((command) => command.samples.map((sample) => sample.cpuMsEstimate))
246
+ .sort((left, right) => left - right);
247
+ return {
248
+ category,
249
+ commandCount: categoryCommands.length,
250
+ p50WallMs: percentile(wallTimes, 0.5),
251
+ p95WallMs: percentile(wallTimes, 0.95),
252
+ maxPeakRssMb: peakRss.at(-1) ?? 0,
253
+ maxCpuMsEstimate: cpuMs.at(-1) ?? 0,
254
+ commands: categoryCommands.map((command) => command.id),
255
+ };
256
+ });
257
+ }
258
+
259
+ function summarizeNumbers(values) {
260
+ return {
261
+ min: values[0],
262
+ median: percentile(values, 0.5),
263
+ max: values.at(-1),
264
+ };
265
+ }
266
+
267
+ async function profileCommand(command, options) {
268
+ const args = [...(command.args ?? [])];
269
+ if (command.openclaw) {
270
+ if (options.openclawPath === false) {
271
+ args.push("--no-openclaw");
272
+ } else if (options.openclawPath) {
273
+ args.push("--openclaw", options.openclawPath);
274
+ }
275
+ }
276
+
277
+ return runProfiledProcess({
278
+ command: command.command ?? process.execPath,
279
+ args,
280
+ cwd: command.cwd ?? options.rootDir,
281
+ env: { ...process.env, ...options.env, ...command.env },
282
+ stdio: ["ignore", "pipe", "pipe"],
283
+ roundAverageCpuPercent: true,
284
+ });
285
+ }
286
+
287
+ function markdownTable(rows, headers) {
288
+ return renderPaddedMarkdownTable(rows, headers);
289
+ }
@@ -0,0 +1,61 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function createMockSdkPackage(rootDir) {
5
+ const packageDir = path.join(rootDir, "node_modules", "openclaw");
6
+ const pluginSdkDir = path.join(packageDir, "plugin-sdk");
7
+ await mkdir(pluginSdkDir, { recursive: true });
8
+ await writeFile(
9
+ path.join(packageDir, "package.json"),
10
+ `${JSON.stringify(
11
+ {
12
+ name: "openclaw",
13
+ version: "0.0.0-plugin-inspector-mock",
14
+ type: "module",
15
+ exports: {
16
+ "./plugin-sdk": "./plugin-sdk/index.js",
17
+ "./plugin-sdk/*": "./plugin-sdk/index.js",
18
+ },
19
+ },
20
+ null,
21
+ 2,
22
+ )}\n`,
23
+ "utf8",
24
+ );
25
+ await writeFile(path.join(pluginSdkDir, "index.js"), mockSdkSource(), "utf8");
26
+ return packageDir;
27
+ }
28
+
29
+ function mockSdkSource() {
30
+ return `export function definePluginEntry(entry) {
31
+ return typeof entry === "function" ? { register: entry } : entry;
32
+ }
33
+
34
+ export function defineChannelPluginEntry(entry) {
35
+ return typeof entry === "function" ? { register: entry } : entry;
36
+ }
37
+
38
+ export function createChatChannelPlugin(entry) {
39
+ return typeof entry === "function" ? { register: entry } : entry;
40
+ }
41
+
42
+ export function definePlugin(entry) {
43
+ return definePluginEntry(entry);
44
+ }
45
+
46
+ export function createPlugin(entry) {
47
+ return definePluginEntry(entry);
48
+ }
49
+
50
+ export const pluginSdkMock = true;
51
+
52
+ export default {
53
+ createChatChannelPlugin,
54
+ createPlugin,
55
+ defineChannelPluginEntry,
56
+ definePlugin,
57
+ definePluginEntry,
58
+ pluginSdkMock,
59
+ };
60
+ `;
61
+ }
package/src/stats.js ADDED
@@ -0,0 +1,13 @@
1
+ export function percentile(sortedValues, percentileValue) {
2
+ if (sortedValues.length === 0) {
3
+ return 0;
4
+ }
5
+ const index = Math.min(sortedValues.length - 1, Math.ceil(sortedValues.length * percentileValue) - 1);
6
+ return sortedValues[index];
7
+ }
8
+
9
+ export function assertRunCount(runs, max) {
10
+ if (!Number.isInteger(runs) || runs < 1 || runs > max) {
11
+ throw new Error(`runs must be an integer between 1 and ${max}`);
12
+ }
13
+ }