openclaw-smartmeter 0.2.2 → 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.
@@ -90,19 +90,21 @@ export async function cmdAnalyze(opts = {}) {
90
90
  // Start API server in background
91
91
  console.log("✓ Starting API server...");
92
92
  const apiServer = await startApiServer({ port: apiPort });
93
+ const actualApiPort = apiServer.server.address().port;
93
94
 
94
95
  // Start static file server in background
95
96
  console.log("✓ Starting dashboard server...");
96
97
  const staticServer = await startStaticFileServer(deployer.canvasDir, port);
98
+ const actualPort = staticServer.port;
97
99
 
98
- const url = `http://localhost:${port}`;
100
+ const url = `http://localhost:${actualPort}`;
99
101
 
100
102
  console.log(`
101
103
  ✅ Dashboard is live!
102
104
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
103
105
 
104
106
  🌐 Dashboard URL: ${url}
105
- 📡 API Server: http://localhost:${apiPort}
107
+ 📡 API Server: http://localhost:${actualApiPort}
106
108
 
107
109
  💡 Features:
108
110
  • View cost savings and recommendations
@@ -117,7 +119,7 @@ Opening in your browser...
117
119
 
118
120
  // Open browser
119
121
  try {
120
- await deployer.openDashboard(port);
122
+ await deployer.openDashboard(actualPort);
121
123
  console.log("✓ Browser opened\n");
122
124
  } catch (err) {
123
125
  console.log(`⚠ Could not open browser automatically`);
@@ -143,13 +145,18 @@ Opening in your browser...
143
145
  process.on("SIGINT", shutdownHandler);
144
146
  process.on("SIGTERM", shutdownHandler);
145
147
 
146
- // Store server references for cleanup
147
- analysis._servers = { staticServer, apiServer };
148
+ // Keep process alive - wait indefinitely
149
+ // This prevents the CLI from exiting and killing the servers
150
+ await new Promise(() => {}); // Never resolves, keeps process alive
148
151
 
149
152
  } catch (err) {
150
- console.error(`\n Could not start dashboard: ${err.message}`);
151
- console.log(`\nYou can view your analysis by running:`);
152
- console.log(` smartmeter status`);
153
+ console.error(`\n Could not start dashboard: ${err.message}\n`);
154
+ console.log(`💡 Tips:`);
155
+ console.log(` Make sure no other SmartMeter instances are running`);
156
+ console.log(` • Try specifying different ports: --port 8081 --api-port 3002`);
157
+ console.log(` • Check for processes using ports: lsof -i :${opts.port || 8080} -i :${opts.apiPort || 3001}`);
158
+ console.log(`\nYou can still view your analysis by running:`);
159
+ console.log(` smartmeter status\n`);
153
160
  console.log(`\nOr start the dashboard manually:`);
154
161
  console.log(` smartmeter serve\n`);
155
162
  }
@@ -530,6 +537,224 @@ smartmeter rollback
530
537
  return { analysis, config };
531
538
  }
532
539
 
540
+ /**
541
+ * Compare two config versions or current vs optimized.
542
+ */
543
+ export async function cmdDiff(opts = {}) {
544
+ const configPath = opts.configPath || CONFIG_PATH;
545
+ const backupDir = opts.backupDir || OPENCLAW_DIR;
546
+
547
+ // Get current config
548
+ const currentConfig = await readCurrentConfig(configPath);
549
+
550
+ let compareConfig;
551
+ let compareLabel;
552
+
553
+ if (opts.version) {
554
+ // Compare with specific backup version
555
+ const backupPath = join(backupDir, opts.version);
556
+ try {
557
+ const data = await readFile(backupPath, "utf8");
558
+ compareConfig = JSON.parse(data);
559
+ compareLabel = opts.version;
560
+ } catch {
561
+ console.log(`Backup not found: ${opts.version}`);
562
+ return null;
563
+ }
564
+ } else {
565
+ // Compare current with optimized
566
+ const analysis = await runPipeline(opts);
567
+ if (!analysis) {
568
+ console.log("No session data found.");
569
+ return null;
570
+ }
571
+ const { config } = generateConfig(analysis, currentConfig);
572
+ compareConfig = config;
573
+ compareLabel = "optimized";
574
+ }
575
+
576
+ console.log(`\n╔════════════════════════════════════════════════════════╗`);
577
+ console.log(`║ SmartMeter Config Diff ║`);
578
+ console.log(`╚════════════════════════════════════════════════════════╝\n`);
579
+ console.log(`Comparing: current ↔ ${compareLabel}\n`);
580
+
581
+ const diffs = diffObjects(currentConfig, compareConfig);
582
+ if (diffs.length === 0) {
583
+ console.log(" No differences found.");
584
+ } else {
585
+ for (const d of diffs) {
586
+ console.log(` ${d.path}:`);
587
+ console.log(` - ${JSON.stringify(d.old)}`);
588
+ console.log(` + ${JSON.stringify(d.new)}\n`);
589
+ }
590
+ }
591
+
592
+ return diffs;
593
+ }
594
+
595
+ /**
596
+ * Show cost breakdown by model and category.
597
+ */
598
+ export async function cmdCosts(opts = {}) {
599
+ const storageDir = opts.storageDir || SMARTMETER_DIR;
600
+ let analysis = await readAnalysis(storageDir);
601
+
602
+ if (!analysis) {
603
+ // Try running pipeline
604
+ analysis = await runPipeline(opts);
605
+ if (!analysis) {
606
+ console.log("No analysis found. Run `smartmeter analyze` first.");
607
+ return null;
608
+ }
609
+ }
610
+
611
+ console.log(`\n╔════════════════════════════════════════════════════════╗`);
612
+ console.log(`║ SmartMeter Cost Breakdown ║`);
613
+ console.log(`╚════════════════════════════════════════════════════════╝`);
614
+
615
+ console.log(`\n📊 By Model:\n`);
616
+ const models = Object.entries(analysis.models || {});
617
+ models.sort((a, b) => b[1].cost - a[1].cost);
618
+ for (const [name, m] of models) {
619
+ const pct = analysis.summary.totalCost > 0
620
+ ? ((m.cost / analysis.summary.totalCost) * 100).toFixed(1)
621
+ : "0.0";
622
+ console.log(` ${name}`);
623
+ console.log(` Tasks: ${m.count} | Cost: $${m.cost.toFixed(2)} | Avg: $${m.avgCostPerTask.toFixed(4)}/task | Share: ${pct}%`);
624
+ }
625
+
626
+ console.log(`\n📂 By Category:\n`);
627
+ const cats = Object.entries(analysis.categories || {});
628
+ for (const [name, c] of cats) {
629
+ let catCost = 0;
630
+ for (const mb of Object.values(c.modelBreakdown)) {
631
+ catCost += mb.totalCost || 0;
632
+ }
633
+ console.log(` ${name}: ${c.count} tasks, $${catCost.toFixed(2)} total`);
634
+ for (const [model, mb] of Object.entries(c.modelBreakdown)) {
635
+ console.log(` └─ ${model}: ${mb.count} tasks, $${mb.avgCost.toFixed(4)}/task`);
636
+ }
637
+ }
638
+
639
+ console.log(`\n💰 Summary:`);
640
+ console.log(` Total cost: $${analysis.summary.totalCost.toFixed(2)}`);
641
+ console.log(` Monthly proj: $${analysis.summary.currentMonthlyCost.toFixed(2)}`);
642
+ console.log(` Cache savings: $${(analysis.caching?.estimatedCacheSavings || 0).toFixed(2)}\n`);
643
+
644
+ return analysis;
645
+ }
646
+
647
+ /**
648
+ * Get or set SmartMeter configuration values.
649
+ */
650
+ export async function cmdConfig(opts = {}) {
651
+ const { getConfig, saveConfig } = await import("../analyzer/config-manager.js");
652
+ const config = await getConfig();
653
+
654
+ if (opts.key && opts.value !== undefined) {
655
+ // Set mode
656
+ config[opts.key] = opts.value;
657
+ config.lastUpdated = new Date().toISOString();
658
+ await saveConfig(config);
659
+ console.log(`✓ Set ${opts.key} = ${opts.value}`);
660
+ return config;
661
+ }
662
+
663
+ if (opts.key) {
664
+ // Get specific key
665
+ const val = config[opts.key];
666
+ if (val !== undefined) {
667
+ console.log(`${opts.key} = ${JSON.stringify(val)}`);
668
+ } else {
669
+ console.log(`Key '${opts.key}' not set.`);
670
+ }
671
+ return val;
672
+ }
673
+
674
+ // Show all config
675
+ console.log(`\n╔════════════════════════════════════════════════════════╗`);
676
+ console.log(`║ SmartMeter Configuration ║`);
677
+ console.log(`╚════════════════════════════════════════════════════════╝\n`);
678
+
679
+ for (const [key, value] of Object.entries(config)) {
680
+ if (key === "openRouterApiKey" && value) {
681
+ console.log(` ${key} = ${value.substring(0, 9)}...${value.substring(value.length - 4)}`);
682
+ } else {
683
+ console.log(` ${key} = ${JSON.stringify(value)}`);
684
+ }
685
+ }
686
+ console.log();
687
+
688
+ return config;
689
+ }
690
+
691
+ /**
692
+ * Show config backup/version history.
693
+ */
694
+ export async function cmdHistory(opts = {}) {
695
+ const backupDir = opts.backupDir || OPENCLAW_DIR;
696
+
697
+ let entries;
698
+ try {
699
+ entries = await readdir(backupDir);
700
+ } catch {
701
+ console.log("No backup directory found.");
702
+ return [];
703
+ }
704
+
705
+ const backups = entries
706
+ .filter((f) => f.startsWith("openclaw.json.backup-"))
707
+ .sort()
708
+ .reverse();
709
+
710
+ console.log(`\n╔════════════════════════════════════════════════════════╗`);
711
+ console.log(`║ SmartMeter Config History ║`);
712
+ console.log(`╚════════════════════════════════════════════════════════╝\n`);
713
+
714
+ if (backups.length === 0) {
715
+ console.log(" No config backups found. Backups are created when you run `smartmeter apply`.\n");
716
+ return [];
717
+ }
718
+
719
+ console.log(` Found ${backups.length} backup(s):\n`);
720
+ for (let i = 0; i < backups.length; i++) {
721
+ const ts = backups[i].replace("openclaw.json.backup-", "").replace(/-/g, function(m, offset) {
722
+ // First 10 chars are date, rest is time
723
+ return offset < 10 ? "-" : ":";
724
+ });
725
+ const label = i === 0 ? " (latest)" : "";
726
+ console.log(` ${i + 1}. ${backups[i]}${label}`);
727
+ }
728
+
729
+ console.log(`\n💡 To rollback to the latest: smartmeter rollback`);
730
+ console.log(`💡 To compare with a backup: smartmeter diff --version <backup-filename>\n`);
731
+
732
+ return backups;
733
+ }
734
+
735
+ /**
736
+ * Simple deep diff between two objects. Returns array of { path, old, new }.
737
+ */
738
+ function diffObjects(obj1, obj2, prefix = "") {
739
+ const diffs = [];
740
+ const allKeys = new Set([...Object.keys(obj1 || {}), ...Object.keys(obj2 || {})]);
741
+
742
+ for (const key of allKeys) {
743
+ if (key === "_smartmeter") continue; // Skip metadata
744
+ const path = prefix ? `${prefix}.${key}` : key;
745
+ const v1 = (obj1 || {})[key];
746
+ const v2 = (obj2 || {})[key];
747
+
748
+ if (typeof v1 === "object" && typeof v2 === "object" && v1 !== null && v2 !== null && !Array.isArray(v1) && !Array.isArray(v2)) {
749
+ diffs.push(...diffObjects(v1, v2, path));
750
+ } else if (JSON.stringify(v1) !== JSON.stringify(v2)) {
751
+ diffs.push({ path, old: v1, new: v2 });
752
+ }
753
+ }
754
+
755
+ return diffs;
756
+ }
757
+
533
758
  export async function cmdServe(opts = {}) {
534
759
  const port = opts.port || 8080;
535
760
  const apiPort = opts.apiPort || 3001;
@@ -562,22 +787,25 @@ export async function cmdServe(opts = {}) {
562
787
  console.log("⚠ No analysis data. Run 'smartmeter analyze' first.");
563
788
  }
564
789
 
565
- // Start API server
566
- console.log("\n🚀 Starting API server...");
567
- const apiServer = await startApiServer({ port: apiPort });
568
-
569
- // Start static file server (using Node.js)
570
- console.log(`\n🚀 Starting dashboard server on port ${port}...`);
571
- const staticServer = await startStaticFileServer(deployer.canvasDir, port);
572
-
573
- const url = `http://localhost:${port}`;
574
- console.log(`
790
+ try {
791
+ // Start API server
792
+ console.log("\n🚀 Starting API server...");
793
+ const apiServer = await startApiServer({ port: apiPort });
794
+ const actualApiPort = apiServer.server.address().port;
795
+
796
+ // Start static file server (using Node.js)
797
+ console.log(`\n🚀 Starting dashboard server on port ${port}...`);
798
+ const staticServer = await startStaticFileServer(deployer.canvasDir, port);
799
+ const actualPort = staticServer.port;
800
+
801
+ const url = `http://localhost:${actualPort}`;
802
+ console.log(`
575
803
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
576
804
  ✅ SmartMeter is ready!
577
805
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
578
806
 
579
807
  🌐 Dashboard: ${url}
580
- 📡 API Server: http://localhost:${apiPort}
808
+ 📡 API Server: http://localhost:${actualApiPort}
581
809
 
582
810
  💡 Features enabled:
583
811
  • Live dashboard updates (auto-refresh every 5s)
@@ -589,30 +817,74 @@ Press Ctrl+C to stop all servers
589
817
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
590
818
  `);
591
819
 
592
- if (shouldOpen) {
593
- console.log("🌐 Opening dashboard in browser...");
594
- try {
595
- await deployer.openDashboard(port);
596
- } catch (err) {
597
- console.log(` Open manually: ${url}`);
820
+ if (shouldOpen) {
821
+ console.log("🌐 Opening dashboard in browser...");
822
+ try {
823
+ await deployer.openDashboard(actualPort);
824
+ } catch (err) {
825
+ console.log(` Open manually: ${url}`);
826
+ }
598
827
  }
599
- }
600
828
 
601
- // Handle shutdown
602
- process.on("SIGINT", async () => {
603
- console.log("\n\n🛑 Shutting down servers...");
604
- await staticServer.stop();
605
- await apiServer.stop();
606
- console.log("✓ Servers stopped");
607
- process.exit(0);
608
- });
829
+ // Handle shutdown
830
+ process.on("SIGINT", async () => {
831
+ console.log("\n\n🛑 Shutting down servers...");
832
+ await staticServer.stop();
833
+ await apiServer.stop();
834
+ console.log("✓ Servers stopped");
835
+ process.exit(0);
836
+ });
609
837
 
610
- return { url, apiPort, deployer, apiServer, staticServer };
838
+ return { url, apiPort: actualApiPort, deployer, apiServer, staticServer };
839
+ } catch (err) {
840
+ console.error(`\n❌ Failed to start servers: ${err.message}\n`);
841
+ console.log(`💡 Tips:`);
842
+ console.log(` • Make sure no other SmartMeter instances are running`);
843
+ console.log(` • Try specifying different ports: --port 8081 --api-port 3002`);
844
+ console.log(` • Check for processes using these ports: lsof -i :${port} -i :${apiPort}\n`);
845
+ return null;
846
+ }
611
847
  }
612
848
 
613
849
  /**
614
850
  * Start a simple static file server
615
851
  */
852
+ /**
853
+ * Check if a port is available
854
+ * @param {number} port - Port to check
855
+ * @returns {Promise<boolean>} - True if port is available
856
+ */
857
+ async function isPortAvailable(port) {
858
+ const { createServer } = await import("node:http");
859
+ return new Promise((resolve) => {
860
+ const server = createServer();
861
+ server.once('error', (err) => {
862
+ resolve(false);
863
+ });
864
+ server.once('listening', () => {
865
+ server.close();
866
+ resolve(true);
867
+ });
868
+ server.listen(port);
869
+ });
870
+ }
871
+
872
+ /**
873
+ * Find an available port in a range
874
+ * @param {number} startPort - Starting port number
875
+ * @param {number} maxAttempts - Maximum number of ports to try (default: 10)
876
+ * @returns {Promise<number|null>} - Available port or null if none found
877
+ */
878
+ async function findAvailablePort(startPort, maxAttempts = 10) {
879
+ for (let i = 0; i < maxAttempts; i++) {
880
+ const port = startPort + i;
881
+ if (await isPortAvailable(port)) {
882
+ return port;
883
+ }
884
+ }
885
+ return null;
886
+ }
887
+
616
888
  async function startStaticFileServer(directory, port) {
617
889
  const { createServer } = await import("node:http");
618
890
  const { readFile, stat } = await import("node:fs/promises");
@@ -656,16 +928,50 @@ async function startStaticFileServer(directory, port) {
656
928
  }
657
929
  });
658
930
 
659
- return new Promise((resolve, reject) => {
660
- server.listen(port, (err) => {
661
- if (err) {
662
- reject(err);
931
+ // Try to start on the requested port, or find an alternative
932
+ const requestedPort = port;
933
+
934
+ return new Promise(async (resolve, reject) => {
935
+ // First try the requested port
936
+ server.once('error', async (err) => {
937
+ if (err.code === 'EADDRINUSE') {
938
+ console.warn(`⚠ Port ${requestedPort} is already in use, finding alternative...`);
939
+
940
+ // Find an available port
941
+ const availablePort = await findAvailablePort(requestedPort + 1, 10);
942
+
943
+ if (availablePort) {
944
+ console.log(`✓ Using port ${availablePort} instead`);
945
+ server.listen(availablePort, (err) => {
946
+ if (err) {
947
+ reject(err);
948
+ } else {
949
+ resolve({
950
+ server,
951
+ port: availablePort,
952
+ stop: () => new Promise((res) => server.close(() => res())),
953
+ });
954
+ }
955
+ });
956
+ } else {
957
+ reject(new Error(`Unable to find available port. Tried ports ${requestedPort}-${requestedPort + 10}. Please close other SmartMeter instances or specify a different port with --port.`));
958
+ }
663
959
  } else {
960
+ reject(err);
961
+ }
962
+ });
963
+
964
+ server.listen(requestedPort, (err) => {
965
+ if (err && err.code !== 'EADDRINUSE') {
966
+ reject(err);
967
+ } else if (!err) {
664
968
  resolve({
665
969
  server,
970
+ port: requestedPort,
666
971
  stop: () => new Promise((res) => server.close(() => res())),
667
972
  });
668
973
  }
974
+ // EADDRINUSE is handled by the error listener above
669
975
  });
670
976
  });
671
977
  }
package/src/cli/index.js CHANGED
@@ -22,6 +22,10 @@ import {
22
22
  cmdEvaluate,
23
23
  cmdGuide,
24
24
  cmdServe,
25
+ cmdDiff,
26
+ cmdCosts,
27
+ cmdConfig,
28
+ cmdHistory,
25
29
  } from "./commands.js";
26
30
 
27
31
  const program = new Command();
@@ -148,4 +152,29 @@ program
148
152
  open: opts.open
149
153
  }));
150
154
 
155
+ program
156
+ .command("diff")
157
+ .description("Compare current config with optimized or a backup version")
158
+ .option("-d, --data-dir <path>", "OpenClaw data directory (default: ~/.openclaw)")
159
+ .option("--version <filename>", "Compare with specific backup file")
160
+ .action((opts) => cmdDiff({ ...dataDirOpts(opts), version: opts.version }));
161
+
162
+ program
163
+ .command("costs")
164
+ .description("Show detailed cost breakdown by model and category")
165
+ .option("-d, --data-dir <path>", "OpenClaw data directory (default: ~/.openclaw)")
166
+ .action((opts) => cmdCosts(dataDirOpts(opts)));
167
+
168
+ program
169
+ .command("config")
170
+ .description("Get or set SmartMeter configuration")
171
+ .argument("[key]", "Configuration key to get or set")
172
+ .argument("[value]", "Value to set")
173
+ .action((key, value) => cmdConfig({ key, value }));
174
+
175
+ program
176
+ .command("history")
177
+ .description("Show config backup/version history")
178
+ .action(() => cmdHistory());
179
+
151
180
  program.parse();
@@ -68,9 +68,14 @@ export function generateConfig(analysis, currentConfig = {}) {
68
68
  }
69
69
 
70
70
  // 5. Budget controls
71
+ // Use minimum budget values when costs are zero or very low to ensure valid config
72
+ const MIN_DAILY_BUDGET = 1.00; // $1/day minimum
73
+ const MIN_WEEKLY_BUDGET = 5.00; // $5/week minimum
74
+
71
75
  const dailyAvg = (analysis.summary.currentMonthlyCost || 0) / 30;
72
- const dailyBudget = Math.ceil(dailyAvg * 1.2 * 100) / 100;
73
- const weeklyBudget = Math.ceil(dailyBudget * 7 * 100) / 100;
76
+ const calculatedDaily = Math.ceil(dailyAvg * 1.2 * 100) / 100;
77
+ const dailyBudget = Math.max(calculatedDaily, MIN_DAILY_BUDGET);
78
+ const weeklyBudget = Math.max(Math.ceil(dailyBudget * 7 * 100) / 100, MIN_WEEKLY_BUDGET);
74
79
 
75
80
  config.agents.defaults.budget = deepMerge(
76
81
  config.agents.defaults.budget || {},
@@ -1,4 +1,4 @@
1
- const ALLOWED_TOP_KEYS = new Set([
1
+ const SMARTMETER_KEYS = new Set([
2
2
  "agents",
3
3
  "skills",
4
4
  "models",
@@ -9,6 +9,10 @@ const ALLOWED_TOP_KEYS = new Set([
9
9
  /**
10
10
  * Validate an openclaw config object.
11
11
  * Returns { valid: boolean, errors: string[] }.
12
+ *
13
+ * Note: This validator focuses on SmartMeter-managed fields only.
14
+ * It allows other OpenClaw config keys (meta, wizard, auth, tools, etc.)
15
+ * to pass through without validation.
12
16
  */
13
17
  export function validate(config) {
14
18
  const errors = [];
@@ -17,12 +21,8 @@ export function validate(config) {
17
21
  return { valid: false, errors: ["Config must be a non-null object"] };
18
22
  }
19
23
 
20
- // Check top-level keys
21
- for (const key of Object.keys(config)) {
22
- if (!ALLOWED_TOP_KEYS.has(key)) {
23
- errors.push(`Unknown top-level key: "${key}"`);
24
- }
25
- }
24
+ // Only validate SmartMeter-managed keys, not the entire OpenClaw config
25
+ // This allows existing OpenClaw configs to merge without validation errors
26
26
 
27
27
  // agents.defaults.model.primary must exist and be a string
28
28
  const primary = config.agents?.defaults?.model?.primary;