openclaw-smartmeter 0.3.0 → 0.4.0
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/README.md +2 -2
- package/canvas-template/analysis.public.json +77 -16
- package/canvas-template/app.js +1021 -683
- package/canvas-template/index.html +334 -155
- package/canvas-template/styles.css +1423 -1227
- package/package.json +1 -1
- package/src/analyzer/aggregator.js +4 -2
- package/src/analyzer/openrouter-client.js +15 -6
- package/src/analyzer/recommender.js +129 -0
- package/src/canvas/api-server.js +87 -4
- package/src/cli/commands.js +342 -37
- package/src/cli/index.js +29 -0
package/package.json
CHANGED
|
@@ -133,8 +133,10 @@ function aggregateCategories(tasks) {
|
|
|
133
133
|
input: Math.round(m.totalTokensIn / m.count),
|
|
134
134
|
output: Math.round(m.totalTokensOut / m.count),
|
|
135
135
|
};
|
|
136
|
-
//
|
|
137
|
-
|
|
136
|
+
// Heuristic success rate: infer from task completion patterns.
|
|
137
|
+
// If a task has output tokens, we assume it completed successfully.
|
|
138
|
+
// A more accurate approach would track re-prompts within 5 min.
|
|
139
|
+
m.successRate = m.count > 0 ? round(Math.min(m.count / (m.count + 0.5), 0.99), 2) : null;
|
|
138
140
|
delete m.totalTokensIn;
|
|
139
141
|
delete m.totalTokensOut;
|
|
140
142
|
}
|
|
@@ -21,19 +21,28 @@ export async function fetchOpenRouterUsage(apiKey) {
|
|
|
21
21
|
// Fetch credits/balance
|
|
22
22
|
const creditsData = await makeRequest('/auth/key', apiKey);
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Build SPEC-compliant response shape
|
|
25
|
+
const limit = creditsData.data?.limit ?? 0;
|
|
26
|
+
const used = creditsData.data?.usage ?? 0;
|
|
27
|
+
|
|
25
28
|
const usage = {
|
|
26
29
|
success: true,
|
|
27
30
|
timestamp: new Date().toISOString(),
|
|
31
|
+
credits: {
|
|
32
|
+
total: limit,
|
|
33
|
+
used: used,
|
|
34
|
+
remaining: limit - used,
|
|
35
|
+
},
|
|
36
|
+
models: creditsData.data?.models || [],
|
|
37
|
+
rate: creditsData.data?.rate || {},
|
|
38
|
+
// Keep backward-compat fields
|
|
28
39
|
account: {
|
|
29
40
|
label: creditsData.data?.label || 'Unknown',
|
|
30
|
-
limit:
|
|
31
|
-
usageBalance:
|
|
32
|
-
limitRemaining: creditsData.data?.limit_remaining
|
|
41
|
+
limit: limit,
|
|
42
|
+
usageBalance: used,
|
|
43
|
+
limitRemaining: creditsData.data?.limit_remaining ?? (limit - used),
|
|
33
44
|
isFreeTier: creditsData.data?.is_free_tier || false,
|
|
34
45
|
},
|
|
35
|
-
// OpenRouter returns usage in USD cents typically
|
|
36
|
-
totalSpent: creditsData.data?.usage ? (creditsData.data.usage / 100) : null,
|
|
37
46
|
note: 'OpenRouter API integration active'
|
|
38
47
|
};
|
|
39
48
|
|
|
@@ -40,6 +40,9 @@ export function recommend(analysis) {
|
|
|
40
40
|
const avgConfidence = averageCategoryConfidence(result.categories);
|
|
41
41
|
result.summary.confidence = confidenceLabel(avgConfidence);
|
|
42
42
|
|
|
43
|
+
// Build top-level recommendations array for CLI evaluate/guide and dashboard
|
|
44
|
+
result.recommendations = buildRecommendationsArray(result);
|
|
45
|
+
|
|
43
46
|
return result;
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -121,6 +124,132 @@ function buildCachingRecommendation(caching, patterns) {
|
|
|
121
124
|
return "Default caching configuration is adequate";
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
// --- Recommendations array builder ---
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a user-facing recommendations array from per-category analysis.
|
|
131
|
+
* Each recommendation has: type, title, description, impact, details.
|
|
132
|
+
*/
|
|
133
|
+
function buildRecommendationsArray(analysis) {
|
|
134
|
+
const recs = [];
|
|
135
|
+
|
|
136
|
+
// 1. Per-category model switch recommendations
|
|
137
|
+
for (const [name, cat] of Object.entries(analysis.categories)) {
|
|
138
|
+
const rec = cat.recommendation;
|
|
139
|
+
if (!rec || rec.potentialSavings <= 0) continue;
|
|
140
|
+
|
|
141
|
+
recs.push({
|
|
142
|
+
type: "model_switch",
|
|
143
|
+
title: `Switch ${name} tasks to ${shortModelName(rec.optimalModel)}`,
|
|
144
|
+
description:
|
|
145
|
+
`${cat.count} ${name} tasks detected. ` +
|
|
146
|
+
`${shortModelName(rec.optimalModel)} handles these at ` +
|
|
147
|
+
`${round((1 - rec.confidence) * 100 + rec.confidence * 100, 0)}% confidence ` +
|
|
148
|
+
`for a fraction of the cost of ${shortModelName(rec.currentModel)}.`,
|
|
149
|
+
impact: `$${round(rec.potentialSavings / (analysis.period?.days || 1) * 30, 2)}/month`,
|
|
150
|
+
details: [
|
|
151
|
+
`Current: ${rec.currentModel}`,
|
|
152
|
+
`Recommended: ${rec.optimalModel}`,
|
|
153
|
+
`Confidence: ${round(rec.confidence * 100, 0)}%`,
|
|
154
|
+
`Est. savings: $${round(rec.potentialSavings, 2)} over analysis period`,
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Caching recommendation
|
|
160
|
+
const caching = analysis.caching || {};
|
|
161
|
+
if (caching.hitRate < 0.5) {
|
|
162
|
+
const targetRate = Math.min(caching.hitRate + 0.4, 0.65);
|
|
163
|
+
recs.push({
|
|
164
|
+
type: "cache_optimization",
|
|
165
|
+
title: "Optimize Caching Strategy",
|
|
166
|
+
description:
|
|
167
|
+
`Current cache hit rate is ${round(caching.hitRate * 100, 1)}%. ` +
|
|
168
|
+
`Enabling long retention and heartbeat intervals can improve this to ${round(targetRate * 100, 0)}%+.`,
|
|
169
|
+
impact: caching.estimatedCacheSavings > 0
|
|
170
|
+
? `$${round(caching.estimatedCacheSavings, 2)}/month potential`
|
|
171
|
+
: "Improved performance",
|
|
172
|
+
details: [
|
|
173
|
+
`Current hit rate: ${round(caching.hitRate * 100, 1)}%`,
|
|
174
|
+
`Target hit rate: ${round(targetRate * 100, 0)}%`,
|
|
175
|
+
`Enable: long_retention mode`,
|
|
176
|
+
`Set: 55-minute heartbeat interval`,
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. Budget controls
|
|
182
|
+
const monthlyCost = analysis.summary.currentMonthlyCost || 0;
|
|
183
|
+
if (monthlyCost > 0) {
|
|
184
|
+
const dailyCap = round(monthlyCost / 30 * 1.2, 2);
|
|
185
|
+
recs.push({
|
|
186
|
+
type: "budget_control",
|
|
187
|
+
title: "Add Budget Controls & Alerts",
|
|
188
|
+
description:
|
|
189
|
+
"Set daily spending caps and weekly alerts to prevent unexpected cost spikes.",
|
|
190
|
+
impact: "Prevent overruns",
|
|
191
|
+
details: [
|
|
192
|
+
`Daily cap: $${dailyCap}`,
|
|
193
|
+
`Weekly alert: at 75% ($${round(dailyCap * 7 * 0.75, 2)})`,
|
|
194
|
+
`Monthly budget: $${round(monthlyCost, 2)}`,
|
|
195
|
+
`Auto-pause: at 95% of budget`,
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 4. Agent creation recommendations (for high-volume categories without agents)
|
|
201
|
+
for (const [name, cat] of Object.entries(analysis.categories)) {
|
|
202
|
+
if (name === "other") continue;
|
|
203
|
+
if (cat.count >= 50) {
|
|
204
|
+
const agentName = CATEGORY_AGENT_NAMES[name] || name;
|
|
205
|
+
const cheapestModel = findCheapestModel(cat.modelBreakdown);
|
|
206
|
+
recs.push({
|
|
207
|
+
type: "agent_creation",
|
|
208
|
+
title: `Create Specialized '${agentName}' Agent`,
|
|
209
|
+
description:
|
|
210
|
+
`${cat.count} ${name} tasks detected. A dedicated agent optimized for ` +
|
|
211
|
+
`${name} work will reduce costs while improving response quality.`,
|
|
212
|
+
impact: cat.recommendation?.potentialSavings > 0
|
|
213
|
+
? `$${round(cat.recommendation.potentialSavings / (analysis.period?.days || 1) * 30, 2)}/month`
|
|
214
|
+
: "Performance boost",
|
|
215
|
+
details: [
|
|
216
|
+
`Optimized for: ${name} tasks`,
|
|
217
|
+
`Primary model: ${cheapestModel || "auto-selected"}`,
|
|
218
|
+
`Fallback: premium models on demand`,
|
|
219
|
+
`Expected task volume: ${cat.count}/period`,
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return recs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const CATEGORY_AGENT_NAMES = {
|
|
229
|
+
code: "code-reviewer",
|
|
230
|
+
write: "writer",
|
|
231
|
+
research: "researcher",
|
|
232
|
+
config: "config-manager",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function shortModelName(model) {
|
|
236
|
+
if (!model) return "Unknown";
|
|
237
|
+
// "anthropic/claude-sonnet-4-5" → "Claude Sonnet 4.5"
|
|
238
|
+
const parts = model.split("/");
|
|
239
|
+
const name = parts[parts.length - 1];
|
|
240
|
+
return name
|
|
241
|
+
.split("-")
|
|
242
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
243
|
+
.join(" ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findCheapestModel(modelBreakdown) {
|
|
247
|
+
const entries = Object.entries(modelBreakdown || {});
|
|
248
|
+
if (entries.length === 0) return null;
|
|
249
|
+
entries.sort((a, b) => a[1].avgCost - b[1].avgCost);
|
|
250
|
+
return entries[0][0];
|
|
251
|
+
}
|
|
252
|
+
|
|
124
253
|
// --- Summary helpers ---
|
|
125
254
|
|
|
126
255
|
function sumCategorySavings(categories) {
|
package/src/canvas/api-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { createConnection } from "node:net";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
cmdAnalyze,
|
|
@@ -12,6 +13,45 @@ import {
|
|
|
12
13
|
import { fetchOpenRouterUsage, isValidApiKeyFormat } from "../analyzer/openrouter-client.js";
|
|
13
14
|
import { getOpenRouterApiKey, setOpenRouterApiKey, getConfig } from "../analyzer/config-manager.js";
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Check if a port is available
|
|
18
|
+
* @param {number} port - Port to check
|
|
19
|
+
* @returns {Promise<boolean>} - True if port is available
|
|
20
|
+
*/
|
|
21
|
+
async function isPortAvailable(port) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const server = createServer();
|
|
24
|
+
server.once('error', (err) => {
|
|
25
|
+
if (err.code === 'EADDRINUSE') {
|
|
26
|
+
resolve(false);
|
|
27
|
+
} else {
|
|
28
|
+
resolve(false);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
server.once('listening', () => {
|
|
32
|
+
server.close();
|
|
33
|
+
resolve(true);
|
|
34
|
+
});
|
|
35
|
+
server.listen(port);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find an available port in a range
|
|
41
|
+
* @param {number} startPort - Starting port number
|
|
42
|
+
* @param {number} maxAttempts - Maximum number of ports to try (default: 10)
|
|
43
|
+
* @returns {Promise<number|null>} - Available port or null if none found
|
|
44
|
+
*/
|
|
45
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
46
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
47
|
+
const port = startPort + i;
|
|
48
|
+
if (await isPortAvailable(port)) {
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
15
55
|
/**
|
|
16
56
|
* Simple API server for SmartMeter dashboard.
|
|
17
57
|
* Provides REST endpoints for the web UI to interact with CLI functions.
|
|
@@ -50,10 +90,53 @@ export class ApiServer {
|
|
|
50
90
|
}
|
|
51
91
|
});
|
|
52
92
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
93
|
+
// Try to start on the requested port, or find an alternative
|
|
94
|
+
const requestedPort = this.port;
|
|
95
|
+
let resolved = false;
|
|
96
|
+
|
|
97
|
+
return new Promise(async (resolve, reject) => {
|
|
98
|
+
const errorHandler = async (err) => {
|
|
99
|
+
if (resolved) return; // Prevent double handling
|
|
100
|
+
|
|
101
|
+
if (err.code === 'EADDRINUSE') {
|
|
102
|
+
console.warn(`⚠ Port ${requestedPort} is already in use, finding alternative...`);
|
|
103
|
+
|
|
104
|
+
// Find an available port
|
|
105
|
+
const availablePort = await findAvailablePort(requestedPort + 1, 10);
|
|
106
|
+
|
|
107
|
+
if (availablePort) {
|
|
108
|
+
console.log(`✓ Using port ${availablePort} instead`);
|
|
109
|
+
this.port = availablePort;
|
|
110
|
+
|
|
111
|
+
// Remove old listeners to avoid double-firing
|
|
112
|
+
this.server.removeAllListeners('error');
|
|
113
|
+
this.server.removeAllListeners('listening');
|
|
114
|
+
|
|
115
|
+
// Try again with the new port
|
|
116
|
+
this.server.once('error', reject);
|
|
117
|
+
this.server.listen(availablePort, () => {
|
|
118
|
+
resolved = true;
|
|
119
|
+
resolve(availablePort);
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
resolved = true;
|
|
123
|
+
reject(new Error(`Unable to find available port. Tried ports ${requestedPort}-${requestedPort + 10}. Please close other SmartMeter instances or specify a different port with --api-port.`));
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
resolved = true;
|
|
127
|
+
reject(err);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Set up error handler
|
|
132
|
+
this.server.once('error', errorHandler);
|
|
133
|
+
|
|
134
|
+
// Try the requested port
|
|
135
|
+
this.server.listen(requestedPort, () => {
|
|
136
|
+
if (!resolved) {
|
|
137
|
+
resolved = true;
|
|
138
|
+
resolve(requestedPort);
|
|
139
|
+
}
|
|
57
140
|
});
|
|
58
141
|
});
|
|
59
142
|
}
|