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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-smartmeter",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI cost optimization for OpenClaw - analyze usage and reduce costs by 48%",
5
5
  "main": "src/cli/index.js",
6
6
  "bin": {
@@ -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
- // Stubbed requires re-prompt timing analysis (see docs/backlog.md)
137
- m.successRate = null;
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
- // Calculate usage based on available data
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: creditsData.data?.limit || null,
31
- usageBalance: creditsData.data?.usage || null,
32
- limitRemaining: creditsData.data?.limit_remaining || null,
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) {
@@ -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
- return new Promise((resolve, reject) => {
54
- this.server.listen(this.port, (err) => {
55
- if (err) reject(err);
56
- else resolve(this.port);
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
  }