From 5aa1f324c610c3b1e9c00814abd2f177c5d3b7a0 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 10:19:19 +0800 Subject: [PATCH] Initial commit with translated description --- AGENTS.md | 342 ++ CODE_OF_CONDUCT.md | 87 + CONTRIBUTING.md | 293 ++ README.md | 400 ++ README.zh-CN.md | 293 ++ SKILL.md | 69 + _meta.json | 6 + config/dashboard.example.json | 102 + config/system-deps.json | 46 + docs/ENHANCEMENTS.md | 212 ++ docs/PRODUCT_HUNT_LAUNCH.md | 274 ++ docs/README.md | 49 + docs/architecture/OVERVIEW.md | 234 ++ docs/research/kloss-dashboard-prompt.md | 95 + eslint.config.mjs | 45 + lib/server.js | 3874 +++++++++++++++++++ package-lock.json | 1748 +++++++++ package.json | 49 + public/css/dashboard.css | 1936 ++++++++++ public/data/AGENTS.md | 29 + public/favicon.svg | 3 + public/index.html | 4661 +++++++++++++++++++++++ public/jobs.html | 1379 +++++++ public/js/api.js | 119 + public/js/app.js | 309 ++ public/js/i18n.js | 386 ++ public/js/lib/morphdom.min.js | 507 +++ public/js/sidebar.js | 357 ++ public/js/store.js | 87 + public/js/utils.js | 47 + public/locales/en.json | 111 + public/locales/zh-CN.json | 521 +++ public/partials/sidebar.html | 167 + scripts/checks/README.md | 48 + scripts/checks/no-secrets.sh | 39 + scripts/checks/no-user-data.sh | 34 + scripts/checks/version-sync.sh | 35 + scripts/dashboard-loop.sh | 48 + scripts/install-system-deps.sh | 117 + scripts/linear-sync.js | 564 +++ scripts/release.sh | 157 + scripts/run-server.sh | 11 + scripts/setup.sh | 143 + scripts/start.sh | 70 + scripts/stop.sh | 33 + scripts/tmux-dashboard.sh | 66 + scripts/topic-classifier.js | 700 ++++ scripts/verify.sh | 119 + src/actions.js | 76 + src/auth.js | 149 + src/cerebro.js | 265 ++ src/config.js | 282 ++ src/cron.js | 159 + src/data.js | 33 + src/index.js | 671 ++++ src/jobs.js | 261 ++ src/llm-usage.js | 305 ++ src/openclaw.js | 101 + src/operators.js | 233 ++ src/privacy.js | 44 + src/sessions.js | 589 +++ src/state.js | 664 ++++ src/tokens.js | 458 +++ src/topics.js | 81 + src/utils.js | 78 + src/vitals.js | 448 +++ tests/actions.test.js | 79 + tests/auth.test.js | 191 + tests/config.test.js | 154 + tests/cron.test.js | 79 + tests/data.test.js | 67 + tests/iostat-leak.test.js | 127 + tests/jobs.test.js | 98 + tests/llm-usage.test.js | 88 + tests/openclaw.test.js | 39 + tests/privacy.test.js | 78 + tests/server.test.js | 95 + tests/tokens.test.js | 80 + tests/topic-classifier.test.js | 263 ++ tests/topics.test.js | 85 + tests/utils.test.js | 85 + 81 files changed, 27526 insertions(+) create mode 100644 AGENTS.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 config/dashboard.example.json create mode 100644 config/system-deps.json create mode 100644 docs/ENHANCEMENTS.md create mode 100644 docs/PRODUCT_HUNT_LAUNCH.md create mode 100644 docs/README.md create mode 100644 docs/architecture/OVERVIEW.md create mode 100644 docs/research/kloss-dashboard-prompt.md create mode 100644 eslint.config.mjs create mode 100644 lib/server.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/css/dashboard.css create mode 100644 public/data/AGENTS.md create mode 100644 public/favicon.svg create mode 100644 public/index.html create mode 100644 public/jobs.html create mode 100644 public/js/api.js create mode 100644 public/js/app.js create mode 100644 public/js/i18n.js create mode 100644 public/js/lib/morphdom.min.js create mode 100644 public/js/sidebar.js create mode 100644 public/js/store.js create mode 100644 public/js/utils.js create mode 100644 public/locales/en.json create mode 100644 public/locales/zh-CN.json create mode 100644 public/partials/sidebar.html create mode 100644 scripts/checks/README.md create mode 100644 scripts/checks/no-secrets.sh create mode 100644 scripts/checks/no-user-data.sh create mode 100644 scripts/checks/version-sync.sh create mode 100644 scripts/dashboard-loop.sh create mode 100644 scripts/install-system-deps.sh create mode 100644 scripts/linear-sync.js create mode 100644 scripts/release.sh create mode 100644 scripts/run-server.sh create mode 100644 scripts/setup.sh create mode 100644 scripts/start.sh create mode 100644 scripts/stop.sh create mode 100644 scripts/tmux-dashboard.sh create mode 100644 scripts/topic-classifier.js create mode 100644 scripts/verify.sh create mode 100644 src/actions.js create mode 100644 src/auth.js create mode 100644 src/cerebro.js create mode 100644 src/config.js create mode 100644 src/cron.js create mode 100644 src/data.js create mode 100644 src/index.js create mode 100644 src/jobs.js create mode 100644 src/llm-usage.js create mode 100644 src/openclaw.js create mode 100644 src/operators.js create mode 100644 src/privacy.js create mode 100644 src/sessions.js create mode 100644 src/state.js create mode 100644 src/tokens.js create mode 100644 src/topics.js create mode 100644 src/utils.js create mode 100644 src/vitals.js create mode 100644 tests/actions.test.js create mode 100644 tests/auth.test.js create mode 100644 tests/config.test.js create mode 100644 tests/cron.test.js create mode 100644 tests/data.test.js create mode 100644 tests/iostat-leak.test.js create mode 100644 tests/jobs.test.js create mode 100644 tests/llm-usage.test.js create mode 100644 tests/openclaw.test.js create mode 100644 tests/privacy.test.js create mode 100644 tests/server.test.js create mode 100644 tests/tokens.test.js create mode 100644 tests/topic-classifier.test.js create mode 100644 tests/topics.test.js create mode 100644 tests/utils.test.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4895224 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,342 @@ +# AGENTS.md — AI Workspace Guide + +> _"The Overmind speaks through many voices, but with one purpose."_ + +Welcome, AI agent. This document defines how you should interact with this codebase. + +## ⚠️ CRITICAL: Pull Request Workflow + +**All changes to this repository MUST go through pull requests.** + +This is a public open source project. Even maintainers (including AI agents working on behalf of maintainers) must: + +1. Create a feature branch (`git checkout -b type/description`) +2. Make changes and commit +3. Push branch and open a PR +4. Get approval before merging + +**Never push directly to `main`.** This applies to everyone, including the repo owner. + +## 🎯 Mission + +OpenClaw Command Center is the central dashboard for AI assistant management. Your mission is to help build, maintain, and improve this system while maintaining the Starcraft/Zerg thematic elements that make it unique. + +## 🏛️ Architecture + +**Read First**: [`docs/architecture/OVERVIEW.md`](docs/architecture/OVERVIEW.md) + +Key architectural principles: + +1. **DRY** — Don't Repeat Yourself. Extract shared code to partials/modules. +2. **Zero Build Step** — Plain HTML/CSS/JS, no compilation needed. +3. **Real-Time First** — SSE for live updates, polling as fallback. +4. **Progressive Enhancement** — Works without JS, enhanced with JS. + +## 📁 Workspace Structure + +``` +openclaw-command-center/ +├── lib/ # Core server logic +│ ├── server.js # Main HTTP server and API routes +│ ├── config.js # Configuration loader with auto-detection +│ ├── jobs.js # Jobs/scheduler API integration +│ ├── linear-sync.js # Linear issue tracker integration +│ └── topic-classifier.js # NLP-based topic classification +├── public/ # Frontend assets +│ ├── index.html # Main dashboard UI +│ ├── jobs.html # AI Jobs management UI +│ ├── partials/ # ⭐ Shared HTML partials (DRY!) +│ │ └── sidebar.html # Navigation sidebar component +│ ├── css/ +│ │ └── dashboard.css # Shared styles +│ └── js/ +│ ├── sidebar.js # Sidebar loader + SSE badges +│ ├── app.js # Main dashboard logic +│ └── lib/ # Third-party libraries +├── scripts/ # Operational scripts +├── config/ # Configuration (be careful!) +├── docs/ # Documentation +│ └── architecture/ # Architecture Decision Records +├── tests/ # Test files +├── SKILL.md # ClawHub skill metadata +└── package.json # Version and dependencies +``` + +## ✅ Safe Operations + +Do freely: + +- Read any file to understand the codebase +- Create/modify files in `lib/`, `public/`, `docs/`, `tests/` +- Add tests +- Update documentation +- Create feature branches + +## ⚠️ Ask First + +Check with a human before: + +- Modifying `config/` files +- Changing CI/CD workflows +- Adding new dependencies to `package.json` +- Making breaking API changes +- Anything touching authentication/secrets + +## 🚫 Never + +- **Push directly to `main` branch** — ALL changes require PRs +- Commit secrets, API keys, or credentials +- Commit user-specific data files (see `public/data/AGENTS.md`) +- Delete files without confirmation +- Expose internal endpoints publicly + +## 🛠️ Development Workflow + +### 0. First-Time Setup + +```bash +# Install pre-commit hooks (required for all contributors) +make install-hooks + +# Or manually: +cp scripts/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +The pre-commit hook enforces rules from this file automatically. + +### 1. Feature Development + +```bash +# Create feature branch +git checkout -b feat/your-feature-name + +# Make changes, then test locally +npm test +npm run lint +make check # Run pre-commit checks manually + +# Commit with descriptive message +git commit -m "feat: add overlord status indicator" + +# Push and create PR +git push -u origin feat/your-feature-name +``` + +### 2. Commit Message Convention + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` — New feature +- `fix:` — Bug fix +- `docs:` — Documentation only +- `style:` — Formatting, no code change +- `refactor:` — Code restructuring +- `test:` — Adding tests +- `chore:` — Maintenance tasks + +### 3. Code Style + +- Use ESLint configuration provided +- Prettier for formatting +- JSDoc comments for public functions +- Meaningful variable names (thematic names encouraged!) + +## 📦 ClawHub Skill Workflow + +This project is distributed as a ClawHub skill. After changes are merged to `main`, they need to be published to the registry so users can install/update via `clawhub install command-center`. + +### Understanding Skill Metadata + +Two files control the skill identity: + +- **`SKILL.md`** — Frontmatter (`name`, `version`, `description`) used by ClawHub for discovery and search +- **`package.json`** — `version` field for npm compatibility + +⚠️ **CRITICAL: Version Sync Required** + +Both `package.json` and `SKILL.md` **MUST have the same version number**. This is enforced by pre-commit hooks. + +```bash +# If you change version in one file, change it in both: +# package.json: "version": "1.0.4" +# SKILL.md: version: 1.0.4 +``` + +The pre-commit hook will block commits if versions are out of sync. + +### Publishing Updates + +```bash +# 1. Authenticate (one-time) +clawhub login +clawhub whoami + +# 2. Bump version in package.json (follow semver) +# patch: bug fixes (0.1.0 → 0.1.1) +# minor: new features (0.1.0 → 0.2.0) +# major: breaking changes (0.1.0 → 1.0.0) + +# 3. Tag the release +git tag -a v -m "v — short description" +git push origin --tags + +# 4. Publish (--registry flag required until upstream redirect is fixed) +clawhub publish . --registry https://www.clawhub.ai \ + --slug command-center --version \ + --changelog "Description of what changed" +``` + +### Verifying a Publish + +```bash +# Check published metadata +clawhub inspect command-center + +# Test install into a workspace +clawhub install command-center --workdir /path/to/workspace +``` + +### Updating an Installed Skill + +Users update with: + +```bash +clawhub update command-center +``` + +The installed version is tracked in `.clawhub/origin.json` within the skill directory. + +### Who Can Publish? + +Only maintainers with ClawHub credentials for `jontsai/command-center` can publish. Currently: + +- @jontsai (owner) + +Contributors: Submit PRs. After merge, a maintainer will handle the ClawHub publish. + +### Release Checklist + +Before publishing a new version: + +1. [ ] All PRs for the release are merged to `main` +2. [ ] Version bumped in both `package.json` and `SKILL.md` frontmatter +3. [ ] CHANGELOG updated (if maintained) +4. [ ] Tests pass: `npm test` +5. [ ] Lint passes: `npm run lint` +6. [ ] Git tag created: `git tag -a v -m "v"` +7. [ ] Tag pushed: `git push origin --tags` +8. [ ] Published to ClawHub with changelog + +## 🎨 Thematic Guidelines + +This project has a Starcraft/Zerg theme. When naming things: + +| Concept | Thematic Name | +| ------------------ | ------------- | +| Main controller | Overmind | +| Worker processes | Drones | +| Monitoring service | Overlord | +| Cache layer | Creep | +| Message queue | Spawning Pool | +| Health check | Essence scan | +| Error state | Corrupted | + +Example: + +```javascript +// Instead of: const cacheService = new Cache(); +const creepLayer = new CreepCache(); + +// Instead of: function checkHealth() +function scanEssence() +``` + +## 📝 Documentation Standards + +When you add features, document them: + +1. **Code comments** — JSDoc for functions +2. **README updates** — If user-facing +3. **API docs** — In `docs/api/` for endpoints +4. **Architecture Decision Records** — In `docs/architecture/` for major changes + +## 🧪 Testing + +```bash +# Run all tests +npm test + +# Coverage report +npm run test:coverage +``` + +Aim for meaningful test coverage. Test the logic, not the framework. + +## 🐛 Debugging + +```bash +# Enable all command-center debug output +DEBUG=openclaw:* npm run dev + +# Specific namespaces +DEBUG=openclaw:api npm run dev +DEBUG=openclaw:overlord npm run dev +``` + +## 🔄 Handoff Protocol + +When handing off to another AI or ending a session: + +1. Commit all work in progress +2. Document current state in a comment or commit message +3. List any unfinished tasks +4. Note any decisions that need human input + +## 📖 Lessons Learned + +### DRY is Non-Negotiable + +**Problem**: Sidebar was duplicated across `index.html` and `jobs.html`, causing inconsistencies. +**Solution**: Extract to `/partials/sidebar.html` + `/js/sidebar.js` for loading. +**Lesson**: When you see similar code in multiple places, stop and extract it. The cost of extraction is always lower than maintaining duplicates. + +### Naming Consistency Matters + +**Problem**: "Scheduled Jobs" vs "Cron Jobs" vs "Jobs" caused confusion. +**Solution**: Established naming convention: "Cron Jobs" for OpenClaw scheduled tasks, "AI Jobs" for advanced agent jobs. +**Lesson**: Agree on terminology early. Document it. Enforce it. + +### Zero-Build Architecture Has Trade-offs + +**Context**: No build step keeps things simple but limits some patterns. +**Solution**: Use `fetch()` to load partials dynamically, ` + + + + + + + + + +
+
+
+ +

OpenClaw Command Center

+
+ 🌐 + +
+
+
+
+ Connecting... +
+
+ +
+
+
-
+
Total Tokens
+
+
+
-
+
Input
+
+
+
-
+
Output
+
+
+
-
+
Recently active (15m)
+
+
+
-
+
Est. Cost (24h) 📊
+
+ +
+
-
+
Main
+
+
+
-
+
Sub-agents
+
+
+ + +
+ +
+
+
+
+ 🖥️ System Vitals + - +
+
Uptime: -
+
+
+ +
+
+ ⚡ CPU + -% +
+
+
+
+
+
+
+ -% + user +
+
+ -% + sys +
+
+ -% + idle +
+
+
+
+ - + 1m avg +
+
+ - + 5m avg +
+
+ - + 15m avg +
+
+ - + cores +
+
+
+
+ + +
+
+ 🧠 Memory + -% used +
+
+
+
+
+
+ - + used of total +
+
+ - + available +
+
+
+
+ - + App +
+
+ - + Wired +
+
+ - + Compressed +
+
+ - + Cached +
+
+
+ Normal +
+
+ + +
+
+ 💾 Disk + -% used +
+
+
+
+
+
+ - + used of total +
+
+ - + available +
+
+
+
+ - + IOPS +
+
+ - + MB/s +
+
+ - + KB/t +
+
+
+ + +
+
+ 🌡️ Temperature +
+
+ - + °C +
+
+ Checking... +
+
+
+
+
+ + +
+
+
+
🧠
+
+
Claude (Anthropic)
+
API Usage
+
+
+
+
+ Session Usage + -% used +
+
+
+
+
+
+
+ Weekly Usage + -% used +
+
+
+
+
+
+ +
+
+
+
+
Codex (OpenAI)
+
ChatGPT Plus
+
+
+
+
+ 5-Hour Usage + 0% used +
+
+
+
+
+
+
+ Daily Usage + 0% used +
+
+
+
+
+
+
+ Tasks Today + 0 +
+
+
+
+ + +
+
⚡ Quick Actions
+ + + +
+ + +
+
+
+ 🦞 Active Sub-agents + 0 +
+
+
+ +
+
+ + +
+
+
+
+ ⛽ LLM Fuel Gauges + - +
+
Routing: -
+
+
+ +
+
+ 🔮 Session Limit + -% used +
+
+
+
+
+
+ -% + remaining +
+
+ - + resets in +
+
+
+ + +
+
+ 📅 Weekly (All Models) + -% used +
+
+
+
+
+
+ -% + remaining +
+
+ - + resets in +
+
+
+ + +
+
+ ✨ Sonnet Weekly + -% used +
+
+
+
+
+
+ -% + remaining +
+
+ - + resets +
+
+
+ + +
+
+ 🦞 Task Routing + - +
+
+
+ - + Claude +
+
+ - + Codex +
+
+
+
+ - + 🦙 Llama +
+
+ - + 🐱 Qwen +
+
+
+ Avg latency: - +
+
+
+
+
+ + +
+
+
+ 📡 Sessions 0 +
+
+ + +
+
+ Status: + + + + +
+
+ Channel: + + + + + + +
+
+ Kind: + + + + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+ + +
+
+
+ ⏰ Cron Jobs 0 +
+
+ + +
+
+ Status: + + + +
+
+ Schedule: + + + + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ 🧠 Memory 0 files +
+
+ + +
+
+ Type: + + + +
+
+ Age: + + + + +
+
+ +
+
+ +
+
+ 📜 MEMORY.md +
+
+
+ - + size +
+
+ - + lines +
+
+
+ Long-term curated memories +
+
+ + +
+
+ 📅 Daily Notes +
+
+
+ - + files +
+
+ - + total +
+
+
+ Raw logs by date +
+
+ + +
+
+ 🕐 Recent Memory Files +
+
+ Loading... +
+
+
+ +
+ + 🔒 Memory editing UI coming soon (Inside Out style!) + +
+
+
+ + +
+
+
+ 🔮 Cerebro 0 topics +
+ +
+ +
+ + + + + +
+
+ + +
+
+
👥 Operators
+
+ +
+
+
+
Loading operators...
+
+ +
+
+ Permission Levels +
+
+
+ + Owner + Full control +
+
+ + Admin + Manage users & settings +
+
+ + User + Dashboard access +
+
+
+
+ + +
+
+
ℹ️ About
+
+
+
+
🦞
+

+ OpenClaw Command Center +

+

+ A Starcraft-inspired dashboard for OpenClaw orchestration +

+ +
+ v... + MIT License +
+ + + + +
+ +
+
+ INSPIRED BY +
+
+ 🎮 Starcraft + 📊 iStatMenus + 💾 DaisyDisk + 📧 Gmail + 🧠 Inside Out +
+
+
+
+
+ +
+ Real-time updates via SSE ⚡ + • Last updated: + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + diff --git a/public/jobs.html b/public/jobs.html new file mode 100644 index 0000000..9e12a2d --- /dev/null +++ b/public/jobs.html @@ -0,0 +1,1379 @@ + + + + + + AI Jobs - OpenClaw Command Center + + + + + + + + + + +
+
+
+

🤖 AI Jobs Dashboard

+
+
+ +
+
+ + +
+
+
+
Total Jobs
+
+
+
+
Active
+
+
+
+
Paused
+
+
+
+
Running
+
+
+
+
Success Rate
+
+
+
+
Recent Failures
+
+
+ +
+ +
+ + + + +
+ + +
+
+
+ Loading jobs... +
+ + + + +
+
+
+ + + + + +
+ + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..01c59c9 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,119 @@ +/** + * API and SSE connection management for Command Center + */ + +// SSE connection state +let eventSource = null; +let sseConnected = false; +let sseReconnectAttempts = 0; +let pollInterval = null; + +const SSE_MAX_RECONNECT_DELAY = 30000; + +/** + * Fetch the unified state from the server + * @returns {Promise} Dashboard state + */ +export async function fetchState() { + const response = await fetch("/api/state"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + +/** + * Connect to SSE for real-time updates + * @param {Function} onUpdate - Callback when state updates + * @param {Function} onStatusChange - Callback for connection status changes + */ +export function connectSSE(onUpdate, onStatusChange) { + if (typeof EventSource === "undefined") { + console.warn("[SSE] EventSource not supported, using polling fallback"); + onStatusChange?.("polling", "Polling Mode"); + startPolling(onUpdate); + return; + } + + onStatusChange?.("connecting", "Connecting..."); + + try { + eventSource = new EventSource("/api/events"); + + eventSource.onopen = function () { + console.log("[SSE] Connected"); + sseConnected = true; + sseReconnectAttempts = 0; + onStatusChange?.("connected", "🟢 Live"); + stopPolling(); + }; + + eventSource.addEventListener("connected", function (e) { + try { + const data = JSON.parse(e.data); + console.log("[SSE] Server greeting:", data.message); + } catch (err) {} + }); + + eventSource.addEventListener("update", function (e) { + try { + const data = JSON.parse(e.data); + onUpdate?.(data); + } catch (err) { + console.error("[SSE] Failed to parse update:", err); + } + }); + + eventSource.addEventListener("heartbeat", function (e) { + try { + const data = JSON.parse(e.data); + console.log("[SSE] Heartbeat, clients:", data.clients); + } catch (err) {} + }); + + eventSource.onerror = function (e) { + console.error("[SSE] Connection error"); + sseConnected = false; + eventSource.close(); + eventSource = null; + onStatusChange?.("disconnected", "🔴 Disconnected"); + + // Exponential backoff for reconnection + sseReconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, sseReconnectAttempts - 1), SSE_MAX_RECONNECT_DELAY); + console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${sseReconnectAttempts})`); + + // Start polling as fallback while disconnected + startPolling(onUpdate); + + setTimeout(() => connectSSE(onUpdate, onStatusChange), delay); + }; + } catch (err) { + console.error("[SSE] Failed to create EventSource:", err); + onStatusChange?.("disconnected", "🔴 Error"); + startPolling(onUpdate); + } +} + +function startPolling(onUpdate) { + if (pollInterval) return; + console.log("[Polling] Starting fallback polling"); + pollInterval = setInterval(async () => { + try { + const state = await fetchState(); + onUpdate?.(state); + } catch (err) { + console.error("[Polling] Failed:", err); + } + }, 2000); +} + +function stopPolling() { + if (pollInterval) { + console.log("[Polling] Stopping fallback polling (SSE connected)"); + clearInterval(pollInterval); + pollInterval = null; + } +} + +export function isConnected() { + return sseConnected; +} diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..3892fb7 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,309 @@ +/** + * OpenClaw Command Center - Main Application + * + * Uses morphdom for efficient DOM updates (only patches what changed). + */ + +// Import morphdom (loaded as UMD, available as global `morphdom`) +// must be loaded first + +// ============================================================================ +// STATE MANAGEMENT +// ============================================================================ + +const state = { + vitals: null, + sessions: [], + tokenStats: {}, + statusCounts: { all: 0, live: 0, recent: 0, idle: 0 }, + capacity: { main: { active: 0, max: 12 }, subagent: { active: 0, max: 24 } }, + operators: { operators: [], roles: {} }, + llmUsage: null, + cron: [], + memory: null, + cerebro: null, + subagents: [], + lastUpdated: null, + connected: false, +}; + +// ============================================================================ +// SSE CONNECTION +// ============================================================================ + +let eventSource = null; +let reconnectAttempts = 0; +const MAX_RECONNECT_DELAY = 30000; + +function connectSSE() { + if (typeof EventSource === "undefined") { + console.warn("[SSE] Not supported, falling back to polling"); + startPolling(); + return; + } + + updateConnectionStatus("connecting"); + + eventSource = new EventSource("/api/events"); + + eventSource.onopen = () => { + console.log("[SSE] Connected"); + state.connected = true; + reconnectAttempts = 0; + updateConnectionStatus("connected"); + }; + + eventSource.addEventListener("connected", (e) => { + const data = JSON.parse(e.data); + console.log("[SSE] Server greeting:", data.message); + }); + + eventSource.addEventListener("update", (e) => { + const data = JSON.parse(e.data); + handleStateUpdate(data); + }); + + eventSource.addEventListener("heartbeat", (e) => { + const data = JSON.parse(e.data); + state.lastUpdated = new Date(); + updateTimestamp(); + }); + + eventSource.onerror = () => { + console.error("[SSE] Connection error"); + state.connected = false; + eventSource.close(); + updateConnectionStatus("disconnected"); + + // Exponential backoff + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY); + console.log(`[SSE] Reconnecting in ${delay}ms`); + setTimeout(connectSSE, delay); + }; +} + +// ============================================================================ +// STATE UPDATES +// ============================================================================ + +function handleStateUpdate(data) { + // Merge new data into state + if (data.vitals) state.vitals = data.vitals; + if (data.sessions) state.sessions = data.sessions; + if (data.tokenStats) state.tokenStats = data.tokenStats; + if (data.statusCounts) state.statusCounts = data.statusCounts; + if (data.capacity) state.capacity = data.capacity; + if (data.operators) state.operators = data.operators; + if (data.llmUsage) state.llmUsage = data.llmUsage; + if (data.cron) state.cron = data.cron; + if (data.memory) state.memory = data.memory; + if (data.cerebro) state.cerebro = data.cerebro; + if (data.subagents) state.subagents = data.subagents; + + state.lastUpdated = new Date(); + + // Re-render affected components using morphdom + renderAll(); +} + +// ============================================================================ +// RENDERING (with morphdom) +// ============================================================================ + +function renderAll() { + // Each render function generates HTML and morphdom patches the DOM + renderVitals(); + renderTokenStats(); + renderLlmUsage(); + renderSessions(); + renderCron(); + renderMemory(); + renderCerebro(); + renderOperators(); + updateTimestamp(); +} + +// Utility: safely patch a container using morphdom +function patchElement(containerId, newHtml) { + const container = document.getElementById(containerId); + if (!container) return; + + // Create a temporary element with the new content + const temp = document.createElement("div"); + temp.innerHTML = newHtml; + + // Use morphdom to efficiently patch only what changed + if (typeof morphdom !== "undefined") { + // Patch each child + while (container.firstChild && temp.firstChild) { + morphdom(container.firstChild, temp.firstChild); + temp.removeChild(temp.firstChild); + } + // Add any new children + while (temp.firstChild) { + container.appendChild(temp.firstChild); + } + // Remove extra children + while (container.childNodes.length > temp.childNodes.length) { + container.removeChild(container.lastChild); + } + } else { + // Fallback: direct innerHTML replacement + container.innerHTML = newHtml; + } +} + +// ============================================================================ +// COMPONENT RENDERERS (to be extracted to separate files) +// ============================================================================ + +function renderVitals() { + if (!state.vitals) return; + const v = state.vitals; + + // Update individual elements (simpler than full morphdom for now) + setText("vitals-hostname", v.hostname || "-"); + setText("vitals-uptime", v.uptime || "-"); + + if (v.cpu) { + const cpuPct = v.cpu.usage || 0; + setText("cpu-percent", cpuPct + "%"); + setWidth("cpu-bar", cpuPct + "%"); + setText("cpu-user", (v.cpu.userPercent?.toFixed(1) || "-") + "%"); + setText("cpu-sys", (v.cpu.sysPercent?.toFixed(1) || "-") + "%"); + setText("cpu-idle", (v.cpu.idlePercent?.toFixed(1) || "-") + "%"); + setText("cpu-chip", v.cpu.chip || v.cpu.brand || ""); + } + + if (v.memory) { + const memPct = v.memory.percent || 0; + setText("mem-percent", memPct + "% used"); + setWidth("mem-bar", memPct + "%"); + setText("mem-summary", `${v.memory.usedFormatted || "-"} of ${v.memory.totalFormatted || "-"}`); + } + + if (v.disk) { + const diskPct = v.disk.percent || 0; + setText("disk-percent", diskPct + "% used"); + setWidth("disk-bar", diskPct + "%"); + setText("disk-summary", `${v.disk.usedFormatted || "-"} of ${v.disk.totalFormatted || "-"}`); + } +} + +function renderTokenStats() { + if (!state.tokenStats) return; + const t = state.tokenStats; + + setText("stat-total-tokens", t.total || "-"); + setText("stat-input", t.input || "-"); + setText("stat-output", t.output || "-"); + setText("stat-active", t.activeCount || "0"); + setText("stat-cost", t.estCost || "-"); + setText("stat-main", `${t.activeMainCount || 0}/${t.mainLimit || 12}`); + setText("stat-subagents", `${t.activeSubagentCount || 0}/${t.subagentLimit || 24}`); +} + +function renderLlmUsage() { + // Placeholder - will be extracted to component +} + +function renderSessions() { + // Placeholder - will be extracted to component +} + +function renderCron() { + // Placeholder - will be extracted to component +} + +function renderMemory() { + // Placeholder - will be extracted to component +} + +function renderCerebro() { + // Placeholder - will be extracted to component +} + +function renderOperators() { + // Placeholder - will be extracted to component +} + +// ============================================================================ +// UTILITIES +// ============================================================================ + +function setText(id, text) { + const el = document.getElementById(id); + if (el && el.textContent !== text) { + el.textContent = text; + } +} + +function setWidth(id, width) { + const el = document.getElementById(id); + if (el && el.style.width !== width) { + el.style.width = width; + } +} + +function updateTimestamp() { + const now = state.lastUpdated || new Date(); + const timeStr = now.toLocaleTimeString(); + const indicator = state.connected ? " ⚡" : ""; + setText("last-updated", timeStr + indicator); + setText("sidebar-updated", state.connected ? `Live: ${timeStr}` : `Updated: ${timeStr}`); +} + +function updateConnectionStatus(status) { + const el = document.getElementById("connection-status"); + if (!el) return; + + el.className = "connection-status " + status; + el.textContent = + status === "connected" + ? "🟢 Live" + : status === "connecting" + ? "🟡 Connecting..." + : "🔴 Disconnected"; +} + +// ============================================================================ +// POLLING FALLBACK +// ============================================================================ + +let pollInterval = null; + +function startPolling() { + if (pollInterval) return; + pollInterval = setInterval(fetchState, 5000); + fetchState(); +} + +async function fetchState() { + try { + const response = await fetch("/api/state"); + const data = await response.json(); + handleStateUpdate(data); + } catch (e) { + console.error("[Polling] Failed:", e); + } +} + +// ============================================================================ +// INITIALIZATION +// ============================================================================ + +function init() { + console.log("[App] Initializing OpenClaw Command Center"); + connectSSE(); + + // Initial fetch to populate immediately + setTimeout(fetchState, 100); +} + +// Start when DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + setTimeout(init, 0); +} diff --git a/public/js/i18n.js b/public/js/i18n.js new file mode 100644 index 0000000..1e495b8 --- /dev/null +++ b/public/js/i18n.js @@ -0,0 +1,386 @@ +(function () { + "use strict"; + + const DEFAULT_LOCALE = "en"; + const SUPPORTED_LOCALES = ["en", "zh-CN"]; + const STORAGE_KEY = "occ.locale"; + const loadedMessages = new Map(); + const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "CODE", "PRE"]); + + let currentLocale = DEFAULT_LOCALE; + let activeMessages = {}; + let observer = null; + let isApplyingTranslations = false; + + function normalizeLocale(input) { + if (!input || typeof input !== "string") return DEFAULT_LOCALE; + const lc = input.toLowerCase(); + if (lc === "zh" || lc.startsWith("zh-")) return "zh-CN"; + return "en"; + } + + function getInitialLocale() { + const fromQuery = new URLSearchParams(window.location.search).get("lang"); + if (fromQuery) return normalizeLocale(fromQuery); + + const fromStorage = localStorage.getItem(STORAGE_KEY); + if (fromStorage) return normalizeLocale(fromStorage); + + return normalizeLocale(navigator.language || DEFAULT_LOCALE); + } + + async function loadLocaleMessages(locale) { + const normalized = normalizeLocale(locale); + if (loadedMessages.has(normalized)) return loadedMessages.get(normalized); + + const response = await fetch(`/locales/${normalized}.json`, { cache: "no-cache" }); + if (!response.ok) throw new Error(`Failed to load locale: ${normalized}`); + const data = await response.json(); + loadedMessages.set(normalized, data || {}); + return data || {}; + } + + function getByPath(obj, path) { + return String(path) + .split(".") + .reduce( + (acc, key) => + acc && Object.prototype.hasOwnProperty.call(acc, key) ? acc[key] : undefined, + obj, + ); + } + + function interpolate(template, params = {}) { + if (typeof template !== "string") return template; + return template.replace(/\{(\w+)\}/g, (_, key) => { + return Object.prototype.hasOwnProperty.call(params, key) ? String(params[key]) : `{${key}}`; + }); + } + + function t(key, params = {}, fallback = undefined) { + const value = getByPath(activeMessages, key); + if (value === undefined || value === null) { + if (fallback !== undefined) return interpolate(fallback, params); + return String(key); + } + return interpolate(value, params); + } + + function buildReverseMap(source = {}) { + const reversed = {}; + for (const [from, to] of Object.entries(source)) { + if (typeof from !== "string" || typeof to !== "string") continue; + if (!Object.prototype.hasOwnProperty.call(reversed, to)) { + reversed[to] = from; + } + } + return reversed; + } + + function getExactPhraseMap() { + const exact = getByPath(activeMessages, "phrases.exact") || {}; + if (currentLocale !== DEFAULT_LOCALE) return exact; + const zh = loadedMessages.get("zh-CN") || {}; + const zhExact = getByPath(zh, "phrases.exact") || {}; + return buildReverseMap(zhExact); + } + + function getPatternRules() { + const localeRules = getByPath(activeMessages, "phrases.patterns"); + if (Array.isArray(localeRules)) return localeRules; + if (currentLocale !== DEFAULT_LOCALE) return []; + const zh = loadedMessages.get("zh-CN") || {}; + const zhRules = getByPath(zh, "phrases.reversePatterns"); + return Array.isArray(zhRules) ? zhRules : []; + } + + function translateTextValue(input) { + if (typeof input !== "string") return input; + if (!input.trim()) return input; + + const leading = input.match(/^\s*/)?.[0] || ""; + const trailing = input.match(/\s*$/)?.[0] || ""; + let core = input.trim(); + + const exactMap = getExactPhraseMap(); + if (Object.prototype.hasOwnProperty.call(exactMap, core)) { + return `${leading}${exactMap[core]}${trailing}`; + } + + for (const rule of getPatternRules()) { + if (!rule || typeof rule.pattern !== "string" || typeof rule.replace !== "string") continue; + try { + const regex = new RegExp(rule.pattern); + if (regex.test(core)) { + core = core.replace(regex, rule.replace); + return `${leading}${core}${trailing}`; + } + } catch { + continue; + } + } + + return input; + } + + function setAttrIfChanged(el, attr, value) { + if (!el || typeof value !== "string") return; + if (el.getAttribute(attr) !== value) { + el.setAttribute(attr, value); + } + } + + function translateLooseAttributes(el) { + if (el.hasAttribute("data-i18n-title")) { + setAttrIfChanged(el, "title", t(el.getAttribute("data-i18n-title"))); + } else if (el.hasAttribute("title")) { + setAttrIfChanged(el, "title", translateTextValue(el.getAttribute("title"))); + } + + if (el.hasAttribute("data-i18n-placeholder")) { + setAttrIfChanged(el, "placeholder", t(el.getAttribute("data-i18n-placeholder"))); + } else if (el.hasAttribute("placeholder")) { + setAttrIfChanged(el, "placeholder", translateTextValue(el.getAttribute("placeholder"))); + } + + if (el.hasAttribute("data-i18n-aria-label")) { + setAttrIfChanged(el, "aria-label", t(el.getAttribute("data-i18n-aria-label"))); + } else if (el.hasAttribute("aria-label")) { + setAttrIfChanged(el, "aria-label", translateTextValue(el.getAttribute("aria-label"))); + } + + if (el.hasAttribute("data-tooltip")) { + setAttrIfChanged(el, "data-tooltip", translateTextValue(el.getAttribute("data-tooltip"))); + } + } + + function translateTextNodes(root) { + if (!root) return; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + let node; + while ((node = walker.nextNode())) { + nodes.push(node); + } + + for (const textNode of nodes) { + const parent = textNode.parentElement; + if (!parent) continue; + if (SKIP_TAGS.has(parent.tagName)) continue; + if (parent.hasAttribute("data-i18n")) continue; + const translated = translateTextValue(textNode.nodeValue || ""); + if (translated !== textNode.nodeValue) { + textNode.nodeValue = translated; + } + } + } + + function translateElement(el) { + const textKey = el.getAttribute("data-i18n"); + if (textKey) { + const translatedText = t(textKey); + if (el.textContent !== translatedText) { + el.textContent = translatedText; + } + } + + const titleKey = el.getAttribute("data-i18n-title"); + if (titleKey) { + setAttrIfChanged(el, "title", t(titleKey)); + } + + const placeholderKey = el.getAttribute("data-i18n-placeholder"); + if (placeholderKey) { + setAttrIfChanged(el, "placeholder", t(placeholderKey)); + } + + const ariaLabelKey = el.getAttribute("data-i18n-aria-label"); + if (ariaLabelKey) { + setAttrIfChanged(el, "aria-label", t(ariaLabelKey)); + } + + translateLooseAttributes(el); + } + + function translateSubtree(root = document) { + if (!root) return; + isApplyingTranslations = true; + if (root.nodeType === Node.ELEMENT_NODE) { + translateElement(root); + } + root + .querySelectorAll( + "[data-i18n], [data-i18n-title], [data-i18n-placeholder], [data-i18n-aria-label]", + ) + .forEach(translateElement); + + const elementRoot = + root.nodeType === Node.DOCUMENT_NODE ? root.body || root.documentElement : root; + if (elementRoot) { + translateTextNodes(elementRoot); + if (elementRoot.querySelectorAll) { + elementRoot + .querySelectorAll("[title], [placeholder], [aria-label], [data-tooltip]") + .forEach(translateLooseAttributes); + } + } + isApplyingTranslations = false; + } + + function updateDocumentLang() { + document.documentElement.lang = currentLocale; + } + + function renderLanguageSwitcher() { + const header = document.querySelector("header"); + if (!header) return; + + let container = document.getElementById("lang-switcher"); + let select = document.getElementById("lang-select"); + + if (!container) { + container = document.createElement("div"); + container.id = "lang-switcher"; + container.style.display = "inline-flex"; + container.style.alignItems = "center"; + container.style.gap = "6px"; + container.style.marginLeft = "12px"; + container.style.flexShrink = "0"; + + const label = document.createElement("span"); + label.style.fontSize = "0.75rem"; + label.style.opacity = "0.8"; + label.textContent = "🌐"; + container.appendChild(label); + + select = document.createElement("select"); + select.id = "lang-select"; + select.style.fontSize = "0.8rem"; + select.style.padding = "3px 8px"; + select.style.background = "var(--card-bg, #161b22)"; + select.style.color = "var(--text, #c9d1d9)"; + select.style.border = "1px solid var(--border, #30363d)"; + select.style.borderRadius = "4px"; + select.style.cursor = "pointer"; + select.innerHTML = ` + + + `; + container.appendChild(select); + + const targetHost = header.querySelector(".header-left") || header; + targetHost.appendChild(container); + } + + select = document.getElementById("lang-select"); + if (select && !select.dataset.i18nBound) { + select.addEventListener("change", (e) => { + setLocale(e.target.value, { persist: true }); + }); + select.dataset.i18nBound = "1"; + } + + if (select) { + if (!select.options.length) { + select.innerHTML = ` + + + `; + } + select.value = currentLocale; + } + } + + function installObserver() { + if (observer) observer.disconnect(); + if (!document.body) return; + + observer = new MutationObserver((mutations) => { + if (isApplyingTranslations) return; + isApplyingTranslations = true; + try { + for (const mutation of mutations) { + if (mutation.type === "childList") { + mutation.addedNodes.forEach((addedNode) => { + if (addedNode.nodeType === Node.TEXT_NODE) { + const parent = addedNode.parentElement; + if (parent && !SKIP_TAGS.has(parent.tagName)) { + const translated = translateTextValue(addedNode.nodeValue || ""); + if (translated !== addedNode.nodeValue) addedNode.nodeValue = translated; + } + return; + } + if (addedNode.nodeType === Node.ELEMENT_NODE) { + translateSubtree(addedNode); + } + }); + } + + if (mutation.type === "characterData" && mutation.target?.nodeType === Node.TEXT_NODE) { + const textNode = mutation.target; + const parent = textNode.parentElement; + if (parent && !SKIP_TAGS.has(parent.tagName)) { + const translated = translateTextValue(textNode.nodeValue || ""); + if (translated !== textNode.nodeValue) textNode.nodeValue = translated; + } + } + } + } finally { + isApplyingTranslations = false; + } + }); + + observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: false, + }); + } + + async function setLocale(locale, { persist = true } = {}) { + const normalized = normalizeLocale(locale); + const targetLocale = SUPPORTED_LOCALES.includes(normalized) ? normalized : DEFAULT_LOCALE; + + let localeMessages; + try { + localeMessages = await loadLocaleMessages(targetLocale); + } catch (error) { + console.error("[i18n] Failed to load locale, fallback to English:", error); + localeMessages = await loadLocaleMessages(DEFAULT_LOCALE); + } + + currentLocale = targetLocale; + activeMessages = localeMessages; + if (persist) { + localStorage.setItem(STORAGE_KEY, currentLocale); + } + + updateDocumentLang(); + translateSubtree(document); + renderLanguageSwitcher(); + installObserver(); + window.dispatchEvent(new CustomEvent("i18n:updated", { detail: { locale: currentLocale } })); + } + + async function init() { + await loadLocaleMessages(DEFAULT_LOCALE); + await loadLocaleMessages("zh-CN").catch(() => null); + const initialLocale = getInitialLocale(); + await setLocale(initialLocale, { persist: false }); + } + + window.I18N = { + init, + t, + setLocale, + getLocale: () => currentLocale, + translateSubtree, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, { once: true }); + } else { + init(); + } +})(); diff --git a/public/js/lib/morphdom.min.js b/public/js/lib/morphdom.min.js new file mode 100644 index 0000000..f05778f --- /dev/null +++ b/public/js/lib/morphdom.min.js @@ -0,0 +1,507 @@ +(function (global, factory) { + typeof exports === "object" && typeof module !== "undefined" + ? (module.exports = factory()) + : typeof define === "function" && define.amd + ? define(factory) + : ((global = global || self), (global.morphdom = factory())); +})(this, function () { + "use strict"; + var DOCUMENT_FRAGMENT_NODE = 11; + function morphAttrs(fromNode, toNode) { + var toNodeAttrs = toNode.attributes; + var attr; + var attrName; + var attrNamespaceURI; + var attrValue; + var fromValue; + if ( + toNode.nodeType === DOCUMENT_FRAGMENT_NODE || + fromNode.nodeType === DOCUMENT_FRAGMENT_NODE + ) { + return; + } + for (var i = toNodeAttrs.length - 1; i >= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + if (fromValue !== attrValue) { + if (attr.prefix === "xmlns") { + attrName = attr.name; + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + var fromNodeAttrs = fromNode.attributes; + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } + } + var range; + var NS_XHTML = "http://www.w3.org/1999/xhtml"; + var doc = typeof document === "undefined" ? undefined : document; + var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); + var HAS_RANGE_SUPPORT = + !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); + function createFragmentFromTemplate(str) { + var template = doc.createElement("template"); + template.innerHTML = str; + return template.content.childNodes[0]; + } + function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; + } + function createFragmentFromWrap(str) { + var fragment = doc.createElement("body"); + fragment.innerHTML = str; + return fragment.childNodes[0]; + } + function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + return createFragmentFromTemplate(str); + } else if (HAS_RANGE_SUPPORT) { + return createFragmentFromRange(str); + } + return createFragmentFromWrap(str); + } + function compareNodeNames(fromEl, toEl) { + var fromNodeName = fromEl.nodeName; + var toNodeName = toEl.nodeName; + var fromCodeStart, toCodeStart; + if (fromNodeName === toNodeName) { + return true; + } + fromCodeStart = fromNodeName.charCodeAt(0); + toCodeStart = toNodeName.charCodeAt(0); + if (fromCodeStart <= 90 && toCodeStart >= 97) { + return fromNodeName === toNodeName.toUpperCase(); + } else if (toCodeStart <= 90 && fromCodeStart >= 97) { + return toNodeName === fromNodeName.toUpperCase(); + } else { + return false; + } + } + function createElementNS(name, namespaceURI) { + return !namespaceURI || namespaceURI === NS_XHTML + ? doc.createElement(name) + : doc.createElementNS(namespaceURI, name); + } + function moveChildren(fromEl, toEl) { + var curChild = fromEl.firstChild; + while (curChild) { + var nextChild = curChild.nextSibling; + toEl.appendChild(curChild); + curChild = nextChild; + } + return toEl; + } + function syncBooleanAttrProp(fromEl, toEl, name) { + if (fromEl[name] !== toEl[name]) { + fromEl[name] = toEl[name]; + if (fromEl[name]) { + fromEl.setAttribute(name, ""); + } else { + fromEl.removeAttribute(name); + } + } + } + var specialElHandlers = { + OPTION: function (fromEl, toEl) { + var parentNode = fromEl.parentNode; + if (parentNode) { + var parentName = parentNode.nodeName.toUpperCase(); + if (parentName === "OPTGROUP") { + parentNode = parentNode.parentNode; + parentName = parentNode && parentNode.nodeName.toUpperCase(); + } + if (parentName === "SELECT" && !parentNode.hasAttribute("multiple")) { + if (fromEl.hasAttribute("selected") && !toEl.selected) { + fromEl.setAttribute("selected", "selected"); + fromEl.removeAttribute("selected"); + } + parentNode.selectedIndex = -1; + } + } + syncBooleanAttrProp(fromEl, toEl, "selected"); + }, + INPUT: function (fromEl, toEl) { + syncBooleanAttrProp(fromEl, toEl, "checked"); + syncBooleanAttrProp(fromEl, toEl, "disabled"); + if (fromEl.value !== toEl.value) { + fromEl.value = toEl.value; + } + if (!toEl.hasAttribute("value")) { + fromEl.removeAttribute("value"); + } + }, + TEXTAREA: function (fromEl, toEl) { + var newValue = toEl.value; + if (fromEl.value !== newValue) { + fromEl.value = newValue; + } + var firstChild = fromEl.firstChild; + if (firstChild) { + var oldValue = firstChild.nodeValue; + if (oldValue == newValue || (!newValue && oldValue == fromEl.placeholder)) { + return; + } + firstChild.nodeValue = newValue; + } + }, + SELECT: function (fromEl, toEl) { + if (!toEl.hasAttribute("multiple")) { + var selectedIndex = -1; + var i = 0; + var curChild = fromEl.firstChild; + var optgroup; + var nodeName; + while (curChild) { + nodeName = curChild.nodeName && curChild.nodeName.toUpperCase(); + if (nodeName === "OPTGROUP") { + optgroup = curChild; + curChild = optgroup.firstChild; + } else { + if (nodeName === "OPTION") { + if (curChild.hasAttribute("selected")) { + selectedIndex = i; + break; + } + i++; + } + curChild = curChild.nextSibling; + if (!curChild && optgroup) { + curChild = optgroup.nextSibling; + optgroup = null; + } + } + } + fromEl.selectedIndex = selectedIndex; + } + }, + }; + var ELEMENT_NODE = 1; + var DOCUMENT_FRAGMENT_NODE$1 = 11; + var TEXT_NODE = 3; + var COMMENT_NODE = 8; + function noop() {} + function defaultGetNodeKey(node) { + if (node) { + return (node.getAttribute && node.getAttribute("id")) || node.id; + } + } + function morphdomFactory(morphAttrs) { + return function morphdom(fromNode, toNode, options) { + if (!options) { + options = {}; + } + if (typeof toNode === "string") { + if ( + fromNode.nodeName === "#document" || + fromNode.nodeName === "HTML" || + fromNode.nodeName === "BODY" + ) { + var toNodeHtml = toNode; + toNode = doc.createElement("html"); + toNode.innerHTML = toNodeHtml; + } else { + toNode = toElement(toNode); + } + } else if (toNode.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + toNode = toNode.firstElementChild; + } + var getNodeKey = options.getNodeKey || defaultGetNodeKey; + var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; + var onNodeAdded = options.onNodeAdded || noop; + var onBeforeElUpdated = options.onBeforeElUpdated || noop; + var onElUpdated = options.onElUpdated || noop; + var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; + var onNodeDiscarded = options.onNodeDiscarded || noop; + var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; + var skipFromChildren = options.skipFromChildren || noop; + var addChild = + options.addChild || + function (parent, child) { + return parent.appendChild(child); + }; + var childrenOnly = options.childrenOnly === true; + var fromNodesLookup = Object.create(null); + var keyedRemovalList = []; + function addKeyedRemoval(key) { + keyedRemovalList.push(key); + } + function walkDiscardedChildNodes(node, skipKeyedNodes) { + if (node.nodeType === ELEMENT_NODE) { + var curChild = node.firstChild; + while (curChild) { + var key = undefined; + if (skipKeyedNodes && (key = getNodeKey(curChild))) { + addKeyedRemoval(key); + } else { + onNodeDiscarded(curChild); + if (curChild.firstChild) { + walkDiscardedChildNodes(curChild, skipKeyedNodes); + } + } + curChild = curChild.nextSibling; + } + } + } + function removeNode(node, parentNode, skipKeyedNodes) { + if (onBeforeNodeDiscarded(node) === false) { + return; + } + if (parentNode) { + parentNode.removeChild(node); + } + onNodeDiscarded(node); + walkDiscardedChildNodes(node, skipKeyedNodes); + } + function indexTree(node) { + if (node.nodeType === ELEMENT_NODE || node.nodeType === DOCUMENT_FRAGMENT_NODE$1) { + var curChild = node.firstChild; + while (curChild) { + var key = getNodeKey(curChild); + if (key) { + fromNodesLookup[key] = curChild; + } + indexTree(curChild); + curChild = curChild.nextSibling; + } + } + } + indexTree(fromNode); + function handleNodeAdded(el) { + onNodeAdded(el); + var curChild = el.firstChild; + while (curChild) { + var nextSibling = curChild.nextSibling; + var key = getNodeKey(curChild); + if (key) { + var unmatchedFromEl = fromNodesLookup[key]; + if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { + curChild.parentNode.replaceChild(unmatchedFromEl, curChild); + morphEl(unmatchedFromEl, curChild); + } else { + handleNodeAdded(curChild); + } + } else { + handleNodeAdded(curChild); + } + curChild = nextSibling; + } + } + function cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey) { + while (curFromNodeChild) { + var fromNextSibling = curFromNodeChild.nextSibling; + if ((curFromNodeKey = getNodeKey(curFromNodeChild))) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = fromNextSibling; + } + } + function morphEl(fromEl, toEl, childrenOnly) { + var toElKey = getNodeKey(toEl); + if (toElKey) { + delete fromNodesLookup[toElKey]; + } + if (!childrenOnly) { + if (onBeforeElUpdated(fromEl, toEl) === false) { + return; + } + morphAttrs(fromEl, toEl); + onElUpdated(fromEl); + if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { + return; + } + } + if (fromEl.nodeName !== "TEXTAREA") { + morphChildren(fromEl, toEl); + } else { + specialElHandlers.TEXTAREA(fromEl, toEl); + } + } + function morphChildren(fromEl, toEl) { + var skipFrom = skipFromChildren(fromEl, toEl); + var curToNodeChild = toEl.firstChild; + var curFromNodeChild = fromEl.firstChild; + var curToNodeKey; + var curFromNodeKey; + var fromNextSibling; + var toNextSibling; + var matchingFromEl; + outer: while (curToNodeChild) { + toNextSibling = curToNodeChild.nextSibling; + curToNodeKey = getNodeKey(curToNodeChild); + while (!skipFrom && curFromNodeChild) { + fromNextSibling = curFromNodeChild.nextSibling; + if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + curFromNodeKey = getNodeKey(curFromNodeChild); + var curFromNodeType = curFromNodeChild.nodeType; + var isCompatible = undefined; + if (curFromNodeType === curToNodeChild.nodeType) { + if (curFromNodeType === ELEMENT_NODE) { + if (curToNodeKey) { + if (curToNodeKey !== curFromNodeKey) { + if ((matchingFromEl = fromNodesLookup[curToNodeKey])) { + if (fromNextSibling === matchingFromEl) { + isCompatible = false; + } else { + fromEl.insertBefore(matchingFromEl, curFromNodeChild); + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = matchingFromEl; + curFromNodeKey = getNodeKey(curFromNodeChild); + } + } else { + isCompatible = false; + } + } + } else if (curFromNodeKey) { + isCompatible = false; + } + isCompatible = + isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); + if (isCompatible) { + morphEl(curFromNodeChild, curToNodeChild); + } + } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { + isCompatible = true; + if (curFromNodeChild.nodeValue !== curToNodeChild.nodeValue) { + curFromNodeChild.nodeValue = curToNodeChild.nodeValue; + } + } + } + if (isCompatible) { + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + continue outer; + } + if (curFromNodeKey) { + addKeyedRemoval(curFromNodeKey); + } else { + removeNode(curFromNodeChild, fromEl, true); + } + curFromNodeChild = fromNextSibling; + } + if ( + curToNodeKey && + (matchingFromEl = fromNodesLookup[curToNodeKey]) && + compareNodeNames(matchingFromEl, curToNodeChild) + ) { + if (!skipFrom) { + addChild(fromEl, matchingFromEl); + } + morphEl(matchingFromEl, curToNodeChild); + } else { + var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); + if (onBeforeNodeAddedResult !== false) { + if (onBeforeNodeAddedResult) { + curToNodeChild = onBeforeNodeAddedResult; + } + if (curToNodeChild.actualize) { + curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); + } + addChild(fromEl, curToNodeChild); + handleNodeAdded(curToNodeChild); + } + } + curToNodeChild = toNextSibling; + curFromNodeChild = fromNextSibling; + } + cleanupFromEl(fromEl, curFromNodeChild, curFromNodeKey); + var specialElHandler = specialElHandlers[fromEl.nodeName]; + if (specialElHandler) { + specialElHandler(fromEl, toEl); + } + } + var morphedNode = fromNode; + var morphedNodeType = morphedNode.nodeType; + var toNodeType = toNode.nodeType; + if (!childrenOnly) { + if (morphedNodeType === ELEMENT_NODE) { + if (toNodeType === ELEMENT_NODE) { + if (!compareNodeNames(fromNode, toNode)) { + onNodeDiscarded(fromNode); + morphedNode = moveChildren( + fromNode, + createElementNS(toNode.nodeName, toNode.namespaceURI), + ); + } + } else { + morphedNode = toNode; + } + } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { + if (toNodeType === morphedNodeType) { + if (morphedNode.nodeValue !== toNode.nodeValue) { + morphedNode.nodeValue = toNode.nodeValue; + } + return morphedNode; + } else { + morphedNode = toNode; + } + } + } + if (morphedNode === toNode) { + onNodeDiscarded(fromNode); + } else { + if (toNode.isSameNode && toNode.isSameNode(morphedNode)) { + return; + } + morphEl(morphedNode, toNode, childrenOnly); + if (keyedRemovalList) { + for (var i = 0, len = keyedRemovalList.length; i < len; i++) { + var elToRemove = fromNodesLookup[keyedRemovalList[i]]; + if (elToRemove) { + removeNode(elToRemove, elToRemove.parentNode, false); + } + } + } + } + if (!childrenOnly && morphedNode !== fromNode && fromNode.parentNode) { + if (morphedNode.actualize) { + morphedNode = morphedNode.actualize(fromNode.ownerDocument || doc); + } + fromNode.parentNode.replaceChild(morphedNode, fromNode); + } + return morphedNode; + }; + } + var morphdom = morphdomFactory(morphAttrs); + return morphdom; +}); diff --git a/public/js/sidebar.js b/public/js/sidebar.js new file mode 100644 index 0000000..c1e8c9b --- /dev/null +++ b/public/js/sidebar.js @@ -0,0 +1,357 @@ +/** + * Shared Sidebar Loader + * + * Loads the sidebar partial and connects to SSE for live badge updates. + * Include this script in any page that needs the sidebar. + */ + +(function () { + "use strict"; + + // State for sidebar badges + const sidebarState = { + sessions: 0, + cron: 0, + jobs: 0, + memory: 0, + cerebro: 0, + operators: 0, + tokens: "-", + cost: "-", + monthlyCost: "-", + avgTokens: "-", + avgCost: "-", + lastUpdated: null, + }; + + // SSE connection + let eventSource = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_DELAY = 30000; + + /** + * Load and inject sidebar HTML + */ + async function loadSidebar() { + try { + const response = await fetch("/partials/sidebar.html"); + if (!response.ok) throw new Error("Failed to load sidebar"); + + const html = await response.text(); + + // Find or create sidebar container + let container = document.getElementById("sidebar-container"); + if (!container) { + // Insert at start of body + container = document.createElement("div"); + container.id = "sidebar-container"; + document.body.insertBefore(container, document.body.firstChild); + } + + container.innerHTML = html; + + if (window.I18N?.translateSubtree) { + window.I18N.translateSubtree(container); + } + + // Set active state based on current page + setActiveNavItem(); + + // Connect to SSE for live updates + connectSSE(); + + // Also fetch initial state + fetchSidebarState(); + } catch (error) { + console.error("[Sidebar] Failed to load:", error); + } + } + + /** + * Check if we're on the main page + */ + function isMainPage() { + const path = window.location.pathname; + return path === "/" || path === "/index.html"; + } + + /** + * Set the active nav item based on current URL + */ + function setActiveNavItem() { + const currentPath = window.location.pathname; + const currentHash = window.location.hash; + + document.querySelectorAll(".nav-item").forEach((item) => { + item.classList.remove("active"); + + const itemPage = item.dataset.page; + const itemHref = item.getAttribute("href"); + + // Check if this nav item matches the current page + if (itemPage === "/" && isMainPage()) { + // For main page sections + if (currentHash && itemHref && itemHref === currentHash) { + item.classList.add("active"); + } else if (!currentHash && item.dataset.section === "vitals") { + // Default to vitals on main page with no hash + item.classList.add("active"); + } + } else if (itemHref === currentPath) { + // Exact page match (like /jobs.html) + item.classList.add("active"); + } + }); + } + + /** + * Set up navigation click handlers + * - Hash links on main page: smooth scroll + * - Hash links on other pages: navigate to main page with hash + */ + function setupNavigation() { + document.querySelectorAll(".nav-item[data-section]").forEach((item) => { + item.addEventListener("click", (e) => { + const section = item.dataset.section; + const targetHash = `#${section}-section`; + + if (isMainPage()) { + // On main page: smooth scroll to section + e.preventDefault(); + const target = document.querySelector(targetHash); + if (target) { + target.scrollIntoView({ behavior: "smooth" }); + history.pushState(null, "", targetHash); + setActiveNavItem(); + } + } else { + // On other page: navigate to main page with hash + e.preventDefault(); + window.location.href = "/" + targetHash; + } + }); + }); + } + + /** + * Connect to SSE for live updates + */ + function connectSSE() { + if (typeof EventSource === "undefined") { + console.warn("[Sidebar SSE] Not supported"); + return; + } + + eventSource = new EventSource("/api/events"); + + eventSource.onopen = () => { + console.log("[Sidebar SSE] Connected"); + reconnectAttempts = 0; + }; + + eventSource.addEventListener("update", (e) => { + try { + const data = JSON.parse(e.data); + handleStateUpdate(data); + } catch (err) { + console.error("[Sidebar SSE] Parse error:", err); + } + }); + + eventSource.addEventListener("heartbeat", () => { + sidebarState.lastUpdated = new Date(); + updateTimestamp(); + }); + + eventSource.onerror = () => { + console.error("[Sidebar SSE] Connection error"); + eventSource.close(); + + // Exponential backoff reconnect + reconnectAttempts++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY); + setTimeout(connectSSE, delay); + }; + } + + /** + * Fetch initial sidebar state + */ + async function fetchSidebarState() { + try { + const response = await fetch("/api/state"); + const data = await response.json(); + handleStateUpdate(data); + } catch (error) { + console.error("[Sidebar] Failed to fetch state:", error); + } + } + + /** + * Handle state updates and update badges + */ + function handleStateUpdate(data) { + // Update session count + if (data.sessions) { + sidebarState.sessions = data.sessions.length || 0; + } + if (data.statusCounts) { + sidebarState.sessions = data.statusCounts.all || 0; + } + + // Update cron count + if (data.cron) { + sidebarState.cron = Array.isArray(data.cron) ? data.cron.length : 0; + } + + // Update jobs count (from jobs API if available) + if (data.jobs) { + sidebarState.jobs = Array.isArray(data.jobs) ? data.jobs.length : data.jobs.total || 0; + } + + // Update memory count + if (data.memory) { + sidebarState.memory = data.memory.fileCount || data.memory.totalFiles || 0; + } + + // Update cerebro count + if (data.cerebro) { + sidebarState.cerebro = data.cerebro.topicCount || data.cerebro.totalTopics || 0; + } + + // Update operators count + if (data.operators) { + sidebarState.operators = Array.isArray(data.operators.operators) + ? data.operators.operators.length + : 0; + } + + // Update token stats + if (data.tokenStats) { + sidebarState.tokens = data.tokenStats.totalFormatted || data.tokenStats.total || "-"; + sidebarState.cost = data.tokenStats.estCostFormatted || data.tokenStats.estCost || "-"; + sidebarState.monthlyCost = + data.tokenStats.estMonthlyCostFormatted || data.tokenStats.estMonthlyCost || "-"; + sidebarState.avgTokens = data.tokenStats.avgTokensPerSession || "-"; + sidebarState.avgCost = data.tokenStats.avgCostPerSession || "-"; + } + + sidebarState.lastUpdated = new Date(); + + // Update the DOM + updateBadges(); + updateTimestamp(); + } + + /** + * Update badge elements + */ + function updateBadges() { + const updates = { + "nav-session-count": sidebarState.sessions, + "nav-cron-count": sidebarState.cron, + "nav-jobs-count": sidebarState.jobs || "-", + "nav-memory-count": sidebarState.memory, + "nav-cerebro-count": sidebarState.cerebro, + "nav-operator-count": sidebarState.operators, + "nav-tokens": sidebarState.tokens, + "nav-cost": sidebarState.cost, + "nav-monthly-cost": sidebarState.monthlyCost, + "nav-avg-tokens": sidebarState.avgTokens, + "nav-avg-cost": sidebarState.avgCost, + }; + + for (const [id, value] of Object.entries(updates)) { + const el = document.getElementById(id); + if (el && el.textContent !== String(value)) { + el.textContent = value; + } + } + } + + /** + * Update the timestamp in sidebar footer + */ + function updateTimestamp() { + const el = document.getElementById("sidebar-updated"); + if (el && sidebarState.lastUpdated) { + const timeStr = sidebarState.lastUpdated.toLocaleTimeString(); + const t = window.I18N?.t; + el.textContent = t ? t("sidebar.live", { time: timeStr }) : `Live: ${timeStr}`; + } + } + + /** + * Toggle sidebar collapsed state + */ + window.toggleSidebar = function () { + const sidebar = document.getElementById("sidebar"); + const mainWrapper = document.getElementById("main-wrapper"); + + if (sidebar) { + sidebar.classList.toggle("collapsed"); + } + if (mainWrapper) { + mainWrapper.classList.toggle("sidebar-collapsed"); + } + + // Save preference + const collapsed = sidebar?.classList.contains("collapsed"); + try { + localStorage.setItem("sidebar-collapsed", collapsed ? "true" : "false"); + } catch (e) {} + }; + + /** + * Restore sidebar collapsed state from localStorage + */ + function restoreSidebarState() { + try { + const collapsed = localStorage.getItem("sidebar-collapsed") === "true"; + if (collapsed) { + const sidebar = document.getElementById("sidebar"); + const mainWrapper = document.getElementById("main-wrapper"); + if (sidebar) sidebar.classList.add("collapsed"); + if (mainWrapper) mainWrapper.classList.add("sidebar-collapsed"); + } + } catch (e) {} + } + + // Fetch jobs count separately (since it's a different API) + async function fetchJobsCount() { + try { + const response = await fetch("/api/jobs"); + const data = await response.json(); + sidebarState.jobs = data.jobs?.length || 0; + updateBadges(); + } catch (error) { + // Jobs API may not be available + } + } + + // Initialize on DOM ready + function init() { + loadSidebar().then(() => { + restoreSidebarState(); + setupNavigation(); + fetchJobsCount(); + }); + + // Listen for hash changes to update active state + window.addEventListener("hashchange", setActiveNavItem); + window.addEventListener("i18n:updated", () => { + const container = document.getElementById("sidebar-container"); + if (container && window.I18N?.translateSubtree) { + window.I18N.translateSubtree(container); + } + updateTimestamp(); + setActiveNavItem(); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/public/js/store.js b/public/js/store.js new file mode 100644 index 0000000..ade0b8c --- /dev/null +++ b/public/js/store.js @@ -0,0 +1,87 @@ +/** + * Simple state store for Command Center + * Holds the current dashboard state and notifies subscribers of changes + */ + +let state = { + vitals: null, + sessions: [], + tokenStats: null, + statusCounts: { all: 0, live: 0, recent: 0, idle: 0 }, + capacity: null, + operators: { operators: [], roles: {} }, + llmUsage: null, + cron: [], + memory: null, + cerebro: null, + subagents: [], + pagination: { page: 1, pageSize: 50, totalPages: 1 }, + timestamp: null, +}; + +const subscribers = new Set(); + +/** + * Get the current state + * @returns {Object} Current state + */ +export function getState() { + return state; +} + +/** + * Update the state with new data + * @param {Object} newState - New state data (partial or full) + */ +export function setState(newState) { + state = { ...state, ...newState, timestamp: Date.now() }; + notifySubscribers(); +} + +/** + * Subscribe to state changes + * @param {Function} callback - Called when state changes + * @returns {Function} Unsubscribe function + */ +export function subscribe(callback) { + subscribers.add(callback); + return () => subscribers.delete(callback); +} + +function notifySubscribers() { + for (const callback of subscribers) { + try { + callback(state); + } catch (err) { + console.error("[Store] Subscriber error:", err); + } + } +} + +// Filter state +export const filters = { + session: { status: "all", channel: "all", kind: "all" }, + cron: { status: "all", schedule: "all" }, + memory: { type: "all", age: "all" }, +}; + +export function setFilter(section, key, value) { + if (filters[section]) { + filters[section][key] = value; + notifySubscribers(); + } +} + +// Pagination state +export const pagination = { + page: 1, + pageSize: 20, + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, +}; + +export function setPagination(newPagination) { + Object.assign(pagination, newPagination); +} diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..a9623f8 --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,47 @@ +/** + * Utility functions for Command Center + */ + +export function formatTimeAgo(mins) { + if (mins < 1) return "now"; + if (mins < 60) return `${mins}m`; + if (mins < 1440) return `${Math.round(mins / 60)}h`; + return `${Math.round(mins / 1440)}d`; +} + +export function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +/** + * Smart DOM update using morphdom - only patches what changed + * @param {HTMLElement} targetEl - Element to update + * @param {string} newHtml - New HTML content + */ +export function smartUpdate(targetEl, newHtml) { + if (typeof morphdom === "undefined") { + // Fallback if morphdom not loaded + targetEl.innerHTML = newHtml; + return; + } + // Create a temporary container with the new content + const temp = document.createElement("div"); + temp.innerHTML = newHtml; + // If target has single child and temp has single child, morph directly + if (targetEl.children.length === 1 && temp.children.length === 1) { + morphdom(targetEl.firstElementChild, temp.firstElementChild); + } else { + // Otherwise morph the container itself + morphdom(targetEl, temp, { childrenOnly: true }); + } +} + +export function formatBytes(bytes) { + if (bytes >= 1099511627776) return (bytes / 1099511627776).toFixed(1) + " TB"; + if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB"; + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB"; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..cba7331 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,111 @@ +{ + "app": { + "title": "OpenClaw Command Center", + "connecting": "Connecting...", + "connected": "🟢 Live", + "disconnected": "🔴 Disconnected", + "pollingMode": "Polling Mode", + "error": "🔴 Error" + }, + "sidebar": { + "title": "Command Center", + "navigation": "Navigation", + "settings": "Settings", + "quickStats": "Quick Stats", + "systemVitals": "System Vitals", + "llmUsage": "LLM Usage", + "sessions": "Sessions", + "cronJobs": "Cron Jobs", + "aiJobs": "AI Jobs", + "memory": "Memory", + "cerebro": "Cerebro", + "operators": "Operators", + "privacy": "Privacy", + "about": "About", + "tokens": "Tokens", + "estDaily": "Est. Daily", + "estMonthly": "Est. Monthly", + "avgTokSess": "Avg Tok/Sess", + "avgCostSess": "Avg $/Sess", + "autoRefresh": "Auto-refresh: 30s", + "live": "Live: {time}", + "updated": "Updated: {time}" + }, + "stats": { + "totalTokens": "Total Tokens", + "input": "Input", + "output": "Output", + "active15m": "Active (15m)", + "estCost24h": "Est. Cost (24h) 📊", + "estMonthlySavings": "Est. Monthly Savings", + "main": "Main", + "subagents": "Sub-agents" + }, + "quickActions": { + "title": "⚡ Quick Actions", + "healthCheck": "🔍 Health Check", + "gatewayStatus": "🚪 Gateway Status", + "cleanStale": "🧹 Clean Stale Sessions" + }, + "time": { + "now": "now", + "justNow": "Just now" + }, + "actions": { + "remove": "Remove" + }, + "privacy": { + "noCronHidden": "No cron jobs hidden" + }, + "connection": { + "realtime": "Real-time updates via SSE ⚡", + "polling": "Polling mode (SSE disconnected)" + }, + "jobs": { + "pageTitle": "AI Jobs - OpenClaw Command Center", + "dashboard": "🤖 AI Jobs Dashboard", + "refresh": "🔄 Refresh", + "totalJobs": "Total Jobs", + "active": "Active", + "paused": "Paused", + "running": "Running", + "successRate": "Success Rate", + "recentFailures": "Recent Failures", + "all": "All", + "failed": "Failed", + "loadingJobs": "Loading jobs...", + "noJobs": "No jobs found", + "runHistory": "Run History", + "loadingHistory": "Loading history...", + "status": "Status", + "started": "Started", + "duration": "Duration", + "details": "Details", + "noHistory": "No run history yet", + "statusFailing": "Failing", + "next": "Next: {value}", + "lane": "🛤️ {value}", + "runs": "Runs", + "success": "Success", + "avgTime": "Avg Time", + "lastRun": "Last run: {value}", + "neverRun": "Never run", + "run": "▶️ Run", + "resume": "▶️ Resume", + "pause": "⏸️ Pause", + "history": "📜 History", + "every": "Every {value}", + "at": "At {value}", + "toastLoadFailed": "Failed to load jobs", + "toastRunQueued": "Job \"{id}\" queued for execution", + "toastRunFailed": "Failed to run job", + "toastPaused": "Job \"{id}\" paused", + "toastPauseFailed": "Failed to pause job", + "toastResumed": "Job \"{id}\" resumed", + "toastResumeFailed": "Failed to resume job", + "historyTitle": "Run History: {name}", + "statusSuccess": "Success", + "statusFailed": "Failed", + "statusRunning": "Running" + } +} diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json new file mode 100644 index 0000000..437c0f8 --- /dev/null +++ b/public/locales/zh-CN.json @@ -0,0 +1,521 @@ +{ + "app": { + "title": "OpenClaw 指挥中心", + "connecting": "连接中...", + "connected": "🟢 实时", + "disconnected": "🔴 已断开", + "pollingMode": "轮询模式", + "error": "🔴 错误" + }, + "sidebar": { + "title": "指挥中心", + "navigation": "导航", + "settings": "设置", + "quickStats": "快速统计", + "systemVitals": "系统状态", + "llmUsage": "LLM 用量", + "sessions": "会话", + "cronJobs": "定时任务", + "aiJobs": "AI 任务", + "memory": "记忆", + "cerebro": "Cerebro", + "operators": "Operators(操作者)", + "privacy": "隐私", + "about": "关于", + "tokens": "Token", + "estDaily": "日预估", + "estMonthly": "月预估", + "avgTokSess": "平均 Token/会话", + "avgCostSess": "平均 $/会话", + "autoRefresh": "自动刷新:30秒", + "live": "实时:{time}", + "updated": "更新:{time}" + }, + "stats": { + "totalTokens": "总 Token", + "input": "输入", + "output": "输出", + "active15m": "活跃(15分钟)", + "estCost24h": "预估成本(24小时)📊", + "estMonthlySavings": "预估月节省", + "main": "主会话", + "subagents": "子代理" + }, + "quickActions": { + "title": "⚡ 快捷操作", + "healthCheck": "🔍 健康检查", + "gatewayStatus": "🚪 网关状态", + "cleanStale": "🧹 清理过期会话" + }, + "time": { + "now": "刚刚", + "justNow": "刚刚" + }, + "actions": { + "remove": "移除" + }, + "privacy": { + "noCronHidden": "未隐藏任何定时任务" + }, + "connection": { + "realtime": "通过 SSE 实时更新 ⚡", + "polling": "轮询模式(SSE 已断开)" + }, + "phrases": { + "exact": { + "OpenClaw Command Center": "OpenClaw 指挥中心", + "Command Center": "指挥中心", + "Connecting...": "连接中...", + "Total Tokens": "总 Token", + "Input": "输入", + "Output": "输出", + "Active (15m)": "活跃(15分钟)", + "Est. Cost (24h) 📊": "预估成本(24小时)📊", + "Est. Monthly Savings": "预估月节省", + "Main": "主会话", + "Sub-agents": "子代理", + "System Vitals": "系统状态", + "LLM Fuel Gauges": "LLM 用量仪表", + "Sessions": "会话", + "Cron Jobs": "定时任务", + "Memory": "记忆", + "Operators": "Operators(操作者)", + "About": "关于", + "Quick Actions": "快捷操作", + "Health Check": "健康检查", + "Gateway Status": "网关状态", + "Clean Stale Sessions": "清理过期会话", + "Session Details": "会话详情", + "Overview": "概览", + "Summary": "摘要", + "References": "引用", + "Needs Attention": "需关注", + "Key Facts": "关键信息", + "Tools Used": "使用工具", + "Recent Messages": "最近消息", + "Send Message": "发送消息", + "Refresh": "刷新", + "Clear Session": "清空会话", + "Fix Claude Authentication": "修复 Claude 鉴权", + "Step 1: Refresh Claude Token": "步骤 1:刷新 Claude Token", + "Step 2: Update OpenClaw Agent": "步骤 2:更新 OpenClaw 代理", + "Step 3: Verify": "步骤 3:验证", + "Cost Breakdown (24h)": "成本明细(24小时)", + "Token Usage": "Token 用量", + "Pricing Rates (Claude Opus)": "价格费率(Claude Opus)", + "Calculation": "计算方式", + "Est. Savings": "预估节省", + "Top Sessions by Tokens (24h)": "按 Token 排名前会话(24小时)", + "User Stats": "用户统计", + "Loading user stats...": "正在加载用户统计...", + "Privacy Settings": "隐私设置", + "Hidden Topics": "隐藏话题", + "No topics hidden": "未隐藏任何话题", + "Hidden Sessions": "隐藏会话", + "No sessions hidden": "未隐藏任何会话", + "Hidden Cron Jobs": "隐藏定时任务", + "No cron jobs hidden": "未隐藏任何定时任务", + "Display Options": "显示选项", + "Hide system hostname": "隐藏系统主机名", + "Clear All Privacy Settings": "清除全部隐私设置", + "Status": "状态", + "Channel": "渠道", + "Kind": "类型", + "All": "全部", + "Live": "在线", + "Recent": "最近", + "Idle": "空闲", + "Slack": "Slack", + "Telegram": "Telegram", + "Discord": "Discord", + "Signal": "Signal", + "WhatsApp": "WhatsApp", + "Subagent": "子代理(Subagent)", + "Enabled": "启用", + "Disabled": "禁用", + "Schedule": "计划", + "Frequent (<1h)": "高频(<1小时)", + "Daily": "每日", + "Weekly": "每周", + "Type": "类型", + "Today": "今天", + "This Week": "本周", + "Older": "更早", + "Long-term curated memories": "长期整理后的记忆", + "Raw logs by date": "按日期保存的原始日志", + "Recent Memory Files": "最近记忆文件", + "Loading...": "加载中...", + "Cerebro Not Initialized": "Cerebro 未初始化", + "Topics by Status": "话题状态统计", + "Threads": "线程", + "Recent Active Topics": "最近活跃话题", + "No active topics yet": "暂无活跃话题", + "Open cerebro/": "打开 cerebro/ 目录", + "Loading operators...": "正在加载 Operators(操作者)...", + "Permission Levels": "权限级别", + "Owner": "所有者", + "Full control": "完全控制", + "Admin": "管理员", + "Manage users & settings": "管理用户和设置", + "User": "用户", + "Dashboard access": "看板访问权限", + "A Starcraft-inspired dashboard for OpenClaw orchestration": "受星际争霸启发的 OpenClaw 编排看板", + "BUILT BY": "开发者", + "INSPIRED BY": "灵感来源", + "No active sub-agents": "暂无活跃子代理", + "Session Usage": "会话用量", + "Weekly Usage": "周用量", + "5-Hour Usage": "5 小时用量", + "Daily Usage": "日用量", + "Tasks Today": "今日任务", + "Session Limit": "会话额度", + "1m avg": "1分钟均值", + "5m avg": "5分钟均值", + "15m avg": "15分钟均值", + "used of total": "已用 / 总量", + "IOPS": "IOPS", + "MB/s": "MB/秒", + "KB/t": "KB/次", + "tok/min": "Token/分钟", + "v...": "v...", + "MIT License": "MIT 许可证", + "used": "已用", + "available": "可用", + "user": "用户", + "sys": "系统", + "idle": "空闲", + "cores": "核心", + "App": "应用", + "Wired": "常驻内存(Wired)", + "Compressed": "压缩内存", + "Cached": "缓存", + "Normal": "正常", + "Checking...": "检测中...", + "API Usage": "API 用量", + "ChatGPT Plus": "ChatGPT Plus", + "Status:": "状态:", + "Channel:": "渠道:", + "Kind:": "类型:", + "Schedule:": "计划:", + "Type:": "类型:", + "Age:": "时间:", + "size": "大小", + "lines": "行数", + "files": "文件", + "total": "总计", + "active": "活跃", + "resolved": "已解决", + "parked": "暂挂", + "tracked": "已跟踪", + "orphans": "孤立话题", + "No active sessions": "暂无活跃会话", + "No scheduled jobs": "暂无定时任务", + "No operators configured": "未配置 Operators(操作者)", + "Last Seen": "最后在线", + "Auth Error - Click to Fix": "鉴权错误 - 点击修复", + "N/A": "不可用", + "No memory files yet": "暂无记忆文件", + "Failed to load cost data": "加载成本数据失败", + "Operator not found": "未找到 Operator(操作者)", + "Failed to load user data": "加载用户数据失败", + "Active Sessions": "活跃会话", + "Total Sessions": "会话总数", + "First Seen": "首次出现", + "No sessions found": "未找到会话", + "Input Tokens": "输入 Token", + "Output Tokens": "输出 Token", + "Cache Read": "缓存读取", + "Cache Write": "缓存写入", + "API Requests": "API 请求数", + "Input Cost": "输入成本", + "Output Cost": "输出成本", + "Cache Read Cost": "缓存读取成本", + "Cache Write Cost": "缓存写入成本", + "Est. API Cost": "预估 API 成本", + "Projected Monthly Cost by Window:": "按时间窗口预测月成本:", + "No session data available": "暂无会话数据", + "Est. Cost": "预估成本", + "Cache (R/W)": "缓存(读/写)", + "Channel": "渠道", + "Model": "模型", + "Input / Output": "输入 / 输出", + "Last Active": "最后活跃", + "No summary": "暂无摘要", + "No references detected": "未检测到引用", + "Nothing needs attention": "暂无需关注项", + "No key facts": "暂无关键信息", + "No tools used": "未使用工具", + "No messages": "暂无消息", + "Navigation": "导航", + "Settings": "设置", + "Quick Stats": "快速统计", + "LLM Fuel Gauges": "LLM 用量仪表", + "System Vitals": "系统状态", + "LLM Usage": "LLM 用量", + "Privacy": "隐私", + "Total tokens (24h)": "总 Token(24小时)", + "Click for breakdown": "点击查看明细", + "Projected monthly cost": "预测月成本", + "Average tokens per session": "每会话平均 Token", + "Average cost per session": "每会话平均成本", + "SSE connection status": "SSE 连接状态", + "Sessions active within last 15 minutes": "最近 15 分钟内活跃会话", + "Click for cost breakdown": "点击查看成本明细", + "Main session capacity": "主会话容量", + "Sub-agent capacity": "子代理容量", + "Memory actively used by apps": "应用正在使用的内存", + "Memory that can't be swapped (kernel, drivers)": "不可交换内存(内核、驱动)", + "Memory compressed to save space": "为节省空间而压缩的内存", + "Recently-used data, can be reclaimed": "近期使用数据,可回收", + "Average routing latency (classification + execution)": "平均路由延迟(分类 + 执行)", + "AI Jobs Dashboard": "AI 任务面板", + "Privacy Settings": "隐私设置", + "No topics hidden": "未隐藏任何话题", + "No sessions hidden": "未隐藏任何会话", + "Hide topics and sessions from display for privacy during demos/screenshots. Settings are stored in your browser only.": "为保护演示/截图隐私,可隐藏话题和会话。设置仅保存在你的浏览器中。", + "Tip: You can also click the 👁️ icon on any session card to hide it quickly.": "提示:你也可以点击任意会话卡片上的 👁️ 图标快速隐藏。", + "Open Terminal and run:": "打开终端并运行:", + "Follow the prompts to authenticate with your Claude account.": "按提示完成 Claude 账号认证。", + "Run the onboard wizard to update your agent credentials:": "运行引导向导更新代理凭据:", + "Or manually update the main agent:": "或手动更新主代理:", + "Select \"Claude Code CLI\" when prompted for the auth source.": "当提示选择认证来源时,选择 \"Claude Code CLI\"。", + "Refresh this dashboard or run:": "刷新此看板,或运行:", + "You should see your usage percentages instead of an auth error.": "你应该看到用量百分比,而不是鉴权错误。", + "Memory editing UI coming soon (Inside Out style!)": "记忆编辑 UI 即将上线(Inside Out 风格!)", + "Cerebro tracks conversation topics and threads across sessions.": "Cerebro 用于跨会话追踪对话话题与线程。", + "To initialize Cerebro:": "初始化 Cerebro:", + "Threads linked to topics": "关联到话题的线程", + "No active sub-agents": "暂无活跃子代理", + "No active topics yet": "暂无活跃话题", + "No memory files yet": "暂无记忆文件", + "No references detected": "未检测到引用", + "Nothing needs attention": "暂无需关注项", + "No key facts": "暂无关键信息", + "No tools used": "未使用工具", + "No messages": "暂无消息", + "No summary": "暂无摘要", + "No session data available": "暂无会话数据", + "No sessions found": "未找到会话", + "Loading user stats...": "正在加载用户统计...", + "Loading operators...": "正在加载 Operators(操作者)...", + "Loading...": "加载中...", + "Add": "添加", + "Remove": "移除", + "Prev": "上一页", + "Next": "下一页", + "Last updated:": "最后更新:", + "Built by": "开发者", + "remaining": "剩余", + "resets in": "后重置", + "resets": "重置", + "Avg latency": "平均延迟" + }, + "patterns": [ + { + "pattern": "^Live:\\s*(.+)$", + "replace": "实时:$1" + }, + { + "pattern": "^Updated:\\s*(.+)$", + "replace": "更新:$1" + }, + { + "pattern": "^Uptime:\\s*(.+)$", + "replace": "运行时长:$1" + }, + { + "pattern": "^Routing:\\s*(.+)$", + "replace": "路由:$1" + }, + { + "pattern": "^Last updated:\\s*(.*)$", + "replace": "最后更新:$1" + }, + { + "pattern": "^Last run:\\s*(.+)$", + "replace": "上次运行:$1" + }, + { + "pattern": "^Run History:\\s*(.+)$", + "replace": "运行历史:$1" + }, + { + "pattern": "^Operator not found:\\s*(.+)$", + "replace": "未找到 Operator(操作者):$1" + }, + { + "pattern": "^Error:\\s*(.+)$", + "replace": "错误:$1" + }, + { + "pattern": "^via\\s+(.+)$", + "replace": "来源:$1" + }, + { + "pattern": "^Avg latency:\\s*(.+)$", + "replace": "平均延迟:$1" + }, + { + "pattern": "^(\\d+) total topics$", + "replace": "$1 个话题" + }, + { + "pattern": "^(\\d+) tracked$", + "replace": "$1 个已跟踪" + }, + { + "pattern": "^(\\d+) orphans$", + "replace": "$1 个孤立" + }, + { + "pattern": "^ID:\\s*(.+) • (\\d+) tokens • (.+)$", + "replace": "ID:$1 • $2 Token • $3" + }, + { + "pattern": "^Auto-refresh:\\s*30s$", + "replace": "自动刷新:30秒" + }, + { + "pattern": "^Polling Mode$", + "replace": "轮询模式" + }, + { + "pattern": "^Real-time updates via SSE$", + "replace": "通过 SSE 实时更新" + }, + { + "pattern": "^Just now$", + "replace": "刚刚" + }, + { + "pattern": "^now$", + "replace": "刚刚" + } + ], + "reversePatterns": [ + { + "pattern": "^实时:\\s*(.+)$", + "replace": "Live: $1" + }, + { + "pattern": "^更新:\\s*(.+)$", + "replace": "Updated: $1" + }, + { + "pattern": "^运行时长:\\s*(.+)$", + "replace": "Uptime: $1" + }, + { + "pattern": "^路由:\\s*(.+)$", + "replace": "Routing: $1" + }, + { + "pattern": "^最后更新:\\s*(.*)$", + "replace": "Last updated: $1" + }, + { + "pattern": "^上次运行:\\s*(.+)$", + "replace": "Last run: $1" + }, + { + "pattern": "^运行历史:\\s*(.+)$", + "replace": "Run History: $1" + }, + { + "pattern": "^未找到 Operator(操作者):\\s*(.+)$", + "replace": "Operator not found: $1" + }, + { + "pattern": "^错误:\\s*(.+)$", + "replace": "Error: $1" + }, + { + "pattern": "^来源:\\s*(.+)$", + "replace": "via $1" + }, + { + "pattern": "^平均延迟:\\s*(.+)$", + "replace": "Avg latency: $1" + }, + { + "pattern": "^(\\d+) 个话题$", + "replace": "$1 total topics" + }, + { + "pattern": "^(\\d+) 个已跟踪$", + "replace": "$1 tracked" + }, + { + "pattern": "^(\\d+) 个孤立$", + "replace": "$1 orphans" + }, + { + "pattern": "^ID:\\s*(.+) • (\\d+) Token • (.+)$", + "replace": "ID: $1 • $2 tokens • $3" + }, + { + "pattern": "^自动刷新:30秒$", + "replace": "Auto-refresh: 30s" + }, + { + "pattern": "^轮询模式$", + "replace": "Polling Mode" + }, + { + "pattern": "^通过 SSE 实时更新$", + "replace": "Real-time updates via SSE" + }, + { + "pattern": "^刚刚$", + "replace": "Just now" + } + ] + }, + "jobs": { + "pageTitle": "AI 任务 - OpenClaw 指挥中心", + "dashboard": "🤖 AI 任务面板", + "refresh": "🔄 刷新", + "totalJobs": "任务总数", + "active": "启用", + "paused": "暂停", + "running": "运行中", + "successRate": "成功率", + "recentFailures": "最近失败", + "all": "全部", + "failed": "失败", + "loadingJobs": "正在加载任务...", + "noJobs": "未找到任务", + "runHistory": "运行历史", + "loadingHistory": "正在加载历史...", + "status": "状态", + "started": "开始时间", + "duration": "耗时", + "details": "详情", + "noHistory": "暂无运行历史", + "statusFailing": "持续失败", + "next": "下次:{value}", + "lane": "🛤️ {value}", + "runs": "运行次数", + "success": "成功", + "avgTime": "平均耗时", + "lastRun": "上次运行:{value}", + "neverRun": "从未运行", + "run": "▶️ 运行", + "resume": "▶️ 恢复", + "pause": "⏸️ 暂停", + "history": "📜 历史", + "every": "每 {value}", + "at": "在 {value}", + "toastLoadFailed": "加载任务失败", + "toastRunQueued": "任务 \"{id}\" 已加入执行队列", + "toastRunFailed": "执行任务失败", + "toastPaused": "任务 \"{id}\" 已暂停", + "toastPauseFailed": "暂停任务失败", + "toastResumed": "任务 \"{id}\" 已恢复", + "toastResumeFailed": "恢复任务失败", + "historyTitle": "运行历史:{name}", + "statusSuccess": "成功", + "statusFailed": "失败", + "statusRunning": "运行中" + } +} diff --git a/public/partials/sidebar.html b/public/partials/sidebar.html new file mode 100644 index 0000000..7d4b0e3 --- /dev/null +++ b/public/partials/sidebar.html @@ -0,0 +1,167 @@ + + diff --git a/scripts/checks/README.md b/scripts/checks/README.md new file mode 100644 index 0000000..17d1b4e --- /dev/null +++ b/scripts/checks/README.md @@ -0,0 +1,48 @@ +# Pre-commit Checks + +Automated enforcement of rules from `AGENTS.md` and `CONTRIBUTING.md`. + +## Checks + +| Check | Rule Source | Description | +| ----------------- | --------------------- | ---------------------------------------------------- | +| `version-sync.sh` | CONTRIBUTING.md | Ensures `package.json` and `SKILL.md` versions match | +| `no-user-data.sh` | public/data/AGENTS.md | Blocks commits of user-specific data files | +| `no-secrets.sh` | AGENTS.md | Scans for accidentally committed secrets | + +## Adding New Checks + +1. Create a new script in `scripts/checks/` named `.sh` +2. Script must: + - Accept repo root as first argument (`$1`) + - Exit `0` on success + - Exit `1` on failure + - Print clear error messages when failing +3. Make it executable: `chmod +x scripts/checks/.sh` + +## Running Manually + +```bash +# Run all checks +./scripts/pre-commit + +# Run individual check +./scripts/checks/version-sync.sh . +``` + +## Installing the Hook + +```bash +make install-hooks +# or manually: +cp scripts/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## Bypassing (Not Recommended) + +```bash +git commit --no-verify +``` + +Only use this if you understand why the check is failing and have a valid reason to bypass. diff --git a/scripts/checks/no-secrets.sh b/scripts/checks/no-secrets.sh new file mode 100644 index 0000000..d00e8ca --- /dev/null +++ b/scripts/checks/no-secrets.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Check: No Secrets +# Basic check for accidentally committed secrets +# +# Rule: AGENTS.md - never commit secrets, API keys, or credentials +# + +REPO_ROOT="${1:-.}" + +# Patterns that might indicate secrets +SECRET_PATTERNS=( + 'sk-[a-zA-Z0-9]{20,}' # OpenAI API keys + 'xoxb-[0-9]+-[0-9]+-[a-zA-Z0-9]+' # Slack bot tokens + 'xoxp-[0-9]+-[0-9]+-[a-zA-Z0-9]+' # Slack user tokens + 'ghp_[a-zA-Z0-9]{36}' # GitHub personal access tokens + 'gho_[a-zA-Z0-9]{36}' # GitHub OAuth tokens + 'AKIA[0-9A-Z]{16}' # AWS access key IDs + 'password\s*[=:]\s*["\047][^"\047]{8,}' # Hardcoded passwords +) + +# Get staged file contents (only added/modified lines) +STAGED_DIFF=$(git diff --cached --diff-filter=AM 2>/dev/null || echo "") + +FOUND_SECRETS=0 + +for pattern in "${SECRET_PATTERNS[@]}"; do + if echo "$STAGED_DIFF" | grep -qE "$pattern"; then + echo " ⚠️ Potential secret detected matching pattern: $pattern" + FOUND_SECRETS=1 + fi +done + +if [[ $FOUND_SECRETS -eq 1 ]]; then + echo " Review staged changes and remove any secrets before committing." + exit 1 +fi + +exit 0 diff --git a/scripts/checks/no-user-data.sh b/scripts/checks/no-user-data.sh new file mode 100644 index 0000000..4b94de0 --- /dev/null +++ b/scripts/checks/no-user-data.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Check: No User Data +# Ensures user-specific data files are not staged for commit +# +# Rule: public/data/AGENTS.md - never commit operators.json or privacy-settings.json +# + +REPO_ROOT="${1:-.}" + +# Check if any user data files are staged +STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || echo "") + +USER_DATA_FILES=( + "public/data/operators.json" + "public/data/privacy-settings.json" +) + +FOUND_USER_DATA=0 + +for file in "${USER_DATA_FILES[@]}"; do + if echo "$STAGED_FILES" | grep -q "^$file$"; then + echo " ⚠️ User data file staged: $file" + echo " This file contains user-specific data and should not be committed." + echo " Use 'git reset HEAD $file' to unstage." + FOUND_USER_DATA=1 + fi +done + +if [[ $FOUND_USER_DATA -eq 1 ]]; then + exit 1 +fi + +exit 0 diff --git a/scripts/checks/version-sync.sh b/scripts/checks/version-sync.sh new file mode 100644 index 0000000..33bb8ae --- /dev/null +++ b/scripts/checks/version-sync.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Check: Version Sync +# Ensures package.json and SKILL.md versions are in sync +# +# Rule: AGENTS.md / CONTRIBUTING.md - versions must match +# + +REPO_ROOT="${1:-.}" + +# Extract version from package.json +PKG_VERSION=$(grep -o '"version": *"[^"]*"' "$REPO_ROOT/package.json" | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/') + +# Extract version from SKILL.md frontmatter +SKILL_VERSION=$(grep -E '^version:' "$REPO_ROOT/SKILL.md" | head -1 | sed 's/version: *//') + +if [[ -z "$PKG_VERSION" ]]; then + echo " ⚠️ Could not read version from package.json" + exit 1 +fi + +if [[ -z "$SKILL_VERSION" ]]; then + echo " ⚠️ Could not read version from SKILL.md" + exit 1 +fi + +if [[ "$PKG_VERSION" != "$SKILL_VERSION" ]]; then + echo " ⚠️ Version mismatch:" + echo " package.json: $PKG_VERSION" + echo " SKILL.md: $SKILL_VERSION" + echo " → Both files must have the same version" + exit 1 +fi + +exit 0 diff --git a/scripts/dashboard-loop.sh b/scripts/dashboard-loop.sh new file mode 100644 index 0000000..16ff86f --- /dev/null +++ b/scripts/dashboard-loop.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Auto-restart loop for OpenClaw Command Center +# Keeps the dashboard running with exponential backoff on crashes + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DASHBOARD_DIR="$(dirname "$SCRIPT_DIR")" +LOG_DIR="${HOME}/.openclaw-command-center/logs" +LOG_FILE="${LOG_DIR}/dashboard.log" + +mkdir -p "$LOG_DIR" + +# Backoff settings +INITIAL_DELAY=1 +MAX_DELAY=30 +DELAY=$INITIAL_DELAY + +cd "$DASHBOARD_DIR" + +# Ensure node is available (nvm support) +if [ -f "$HOME/.nvm/nvm.sh" ]; then + source "$HOME/.nvm/nvm.sh" +fi + +echo "🦞 OpenClaw Command Center - Auto-restart loop" +echo " Logs: $LOG_FILE" +echo " Press Ctrl+C to stop" +echo "" + +while true; do + echo "[$(date)] Starting dashboard..." | tee -a "$LOG_FILE" + + # Run the server + if node lib/server.js 2>&1 | tee -a "$LOG_FILE"; then + # Clean exit + echo "[$(date)] Dashboard exited cleanly" | tee -a "$LOG_FILE" + DELAY=$INITIAL_DELAY + else + # Crash - backoff + echo "[$(date)] Dashboard crashed! Restarting in ${DELAY}s..." | tee -a "$LOG_FILE" + sleep $DELAY + DELAY=$((DELAY * 2)) + if [ $DELAY -gt $MAX_DELAY ]; then + DELAY=$MAX_DELAY + fi + fi +done diff --git a/scripts/install-system-deps.sh b/scripts/install-system-deps.sh new file mode 100644 index 0000000..2f06fdf --- /dev/null +++ b/scripts/install-system-deps.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# +# install-system-deps.sh - Install optional system dependencies +# +# Reads config/system-deps.json and installs missing packages +# using the detected package manager (apt, brew, dnf, etc.) +# +# Usage: ./scripts/install-system-deps.sh [--dry-run] +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +DEPS_FILE="$PROJECT_DIR/config/system-deps.json" +DRY_RUN="${1:-}" + +if [[ "$DRY_RUN" == "-h" || "$DRY_RUN" == "--help" ]]; then + echo "Usage: install-system-deps.sh [--dry-run]" + echo " --dry-run Show what would be installed without installing" + exit 0 +fi + +if [[ ! -f "$DEPS_FILE" ]]; then + echo "Error: $DEPS_FILE not found" + exit 1 +fi + +echo "🦞 OpenClaw Command Center - System Dependencies" +echo "=================================================" +echo "" + +# Let node do all the heavy lifting — it parses the JSON, detects the +# platform/package-manager/chip, checks which binaries exist, and +# prints shell commands to stdout for us to eval. +node -e " +const { execSync } = require('child_process'); +const os = require('os'); +const deps = require('$DEPS_FILE'); + +const platform = process.platform === 'linux' ? 'linux' : process.platform === 'darwin' ? 'darwin' : null; +if (!platform) { console.log('echo \"Unsupported platform\"'); process.exit(0); } + +// Detect package manager +const pmCandidates = platform === 'linux' + ? ['apt', 'dnf', 'yum', 'pacman', 'apk'] + : ['brew']; +let pkgManager = null; +for (const pm of pmCandidates) { + try { execSync('which ' + pm, { stdio: 'ignore' }); pkgManager = pm; break; } catch {} +} + +console.log('Platform: ' + platform); +console.log('Package manager: ' + (pkgManager || 'none')); +console.log(''); + +if (!pkgManager) { + console.log('No supported package manager found.'); + console.log('Supported: apt, dnf, yum, pacman, apk, brew'); + process.exit(1); +} + +// Detect chip +let isAppleSilicon = false; +if (platform === 'darwin') { + try { + const chip = execSync('sysctl -n machdep.cpu.brand_string', { encoding: 'utf8' }); + isAppleSilicon = /apple/i.test(chip); + } catch {} +} + +const entries = deps[platform] || []; +let installed = 0, toInstall = 0; + +for (const dep of entries) { + if (dep.condition === 'intel' && isAppleSilicon) continue; + const cmd = dep.install[pkgManager]; + if (!cmd) continue; + + let hasBinary = false; + try { execSync('which ' + dep.binary, { stdio: 'ignore' }); hasBinary = true; } catch {} + if (!hasBinary && dep.binary === 'osx-cpu-temp') { + try { execSync('test -x ' + os.homedir() + '/bin/osx-cpu-temp', { stdio: 'ignore' }); hasBinary = true; } catch {} + } + + if (hasBinary) { + console.log('✅ ' + dep.name + ' — already installed (' + dep.purpose + ')'); + installed++; + } else { + toInstall++; + if ('$DRY_RUN' === '--dry-run') { + console.log('📦 ' + dep.name + ' — would install (' + dep.purpose + ')'); + console.log(' Command: ' + cmd); + } else { + console.log('📦 Installing ' + dep.name + ' — ' + dep.purpose + '...'); + console.log(' Running: ' + cmd); + try { + execSync(cmd, { stdio: 'inherit' }); + console.log(' ✅ Installed successfully'); + } catch (e) { + console.log(' ⚠️ Install failed: ' + e.message); + } + } + } +} + +console.log(''); +if ('$DRY_RUN' === '--dry-run') { + console.log('Dry run complete. ' + installed + ' already installed, ' + toInstall + ' would be installed.'); +} else { + console.log('Done! ' + installed + ' already installed, ' + toInstall + ' newly installed.'); + if (toInstall > 0) { + console.log(''); + console.log('Restart the Command Center to see enhanced vitals.'); + } +} +" diff --git a/scripts/linear-sync.js b/scripts/linear-sync.js new file mode 100644 index 0000000..e085cf0 --- /dev/null +++ b/scripts/linear-sync.js @@ -0,0 +1,564 @@ +#!/usr/bin/env node +/** + * Linear Integration Module for OpenClaw Dashboard + * + * Syncs session state to Linear issues: + * - Extracts JON-XXX issue IDs from session transcripts + * - Updates Linear issue status when session state changes + * - Adds comments on state transitions + */ + +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const { getOpenClawDir } = require("../src/config"); + +// Linear API configuration +const LINEAR_API_URL = "https://api.linear.app/graphql"; +const LINEAR_API_KEY = process.env.LINEAR_API_KEY; + +// Workflow State IDs for team JON (from TOOLS.md) +const LINEAR_STATES = { + TODO: "2ee58f08-499b-47ee-bbe3-a254957517c5", + IN_PROGRESS: "c2c429d8-11d0-4fa5-bbe7-7bc7febbd42e", + DONE: "b82d1646-6044-48ad-b2e9-04f87739e16f", +}; + +// Session state to Linear state mapping +const STATE_MAP = { + active: LINEAR_STATES.IN_PROGRESS, + live: LINEAR_STATES.IN_PROGRESS, + idle: LINEAR_STATES.TODO, + completed: LINEAR_STATES.DONE, +}; + +// Track synced issues to avoid duplicate updates +// Key: issueId, Value: { lastState, lastUpdated } +const syncState = new Map(); + +// Path to persist sync state +const SYNC_STATE_FILE = path.join(__dirname, "..", "state", "linear-sync-state.json"); + +/** + * Load sync state from disk + */ +function loadSyncState() { + try { + if (fs.existsSync(SYNC_STATE_FILE)) { + const data = JSON.parse(fs.readFileSync(SYNC_STATE_FILE, "utf8")); + Object.entries(data).forEach(([key, value]) => { + syncState.set(key, value); + }); + console.log(`[Linear] Loaded sync state: ${syncState.size} issues tracked`); + } + } catch (e) { + console.error("[Linear] Failed to load sync state:", e.message); + } +} + +/** + * Save sync state to disk + */ +function saveSyncState() { + try { + const data = Object.fromEntries(syncState); + const dir = path.dirname(SYNC_STATE_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(SYNC_STATE_FILE, JSON.stringify(data, null, 2)); + } catch (e) { + console.error("[Linear] Failed to save sync state:", e.message); + } +} + +/** + * Make a GraphQL request to Linear API + * @param {string} query - GraphQL query/mutation + * @param {object} variables - Query variables + * @returns {Promise} Response data + */ +function linearRequest(query, variables = {}) { + return new Promise((resolve, reject) => { + if (!LINEAR_API_KEY) { + reject(new Error("LINEAR_API_KEY not set")); + return; + } + + const payload = JSON.stringify({ query, variables }); + + const options = { + hostname: "api.linear.app", + port: 443, + path: "/graphql", + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: LINEAR_API_KEY, + "Content-Length": Buffer.byteLength(payload), + }, + }; + + const req = https.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + const parsed = JSON.parse(data); + if (parsed.errors) { + reject(new Error(parsed.errors[0]?.message || "GraphQL error")); + } else { + resolve(parsed.data); + } + } catch (e) { + reject(new Error(`Failed to parse response: ${e.message}`)); + } + }); + }); + + req.on("error", reject); + req.write(payload); + req.end(); + }); +} + +/** + * Extract Linear issue IDs (JON-XXX pattern) from text + * @param {string} text - Text to search + * @returns {string[]} Array of unique issue identifiers + */ +function extractLinearIds(text) { + if (!text) return []; + + // Match JON-XXX pattern (case insensitive, 1-5 digits) + const pattern = /\bJON-(\d{1,5})\b/gi; + const matches = text.match(pattern) || []; + + // Normalize to uppercase and dedupe + const unique = [...new Set(matches.map((m) => m.toUpperCase()))]; + return unique; +} + +/** + * Extract Linear IDs from a session transcript + * @param {Array} transcript - Array of transcript entries + * @returns {string[]} Array of unique issue identifiers + */ +function extractLinearIdsFromTranscript(transcript) { + const allIds = new Set(); + + transcript.forEach((entry) => { + if (entry.type !== "message" || !entry.message) return; + + const msg = entry.message; + let text = ""; + + if (typeof msg.content === "string") { + text = msg.content; + } else if (Array.isArray(msg.content)) { + text = msg.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join(" "); + } + + extractLinearIds(text).forEach((id) => allIds.add(id)); + }); + + return [...allIds]; +} + +/** + * Get issue details by identifier (e.g., "JON-29") + * @param {string} identifier - Issue identifier + * @returns {Promise} Issue data or null + */ +async function getIssue(identifier) { + const query = ` + query GetIssue($id: String!) { + issue(id: $id) { + id + identifier + title + description + url + state { + id + name + type + } + priority + } + } + `; + + try { + const data = await linearRequest(query, { id: identifier }); + return data.issue; + } catch (e) { + console.error(`[Linear] Failed to get issue ${identifier}:`, e.message); + return null; + } +} + +/** + * Update issue state + * @param {string} issueId - Issue UUID (not identifier) + * @param {string} stateId - New state UUID + * @returns {Promise} Success status + */ +async function updateIssueState(issueId, stateId) { + const mutation = ` + mutation UpdateIssueState($id: String!, $stateId: String!) { + issueUpdate(id: $id, input: { stateId: $stateId }) { + success + issue { + id + identifier + state { + name + } + } + } + } + `; + + try { + const data = await linearRequest(mutation, { id: issueId, stateId }); + return data.issueUpdate?.success || false; + } catch (e) { + console.error(`[Linear] Failed to update issue state:`, e.message); + return false; + } +} + +/** + * Add a comment to an issue + * @param {string} issueId - Issue UUID (not identifier) + * @param {string} body - Comment body (markdown supported) + * @returns {Promise} Success status + */ +async function addComment(issueId, body) { + const mutation = ` + mutation AddComment($issueId: String!, $body: String!) { + commentCreate(input: { issueId: $issueId, body: $body }) { + success + comment { + id + } + } + } + `; + + try { + const data = await linearRequest(mutation, { issueId, body }); + return data.commentCreate?.success || false; + } catch (e) { + console.error(`[Linear] Failed to add comment:`, e.message); + return false; + } +} + +/** + * Determine session state from session data + * @param {object} session - Session object with ageMs, etc. + * @returns {string} State: 'active', 'idle', or 'completed' + */ +function determineSessionState(session) { + const ageMs = session.ageMs || 0; + const thirtyMinutes = 30 * 60 * 1000; + + // Check if session is marked complete (this would come from session metadata) + if (session.status === "completed" || session.completed) { + return "completed"; + } + + // Active if activity within 30 minutes + if (ageMs < thirtyMinutes) { + return "active"; + } + + // Idle if no activity for 30+ minutes + return "idle"; +} + +/** + * Sync a session's Linear issues based on session state + * @param {object} session - Session data including transcript + * @param {Array} transcript - Session transcript entries + * @returns {Promise} Sync results + */ +async function syncSessionToLinear(session, transcript) { + const results = { + issuesFound: [], + updated: [], + skipped: [], + errors: [], + }; + + if (!LINEAR_API_KEY) { + results.errors.push("LINEAR_API_KEY not configured"); + return results; + } + + // Extract Linear issue IDs from transcript + const issueIds = extractLinearIdsFromTranscript(transcript); + results.issuesFound = issueIds; + + if (issueIds.length === 0) { + return results; + } + + // Determine current session state + const sessionState = determineSessionState(session); + const targetStateId = STATE_MAP[sessionState]; + + if (!targetStateId) { + results.errors.push(`Unknown session state: ${sessionState}`); + return results; + } + + // Process each issue + for (const identifier of issueIds) { + try { + // Check sync state to avoid duplicate updates + const syncKey = `${identifier}:${session.key || session.sessionId}`; + const lastSync = syncState.get(syncKey); + + if (lastSync && lastSync.lastState === sessionState) { + results.skipped.push({ + identifier, + reason: "Already synced to this state", + }); + continue; + } + + // Get issue details + const issue = await getIssue(identifier); + if (!issue) { + results.errors.push(`Issue ${identifier} not found`); + continue; + } + + // Check if state change is needed + if (issue.state.id === targetStateId) { + // Update sync state even if no change needed + syncState.set(syncKey, { + lastState: sessionState, + lastUpdated: new Date().toISOString(), + }); + results.skipped.push({ + identifier, + reason: `Already in ${issue.state.name}`, + }); + continue; + } + + // Update issue state + const updateSuccess = await updateIssueState(issue.id, targetStateId); + + if (updateSuccess) { + // Add comment explaining the state change + const comment = generateStateChangeComment(sessionState, session); + await addComment(issue.id, comment); + + // Update sync state + syncState.set(syncKey, { + lastState: sessionState, + lastUpdated: new Date().toISOString(), + }); + saveSyncState(); + + results.updated.push({ + identifier, + fromState: issue.state.name, + toState: sessionState, + url: issue.url, + }); + } else { + results.errors.push(`Failed to update ${identifier}`); + } + } catch (e) { + results.errors.push(`Error processing ${identifier}: ${e.message}`); + } + } + + return results; +} + +/** + * Generate a comment for state change + * @param {string} newState - New session state + * @param {object} session - Session data + * @returns {string} Comment body + */ +function generateStateChangeComment(newState, session) { + const timestamp = new Date().toISOString(); + const sessionLabel = session.label || session.key || "Unknown session"; + + switch (newState) { + case "active": + case "live": + return ( + `🟢 **Work resumed** on this issue.\n\n` + + `Session: \`${sessionLabel}\`\n` + + `Time: ${timestamp}\n\n` + + `_Updated automatically by OpenClaw Dashboard_` + ); + + case "idle": + return ( + `⏸️ **Work paused** on this issue (session idle >30 min).\n\n` + + `Session: \`${sessionLabel}\`\n` + + `Time: ${timestamp}\n\n` + + `_Updated automatically by OpenClaw Dashboard_` + ); + + case "completed": + return ( + `✅ **Work completed** on this issue.\n\n` + + `Session: \`${sessionLabel}\`\n` + + `Time: ${timestamp}\n\n` + + `_Updated automatically by OpenClaw Dashboard_` + ); + + default: + return ( + `📝 Session state changed to: ${newState}\n\n` + + `Session: \`${sessionLabel}\`\n` + + `Time: ${timestamp}` + ); + } +} + +/** + * Read session transcript from JSONL file + * (Mirrors the function in server.js) + * @param {string} sessionId - Session ID + * @returns {Array} Transcript entries + */ +function readTranscript(sessionId) { + const openclawDir = getOpenClawDir(); + const transcriptPath = path.join(openclawDir, "agents", "main", "sessions", `${sessionId}.jsonl`); + + try { + if (!fs.existsSync(transcriptPath)) return []; + const content = fs.readFileSync(transcriptPath, "utf8"); + return content + .trim() + .split("\n") + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); + } catch (e) { + console.error("[Linear] Failed to read transcript:", e.message); + return []; + } +} + +/** + * Hook for server.js to call on session updates + * @param {object} session - Session data from OpenClaw + */ +async function onSessionUpdate(session) { + if (!session.sessionId) { + console.error("[Linear] Session missing sessionId"); + return { error: "Missing sessionId" }; + } + + const transcript = readTranscript(session.sessionId); + const results = await syncSessionToLinear(session, transcript); + + if (results.updated.length > 0) { + console.log( + `[Linear] Updated ${results.updated.length} issues:`, + results.updated.map((u) => u.identifier).join(", "), + ); + } + + return results; +} + +/** + * Batch sync all active sessions + * Useful for periodic sync via cron or manual trigger + */ +async function syncAllSessions() { + const { execSync } = require("child_process"); + + try { + const output = execSync("openclaw sessions --json 2>/dev/null", { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }); + + const data = JSON.parse(output); + const sessions = data.sessions || []; + + const allResults = { + sessionsProcessed: 0, + totalIssuesFound: 0, + totalUpdated: 0, + errors: [], + }; + + for (const session of sessions) { + const results = await onSessionUpdate(session); + allResults.sessionsProcessed++; + allResults.totalIssuesFound += results.issuesFound?.length || 0; + allResults.totalUpdated += results.updated?.length || 0; + if (results.errors?.length) { + allResults.errors.push(...results.errors); + } + } + + return allResults; + } catch (e) { + console.error("[Linear] Failed to sync all sessions:", e.message); + return { error: e.message }; + } +} + +// Initialize: load sync state +loadSyncState(); + +// Exports for server.js integration +module.exports = { + // Core functions + extractLinearIds, + extractLinearIdsFromTranscript, + getIssue, + updateIssueState, + addComment, + + // Session sync + syncSessionToLinear, + onSessionUpdate, + syncAllSessions, + + // State helpers + determineSessionState, + + // Constants + LINEAR_STATES, + STATE_MAP, +}; + +// CLI mode: run sync if called directly +if (require.main === module) { + console.log("[Linear] Running batch sync..."); + syncAllSessions() + .then((results) => { + console.log("[Linear] Sync complete:", JSON.stringify(results, null, 2)); + process.exit(results.error ? 1 : 0); + }) + .catch((e) => { + console.error("[Linear] Sync failed:", e.message); + process.exit(1); + }); +} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..b15ad64 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# release.sh - Create a versioned release with git tag and ClawHub publish +# +# Usage: +# ./scripts/release.sh # Create tag + publish +# ./scripts/release.sh --tag-only # Create tag only +# ./scripts/release.sh --current # Show current version +# +# Examples: +# ./scripts/release.sh 0.4.0 +# ./scripts/release.sh 1.0.0-beta.1 +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$REPO_DIR" + +# Get current version from latest tag +get_current_version() { + git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0" +} + +# Show help +show_help() { + echo "Usage: release.sh [--tag-only]" + echo " release.sh --current" + echo "" + echo "Options:" + echo " Semver version (e.g., 0.4.0, 1.0.0-beta.1)" + echo " --tag-only Create git tag without ClawHub publish" + echo " --current Show current version from git tags" + echo " -h, --help Show this help" +} + +# Parse args +if [[ $# -eq 0 ]]; then + show_help + exit 1 +fi + +TAG_ONLY=false +VERSION="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --current) + echo "Current version: $(get_current_version)" + exit 0 + ;; + --tag-only) + TAG_ONLY=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + VERSION="$1" + shift + ;; + esac +done + +if [[ -z "$VERSION" ]]; then + echo "❌ Version required" + show_help + exit 1 +fi + +# Validate semver (basic check) +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "❌ Invalid semver: $VERSION" + echo " Expected format: X.Y.Z or X.Y.Z-prerelease" + exit 1 +fi + +TAG="v$VERSION" +CURRENT=$(get_current_version) + +echo "📦 Release: $CURRENT → $VERSION" +echo "" + +# Check for uncommitted changes +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "❌ Uncommitted changes detected. Commit or stash first." + exit 1 +fi + +# Check if tag already exists +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Tag $TAG already exists" + exit 1 +fi + +# Update package.json version +if [[ -f package.json ]]; then + # Use npm version without git tag (we do our own tagging) + npm version "$VERSION" --no-git-tag-version +fi + +# Update SKILL.md version if it exists +if [[ -f SKILL.md ]]; then + sed -i '' "s/\*\*Version\*\* | \`[^\"]*\`/\*\*Version\*\* | \`$VERSION\`/" SKILL.md 2>/dev/null || \ + sed -i "s/\*\*Version\*\* | \`[^\"]*\`/\*\*Version\*\* | \`$VERSION\`/" SKILL.md +fi + +# Commit version bump +git add package.json package-lock.json SKILL.md 2>/dev/null || true +git commit -m "chore: release v$VERSION" --allow-empty + +# Create annotated tag +echo "🏷️ Creating tag $TAG..." +git tag -a "$TAG" -m "Release $VERSION" + +# Push commit and tag +echo "⬆️ Pushing to origin..." +git push origin main +git push origin "$TAG" + +echo "" +echo "✅ Tagged $TAG" + +# Publish to ClawHub unless --tag-only +if [[ "$TAG_ONLY" == "false" ]]; then + echo "" + echo "📤 Publishing to ClawHub..." + + # Get changelog from CHANGELOG.md if available + CHANGELOG="" + if [[ -f CHANGELOG.md ]]; then + CHANGELOG=$(awk '/^## \['"$VERSION"'\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md | head -20) + fi + + if command -v clawhub &>/dev/null; then + if [[ -n "$CHANGELOG" ]]; then + clawhub publish . --version "$VERSION" --changelog "$CHANGELOG" + else + clawhub publish . --version "$VERSION" --changelog "Release v$VERSION" + fi + echo "" + echo "✅ Published to ClawHub: $VERSION" + else + echo "⚠️ clawhub CLI not found. Skipping ClawHub publish." + echo " Install: npm install -g clawhub" + fi +fi + +echo "" +echo "🎉 Release $VERSION complete!" +echo "" +echo " Git tag: $TAG" +echo " GitHub: https://github.com/jontsai/openclaw-command-center/releases/tag/$TAG" diff --git a/scripts/run-server.sh b/scripts/run-server.sh new file mode 100644 index 0000000..504172c --- /dev/null +++ b/scripts/run-server.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Wrapper script to ensure PATH includes system directories +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" + +# Find node - prefer nvm if available +if [ -f "$HOME/.nvm/nvm.sh" ]; then + source "$HOME/.nvm/nvm.sh" +fi + +cd "$(dirname "$0")/.." +exec node lib/server.js diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..f847a7b --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# OpenClaw Command Center - First-time setup +# Creates necessary directories and config file + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +CONFIG_DIR="$PROJECT_DIR/config" + +echo "🦞 OpenClaw Command Center Setup" +echo "=================================" +echo "" + +# Check for Node.js +if ! command -v node &> /dev/null; then + echo "❌ Node.js not found. Please install Node.js 20+ first." + exit 1 +fi + +NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) +if [ "$NODE_VERSION" -lt 20 ]; then + echo "⚠️ Node.js version $NODE_VERSION detected. Version 20+ recommended." +fi + +# Install dependencies +echo "📦 Installing dependencies..." +cd "$PROJECT_DIR" +npm install --silent + +# Create config if not exists +if [ ! -f "$CONFIG_DIR/dashboard.json" ]; then + echo "" + echo "📝 Creating configuration file..." + cp "$CONFIG_DIR/dashboard.example.json" "$CONFIG_DIR/dashboard.json" + echo " Created: config/dashboard.json" + echo "" + echo " Edit this file to customize your dashboard." +else + echo " Config file already exists: config/dashboard.json" +fi + +# Create log directory +LOG_DIR="$HOME/.openclaw-command-center/logs" +mkdir -p "$LOG_DIR" +echo " Log directory: $LOG_DIR" + +# Detect workspace +echo "" +echo "🔍 Detecting OpenClaw workspace..." + +DETECTED_WORKSPACE="" +for candidate in \ + "$OPENCLAW_WORKSPACE" \ + "$HOME/openclaw-workspace" \ + "$HOME/.openclaw-workspace" \ + "$HOME/molty" \ + "$HOME/clawd" \ + "$HOME/moltbot"; do + if [ -n "$candidate" ] && [ -d "$candidate" ]; then + if [ -d "$candidate/memory" ] || [ -d "$candidate/state" ]; then + DETECTED_WORKSPACE="$candidate" + break + fi + fi +done + +if [ -n "$DETECTED_WORKSPACE" ]; then + echo " ✅ Found workspace: $DETECTED_WORKSPACE" +else + echo " ⚠️ No existing workspace found." + echo " The dashboard will create ~/.openclaw-workspace on first run," + echo " or you can set OPENCLAW_WORKSPACE environment variable." +fi + +# Create Makefile.local if not exists +if [ ! -f "$PROJECT_DIR/Makefile.local" ]; then + echo "" + echo "📝 Creating Makefile.local with 'lfg' command..." + cat > "$PROJECT_DIR/Makefile.local" << 'EOF' +# Private Makefile overrides (not tracked in git) + +.PHONY: lfg + +lfg: ## Start dashboard and drop into cockpit + @$(MAKE) ensure + @$(MAKE) attach +EOF + echo " Created: Makefile.local" +fi + +# Check optional system dependencies +echo "" +echo "🔍 Checking optional system dependencies..." + +OS_TYPE="$(uname -s)" +OPT_MISSING=0 + +if [ "$OS_TYPE" = "Linux" ]; then + if command -v iostat &> /dev/null; then + echo " ✅ sysstat (iostat) — disk I/O vitals" + else + echo " 💡 sysstat — install for disk I/O vitals: sudo apt install sysstat" + OPT_MISSING=$((OPT_MISSING + 1)) + fi + if command -v sensors &> /dev/null; then + echo " ✅ lm-sensors — temperature sensors" + else + echo " 💡 lm-sensors — install for temperature sensors: sudo apt install lm-sensors" + OPT_MISSING=$((OPT_MISSING + 1)) + fi +elif [ "$OS_TYPE" = "Darwin" ]; then + # Check for Apple Silicon vs Intel + CHIP="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "")" + if echo "$CHIP" | grep -qi "apple"; then + if sudo -n true 2>/dev/null; then + echo " ✅ passwordless sudo — Apple Silicon CPU temperature" + else + echo " 💡 passwordless sudo — configure for CPU temperature via powermetrics" + fi + else + if command -v osx-cpu-temp &> /dev/null || [ -x "$HOME/bin/osx-cpu-temp" ]; then + echo " ✅ osx-cpu-temp — Intel Mac CPU temperature" + else + echo " 💡 osx-cpu-temp — install for CPU temperature: https://github.com/lavoiesl/osx-cpu-temp" + OPT_MISSING=$((OPT_MISSING + 1)) + fi + fi +fi + +if [ "$OPT_MISSING" -eq 0 ]; then + echo " All optional dependencies available!" +fi + +echo "" +echo "✅ Setup complete!" +echo "" +echo "Quick start:" +echo " cd $PROJECT_DIR" +echo " make start # Start dashboard" +echo " make lfg # Start and attach to tmux" +echo "" +echo "Dashboard will be available at: http://localhost:3333" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..d70a3b6 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Start OpenClaw Command Center +# Usage: ./start.sh [--tunnel] + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PORT=3333 +TUNNEL=false +PID_FILE="/tmp/openclaw-dashboard.pid" +TUNNEL_PID_FILE="/tmp/openclaw-tunnel.pid" + +# Parse args +while [[ $# -gt 0 ]]; do + case $1 in + --tunnel|-t) + TUNNEL=true + shift + ;; + --port|-p) + PORT="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +# Check if already running +if [ -f "$PID_FILE" ] && kill -0 "$(cat $PID_FILE)" 2>/dev/null; then + echo "⚠️ Dashboard already running (PID: $(cat $PID_FILE))" + echo " Stop it first: ./stop.sh" + exit 1 +fi + +echo "🚀 Starting OpenClaw Command Center..." +echo "" + +# Start the Node.js server +cd "$SCRIPT_DIR/.." +PORT=$PORT node lib/server.js & +SERVER_PID=$! +echo $SERVER_PID > "$PID_FILE" + +sleep 1 + +# Check if server started +if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "❌ Failed to start server" + exit 1 +fi + +echo "✅ Dashboard running at http://localhost:$PORT" + +# Start tunnel if requested +if [ "$TUNNEL" = true ]; then + echo "" + echo "🌐 Starting Cloudflare tunnel..." + cloudflared tunnel --url http://localhost:$PORT & + TUNNEL_PID=$! + echo $TUNNEL_PID > "$TUNNEL_PID_FILE" + + # Wait a moment for the tunnel URL to appear + sleep 3 + echo "" + echo "📋 Tunnel should be active. Look for the trycloudflare.com URL above." +fi + +echo "" +echo "📊 Dashboard: http://localhost:$PORT" +echo "🛑 To stop: $SCRIPT_DIR/stop.sh" diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100644 index 0000000..b45eaba --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Stop OpenClaw Command Center + +PID_FILE="/tmp/openclaw-dashboard.pid" +TUNNEL_PID_FILE="/tmp/openclaw-tunnel.pid" + +echo "🛑 Stopping OpenClaw Command Center..." + +# Stop tunnel +if [ -f "$TUNNEL_PID_FILE" ]; then + PID=$(cat "$TUNNEL_PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" + echo " Tunnel stopped" + fi + rm -f "$TUNNEL_PID_FILE" +fi + +# Stop server +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" + echo " Server stopped" + fi + rm -f "$PID_FILE" +fi + +# Also kill any orphaned processes +pkill -f "node.*lib/server.js" 2>/dev/null +pkill -f "cloudflared.*localhost:3333" 2>/dev/null + +echo "✅ Done" diff --git a/scripts/tmux-dashboard.sh b/scripts/tmux-dashboard.sh new file mode 100644 index 0000000..e25ac7d --- /dev/null +++ b/scripts/tmux-dashboard.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Clawd Status Dashboard - tmux layout +# Creates a tmux session with live status panes + +SESSION="openclaw-status" +OPENCLAW_DIR="${OPENCLAW_WORKSPACE:-$HOME/.openclaw-workspace}" + +# Kill existing session if it exists +tmux kill-session -t "$SESSION" 2>/dev/null + +# Create new session (detached) +tmux new-session -d -s "$SESSION" -c "$OPENCLAW_DIR" + +# Rename first window +tmux rename-window -t "$SESSION:0" "dashboard" + +# Layout: +# +------------------+------------------+ +# | Sessions | Cron Jobs | +# +------------------+------------------+ +# | Gateway | Activity | +# +------------------+------------------+ + +# Pane 0: Sessions (watch openclaw sessions) +tmux send-keys -t "$SESSION:0" "watch -n 10 -c 'echo \"📡 ACTIVE SESSIONS\"; echo; openclaw sessions 2>/dev/null || echo \"No sessions\"'" Enter + +# Split horizontally for pane 1: Cron Jobs +tmux split-window -h -t "$SESSION:0" -c "$OPENCLAW_DIR" +tmux send-keys -t "$SESSION:0.1" "watch -n 30 -c 'echo \"⏰ CRON JOBS\"; echo; openclaw cron list 2>/dev/null || echo \"No cron jobs\"'" Enter + +# Split pane 0 vertically for pane 2: Gateway Status +tmux split-window -v -t "$SESSION:0.0" -c "$OPENCLAW_DIR" +tmux send-keys -t "$SESSION:0.2" "watch -n 15 -c 'echo \"🤖 GATEWAY STATUS\"; echo; openclaw gateway status 2>/dev/null; echo; echo \"---\"; openclaw status 2>/dev/null'" Enter + +# Split pane 1 vertically for pane 3: Activity Log +tmux split-window -v -t "$SESSION:0.1" -c "$OPENCLAW_DIR" +tmux send-keys -t "$SESSION:0.3" "watch -n 30 -c 'echo \"📝 RECENT ACTIVITY\"; echo; today=\$(date +%Y-%m-%d); if [ -f \"memory/\$today.md\" ]; then tail -20 \"memory/\$today.md\"; else echo \"No activity today\"; fi'" Enter + +# Make panes more even +tmux select-layout -t "$SESSION:0" tiled + +# Add a second window for logs +tmux new-window -t "$SESSION" -n "logs" -c "$OPENCLAW_DIR" +tmux send-keys -t "$SESSION:1" "echo '📜 Gateway Logs'; echo 'Run: openclaw gateway logs -f'; echo" Enter + +# Add a third window for interactive shell +tmux new-window -t "$SESSION" -n "shell" -c "$OPENCLAW_DIR" +tmux send-keys -t "$SESSION:2" "echo '🐚 Interactive Shell'; echo 'Ready for commands...'; echo" Enter + +# Go back to first window +tmux select-window -t "$SESSION:0" + +echo "✅ OpenClaw dashboard created!" +echo "" +echo "To attach: tmux attach -t $SESSION" +echo "To detach: Ctrl+B, then D" +echo "" + +# If not already in tmux, offer to attach +if [ -z "$TMUX" ]; then + read -p "Attach now? [Y/n] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + tmux attach -t "$SESSION" + fi +fi diff --git a/scripts/topic-classifier.js b/scripts/topic-classifier.js new file mode 100644 index 0000000..3e53100 --- /dev/null +++ b/scripts/topic-classifier.js @@ -0,0 +1,700 @@ +/** + * Topic Classifier for OpenClaw Sessions + * + * Analyzes session transcript content to: + * - Match against existing topics + * - Detect when existing topics don't fit well + * - Suggest new topic names based on content patterns + * - Maintain a discovered-topics.json file for learned topics + * + * @module topic-classifier + */ + +const fs = require("fs"); +const path = require("path"); +const { CONFIG: APP_CONFIG } = require("../src/config"); + +// Default config +const CONFIG = { + // Minimum TF-IDF score to consider a term significant + minTermScore: 0.1, + // Minimum topic match confidence to consider a match "good" + matchThreshold: 0.3, + // Minimum occurrences for a term to be considered + minTermFrequency: 2, + // Path to discovered topics state file + discoveredTopicsPath: path.join(APP_CONFIG.paths.state, "discovered-topics.json"), + // Maximum suggested topics per classification + maxSuggestions: 3, +}; + +// Stop words to filter out (common English words) +const STOP_WORDS = new Set([ + "a", + "an", + "the", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "from", + "up", + "about", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "between", + "under", + "again", + "further", + "then", + "once", + "here", + "there", + "when", + "where", + "why", + "how", + "all", + "each", + "few", + "more", + "most", + "other", + "some", + "such", + "no", + "nor", + "not", + "only", + "own", + "same", + "so", + "than", + "too", + "very", + "s", + "t", + "can", + "will", + "just", + "don", + "should", + "now", + "i", + "me", + "my", + "myself", + "we", + "our", + "ours", + "you", + "your", + "yours", + "he", + "him", + "his", + "she", + "her", + "hers", + "it", + "its", + "they", + "them", + "their", + "theirs", + "what", + "which", + "who", + "whom", + "this", + "that", + "these", + "those", + "am", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "having", + "do", + "does", + "did", + "doing", + "would", + "could", + "ought", + "let", + "like", + "need", + "want", + "got", + "get", + "make", + "made", + "see", + "look", + "think", + "know", + "take", + "come", + "go", + "say", + "said", + "tell", + "told", + "ask", + "use", + "used", + "find", + "give", + "gave", + "yes", + "no", + "ok", + "okay", + "yeah", + "sure", + "right", + "well", + "also", + "just", + "really", + "actually", + "basically", + "probably", + "maybe", + // Tech-common words that are too generic + "file", + "code", + "run", + "check", + "help", + "please", + "thanks", + "hello", + "hi", + "hey", + "good", + "great", + "nice", + "cool", + "awesome", + "perfect", +]); + +// Known topic patterns for seeding - maps keywords to topic names +const TOPIC_PATTERNS = { + // Development + git: "version-control", + github: "version-control", + commit: "version-control", + branch: "version-control", + merge: "version-control", + pull: "version-control", + push: "version-control", + + debug: "debugging", + error: "debugging", + bug: "debugging", + fix: "debugging", + stack: "debugging", + trace: "debugging", + exception: "debugging", + + test: "testing", + unittest: "testing", + jest: "testing", + pytest: "testing", + coverage: "testing", + + deploy: "deployment", + production: "deployment", + staging: "deployment", + ci: "deployment", + cd: "deployment", + pipeline: "deployment", + + api: "api-integration", + endpoint: "api-integration", + rest: "api-integration", + graphql: "api-integration", + webhook: "api-integration", + + database: "database", + sql: "database", + postgres: "database", + mysql: "database", + mongodb: "database", + query: "database", + + docker: "containers", + kubernetes: "containers", + k8s: "containers", + container: "containers", + pod: "containers", + + aws: "cloud-infra", + gcp: "cloud-infra", + azure: "cloud-infra", + terraform: "cloud-infra", + cloudformation: "cloud-infra", + + // Communication + slack: "slack-integration", + channel: "slack-integration", + message: "messaging", + email: "email", + notification: "notifications", + + // Automation + cron: "scheduling", + schedule: "scheduling", + timer: "scheduling", + job: "scheduling", + + script: "automation", + automate: "automation", + workflow: "automation", + + // Research + research: "research", + search: "research", + wikipedia: "research", + lookup: "research", + + // Finance + finance: "finance", + investment: "finance", + stock: "finance", + portfolio: "finance", + budget: "finance", + + // System + config: "configuration", + settings: "configuration", + setup: "configuration", + install: "setup", + + // Writing + document: "documentation", + readme: "documentation", + docs: "documentation", + write: "writing", + draft: "writing", + + // AI/ML + model: "ai-ml", + claude: "ai-ml", + openai: "ai-ml", + gpt: "ai-ml", + llm: "ai-ml", + prompt: "prompt-engineering", + + // UI + dashboard: "dashboard", + ui: "ui-development", + frontend: "ui-development", + css: "ui-development", + html: "ui-development", + react: "ui-development", +}; + +/** + * Tokenize text into words + * @param {string} text - Raw text to tokenize + * @returns {string[]} Array of lowercase tokens + */ +function tokenize(text) { + if (!text || typeof text !== "string") return []; + + return ( + text + .toLowerCase() + // Remove code blocks + .replace(/```[\s\S]*?```/g, " ") + // Remove inline code + .replace(/`[^`]+`/g, " ") + // Remove URLs + .replace(/https?:\/\/\S+/g, " ") + // Remove special characters but keep hyphens in words + .replace(/[^a-z0-9\s-]/g, " ") + // Split on whitespace + .split(/\s+/) + // Filter valid tokens + .filter( + (token) => + token.length > 2 && token.length < 30 && !STOP_WORDS.has(token) && !/^\d+$/.test(token), + ) + ); +} + +/** + * Calculate term frequency for a document + * @param {string[]} tokens - Array of tokens + * @returns {Map} Term frequency map + */ +function calculateTF(tokens) { + const tf = new Map(); + const total = tokens.length || 1; + + tokens.forEach((token) => { + tf.set(token, (tf.get(token) || 0) + 1); + }); + + // Normalize by document length + tf.forEach((count, term) => { + tf.set(term, count / total); + }); + + return tf; +} + +/** + * Calculate inverse document frequency using corpus statistics + * For a single document, we use term rarity as a proxy + * @param {Map} tf - Term frequency map + * @param {number} vocabSize - Size of vocabulary + * @returns {Map} IDF scores + */ +function calculateIDF(tf, vocabSize) { + const idf = new Map(); + + tf.forEach((freq, term) => { + // Boost terms that appear in known patterns + const patternBoost = TOPIC_PATTERNS[term] ? 2.0 : 1.0; + // Simple IDF approximation: rarer terms get higher scores + const score = Math.log(vocabSize / (1 + freq * vocabSize)) * patternBoost; + idf.set(term, Math.max(0, score)); + }); + + return idf; +} + +/** + * Extract key terms using TF-IDF + * @param {string} text - Text to analyze + * @returns {Array<{term: string, score: number}>} Ranked terms + */ +function extractKeyTerms(text) { + const tokens = tokenize(text); + if (tokens.length === 0) return []; + + const tf = calculateTF(tokens); + const idf = calculateIDF(tf, tf.size); + + const tfidf = []; + tf.forEach((tfScore, term) => { + const idfScore = idf.get(term) || 0; + const score = tfScore * idfScore; + + // Only include terms that meet minimum thresholds + const rawCount = tokens.filter((t) => t === term).length; + if (rawCount >= CONFIG.minTermFrequency && score >= CONFIG.minTermScore) { + tfidf.push({ term, score, count: rawCount }); + } + }); + + // Sort by score descending + return tfidf.sort((a, b) => b.score - a.score); +} + +/** + * Match text against existing topics + * @param {string} text - Text to match + * @param {string[]} existingTopics - List of existing topic names + * @returns {Array<{topic: string, confidence: number}>} Matched topics with confidence + */ +function matchTopics(text, existingTopics) { + const tokens = tokenize(text); + const matches = new Map(); + + // Score each existing topic + existingTopics.forEach((topic) => { + let score = 0; + const topicTokens = tokenize(topic); + + // Direct token match + topicTokens.forEach((tt) => { + const count = tokens.filter((t) => t === tt || t.includes(tt) || tt.includes(t)).length; + score += count * 0.3; + }); + + // Pattern-based matching + tokens.forEach((token) => { + const mappedTopic = TOPIC_PATTERNS[token]; + if (mappedTopic === topic) { + score += 0.5; + } + }); + + if (score > 0) { + // Normalize by text length (log scale to avoid penalizing long texts too much) + const normalizedScore = score / Math.log2(tokens.length + 2); + matches.set(topic, Math.min(1, normalizedScore)); + } + }); + + // Convert to sorted array + return Array.from(matches.entries()) + .map(([topic, confidence]) => ({ topic, confidence })) + .sort((a, b) => b.confidence - a.confidence); +} + +/** + * Generate topic suggestions based on content + * @param {Array<{term: string, score: number}>} keyTerms - Key terms from text + * @param {string[]} existingTopics - Topics to avoid suggesting + * @returns {string[]} Suggested new topic names + */ +function generateSuggestions(keyTerms, existingTopics) { + const existingSet = new Set(existingTopics.map((t) => t.toLowerCase())); + const suggestions = new Set(); + + // Strategy 1: Use known patterns for top terms + keyTerms.slice(0, 15).forEach(({ term }) => { + const mapped = TOPIC_PATTERNS[term]; + if (mapped && !existingSet.has(mapped)) { + suggestions.add(mapped); + } + }); + + // Strategy 2: Create compound topics from top co-occurring terms + if (keyTerms.length >= 2 && suggestions.size < CONFIG.maxSuggestions) { + const topTerms = keyTerms.slice(0, 5).map((t) => t.term); + + // Look for related pairs + const pairs = [ + ["api", "integration"], + ["code", "review"], + ["data", "analysis"], + ["error", "handling"], + ["file", "management"], + ["memory", "optimization"], + ["performance", "tuning"], + ["security", "audit"], + ["system", "design"], + ["user", "interface"], + ]; + + pairs.forEach(([a, b]) => { + if (topTerms.some((t) => t.includes(a)) && topTerms.some((t) => t.includes(b))) { + const compound = `${a}-${b}`; + if (!existingSet.has(compound)) { + suggestions.add(compound); + } + } + }); + } + + // Strategy 3: Use top-scoring term as-is if it's descriptive enough + if (suggestions.size < CONFIG.maxSuggestions) { + keyTerms.slice(0, 5).forEach(({ term, score }) => { + // Only use single terms that are sufficiently meaningful + if (score > 0.15 && term.length > 4 && !existingSet.has(term)) { + suggestions.add(term); + } + }); + } + + return Array.from(suggestions).slice(0, CONFIG.maxSuggestions); +} + +/** + * Load discovered topics from state file + * @returns {Object} Discovered topics data + */ +function loadDiscoveredTopics() { + try { + if (fs.existsSync(CONFIG.discoveredTopicsPath)) { + return JSON.parse(fs.readFileSync(CONFIG.discoveredTopicsPath, "utf8")); + } + } catch (e) { + console.error("Failed to load discovered topics:", e.message); + } + + return { + version: 1, + topics: {}, + lastUpdated: null, + }; +} + +/** + * Save discovered topics to state file + * @param {Object} data - Topics data to save + */ +function saveDiscoveredTopics(data) { + try { + data.lastUpdated = new Date().toISOString(); + fs.writeFileSync(CONFIG.discoveredTopicsPath, JSON.stringify(data, null, 2)); + } catch (e) { + console.error("Failed to save discovered topics:", e.message); + } +} + +/** + * Update discovered topics with new suggestions + * @param {string[]} suggestions - New topic suggestions + * @param {string} sessionKey - Source session identifier + */ +function updateDiscoveredTopics(suggestions, sessionKey) { + const data = loadDiscoveredTopics(); + + suggestions.forEach((topic) => { + if (!data.topics[topic]) { + data.topics[topic] = { + firstSeen: new Date().toISOString(), + occurrences: 0, + sessions: [], + }; + } + + data.topics[topic].occurrences++; + data.topics[topic].lastSeen = new Date().toISOString(); + + if (!data.topics[topic].sessions.includes(sessionKey)) { + data.topics[topic].sessions.push(sessionKey); + // Keep only last 10 sessions + if (data.topics[topic].sessions.length > 10) { + data.topics[topic].sessions.shift(); + } + } + }); + + saveDiscoveredTopics(data); +} + +/** + * Main classification function + * Analyzes transcript content to match existing topics and suggest new ones + * + * @param {string|Array} transcript - Session transcript (string or array of messages) + * @param {string[]} existingTopics - List of existing topic names + * @param {Object} options - Optional configuration + * @param {string} options.sessionKey - Session identifier for tracking + * @param {boolean} options.persist - Whether to persist discovered topics (default: true) + * @returns {{matched: Array<{topic: string, confidence: number}>, suggested: string[], keyTerms: Array}} + */ +function classifyAndSuggestTopics(transcript, existingTopics = [], options = {}) { + // Normalize transcript to text + let text = ""; + if (Array.isArray(transcript)) { + text = transcript + .map((entry) => { + if (typeof entry === "string") return entry; + if (entry.text) return entry.text; + if (entry.message?.content) { + const content = entry.message.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join(" "); + } + } + return ""; + }) + .join("\n"); + } else if (typeof transcript === "string") { + text = transcript; + } + + if (!text || text.length < 20) { + return { matched: [], suggested: [], keyTerms: [] }; + } + + // Extract key terms + const keyTerms = extractKeyTerms(text); + + // Match against existing topics + const matched = matchTopics(text, existingTopics); + + // Determine if we need suggestions + const bestMatch = matched[0]; + const needsSuggestions = !bestMatch || bestMatch.confidence < CONFIG.matchThreshold; + + let suggested = []; + if (needsSuggestions) { + suggested = generateSuggestions(keyTerms, existingTopics); + + // Persist discovered topics if enabled + if (options.persist !== false && suggested.length > 0 && options.sessionKey) { + updateDiscoveredTopics(suggested, options.sessionKey); + } + } + + return { + matched: matched.slice(0, 5), + suggested, + keyTerms: keyTerms.slice(0, 10), + confidence: bestMatch?.confidence || 0, + }; +} + +/** + * Get all discovered topics sorted by occurrence + * @returns {Array<{name: string, occurrences: number, sessions: number}>} + */ +function getDiscoveredTopics() { + const data = loadDiscoveredTopics(); + + return Object.entries(data.topics) + .map(([name, info]) => ({ + name, + occurrences: info.occurrences, + sessions: info.sessions?.length || 0, + firstSeen: info.firstSeen, + lastSeen: info.lastSeen, + })) + .sort((a, b) => b.occurrences - a.occurrences); +} + +/** + * Promote a discovered topic to the official topic list + * Returns the topic data for external handling + * @param {string} topicName - Topic to promote + * @returns {Object|null} Topic data or null if not found + */ +function promoteDiscoveredTopic(topicName) { + const data = loadDiscoveredTopics(); + + if (data.topics[topicName]) { + const topicData = { ...data.topics[topicName], name: topicName }; + delete data.topics[topicName]; + saveDiscoveredTopics(data); + return topicData; + } + + return null; +} + +// Export public API +module.exports = { + classifyAndSuggestTopics, + getDiscoveredTopics, + promoteDiscoveredTopic, + extractKeyTerms, + matchTopics, + // Export config for testing/tuning + CONFIG, + TOPIC_PATTERNS, +}; diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..073dd73 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# +# verify.sh - Quick dashboard verification script +# +# Checks that all APIs return data and the dashboard is responsive. +# +# Usage: ./scripts/verify.sh [--url URL] +# + +set -euo pipefail + +DASHBOARD_URL="${DASHBOARD_URL:-http://localhost:3333}" + +# Parse args +while [[ $# -gt 0 ]]; do + case "$1" in + --url) DASHBOARD_URL="$2"; shift 2 ;; + -h|--help) + echo "Usage: verify.sh [--url URL]" + echo " --url URL Dashboard URL (default: http://localhost:3333)" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +echo "🔍 Verifying dashboard at $DASHBOARD_URL..." +echo "" + +# Track failures +FAILURES=0 + +# Check server responds +echo -n "📡 Server response... " +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$DASHBOARD_URL" 2>/dev/null || echo "000") +if [[ "$HTTP_CODE" == "200" ]]; then + echo "✅ OK (HTTP $HTTP_CODE)" +else + echo "❌ FAILED (HTTP $HTTP_CODE)" + ((FAILURES++)) +fi + +# Check each API endpoint +ENDPOINTS=( + "vitals:vitals" + "operators:operators" + "llm-usage:claude" + "memory:memory" + "cerebro:topics" + "cron:cron" +) + +echo "" +echo "📊 API Endpoints:" + +for entry in "${ENDPOINTS[@]}"; do + endpoint="${entry%%:*}" + key="${entry##*:}" + + echo -n " /api/$endpoint... " + response=$(curl -s --max-time 5 "$DASHBOARD_URL/api/$endpoint" 2>/dev/null || echo "") + + if [[ -z "$response" ]]; then + echo "❌ No response" + ((FAILURES++)) + elif echo "$response" | grep -q "\"$key\""; then + echo "✅ OK" + elif echo "$response" | grep -q "error"; then + echo "⚠️ Error in response" + ((FAILURES++)) + else + echo "⚠️ Unexpected format" + fi +done + +echo "" + +# Optional dependency status +echo "🔧 Optional System Dependencies:" + +OS_TYPE="$(uname -s)" +if [[ "$OS_TYPE" == "Linux" ]]; then + if command -v iostat &> /dev/null; then + echo " ✅ sysstat (iostat) — disk I/O vitals" + else + echo " ⚠️ sysstat — not installed (disk I/O stats will show zeros)" + fi + if command -v sensors &> /dev/null; then + echo " ✅ lm-sensors — temperature sensors" + else + echo " ⚠️ lm-sensors — not installed (using thermal_zone fallback)" + fi +elif [[ "$OS_TYPE" == "Darwin" ]]; then + CHIP="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo "")" + if echo "$CHIP" | grep -qi "apple"; then + if sudo -n true 2>/dev/null; then + echo " ✅ passwordless sudo — Apple Silicon CPU temperature" + else + echo " ⚠️ passwordless sudo — not configured (CPU temperature unavailable)" + fi + else + if command -v osx-cpu-temp &> /dev/null || [[ -x "$HOME/bin/osx-cpu-temp" ]]; then + echo " ✅ osx-cpu-temp — Intel Mac CPU temperature" + else + echo " ⚠️ osx-cpu-temp — not installed (using battery temp fallback)" + fi + fi +fi + +echo "" + +# Summary +if [[ $FAILURES -eq 0 ]]; then + echo "✅ All checks passed!" + exit 0 +else + echo "❌ $FAILURES check(s) failed" + exit 1 +fi diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000..0386ece --- /dev/null +++ b/src/actions.js @@ -0,0 +1,76 @@ +const ALLOWED_ACTIONS = new Set([ + "gateway-status", + "gateway-restart", + "sessions-list", + "cron-list", + "health-check", + "clear-stale-sessions", +]); + +function executeAction(action, deps) { + const { runOpenClaw, extractJSON, PORT } = deps; + const results = { success: false, action, output: "", error: null }; + + if (!ALLOWED_ACTIONS.has(action)) { + results.error = `Unknown action: ${action}`; + return results; + } + + try { + switch (action) { + case "gateway-status": + results.output = runOpenClaw("gateway status 2>&1") || "Unknown"; + results.success = true; + break; + case "gateway-restart": + results.output = "To restart gateway, run: openclaw gateway restart"; + results.success = true; + results.note = "Dashboard cannot restart gateway for safety"; + break; + case "sessions-list": + results.output = runOpenClaw("sessions 2>&1") || "No sessions"; + results.success = true; + break; + case "cron-list": + results.output = runOpenClaw("cron list 2>&1") || "No cron jobs"; + results.success = true; + break; + case "health-check": { + const gateway = runOpenClaw("gateway status 2>&1"); + const sessions = runOpenClaw("sessions --json 2>&1"); + let sessionCount = 0; + try { + const data = JSON.parse(sessions); + sessionCount = data.sessions?.length || 0; + } catch (e) {} + results.output = [ + `Gateway: ${gateway?.includes("running") ? "OK Running" : "NOT Running"}`, + `Sessions: ${sessionCount}`, + `Dashboard: OK Running on port ${PORT}`, + ].join("\n"); + results.success = true; + break; + } + case "clear-stale-sessions": { + const staleOutput = runOpenClaw("sessions --json 2>&1"); + let staleCount = 0; + try { + const staleJson = extractJSON(staleOutput); + if (staleJson) { + const data = JSON.parse(staleJson); + staleCount = (data.sessions || []).filter((s) => s.ageMs > 24 * 60 * 60 * 1000).length; + } + } catch (e) {} + results.output = `Found ${staleCount} stale sessions (>24h old).\nTo clean: openclaw sessions prune`; + results.success = true; + break; + } + } + } catch (e) { + results.error = e.message; + } + + return results; +} + +module.exports = { executeAction, ALLOWED_ACTIONS }; diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..e50fc88 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,149 @@ +// ============================================================================ +// Authentication Module +// ============================================================================ + +// Auth header names +const AUTH_HEADERS = { + tailscale: { + login: "tailscale-user-login", + name: "tailscale-user-name", + pic: "tailscale-user-profile-pic", + }, + cloudflare: { + email: "cf-access-authenticated-user-email", + }, +}; + +function checkAuth(req, authConfig) { + const mode = authConfig.mode; + const remoteAddr = req.socket?.remoteAddress || ""; + const isLocalhost = + remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1"; + if (isLocalhost) { + return { authorized: true, user: { type: "localhost", login: "localhost" } }; + } + if (mode === "none") { + return { authorized: true, user: null }; + } + if (mode === "token") { + const authHeader = req.headers["authorization"] || ""; + const token = authHeader.replace(/^Bearer\s+/i, ""); + if (token && token === authConfig.token) { + return { authorized: true, user: { type: "token" } }; + } + return { authorized: false, reason: "Invalid or missing token" }; + } + if (mode === "tailscale") { + const login = (req.headers[AUTH_HEADERS.tailscale.login] || "").toLowerCase(); + const name = req.headers[AUTH_HEADERS.tailscale.name] || ""; + const pic = req.headers[AUTH_HEADERS.tailscale.pic] || ""; + if (!login) { + return { authorized: false, reason: "Not accessed via Tailscale Serve" }; + } + const isAllowed = authConfig.allowedUsers.some((allowed) => { + if (allowed === "*") return true; + if (allowed === login) return true; + if (allowed.startsWith("*@")) { + const domain = allowed.slice(2); + return login.endsWith("@" + domain); + } + return false; + }); + if (isAllowed) { + return { authorized: true, user: { type: "tailscale", login, name, pic } }; + } + return { authorized: false, reason: `User ${login} not in allowlist`, user: { login } }; + } + if (mode === "cloudflare") { + const email = (req.headers[AUTH_HEADERS.cloudflare.email] || "").toLowerCase(); + if (!email) { + return { authorized: false, reason: "Not accessed via Cloudflare Access" }; + } + const isAllowed = authConfig.allowedUsers.some((allowed) => { + if (allowed === "*") return true; + if (allowed === email) return true; + if (allowed.startsWith("*@")) { + const domain = allowed.slice(2); + return email.endsWith("@" + domain); + } + return false; + }); + if (isAllowed) { + return { authorized: true, user: { type: "cloudflare", email } }; + } + return { authorized: false, reason: `User ${email} not in allowlist`, user: { email } }; + } + if (mode === "allowlist") { + const clientIP = + req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket?.remoteAddress || ""; + const isAllowed = authConfig.allowedIPs.some((allowed) => { + if (allowed === clientIP) return true; + if (allowed.endsWith("/24")) { + const prefix = allowed.slice(0, -3).split(".").slice(0, 3).join("."); + return clientIP.startsWith(prefix + "."); + } + return false; + }); + if (isAllowed) { + return { authorized: true, user: { type: "ip", ip: clientIP } }; + } + return { authorized: false, reason: `IP ${clientIP} not in allowlist` }; + } + return { authorized: false, reason: "Unknown auth mode" }; +} + +function getUnauthorizedPage(reason, user, authConfig) { + const userInfo = user + ? `` + : ""; + + return ` + + + Access Denied - Command Center + + + +
+
🔐
+

Access Denied

+
${reason}
+ ${userInfo} +
+

This dashboard requires authentication via ${authConfig.mode}.

+ ${authConfig.mode === "tailscale" ? '

Make sure you\'re accessing via your Tailscale URL and your account is in the allowlist.

' : ""} + ${authConfig.mode === "cloudflare" ? '

Make sure you\'re accessing via Cloudflare Access and your email is in the allowlist.

' : ""} +
+
Auth mode: ${authConfig.mode}
+
+ +`; +} + +module.exports = { AUTH_HEADERS, checkAuth, getUnauthorizedPage }; diff --git a/src/cerebro.js b/src/cerebro.js new file mode 100644 index 0000000..cd091a9 --- /dev/null +++ b/src/cerebro.js @@ -0,0 +1,265 @@ +/** + * Cerebro topic management + */ + +const fs = require("fs"); +const path = require("path"); +const { formatTimeAgo } = require("./utils"); + +/** + * Get cerebro topics + * @param {string} cerebroDir - Path to cerebro directory + * @param {object} options - Options (offset, limit, status) + * @returns {object} - Cerebro topics data + */ +function getCerebroTopics(cerebroDir, options = {}) { + const { offset = 0, limit = 20, status: filterStatus = "all" } = options; + const topicsDir = path.join(cerebroDir, "topics"); + const orphansDir = path.join(cerebroDir, "orphans"); + const topics = []; + + // Result in format expected by frontend renderCerebro() + const result = { + initialized: false, + cerebroPath: cerebroDir, + topics: { active: 0, resolved: 0, parked: 0, total: 0 }, + threads: 0, + orphans: 0, + recentTopics: [], + lastUpdated: null, + }; + + try { + // Check if cerebro directory exists + if (!fs.existsSync(cerebroDir)) { + return result; + } + + result.initialized = true; + let latestModified = null; + + if (!fs.existsSync(topicsDir)) { + return result; + } + + const topicNames = fs.readdirSync(topicsDir).filter((name) => { + const topicPath = path.join(topicsDir, name); + return fs.statSync(topicPath).isDirectory() && !name.startsWith("_"); + }); + + // Parse each topic + topicNames.forEach((name) => { + const topicMdPath = path.join(topicsDir, name, "topic.md"); + const topicDirPath = path.join(topicsDir, name); + + // Get stat from topic.md or directory + let stat; + let content = ""; + if (fs.existsSync(topicMdPath)) { + stat = fs.statSync(topicMdPath); + content = fs.readFileSync(topicMdPath, "utf8"); + } else { + stat = fs.statSync(topicDirPath); + } + + try { + // Parse YAML frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + let title = name; + let topicStatus = "active"; + let category = "general"; + let created = null; + + if (frontmatterMatch) { + const frontmatter = frontmatterMatch[1]; + const titleMatch = frontmatter.match(/title:\s*(.+)/); + const statusMatch = frontmatter.match(/status:\s*(.+)/); + const categoryMatch = frontmatter.match(/category:\s*(.+)/); + const createdMatch = frontmatter.match(/created:\s*(.+)/); + + if (titleMatch) title = titleMatch[1].trim(); + if (statusMatch) topicStatus = statusMatch[1].trim().toLowerCase(); + if (categoryMatch) category = categoryMatch[1].trim(); + if (createdMatch) created = createdMatch[1].trim(); + } + + // Count threads + const threadsDir = path.join(topicsDir, name, "threads"); + let threadCount = 0; + if (fs.existsSync(threadsDir)) { + threadCount = fs + .readdirSync(threadsDir) + .filter((f) => f.endsWith(".md") || f.endsWith(".json")).length; + } + + // Accumulate total threads + result.threads += threadCount; + + // Count by status + if (topicStatus === "active") result.topics.active++; + else if (topicStatus === "resolved") result.topics.resolved++; + else if (topicStatus === "parked") result.topics.parked++; + + // Track latest modification + if (!latestModified || stat.mtime > latestModified) { + latestModified = stat.mtime; + } + + topics.push({ + name, + title, + status: topicStatus, + category, + created, + threads: threadCount, + lastModified: stat.mtimeMs, + }); + } catch (e) { + console.error(`Failed to parse topic ${name}:`, e.message); + } + }); + + result.topics.total = topics.length; + + // Sort: active first, then by most recently modified + const statusPriority = { active: 0, resolved: 1, parked: 2 }; + topics.sort((a, b) => { + const statusDiff = (statusPriority[a.status] || 3) - (statusPriority[b.status] || 3); + if (statusDiff !== 0) return statusDiff; + return b.lastModified - a.lastModified; + }); + + // Filter by status for recentTopics display + let filtered = topics; + if (filterStatus !== "all") { + filtered = topics.filter((t) => t.status === filterStatus); + } + + // Format for recentTopics (paginated) + const paginated = filtered.slice(offset, offset + limit); + result.recentTopics = paginated.map((t) => ({ + name: t.name, + title: t.title, + status: t.status, + threads: t.threads, + age: formatTimeAgo(new Date(t.lastModified)), + })); + + // Count orphans + if (fs.existsSync(orphansDir)) { + try { + result.orphans = fs.readdirSync(orphansDir).filter((f) => f.endsWith(".md")).length; + } catch (e) {} + } + + result.lastUpdated = latestModified ? latestModified.toISOString() : null; + } catch (e) { + console.error("Failed to get Cerebro topics:", e.message); + } + + return result; +} + +/** + * Update topic status in topic.md file + * @param {string} cerebroDir - Path to cerebro directory + * @param {string} topicId - Topic identifier + * @param {string} newStatus - New status (active, resolved, parked) + * @returns {object} - Updated topic data or error + */ +function updateTopicStatus(cerebroDir, topicId, newStatus) { + const topicDir = path.join(cerebroDir, "topics", topicId); + const topicFile = path.join(topicDir, "topic.md"); + + // Check if topic exists + if (!fs.existsSync(topicDir)) { + return { error: `Topic '${topicId}' not found`, code: 404 }; + } + + // If topic.md doesn't exist, create it with basic frontmatter + if (!fs.existsSync(topicFile)) { + const content = `--- +title: ${topicId} +status: ${newStatus} +category: general +created: ${new Date().toISOString().split("T")[0]} +--- + +# ${topicId} + +## Overview +*Topic tracking file.* + +## Notes +`; + fs.writeFileSync(topicFile, content, "utf8"); + return { + topic: { + id: topicId, + name: topicId, + title: topicId, + status: newStatus, + }, + }; + } + + // Read existing topic.md + let content = fs.readFileSync(topicFile, "utf8"); + let title = topicId; + + // Check if it has YAML frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (frontmatterMatch) { + // Has frontmatter - update status field + let frontmatter = frontmatterMatch[1]; + + // Extract title if present + const titleMatch = frontmatter.match(/title:\s*["']?([^"'\n]+)["']?/i); + if (titleMatch) title = titleMatch[1]; + + if (frontmatter.includes("status:")) { + // Replace existing status + frontmatter = frontmatter.replace( + /status:\s*(active|resolved|parked)/i, + `status: ${newStatus}`, + ); + } else { + // Add status field + frontmatter = frontmatter.trim() + `\nstatus: ${newStatus}`; + } + + content = content.replace(/^---\n[\s\S]*?\n---/, `---\n${frontmatter}\n---`); + } else { + // No frontmatter - add one + const headerMatch = content.match(/^#\s*(.+)/m); + if (headerMatch) title = headerMatch[1]; + + const frontmatter = `--- +title: ${title} +status: ${newStatus} +category: general +created: ${new Date().toISOString().split("T")[0]} +--- + +`; + content = frontmatter + content; + } + + // Write updated content + fs.writeFileSync(topicFile, content, "utf8"); + + return { + topic: { + id: topicId, + name: topicId, + title: title, + status: newStatus, + }, + }; +} + +module.exports = { + getCerebroTopics, + updateTopicStatus, +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..82961b9 --- /dev/null +++ b/src/config.js @@ -0,0 +1,282 @@ +/** + * Configuration loader with sensible defaults + * + * Priority order: + * 1. Environment variables (highest) + * 2. config/dashboard.json file + * 3. Auto-detected paths + * 4. Sensible defaults (lowest) + */ + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +const HOME = os.homedir(); + +/** + * Get the OpenClaw profile directory (e.g., ~/.openclaw or ~/.openclaw-) + * This is the canonical source for profile-aware paths. + */ +function getOpenClawDir(profile = null) { + const effectiveProfile = profile || process.env.OPENCLAW_PROFILE || ""; + return effectiveProfile + ? path.join(HOME, `.openclaw-${effectiveProfile}`) + : path.join(HOME, ".openclaw"); +} + +/** + * Auto-detect OpenClaw workspace by checking common locations + * Profile-aware: checks profile-specific paths first when OPENCLAW_PROFILE is set + */ +function detectWorkspace() { + const profile = process.env.OPENCLAW_PROFILE || ""; + const openclawDir = getOpenClawDir(); + const defaultWorkspace = path.join(openclawDir, "workspace"); + + // Build candidates list - profile-specific paths come first + const profileCandidates = profile + ? [ + // Profile-specific workspace in home (e.g., ~/.openclaw--workspace) + path.join(HOME, `.openclaw-${profile}-workspace`), + path.join(HOME, `.${profile}-workspace`), + ] + : []; + + const candidates = [ + // Environment variable (highest priority) + process.env.OPENCLAW_WORKSPACE, + // OpenClaw's default workspace location + process.env.OPENCLAW_HOME, + // Gateway config workspace (check early - this is where OpenClaw actually runs) + getWorkspaceFromGatewayConfig(), + // Profile-specific paths (if profile is set) + ...profileCandidates, + // Standard OpenClaw workspace location (profile-aware: ~/.openclaw/workspace or ~/.openclaw-/workspace) + defaultWorkspace, + // Common custom workspace names + path.join(HOME, "openclaw-workspace"), + path.join(HOME, ".openclaw-workspace"), + // Legacy/custom names + path.join(HOME, "molty"), + path.join(HOME, "clawd"), + path.join(HOME, "moltbot"), + ].filter(Boolean); + + // Find first existing candidate that looks like a workspace + const foundWorkspace = candidates.find((candidate) => { + if (!candidate || !fs.existsSync(candidate)) { + return false; + } + + // Verify it looks like a workspace (has memory/ or state/ dir) + const hasMemory = fs.existsSync(path.join(candidate, "memory")); + const hasState = fs.existsSync(path.join(candidate, "state")); + const hasConfig = fs.existsSync(path.join(candidate, ".openclaw")); + + return hasMemory || hasState || hasConfig; + }); + + // Return found workspace or default (will be created on first use) + return foundWorkspace || defaultWorkspace; +} + +/** + * Try to get workspace from OpenClaw gateway config + * Profile-aware: checks the profile directory first when OPENCLAW_PROFILE is set + */ +function getWorkspaceFromGatewayConfig() { + const openclawDir = getOpenClawDir(); + const configPaths = [ + path.join(openclawDir, "config.yaml"), + path.join(openclawDir, "config.json"), + path.join(openclawDir, "openclaw.json"), + path.join(openclawDir, "clawdbot.json"), + // Fallback to standard XDG location + path.join(HOME, ".config", "openclaw", "config.yaml"), + ]; + + for (const configPath of configPaths) { + try { + if (fs.existsSync(configPath)) { + const content = fs.readFileSync(configPath, "utf8"); + // Simple extraction - look for workspace or workdir + const match = + content.match(/workspace[:\s]+["']?([^"'\n]+)/i) || + content.match(/workdir[:\s]+["']?([^"'\n]+)/i); + if (match && match[1]) { + const workspace = match[1].trim().replace(/^~/, HOME); + if (fs.existsSync(workspace)) { + return workspace; + } + } + } + } catch (e) { + // Ignore errors, continue searching + } + } + return null; +} + +/** + * Deep merge two objects (local overrides base) + */ +function deepMerge(base, override) { + const result = { ...base }; + for (const key of Object.keys(override)) { + if ( + override[key] && + typeof override[key] === "object" && + !Array.isArray(override[key]) && + base[key] && + typeof base[key] === "object" + ) { + result[key] = deepMerge(base[key], override[key]); + } else if (override[key] !== null && override[key] !== undefined) { + result[key] = override[key]; + } + } + return result; +} + +/** + * Load config files - base + local overrides + */ +function loadConfigFile() { + const basePath = path.join(__dirname, "..", "config", "dashboard.json"); + const localPath = path.join(__dirname, "..", "config", "dashboard.local.json"); + + let config = {}; + + // Load base config + try { + if (fs.existsSync(basePath)) { + const content = fs.readFileSync(basePath, "utf8"); + config = JSON.parse(content); + } + } catch (e) { + console.warn(`[Config] Failed to load ${basePath}:`, e.message); + } + + // Merge local overrides + try { + if (fs.existsSync(localPath)) { + const content = fs.readFileSync(localPath, "utf8"); + const localConfig = JSON.parse(content); + config = deepMerge(config, localConfig); + console.log(`[Config] Loaded local overrides from ${localPath}`); + } + } catch (e) { + console.warn(`[Config] Failed to load ${localPath}:`, e.message); + } + + return config; +} + +/** + * Expand ~ and environment variables in paths + */ +function expandPath(p) { + if (!p) return p; + return p + .replace(/^~/, HOME) + .replace(/\$HOME/g, HOME) + .replace(/\$\{HOME\}/g, HOME); +} + +/** + * Build final configuration + */ +function loadConfig() { + const fileConfig = loadConfigFile(); + const workspace = + process.env.OPENCLAW_WORKSPACE || expandPath(fileConfig.paths?.workspace) || detectWorkspace(); + + const config = { + // Server settings + server: { + port: parseInt(process.env.PORT || fileConfig.server?.port || "3333", 10), + host: process.env.HOST || fileConfig.server?.host || "localhost", + }, + + // Paths - all relative to workspace unless absolute + paths: { + workspace: workspace, + memory: + expandPath(process.env.OPENCLAW_MEMORY_DIR || fileConfig.paths?.memory) || + path.join(workspace, "memory"), + state: + expandPath(process.env.OPENCLAW_STATE_DIR || fileConfig.paths?.state) || + path.join(workspace, "state"), + cerebro: + expandPath(process.env.OPENCLAW_CEREBRO_DIR || fileConfig.paths?.cerebro) || + path.join(workspace, "cerebro"), + skills: + expandPath(process.env.OPENCLAW_SKILLS_DIR || fileConfig.paths?.skills) || + path.join(workspace, "skills"), + jobs: + expandPath(process.env.OPENCLAW_JOBS_DIR || fileConfig.paths?.jobs) || + path.join(workspace, "jobs"), + logs: + expandPath(process.env.OPENCLAW_LOGS_DIR || fileConfig.paths?.logs) || + path.join(HOME, ".openclaw-command-center", "logs"), + }, + + // Auth settings + auth: { + mode: process.env.DASHBOARD_AUTH_MODE || fileConfig.auth?.mode || "none", + token: process.env.DASHBOARD_TOKEN || fileConfig.auth?.token, + allowedUsers: ( + process.env.DASHBOARD_ALLOWED_USERS || + fileConfig.auth?.allowedUsers?.join(",") || + "" + ) + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + allowedIPs: ( + process.env.DASHBOARD_ALLOWED_IPS || + fileConfig.auth?.allowedIPs?.join(",") || + "127.0.0.1,::1" + ) + .split(",") + .map((s) => s.trim()), + publicPaths: fileConfig.auth?.publicPaths || ["/api/health", "/api/whoami", "/favicon.ico"], + }, + + // Branding + branding: { + name: fileConfig.branding?.name || "OpenClaw Command Center", + theme: fileConfig.branding?.theme || "default", + }, + + // Integrations + integrations: { + linear: { + enabled: !!(process.env.LINEAR_API_KEY || fileConfig.integrations?.linear?.apiKey), + apiKey: process.env.LINEAR_API_KEY || fileConfig.integrations?.linear?.apiKey, + teamId: process.env.LINEAR_TEAM_ID || fileConfig.integrations?.linear?.teamId, + }, + }, + + // Billing - for cost savings calculation + billing: { + claudePlanCost: parseFloat( + process.env.CLAUDE_PLAN_COST || fileConfig.billing?.claudePlanCost || "200", + ), + claudePlanName: + process.env.CLAUDE_PLAN_NAME || fileConfig.billing?.claudePlanName || "Claude Code Max", + }, + }; + + return config; +} + +// Export singleton config +const CONFIG = loadConfig(); + +// Log detected configuration on startup +console.log("[Config] Workspace:", CONFIG.paths.workspace); +console.log("[Config] Auth mode:", CONFIG.auth.mode); + +module.exports = { CONFIG, loadConfig, detectWorkspace, expandPath, getOpenClawDir }; diff --git a/src/cron.js b/src/cron.js new file mode 100644 index 0000000..61fbc8b --- /dev/null +++ b/src/cron.js @@ -0,0 +1,159 @@ +const fs = require("fs"); +const path = require("path"); + +// Convert cron expression to human-readable text +function cronToHuman(expr) { + if (!expr || expr === "—") return null; + + const parts = expr.split(" "); + if (parts.length < 5) return null; + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + + // Helper to format time + function formatTime(h, m) { + const hNum = parseInt(h, 10); + const mNum = parseInt(m, 10); + if (isNaN(hNum)) return null; + const ampm = hNum >= 12 ? "pm" : "am"; + const h12 = hNum === 0 ? 12 : hNum > 12 ? hNum - 12 : hNum; + return mNum === 0 ? `${h12}${ampm}` : `${h12}:${mNum.toString().padStart(2, "0")}${ampm}`; + } + + // Every minute + if (minute === "*" && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + return "Every minute"; + } + + // Every X minutes + if (minute.startsWith("*/")) { + const interval = minute.slice(2); + return `Every ${interval} minutes`; + } + + // Every X hours (*/N in hour field) + if (hour.startsWith("*/")) { + const interval = hour.slice(2); + const minStr = minute === "0" ? "" : `:${minute.padStart(2, "0")}`; + return `Every ${interval} hours${minStr ? " at " + minStr : ""}`; + } + + // Every hour at specific minute + if (minute !== "*" && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + return `Hourly at :${minute.padStart(2, "0")}`; + } + + // Build time string for specific hour + let timeStr = ""; + if (minute !== "*" && hour !== "*" && !hour.startsWith("*/")) { + timeStr = formatTime(hour, minute); + } + + // Daily at specific time + if (timeStr && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + return `Daily at ${timeStr}`; + } + + // Weekdays (Mon-Fri) - check before generic day of week + if ((dayOfWeek === "1-5" || dayOfWeek === "MON-FRI") && dayOfMonth === "*" && month === "*") { + return timeStr ? `Weekdays at ${timeStr}` : "Weekdays"; + } + + // Weekends - check before generic day of week + if ((dayOfWeek === "0,6" || dayOfWeek === "6,0") && dayOfMonth === "*" && month === "*") { + return timeStr ? `Weekends at ${timeStr}` : "Weekends"; + } + + // Specific day of week + if (dayOfMonth === "*" && month === "*" && dayOfWeek !== "*") { + const days = dayOfWeek.split(",").map((d) => { + const num = parseInt(d, 10); + return dayNames[num] || d; + }); + const dayStr = days.length === 1 ? days[0] : days.join(", "); + return timeStr ? `${dayStr} at ${timeStr}` : `Every ${dayStr}`; + } + + // Specific day of month + if (dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") { + const day = parseInt(dayOfMonth, 10); + const suffix = + day === 1 || day === 21 || day === 31 + ? "st" + : day === 2 || day === 22 + ? "nd" + : day === 3 || day === 23 + ? "rd" + : "th"; + return timeStr ? `${day}${suffix} of month at ${timeStr}` : `${day}${suffix} of every month`; + } + + // Fallback: just show the time if we have it + if (timeStr) { + return `At ${timeStr}`; + } + + return expr; // Return original as fallback +} + +// Get cron jobs - reads directly from file for speed (CLI takes 11s+) +function getCronJobs(getOpenClawDir) { + try { + const cronPath = path.join(getOpenClawDir(), "cron", "jobs.json"); + if (fs.existsSync(cronPath)) { + const data = JSON.parse(fs.readFileSync(cronPath, "utf8")); + return (data.jobs || []).map((j) => { + // Parse schedule + let scheduleStr = "—"; + let scheduleHuman = null; + if (j.schedule) { + if (j.schedule.kind === "cron" && j.schedule.expr) { + scheduleStr = j.schedule.expr; + scheduleHuman = cronToHuman(j.schedule.expr); + } else if (j.schedule.kind === "once") { + scheduleStr = "once"; + scheduleHuman = "One-time"; + } + } + + // Format next run + let nextRunStr = "—"; + if (j.state?.nextRunAtMs) { + const next = new Date(j.state.nextRunAtMs); + const now = new Date(); + const diffMs = next - now; + const diffMins = Math.round(diffMs / 60000); + if (diffMins < 0) { + nextRunStr = "overdue"; + } else if (diffMins < 60) { + nextRunStr = `${diffMins}m`; + } else if (diffMins < 1440) { + nextRunStr = `${Math.round(diffMins / 60)}h`; + } else { + nextRunStr = `${Math.round(diffMins / 1440)}d`; + } + } + + return { + id: j.id, + name: j.name || j.id.slice(0, 8), + schedule: scheduleStr, + scheduleHuman: scheduleHuman, + nextRun: nextRunStr, + enabled: j.enabled !== false, + lastStatus: j.state?.lastStatus, + }; + }); + } + } catch (e) { + console.error("Failed to get cron:", e.message); + } + return []; +} + +module.exports = { + cronToHuman, + getCronJobs, +}; diff --git a/src/data.js b/src/data.js new file mode 100644 index 0000000..da10d2c --- /dev/null +++ b/src/data.js @@ -0,0 +1,33 @@ +const fs = require("fs"); +const path = require("path"); + +function migrateDataDir(dataDir, legacyDataDir) { + try { + if (!fs.existsSync(legacyDataDir)) return; + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + const legacyFiles = fs.readdirSync(legacyDataDir); + if (legacyFiles.length === 0) return; + let migrated = 0; + for (const file of legacyFiles) { + const srcPath = path.join(legacyDataDir, file); + const destPath = path.join(dataDir, file); + if (fs.existsSync(destPath)) continue; + const stat = fs.statSync(srcPath); + if (stat.isFile()) { + fs.copyFileSync(srcPath, destPath); + migrated++; + console.log(`[Migration] Copied ${file} to profile-aware data dir`); + } + } + if (migrated > 0) { + console.log(`[Migration] Migrated ${migrated} file(s) to ${dataDir}`); + console.log(`[Migration] Legacy data preserved at ${legacyDataDir}`); + } + } catch (e) { + console.error("[Migration] Failed to migrate data:", e.message); + } +} + +module.exports = { migrateDataDir }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..6a6a987 --- /dev/null +++ b/src/index.js @@ -0,0 +1,671 @@ +/** + * OpenClaw Command Center Dashboard Server + * Serves the dashboard UI and provides API endpoints for status data + */ + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +// ============================================================================ +// CLI ARGUMENT PARSING +// ============================================================================ +const args = process.argv.slice(2); +let cliProfile = null; +let cliPort = null; + +for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--profile": + case "-p": + cliProfile = args[++i]; + break; + case "--port": + cliPort = parseInt(args[++i], 10); + break; + case "--help": + case "-h": + console.log(` +OpenClaw Command Center + +Usage: node lib/server.js [options] + +Options: + --profile, -p OpenClaw profile (uses ~/.openclaw-) + --port Server port (default: 3333) + --help, -h Show this help + +Environment: + OPENCLAW_PROFILE Same as --profile + PORT Same as --port + +Examples: + node lib/server.js --profile production + node lib/server.js -p dev --port 3334 +`); + process.exit(0); + } +} + +// Set profile in environment so CONFIG and all CLI calls pick it up +if (cliProfile) { + process.env.OPENCLAW_PROFILE = cliProfile; +} +if (cliPort) { + process.env.PORT = cliPort.toString(); +} + +// ============================================================================ +// MODULE IMPORTS (after env vars are set) +// ============================================================================ +const { getVersion } = require("./utils"); +const { CONFIG, getOpenClawDir } = require("./config"); +const { handleJobsRequest, isJobsRoute } = require("./jobs"); +const { runOpenClaw, runOpenClawAsync, extractJSON } = require("./openclaw"); +const { getSystemVitals, checkOptionalDeps, getOptionalDeps } = require("./vitals"); +const { checkAuth, getUnauthorizedPage } = require("./auth"); +const { loadPrivacySettings, savePrivacySettings } = require("./privacy"); +const { + loadOperators, + saveOperators, + getOperatorBySlackId, + startOperatorsRefresh, +} = require("./operators"); +const { createSessionsModule } = require("./sessions"); +const { getCronJobs } = require("./cron"); +const { getCerebroTopics, updateTopicStatus } = require("./cerebro"); +const { + getDailyTokenUsage, + getTokenStats, + getCostBreakdown, + startTokenUsageRefresh, + refreshTokenUsageAsync, +} = require("./tokens"); +const { getLlmUsage, getRoutingStats, startLlmUsageRefresh } = require("./llm-usage"); +const { executeAction } = require("./actions"); +const { migrateDataDir } = require("./data"); +const { createStateModule } = require("./state"); + +// ============================================================================ +// CONFIGURATION +// ============================================================================ +const PORT = CONFIG.server.port; +const DASHBOARD_DIR = path.join(__dirname, "../public"); +const PATHS = CONFIG.paths; + +const AUTH_CONFIG = { + mode: CONFIG.auth.mode, + token: CONFIG.auth.token, + allowedUsers: CONFIG.auth.allowedUsers, + allowedIPs: CONFIG.auth.allowedIPs, + publicPaths: CONFIG.auth.publicPaths, +}; + +// Profile-aware data directory +const DATA_DIR = path.join(getOpenClawDir(), "command-center", "data"); +const LEGACY_DATA_DIR = path.join(DASHBOARD_DIR, "data"); + +// ============================================================================ +// SSE (Server-Sent Events) +// ============================================================================ +const sseClients = new Set(); + +function sendSSE(res, event, data) { + try { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } catch (e) { + // Client disconnected + } +} + +function broadcastSSE(event, data) { + for (const client of sseClients) { + sendSSE(client, event, data); + } +} + +// ============================================================================ +// INITIALIZE MODULES (wire up dependencies) +// ============================================================================ + +// Sessions module (factory pattern with dependency injection) +const sessions = createSessionsModule({ + getOpenClawDir, + getOperatorBySlackId: (slackId) => getOperatorBySlackId(DATA_DIR, slackId), + runOpenClaw, + runOpenClawAsync, + extractJSON, +}); + +// State module (factory pattern) +const state = createStateModule({ + CONFIG, + getOpenClawDir, + getSessions: (opts) => sessions.getSessions(opts), + getSystemVitals, + getCronJobs: () => getCronJobs(getOpenClawDir), + loadOperators: () => loadOperators(DATA_DIR), + getLlmUsage: () => getLlmUsage(PATHS.state), + getDailyTokenUsage: () => getDailyTokenUsage(getOpenClawDir), + getTokenStats, + getCerebroTopics: (opts) => getCerebroTopics(PATHS.cerebro, opts), + runOpenClaw, + extractJSON, + readTranscript: (sessionId) => sessions.readTranscript(sessionId), +}); + +// ============================================================================ +// STARTUP: Data migration + background tasks +// ============================================================================ +process.nextTick(() => migrateDataDir(DATA_DIR, LEGACY_DATA_DIR)); +startOperatorsRefresh(DATA_DIR, getOpenClawDir); +startLlmUsageRefresh(); +startTokenUsageRefresh(getOpenClawDir); + +// ============================================================================ +// STATIC FILE SERVER +// ============================================================================ +function serveStatic(req, res) { + // Parse URL to safely extract pathname (ignoring query/hash) + const requestUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const pathname = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname; + + // Reject any path containing ".." segments (path traversal) + if (pathname.includes("..")) { + res.writeHead(400); + res.end("Bad request"); + return; + } + + // Normalize and resolve to ensure path stays within DASHBOARD_DIR + const normalizedPath = path.normalize(pathname).replace(/^[/\\]+/, ""); + const filePath = path.join(DASHBOARD_DIR, normalizedPath); + + const resolvedDashboardDir = path.resolve(DASHBOARD_DIR); + const resolvedFilePath = path.resolve(filePath); + if ( + !resolvedFilePath.startsWith(resolvedDashboardDir + path.sep) && + resolvedFilePath !== resolvedDashboardDir + ) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const ext = path.extname(filePath); + const contentTypes = { + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + }; + + fs.readFile(filePath, (err, content) => { + if (err) { + res.writeHead(404); + res.end("Not found"); + return; + } + const headers = { "Content-Type": contentTypes[ext] || "text/plain" }; + + // Avoid stale dashboards (users frequently hard-refresh while iterating) + if ([".html", ".css", ".js", ".json"].includes(ext)) { + headers["Cache-Control"] = "no-store"; + } + + res.writeHead(200, headers); + res.end(content); + }); +} + +// ============================================================================ +// LEGACY API HANDLER +// ============================================================================ +function handleApi(req, res) { + const sessionsList = sessions.getSessions(); + const capacity = state.getCapacity(); + const tokenStats = getTokenStats(sessionsList, capacity, CONFIG); + + const data = { + sessions: sessionsList, + cron: getCronJobs(getOpenClawDir), + system: state.getSystemStatus(), + activity: state.getRecentActivity(), + tokenStats, + capacity, + timestamp: new Date().toISOString(), + }; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); +} + +// ============================================================================ +// HTTP SERVER +// ============================================================================ +const server = http.createServer((req, res) => { + // CORS headers + res.setHeader("Access-Control-Allow-Origin", "*"); + + const urlParts = req.url.split("?"); + const pathname = urlParts[0]; + const query = new URLSearchParams(urlParts[1] || ""); + + // Fast path for health check + if (pathname === "/api/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", port: PORT, timestamp: new Date().toISOString() })); + return; + } + + // Auth check (unless public path) + const isPublicPath = AUTH_CONFIG.publicPaths.some( + (p) => pathname === p || pathname.startsWith(p + "/"), + ); + + if (!isPublicPath && AUTH_CONFIG.mode !== "none") { + const authResult = checkAuth(req, AUTH_CONFIG); + + if (!authResult.authorized) { + console.log(`[AUTH] Denied: ${authResult.reason} (path: ${pathname})`); + res.writeHead(403, { "Content-Type": "text/html" }); + res.end(getUnauthorizedPage(authResult.reason, authResult.user, AUTH_CONFIG)); + return; + } + + req.authUser = authResult.user; + + if (authResult.user?.login || authResult.user?.email) { + console.log( + `[AUTH] Allowed: ${authResult.user.login || authResult.user.email} (path: ${pathname})`, + ); + } else { + console.log(`[AUTH] Allowed: ${req.socket?.remoteAddress} (path: ${pathname})`); + } + } + + // ---- API Routes ---- + + if (pathname === "/api/status") { + handleApi(req, res); + } else if (pathname === "/api/session") { + const sessionKey = query.get("key"); + if (!sessionKey) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing session key" })); + return; + } + const detail = sessions.getSessionDetail(sessionKey); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(detail, null, 2)); + } else if (pathname === "/api/cerebro") { + const offset = parseInt(query.get("offset") || "0", 10); + const limit = parseInt(query.get("limit") || "20", 10); + const statusFilter = query.get("status") || "all"; + + const data = getCerebroTopics(PATHS.cerebro, { offset, limit, status: statusFilter }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); + } else if ( + pathname.startsWith("/api/cerebro/topic/") && + pathname.endsWith("/status") && + req.method === "POST" + ) { + const topicId = decodeURIComponent( + pathname.replace("/api/cerebro/topic/", "").replace("/status", ""), + ); + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + const { status: newStatus } = JSON.parse(body); + + if (!newStatus || !["active", "resolved", "parked"].includes(newStatus)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ error: "Invalid status. Must be: active, resolved, or parked" }), + ); + return; + } + + const result = updateTopicStatus(PATHS.cerebro, topicId, newStatus); + + if (result.error) { + res.writeHead(result.code || 500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: result.error })); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON body" })); + } + }); + return; + } else if (pathname === "/api/llm-quota") { + const data = getLlmUsage(PATHS.state); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); + } else if (pathname === "/api/cost-breakdown") { + const data = getCostBreakdown(CONFIG, (opts) => sessions.getSessions(opts), getOpenClawDir); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); + } else if (pathname === "/api/subagents") { + const data = state.getSubagentStatus(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ subagents: data }, null, 2)); + } else if (pathname === "/api/action") { + const action = query.get("action"); + if (!action) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing action parameter" })); + return; + } + const result = executeAction(action, { runOpenClaw, extractJSON, PORT }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + } else if (pathname === "/api/events") { + // SSE endpoint + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + + sseClients.add(res); + console.log(`[SSE] Client connected (total: ${sseClients.size})`); + + sendSSE(res, "connected", { message: "Connected to Command Center", timestamp: Date.now() }); + + const cachedState = state.getFullState(); + if (cachedState) { + sendSSE(res, "update", cachedState); + } else { + sendSSE(res, "update", { sessions: [], loading: true }); + } + + req.on("close", () => { + sseClients.delete(res); + console.log(`[SSE] Client disconnected (total: ${sseClients.size})`); + }); + + return; + } else if (pathname === "/api/whoami") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify( + { + authMode: AUTH_CONFIG.mode, + user: req.authUser || null, + }, + null, + 2, + ), + ); + } else if (pathname === "/api/about") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify( + { + name: "OpenClaw Command Center", + version: getVersion(), + description: "A Starcraft-inspired dashboard for AI agent orchestration", + license: "MIT", + repository: "https://github.com/jontsai/openclaw-command-center", + builtWith: ["OpenClaw", "Node.js", "Vanilla JS"], + inspirations: ["Starcraft", "Inside Out", "iStatMenus", "DaisyDisk", "Gmail"], + }, + null, + 2, + ), + ); + } else if (pathname === "/api/state") { + const fullState = state.getFullState(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(fullState, null, 2)); + } else if (pathname === "/api/vitals") { + const vitals = getSystemVitals(); + const optionalDeps = getOptionalDeps(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ vitals, optionalDeps }, null, 2)); + } else if (pathname === "/api/capacity") { + const capacity = state.getCapacity(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(capacity, null, 2)); + } else if (pathname === "/api/sessions") { + const page = parseInt(query.get("page")) || 1; + const pageSize = parseInt(query.get("pageSize")) || 20; + const statusFilter = query.get("status"); + + const allSessions = sessions.getSessions({ limit: null }); + + const statusCounts = { + all: allSessions.length, + live: allSessions.filter((s) => s.active).length, + recent: allSessions.filter((s) => !s.active && s.recentlyActive).length, + idle: allSessions.filter((s) => !s.active && !s.recentlyActive).length, + }; + + let filteredSessions = allSessions; + if (statusFilter === "live") { + filteredSessions = allSessions.filter((s) => s.active); + } else if (statusFilter === "recent") { + filteredSessions = allSessions.filter((s) => !s.active && s.recentlyActive); + } else if (statusFilter === "idle") { + filteredSessions = allSessions.filter((s) => !s.active && !s.recentlyActive); + } + + const total = filteredSessions.length; + const totalPages = Math.ceil(total / pageSize); + const offset = (page - 1) * pageSize; + const displaySessions = filteredSessions.slice(offset, offset + pageSize); + + const tokenStats = getTokenStats(allSessions, state.getCapacity(), CONFIG); + const capacity = state.getCapacity(); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify( + { + sessions: displaySessions, + pagination: { + page, + pageSize, + total, + totalPages, + hasPrev: page > 1, + hasNext: page < totalPages, + }, + statusCounts, + tokenStats, + capacity, + }, + null, + 2, + ), + ); + } else if (pathname === "/api/cron") { + const cron = getCronJobs(getOpenClawDir); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ cron }, null, 2)); + } else if (pathname === "/api/operators") { + const method = req.method; + const data = loadOperators(DATA_DIR); + + if (method === "GET") { + const allSessions = sessions.getSessions({ limit: null }); + const operatorsWithStats = data.operators.map((op) => { + const userSessions = allSessions.filter( + (s) => s.originator?.userId === op.id || s.originator?.userId === op.metadata?.slackId, + ); + return { + ...op, + stats: { + activeSessions: userSessions.filter((s) => s.active).length, + totalSessions: userSessions.length, + lastSeen: + userSessions.length > 0 + ? new Date( + Date.now() - Math.min(...userSessions.map((s) => s.minutesAgo)) * 60000, + ).toISOString() + : op.lastSeen, + }, + }; + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify( + { + operators: operatorsWithStats, + roles: data.roles, + timestamp: Date.now(), + }, + null, + 2, + ), + ); + } else if (method === "POST") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const newOp = JSON.parse(body); + const existingIdx = data.operators.findIndex((op) => op.id === newOp.id); + if (existingIdx >= 0) { + data.operators[existingIdx] = { ...data.operators[existingIdx], ...newOp }; + } else { + data.operators.push({ + ...newOp, + createdAt: new Date().toISOString(), + }); + } + if (saveOperators(DATA_DIR, data)) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true, operator: newOp })); + } else { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Failed to save" })); + } + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON" })); + } + }); + return; + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + } + return; + } else if (pathname === "/api/llm-usage") { + const usage = getLlmUsage(PATHS.state); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(usage, null, 2)); + } else if (pathname === "/api/routing-stats") { + const hours = parseInt(query.get("hours") || "24", 10); + const stats = getRoutingStats(PATHS.skills, PATHS.state, hours); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(stats, null, 2)); + } else if (pathname === "/api/memory") { + const memory = state.getMemoryStats(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ memory }, null, 2)); + } else if (pathname === "/api/privacy") { + if (req.method === "GET") { + const settings = loadPrivacySettings(DATA_DIR); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(settings, null, 2)); + } else if (req.method === "POST" || req.method === "PUT") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const updates = JSON.parse(body); + const current = loadPrivacySettings(DATA_DIR); + + const merged = { + version: current.version || 1, + hiddenTopics: updates.hiddenTopics ?? current.hiddenTopics ?? [], + hiddenSessions: updates.hiddenSessions ?? current.hiddenSessions ?? [], + hiddenCrons: updates.hiddenCrons ?? current.hiddenCrons ?? [], + hideHostname: updates.hideHostname ?? current.hideHostname ?? false, + }; + + if (savePrivacySettings(DATA_DIR, merged)) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true, settings: merged })); + } else { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Failed to save privacy settings" })); + } + } catch (e) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON: " + e.message })); + } + }); + return; + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + } + return; + } else if (isJobsRoute(pathname)) { + handleJobsRequest(req, res, pathname, query, req.method); + } else { + serveStatic(req, res); + } +}); + +// ============================================================================ +// START SERVER +// ============================================================================ +server.listen(PORT, () => { + const profile = process.env.OPENCLAW_PROFILE; + console.log(`\u{1F99E} OpenClaw Command Center running at http://localhost:${PORT}`); + if (profile) { + console.log(` Profile: ${profile} (~/.openclaw-${profile})`); + } + console.log(` Press Ctrl+C to stop`); + + // Pre-warm caches in background + setTimeout(async () => { + console.log("[Startup] Pre-warming caches in background..."); + try { + await Promise.all([sessions.refreshSessionsCache(), refreshTokenUsageAsync(getOpenClawDir)]); + getSystemVitals(); + console.log("[Startup] Caches warmed."); + } catch (e) { + console.log("[Startup] Cache warming error:", e.message); + } + // Check for optional system dependencies (once at startup) + checkOptionalDeps(); + }, 100); + + // Background cache refresh + const SESSIONS_CACHE_TTL = 10000; + setInterval(() => sessions.refreshSessionsCache(), SESSIONS_CACHE_TTL); +}); + +// SSE heartbeat +let sseRefreshing = false; +setInterval(() => { + if (sseClients.size > 0 && !sseRefreshing) { + sseRefreshing = true; + try { + const fullState = state.refreshState(); + broadcastSSE("update", fullState); + broadcastSSE("heartbeat", { clients: sseClients.size, timestamp: Date.now() }); + } catch (e) { + console.error("[SSE] Broadcast error:", e.message); + } + sseRefreshing = false; + } +}, 15000); diff --git a/src/jobs.js b/src/jobs.js new file mode 100644 index 0000000..eabec87 --- /dev/null +++ b/src/jobs.js @@ -0,0 +1,261 @@ +/** + * Jobs Dashboard API Handler + * + * Wraps the jobs API for the dashboard server. + * Uses dynamic imports to bridge CommonJS server with ESM jobs modules. + */ + +const path = require("path"); +const { CONFIG } = require("./config"); + +// Jobs directory (from config with auto-detection) +const JOBS_DIR = CONFIG.paths.jobs; +const JOBS_STATE_DIR = path.join(CONFIG.paths.state, "jobs"); + +let apiInstance = null; +let forceApiUnavailable = false; // For testing + +/** + * Initialize the jobs API (lazy-loaded due to ESM) + */ +async function getAPI() { + if (forceApiUnavailable) return null; + if (apiInstance) return apiInstance; + + try { + const { createJobsAPI } = await import(path.join(JOBS_DIR, "lib/api.js")); + apiInstance = createJobsAPI({ + definitionsDir: path.join(JOBS_DIR, "definitions"), + stateDir: JOBS_STATE_DIR, + }); + return apiInstance; + } catch (e) { + console.error("Failed to load jobs API:", e.message); + return null; + } +} + +/** + * Reset API state for testing purposes + * @param {Object} options - Reset options + * @param {boolean} options.forceUnavailable - If true, getAPI() will return null + */ +function _resetForTesting(options = {}) { + apiInstance = null; + forceApiUnavailable = options.forceUnavailable || false; +} + +/** + * Format relative time + */ +function formatRelativeTime(isoString) { + if (!isoString) return null; + const date = new Date(isoString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.round(diffMs / 60000); + + if (diffMins < 0) { + const futureMins = Math.abs(diffMins); + if (futureMins < 60) return `in ${futureMins}m`; + if (futureMins < 1440) return `in ${Math.round(futureMins / 60)}h`; + return `in ${Math.round(futureMins / 1440)}d`; + } + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`; + return `${Math.round(diffMins / 1440)}d ago`; +} + +/** + * Handle jobs API requests + */ +async function handleJobsRequest(req, res, pathname, query, method) { + const api = await getAPI(); + + if (!api) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Jobs API not available" })); + return; + } + + try { + // Scheduler status: GET /api/jobs/scheduler/status (before single job route) + if (pathname === "/api/jobs/scheduler/status" && method === "GET") { + const status = await api.getSchedulerStatus(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(status, null, 2)); + return; + } + + // Aggregate stats: GET /api/jobs/stats (before single job route) + if (pathname === "/api/jobs/stats" && method === "GET") { + const stats = await api.getAggregateStats(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(stats, null, 2)); + return; + } + + // Clear cache: POST /api/jobs/cache/clear (before single job route) + if (pathname === "/api/jobs/cache/clear" && method === "POST") { + api.clearCache(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true, message: "Cache cleared" })); + return; + } + + // List all jobs: GET /api/jobs + if (pathname === "/api/jobs" && method === "GET") { + const jobs = await api.listJobs(); + + // Enhance with relative times + const enhanced = jobs.map((job) => ({ + ...job, + lastRunRelative: formatRelativeTime(job.lastRun), + nextRunRelative: formatRelativeTime(job.nextRun), + })); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jobs: enhanced, timestamp: Date.now() }, null, 2)); + return; + } + + // Get single job: GET /api/jobs/:id + const jobMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/); + if (jobMatch && method === "GET") { + const jobId = decodeURIComponent(jobMatch[1]); + const job = await api.getJob(jobId); + + if (!job) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Job not found" })); + return; + } + + // Enhance with relative times + job.lastRunRelative = formatRelativeTime(job.lastRun); + job.nextRunRelative = formatRelativeTime(job.nextRun); + if (job.recentRuns) { + job.recentRuns = job.recentRuns.map((run) => ({ + ...run, + startedAtRelative: formatRelativeTime(run.startedAt), + completedAtRelative: formatRelativeTime(run.completedAt), + })); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(job, null, 2)); + return; + } + + // Get job history: GET /api/jobs/:id/history + const historyMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/history$/); + if (historyMatch && method === "GET") { + const jobId = decodeURIComponent(historyMatch[1]); + const limit = parseInt(query.get("limit") || "50", 10); + const runs = await api.getJobHistory(jobId, limit); + + const enhanced = runs.map((run) => ({ + ...run, + startedAtRelative: formatRelativeTime(run.startedAt), + completedAtRelative: formatRelativeTime(run.completedAt), + })); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ runs: enhanced, timestamp: Date.now() }, null, 2)); + return; + } + + // Run job: POST /api/jobs/:id/run + const runMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/run$/); + if (runMatch && method === "POST") { + const jobId = decodeURIComponent(runMatch[1]); + const result = await api.runJob(jobId); + + res.writeHead(result.success ? 200 : 400, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + return; + } + + // Pause job: POST /api/jobs/:id/pause + const pauseMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/pause$/); + if (pauseMatch && method === "POST") { + const jobId = decodeURIComponent(pauseMatch[1]); + + // Parse body for reason + let body = ""; + await new Promise((resolve) => { + req.on("data", (chunk) => (body += chunk)); + req.on("end", resolve); + }); + + let reason = null; + try { + const parsed = JSON.parse(body || "{}"); + reason = parsed.reason; + } catch (_e) { + /* ignore parse errors */ + } + + const result = await api.pauseJob(jobId, { + by: req.authUser?.login || "dashboard", + reason, + }); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + return; + } + + // Resume job: POST /api/jobs/:id/resume + const resumeMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/resume$/); + if (resumeMatch && method === "POST") { + const jobId = decodeURIComponent(resumeMatch[1]); + const result = await api.resumeJob(jobId); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + return; + } + + // Skip job: POST /api/jobs/:id/skip + const skipMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/skip$/); + if (skipMatch && method === "POST") { + const jobId = decodeURIComponent(skipMatch[1]); + const result = await api.skipJob(jobId); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + return; + } + + // Kill job: POST /api/jobs/:id/kill + const killMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/kill$/); + if (killMatch && method === "POST") { + const jobId = decodeURIComponent(killMatch[1]); + const result = await api.killJob(jobId); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result, null, 2)); + return; + } + + // Not found + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } catch (e) { + console.error("Jobs API error:", e); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: e.message })); + } +} + +/** + * Check if a request should be handled by jobs API + */ +function isJobsRoute(pathname) { + return pathname.startsWith("/api/jobs"); +} + +module.exports = { handleJobsRequest, isJobsRoute, _resetForTesting }; diff --git a/src/llm-usage.js b/src/llm-usage.js new file mode 100644 index 0000000..ed59e5f --- /dev/null +++ b/src/llm-usage.js @@ -0,0 +1,305 @@ +const fs = require("fs"); +const path = require("path"); +const { execFile } = require("child_process"); +const { getSafeEnv } = require("./openclaw"); + +// Cache for LLM usage data (openclaw CLI is slow ~4-5s) +let llmUsageCache = { data: null, timestamp: 0, refreshing: false }; +const LLM_CACHE_TTL_MS = 60000; // 60 seconds + +// Background async refresh of LLM usage data +function refreshLlmUsageAsync() { + if (llmUsageCache.refreshing) return; // Already refreshing + llmUsageCache.refreshing = true; + + const profile = process.env.OPENCLAW_PROFILE || ""; + const args = profile + ? ["--profile", profile, "status", "--usage", "--json"] + : ["status", "--usage", "--json"]; + execFile( + "openclaw", + args, + { encoding: "utf8", timeout: 20000, env: getSafeEnv() }, + (err, stdout) => { + llmUsageCache.refreshing = false; + if (err) { + console.error("[LLM Usage] Async refresh failed:", err.message); + return; + } + try { + // Extract JSON portion - openclaw may output doctor warnings before JSON + const jsonStart = stdout.indexOf("{"); + const jsonStr = jsonStart >= 0 ? stdout.slice(jsonStart) : stdout; + const parsed = JSON.parse(jsonStr); + if (parsed.usage) { + const result = transformLiveUsageData(parsed.usage); + llmUsageCache.data = result; + llmUsageCache.timestamp = Date.now(); + console.log("[LLM Usage] Cache refreshed"); + } + } catch (e) { + console.error("[LLM Usage] Parse error:", e.message); + } + }, + ); +} + +// Transform live usage data from OpenClaw CLI +function transformLiveUsageData(usage) { + const anthropic = usage.providers?.find((p) => p.provider === "anthropic"); + const codexProvider = usage.providers?.find((p) => p.provider === "openai-codex"); + + // Check for auth errors + if (anthropic?.error) { + return { + timestamp: new Date().toISOString(), + source: "error", + error: anthropic.error, + errorType: anthropic.error.includes("403") ? "auth" : "unknown", + claude: { + session: { usedPct: null, remainingPct: null, resetsIn: null, error: anthropic.error }, + weekly: { usedPct: null, remainingPct: null, resets: null, error: anthropic.error }, + sonnet: { usedPct: null, remainingPct: null, resets: null, error: anthropic.error }, + lastSynced: null, + }, + codex: { sessionsToday: 0, tasksToday: 0, usage5hPct: 0, usageDayPct: 0 }, + routing: { + total: 0, + claudeTasks: 0, + codexTasks: 0, + claudePct: 0, + codexPct: 0, + codexFloor: 20, + }, + }; + } + + const session5h = anthropic?.windows?.find((w) => w.label === "5h"); + const weekAll = anthropic?.windows?.find((w) => w.label === "Week"); + const sonnetWeek = anthropic?.windows?.find((w) => w.label === "Sonnet"); + const codex5h = codexProvider?.windows?.find((w) => w.label === "5h"); + const codexDay = codexProvider?.windows?.find((w) => w.label === "Day"); + + const formatReset = (resetAt) => { + if (!resetAt) return "?"; + const diff = resetAt - Date.now(); + if (diff < 0) return "now"; + if (diff < 3600000) return Math.round(diff / 60000) + "m"; + if (diff < 86400000) return Math.round(diff / 3600000) + "h"; + return Math.round(diff / 86400000) + "d"; + }; + + return { + timestamp: new Date().toISOString(), + source: "live", + claude: { + session: { + usedPct: Math.round(session5h?.usedPercent || 0), + remainingPct: Math.round(100 - (session5h?.usedPercent || 0)), + resetsIn: formatReset(session5h?.resetAt), + }, + weekly: { + usedPct: Math.round(weekAll?.usedPercent || 0), + remainingPct: Math.round(100 - (weekAll?.usedPercent || 0)), + resets: formatReset(weekAll?.resetAt), + }, + sonnet: { + usedPct: Math.round(sonnetWeek?.usedPercent || 0), + remainingPct: Math.round(100 - (sonnetWeek?.usedPercent || 0)), + resets: formatReset(sonnetWeek?.resetAt), + }, + lastSynced: new Date().toISOString(), + }, + codex: { + sessionsToday: 0, + tasksToday: 0, + usage5hPct: Math.round(codex5h?.usedPercent || 0), + usageDayPct: Math.round(codexDay?.usedPercent || 0), + }, + routing: { total: 0, claudeTasks: 0, codexTasks: 0, claudePct: 0, codexPct: 0, codexFloor: 20 }, + }; +} + +// Get LLM usage stats - returns cached data immediately, refreshes in background +function getLlmUsage(statePath) { + const now = Date.now(); + + // If cache is stale or empty, trigger background refresh + if (!llmUsageCache.data || now - llmUsageCache.timestamp > LLM_CACHE_TTL_MS) { + refreshLlmUsageAsync(); + } + + // Return cached data if available AND not an error + // If cache has error, try file fallback first + if (llmUsageCache.data && llmUsageCache.data.source !== "error") { + return llmUsageCache.data; + } + + // Cache empty or has error - check if we can read from state file + // But don't return misleading 0% values - return error/loading state instead + const stateFile = path.join(statePath, "llm-routing.json"); + try { + if (fs.existsSync(stateFile)) { + const data = JSON.parse(fs.readFileSync(stateFile, "utf8")); + // Only use file data if it has valid (non-placeholder) usage values + // Check for "unknown" resets which indicates placeholder data from failed sync + const sessionValid = + data.claude?.session?.resets_in && data.claude.session.resets_in !== "unknown"; + const weeklyValid = + data.claude?.weekly_all_models?.resets && + data.claude.weekly_all_models.resets !== "unknown"; + if (sessionValid || weeklyValid) { + return { + timestamp: new Date().toISOString(), + source: "file", + claude: { + session: { + usedPct: Math.round((data.claude?.session?.used_pct || 0) * 100), + remainingPct: Math.round((data.claude?.session?.remaining_pct || 1) * 100), + resetsIn: data.claude?.session?.resets_in || "?", + }, + weekly: { + usedPct: Math.round((data.claude?.weekly_all_models?.used_pct || 0) * 100), + remainingPct: Math.round((data.claude?.weekly_all_models?.remaining_pct || 1) * 100), + resets: data.claude?.weekly_all_models?.resets || "?", + }, + sonnet: { + usedPct: Math.round((data.claude?.weekly_sonnet?.used_pct || 0) * 100), + remainingPct: Math.round((data.claude?.weekly_sonnet?.remaining_pct || 1) * 100), + resets: data.claude?.weekly_sonnet?.resets || "?", + }, + lastSynced: data.claude?.last_synced || null, + }, + codex: { + sessionsToday: data.codex?.sessions_today || 0, + tasksToday: data.codex?.tasks_today || 0, + usage5hPct: data.codex?.usage_5h_pct || 0, + usageDayPct: data.codex?.usage_day_pct || 0, + }, + routing: { + total: data.routing?.total_tasks || 0, + claudeTasks: data.routing?.claude_tasks || 0, + codexTasks: data.routing?.codex_tasks || 0, + claudePct: + data.routing?.total_tasks > 0 + ? Math.round((data.routing.claude_tasks / data.routing.total_tasks) * 100) + : 0, + codexPct: + data.routing?.total_tasks > 0 + ? Math.round((data.routing.codex_tasks / data.routing.total_tasks) * 100) + : 0, + codexFloor: Math.round((data.routing?.codex_floor_pct || 0.2) * 100), + }, + }; + } + } + } catch (e) { + console.error("[LLM Usage] File fallback failed:", e.message); + } + + // No valid data - return auth error state (we know API returns 403) + return { + timestamp: new Date().toISOString(), + source: "error", + error: "API key lacks user:profile OAuth scope", + errorType: "auth", + claude: { + session: { usedPct: null, remainingPct: null, resetsIn: null, error: "Auth required" }, + weekly: { usedPct: null, remainingPct: null, resets: null, error: "Auth required" }, + sonnet: { usedPct: null, remainingPct: null, resets: null, error: "Auth required" }, + lastSynced: null, + }, + codex: { sessionsToday: 0, tasksToday: 0, usage5hPct: 0, usageDayPct: 0 }, + routing: { total: 0, claudeTasks: 0, codexTasks: 0, claudePct: 0, codexPct: 0, codexFloor: 20 }, + }; +} + +function getRoutingStats(skillsPath, statePath, hours = 24) { + const safeHours = parseInt(hours, 10) || 24; + try { + const { execFileSync } = require("child_process"); + const skillDir = path.join(skillsPath, "llm_routing"); + const output = execFileSync( + "python", + ["-m", "llm_routing", "stats", "--hours", String(safeHours), "--json"], + { + encoding: "utf8", + timeout: 10000, + cwd: skillDir, + env: getSafeEnv(), + }, + ); + return JSON.parse(output); + } catch (e) { + // Fallback: read JSONL directly + try { + const logFile = path.join(statePath, "routing-log.jsonl"); + if (!fs.existsSync(logFile)) { + return { total_requests: 0, by_model: {}, by_task_type: {} }; + } + + const cutoff = Date.now() - hours * 3600 * 1000; + const lines = fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean); + + const stats = { + total_requests: 0, + by_model: {}, + by_task_type: {}, + escalations: 0, + avg_latency_ms: 0, + success_rate: 0, + }; + + let latencies = []; + let successes = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const ts = new Date(entry.timestamp).getTime(); + if (ts < cutoff) continue; + + stats.total_requests++; + + // By model + const model = entry.selected_model || "unknown"; + stats.by_model[model] = (stats.by_model[model] || 0) + 1; + + // By task type + const tt = entry.task_type || "unknown"; + stats.by_task_type[tt] = (stats.by_task_type[tt] || 0) + 1; + + if (entry.escalation_reason) stats.escalations++; + if (entry.latency_ms) latencies.push(entry.latency_ms); + if (entry.success === true) successes++; + } catch {} + } + + if (latencies.length > 0) { + stats.avg_latency_ms = Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length); + } + if (stats.total_requests > 0) { + stats.success_rate = Math.round((successes / stats.total_requests) * 100); + } + + return stats; + } catch (e2) { + console.error("Failed to read routing stats:", e2.message); + return { error: e2.message }; + } + } +} + +// Start background refresh timers (call explicitly, not on require) +function startLlmUsageRefresh() { + setTimeout(() => refreshLlmUsageAsync(), 1000); + setInterval(() => refreshLlmUsageAsync(), LLM_CACHE_TTL_MS); +} + +module.exports = { + refreshLlmUsageAsync, + transformLiveUsageData, + getLlmUsage, + getRoutingStats, + startLlmUsageRefresh, +}; diff --git a/src/openclaw.js b/src/openclaw.js new file mode 100644 index 0000000..7443197 --- /dev/null +++ b/src/openclaw.js @@ -0,0 +1,101 @@ +/** + * OpenClaw CLI helpers - wrappers for running openclaw commands + */ + +const { execFileSync, execFile } = require("child_process"); +const { promisify } = require("util"); +const execFileAsync = promisify(execFile); + +/** + * Build a minimal env for child processes. + * Avoids leaking secrets (API keys, cloud creds) to shell subprocesses. + */ +function getSafeEnv() { + return { + PATH: process.env.PATH, + HOME: process.env.HOME, + USER: process.env.USER, + SHELL: process.env.SHELL, + LANG: process.env.LANG, + NO_COLOR: "1", + TERM: "dumb", + OPENCLAW_PROFILE: process.env.OPENCLAW_PROFILE || "", + OPENCLAW_WORKSPACE: process.env.OPENCLAW_WORKSPACE || "", + OPENCLAW_HOME: process.env.OPENCLAW_HOME || "", + }; +} + +/** + * Build args array for openclaw CLI, prepending --profile if set. + * Splits the args string on whitespace (shell-injection-safe since + * execFileSync never invokes a shell). + */ +function buildArgs(args) { + const profile = process.env.OPENCLAW_PROFILE || ""; + const profileArgs = profile ? ["--profile", profile] : []; + // Strip shell redirections (e.g. "2>&1", "2>/dev/null") — not needed with execFile + const cleanArgs = args + .replace(/\s*2>&1\s*/g, " ") + .replace(/\s*2>\/dev\/null\s*/g, " ") + .trim(); + return [...profileArgs, ...cleanArgs.split(/\s+/).filter(Boolean)]; +} + +/** + * Run openclaw CLI command synchronously + * Uses execFileSync (no shell) to eliminate injection surface. + * @param {string} args - Command arguments + * @returns {string|null} - Command output or null on error + */ +function runOpenClaw(args) { + try { + const result = execFileSync("openclaw", buildArgs(args), { + encoding: "utf8", + timeout: 3000, + env: getSafeEnv(), + stdio: ["pipe", "pipe", "pipe"], + }); + return result; + } catch (e) { + return null; + } +} + +/** + * Run openclaw CLI command asynchronously + * Uses execFile (no shell) to eliminate injection surface. + * @param {string} args - Command arguments + * @returns {Promise} - Command output or null on error + */ +async function runOpenClawAsync(args) { + try { + const { stdout } = await execFileAsync("openclaw", buildArgs(args), { + encoding: "utf8", + timeout: 20000, + env: getSafeEnv(), + }); + return stdout; + } catch (e) { + console.error("[OpenClaw Async] Error:", e.message); + return null; + } +} + +/** + * Extract JSON from openclaw output (may have non-JSON prefix) + * @param {string} output - Raw CLI output + * @returns {string|null} - JSON string or null + */ +function extractJSON(output) { + if (!output) return null; + const jsonStart = output.search(/[[{]/); + if (jsonStart === -1) return null; + return output.slice(jsonStart); +} + +module.exports = { + runOpenClaw, + runOpenClawAsync, + extractJSON, + getSafeEnv, +}; diff --git a/src/operators.js b/src/operators.js new file mode 100644 index 0000000..3759263 --- /dev/null +++ b/src/operators.js @@ -0,0 +1,233 @@ +const fs = require("fs"); +const path = require("path"); + +function loadOperators(dataDir) { + const operatorsFile = path.join(dataDir, "operators.json"); + try { + if (fs.existsSync(operatorsFile)) { + return JSON.parse(fs.readFileSync(operatorsFile, "utf8")); + } + } catch (e) { + console.error("Failed to load operators:", e.message); + } + return { version: 1, operators: [], roles: {} }; +} + +function saveOperators(dataDir, data) { + try { + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + const operatorsFile = path.join(dataDir, "operators.json"); + fs.writeFileSync(operatorsFile, JSON.stringify(data, null, 2)); + return true; + } catch (e) { + console.error("Failed to save operators:", e.message); + return false; + } +} + +function getOperatorBySlackId(dataDir, slackId) { + const data = loadOperators(dataDir); + return data.operators.find((op) => op.id === slackId || op.metadata?.slackId === slackId); +} + +// Auto-detect operators from session transcripts (runs async in background) +let operatorsRefreshing = false; +async function refreshOperatorsAsync(dataDir, getOpenClawDir) { + if (operatorsRefreshing) return; + operatorsRefreshing = true; + + // Normalize timestamp to ms (handles ISO strings, numbers, and fallback) + const toMs = (ts, fallback) => { + if (typeof ts === "number" && Number.isFinite(ts)) return ts; + if (typeof ts === "string") { + const parsed = Date.parse(ts); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; + }; + + try { + const openclawDir = getOpenClawDir(); + const sessionsDir = path.join(openclawDir, "agents", "main", "sessions"); + + if (!fs.existsSync(sessionsDir)) { + operatorsRefreshing = false; + return; + } + + const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl")); + const operatorsMap = new Map(); // userId -> operator data + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + + for (const file of files) { + const filePath = path.join(sessionsDir, file); + try { + const stat = fs.statSync(filePath); + // Only scan files modified in last 7 days + if (stat.mtimeMs < sevenDaysAgo) continue; + + // Read first 10KB of each file (enough to get user info) + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(10240); + const bytesRead = fs.readSync(fd, buffer, 0, 10240, 0); + fs.closeSync(fd); + + const content = buffer.toString("utf8", 0, bytesRead); + const lines = content.split("\n").slice(0, 20); // First 20 lines + + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type !== "message" || !entry.message) continue; + + const msg = entry.message; + if (msg.role !== "user") continue; + + let text = ""; + if (typeof msg.content === "string") { + text = msg.content; + } else if (Array.isArray(msg.content)) { + const textPart = msg.content.find((c) => c.type === "text"); + if (textPart) text = textPart.text || ""; + } + + if (!text) continue; + + // Extract Slack user: "[Slack #channel +Xm date] username (USERID):" + const slackMatch = text.match(/\[Slack[^\]]*\]\s*([\w.-]+)\s*\(([A-Z0-9]+)\):/); + if (slackMatch) { + const username = slackMatch[1]; + const userId = slackMatch[2]; + + if (!operatorsMap.has(userId)) { + operatorsMap.set(userId, { + id: userId, + name: username, + username: username, + source: "slack", + firstSeen: toMs(entry.timestamp, stat.mtimeMs), + lastSeen: toMs(entry.timestamp, stat.mtimeMs), + sessionCount: 1, + }); + } else { + const op = operatorsMap.get(userId); + op.lastSeen = Math.max(op.lastSeen, toMs(entry.timestamp, stat.mtimeMs)); + op.sessionCount++; + } + break; // Found user for this session, move to next file + } + + // Also check for Telegram users: "[Telegram +Xm date] username:" + const telegramMatch = text.match(/\[Telegram[^\]]*\]\s*([\w.-]+):/); + if (telegramMatch) { + const username = telegramMatch[1]; + const operatorId = `telegram:${username}`; + + if (!operatorsMap.has(operatorId)) { + operatorsMap.set(operatorId, { + id: operatorId, + name: username, + username: username, + source: "telegram", + firstSeen: toMs(entry.timestamp, stat.mtimeMs), + lastSeen: toMs(entry.timestamp, stat.mtimeMs), + sessionCount: 1, + }); + } else { + const op = operatorsMap.get(operatorId); + op.lastSeen = Math.max(op.lastSeen, toMs(entry.timestamp, stat.mtimeMs)); + op.sessionCount++; + } + break; + } + + // Check for Discord users in "Conversation info" JSON block + // Pattern: "sender": "123456789012345678" and "label": "CoolUser123" + const discordSenderMatch = text.match(/"sender":\s*"(\d+)"/); + const discordLabelMatch = text.match(/"label":\s*"([^"]+)"/); + const discordUsernameMatch = text.match(/"username":\s*"([^"]+)"/); + + if (discordSenderMatch) { + const userId = discordSenderMatch[1]; + const label = discordLabelMatch ? discordLabelMatch[1] : userId; + const username = discordUsernameMatch ? discordUsernameMatch[1] : label; + const opId = `discord:${userId}`; + + if (!operatorsMap.has(opId)) { + operatorsMap.set(opId, { + id: opId, + discordId: userId, + name: label, + username: username, + source: "discord", + firstSeen: toMs(entry.timestamp, stat.mtimeMs), + lastSeen: toMs(entry.timestamp, stat.mtimeMs), + sessionCount: 1, + }); + } else { + const op = operatorsMap.get(opId); + op.lastSeen = Math.max(op.lastSeen, toMs(entry.timestamp, stat.mtimeMs)); + op.sessionCount++; + } + break; + } + } catch (e) { + /* skip invalid lines */ + } + } + } catch (e) { + /* skip unreadable files */ + } + } + + // Load existing operators to preserve manual edits + const existing = loadOperators(dataDir); + const existingMap = new Map(existing.operators.map((op) => [op.id, op])); + + // Merge: auto-detected + existing manual entries + for (const [id, autoOp] of operatorsMap) { + if (existingMap.has(id)) { + // Update stats but preserve manual fields + const manual = existingMap.get(id); + manual.lastSeen = Math.max(manual.lastSeen || 0, autoOp.lastSeen); + manual.sessionCount = (manual.sessionCount || 0) + autoOp.sessionCount; + } else { + existingMap.set(id, autoOp); + } + } + + // Save merged operators + const merged = { + version: 1, + operators: Array.from(existingMap.values()).sort( + (a, b) => (b.lastSeen || 0) - (a.lastSeen || 0), + ), + roles: existing.roles || {}, + lastRefreshed: Date.now(), + }; + + saveOperators(dataDir, merged); + console.log(`[Operators] Refreshed: ${merged.operators.length} operators detected`); + } catch (e) { + console.error("[Operators] Refresh failed:", e.message); + } + + operatorsRefreshing = false; +} + +// Start background operators refresh (caller invokes this instead of auto-starting on load) +function startOperatorsRefresh(dataDir, getOpenClawDir) { + setTimeout(() => refreshOperatorsAsync(dataDir, getOpenClawDir), 2000); + setInterval(() => refreshOperatorsAsync(dataDir, getOpenClawDir), 5 * 60 * 1000); // Every 5 minutes +} + +module.exports = { + loadOperators, + saveOperators, + getOperatorBySlackId, + refreshOperatorsAsync, + startOperatorsRefresh, +}; diff --git a/src/privacy.js b/src/privacy.js new file mode 100644 index 0000000..c030984 --- /dev/null +++ b/src/privacy.js @@ -0,0 +1,44 @@ +const fs = require("fs"); +const path = require("path"); + +function getPrivacyFilePath(dataDir) { + return path.join(dataDir, "privacy-settings.json"); +} + +function loadPrivacySettings(dataDir) { + try { + const privacyFile = getPrivacyFilePath(dataDir); + if (fs.existsSync(privacyFile)) { + return JSON.parse(fs.readFileSync(privacyFile, "utf8")); + } + } catch (e) { + console.error("Failed to load privacy settings:", e.message); + } + return { + version: 1, + hiddenTopics: [], + hiddenSessions: [], + hiddenCrons: [], + hideHostname: false, + updatedAt: null, + }; +} + +function savePrivacySettings(dataDir, data) { + try { + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + data.updatedAt = new Date().toISOString(); + fs.writeFileSync(getPrivacyFilePath(dataDir), JSON.stringify(data, null, 2)); + return true; + } catch (e) { + console.error("Failed to save privacy settings:", e.message); + return false; + } +} + +module.exports = { + loadPrivacySettings, + savePrivacySettings, +}; diff --git a/src/sessions.js b/src/sessions.js new file mode 100644 index 0000000..132fcf6 --- /dev/null +++ b/src/sessions.js @@ -0,0 +1,589 @@ +const fs = require("fs"); +const path = require("path"); +const { detectTopics } = require("./topics"); + +// Channel ID to name mapping (auto-populated from Slack) +const CHANNEL_MAP = { + c0aax7y80np: "#cc-meta", + c0ab9f8sdfe: "#cc-research", + c0aan4rq7v5: "#cc-finance", + c0abxulk1qq: "#cc-properties", + c0ab5nz8mkl: "#cc-ai", + c0aan38tzv5: "#cc-dev", + c0ab7wwhqvc: "#cc-home", + c0ab1pjhxef: "#cc-health", + c0ab7txvcqd: "#cc-legal", + c0aay2g3n3r: "#cc-social", + c0aaxrw2wqp: "#cc-business", + c0ab19f3lae: "#cc-random", + c0ab0r74y33: "#cc-food", + c0ab0qrq3r9: "#cc-travel", + c0ab0sbqqlg: "#cc-family", + c0ab0slqdba: "#cc-games", + c0ab1ps7ef2: "#cc-music", + c0absbnrsbe: "#cc-dashboard", +}; + +// Parse session key into readable label +function parseSessionLabel(key) { + // Pattern: agent:main:slack:channel:CHANNEL_ID:thread:TIMESTAMP + // or: agent:main:slack:channel:CHANNEL_ID + // or: agent:main:main (telegram main) + + const parts = key.split(":"); + + if (parts.includes("slack")) { + const channelIdx = parts.indexOf("channel"); + if (channelIdx >= 0 && parts[channelIdx + 1]) { + const channelId = parts[channelIdx + 1].toLowerCase(); + const channelName = CHANNEL_MAP[channelId] || `#${channelId}`; + + // Check if it's a thread + if (parts.includes("thread")) { + const threadTs = parts[parts.indexOf("thread") + 1]; + // Convert timestamp to rough time + const ts = parseFloat(threadTs); + const date = new Date(ts * 1000); + const timeStr = date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); + return `${channelName} thread @ ${timeStr}`; + } + return channelName; + } + } + + if (key.includes("telegram")) { + return "📱 Telegram"; + } + + if (key === "agent:main:main") { + return "🏠 Main Session"; + } + + // Fallback: truncate key + return key.length > 40 ? key.slice(0, 37) + "..." : key; +} + +/** + * Create a sessions module with bound dependencies. + * @param {Object} deps + * @param {Function} deps.getOpenClawDir - Returns the OpenClaw directory path + * @param {Function} deps.getOperatorBySlackId - Look up operator by Slack ID + * @param {Function} deps.runOpenClaw - Run OpenClaw command synchronously + * @param {Function} deps.runOpenClawAsync - Run OpenClaw command asynchronously + * @param {Function} deps.extractJSON - Extract JSON from command output + * @returns {Object} Session management functions + */ +function createSessionsModule(deps) { + const { getOpenClawDir, getOperatorBySlackId, runOpenClaw, runOpenClawAsync, extractJSON } = deps; + + // SESSION CACHE - Async refresh to avoid blocking + let sessionsCache = { sessions: [], timestamp: 0, refreshing: false }; + const SESSIONS_CACHE_TTL = 10000; // 10 seconds + + // Extract session originator from transcript + function getSessionOriginator(sessionId) { + try { + if (!sessionId) return null; + + const openclawDir = getOpenClawDir(); + const transcriptPath = path.join( + openclawDir, + "agents", + "main", + "sessions", + `${sessionId}.jsonl`, + ); + + if (!fs.existsSync(transcriptPath)) return null; + + const content = fs.readFileSync(transcriptPath, "utf8"); + const lines = content.trim().split("\n"); + + // Find the first user message to extract originator + for (let i = 0; i < Math.min(lines.length, 10); i++) { + try { + const entry = JSON.parse(lines[i]); + if (entry.type !== "message" || !entry.message) continue; + + const msg = entry.message; + if (msg.role !== "user") continue; + + let text = ""; + if (typeof msg.content === "string") { + text = msg.content; + } else if (Array.isArray(msg.content)) { + const textPart = msg.content.find((c) => c.type === "text"); + if (textPart) text = textPart.text || ""; + } + + if (!text) continue; + + // Extract Slack user from message patterns: + // Example: "[Slack #channel +6m 2026-01-27 15:31 PST] username (USERID): message" + // Pattern: "username (USERID):" where USERID is the sender's Slack ID + const slackUserMatch = text.match(/\]\s*([\w.-]+)\s*\(([A-Z0-9]+)\):/); + + if (slackUserMatch) { + const username = slackUserMatch[1]; + const userId = slackUserMatch[2]; + + const operator = getOperatorBySlackId(userId); + + return { + userId, + username, + displayName: operator?.name || username, + role: operator?.role || "user", + avatar: operator?.avatar || null, + }; + } + } catch (e) {} + } + + return null; + } catch (e) { + return null; + } + } + + /** + * Get quick topic for a session by reading first portion of transcript + * @param {string} sessionId - Session ID + * @returns {string|null} - Primary topic or null + */ + function getSessionTopic(sessionId) { + if (!sessionId) return null; + try { + const openclawDir = getOpenClawDir(); + const transcriptPath = path.join( + openclawDir, + "agents", + "main", + "sessions", + `${sessionId}.jsonl`, + ); + if (!fs.existsSync(transcriptPath)) return null; + + // Read first 50KB of transcript (enough for topic detection, fast) + const fd = fs.openSync(transcriptPath, "r"); + const buffer = Buffer.alloc(50000); + const bytesRead = fs.readSync(fd, buffer, 0, 50000, 0); + fs.closeSync(fd); + + if (bytesRead === 0) return null; + + const content = buffer.toString("utf8", 0, bytesRead); + const lines = content.split("\n").filter((l) => l.trim()); + + // Extract text from messages + // Transcript format: {type: "message", message: {role: "user"|"assistant", content: [...]}} + let textSamples = []; + for (const line of lines.slice(0, 30)) { + // First 30 entries + try { + const entry = JSON.parse(line); + if (entry.type === "message" && entry.message?.content) { + const msgContent = entry.message.content; + if (Array.isArray(msgContent)) { + msgContent.forEach((c) => { + if (c.type === "text" && c.text) { + textSamples.push(c.text.slice(0, 500)); + } + }); + } else if (typeof msgContent === "string") { + textSamples.push(msgContent.slice(0, 500)); + } + } + } catch (e) { + /* skip malformed lines */ + } + } + + if (textSamples.length === 0) return null; + + const topics = detectTopics(textSamples.join(" ")); + return topics.length > 0 ? topics.slice(0, 2).join(", ") : null; + } catch (e) { + return null; + } + } + + // Helper to map a single session (extracted from getSessions) + function mapSession(s) { + const minutesAgo = s.ageMs ? s.ageMs / 60000 : Infinity; + + // Determine channel type from key (messaging platform) + let channel = "other"; + if (s.key.includes("slack")) channel = "slack"; + else if (s.key.includes("telegram")) channel = "telegram"; + else if (s.key.includes("discord")) channel = "discord"; + else if (s.key.includes("signal")) channel = "signal"; + else if (s.key.includes("whatsapp")) channel = "whatsapp"; + + // Determine session type (main, subagent, cron, channel-based) + let sessionType = "channel"; + if (s.key.includes(":subagent:")) sessionType = "subagent"; + else if (s.key.includes(":cron:")) sessionType = "cron"; + else if (s.key === "agent:main:main") sessionType = "main"; + + const originator = getSessionOriginator(s.sessionId); + const label = s.groupChannel || s.displayName || parseSessionLabel(s.key); + const topic = getSessionTopic(s.sessionId); + + const totalTokens = s.totalTokens || 0; + const sessionAgeMinutes = Math.max(1, Math.min(minutesAgo, 24 * 60)); + const burnRate = Math.round(totalTokens / sessionAgeMinutes); + + return { + sessionKey: s.key, + sessionId: s.sessionId, + label: label, + groupChannel: s.groupChannel || null, + displayName: s.displayName || null, + kind: s.kind, + channel: channel, + sessionType: sessionType, + active: minutesAgo < 15, + recentlyActive: minutesAgo < 60, + minutesAgo: Math.round(minutesAgo), + tokens: s.totalTokens || 0, + model: s.model, + originator: originator, + topic: topic, + metrics: { + burnRate: burnRate, + toolCalls: 0, + minutesActive: Math.max(1, Math.min(Math.round(minutesAgo), 24 * 60)), + }, + }; + } + + async function refreshSessionsCache() { + if (sessionsCache.refreshing) return; // Don't double-refresh + sessionsCache.refreshing = true; + + try { + const output = await runOpenClawAsync("sessions --json 2>/dev/null"); + const jsonStr = extractJSON(output); + if (jsonStr) { + const data = JSON.parse(jsonStr); + const sessions = data.sessions || []; + + // Map sessions (same logic as getSessions) + const mapped = sessions.map((s) => mapSession(s)); + + sessionsCache = { + sessions: mapped, + timestamp: Date.now(), + refreshing: false, + }; + console.log(`[Sessions Cache] Refreshed: ${mapped.length} sessions`); + } + } catch (e) { + console.error("[Sessions Cache] Refresh error:", e.message); + } + sessionsCache.refreshing = false; + } + + // Get sessions from cache, trigger async refresh if stale + function getSessionsCached() { + const now = Date.now(); + const isStale = now - sessionsCache.timestamp > SESSIONS_CACHE_TTL; + + if (isStale && !sessionsCache.refreshing) { + // Trigger async refresh (don't await - return stale data immediately) + refreshSessionsCache(); + } + + return sessionsCache.sessions; + } + + function getSessions(options = {}) { + const limit = Object.prototype.hasOwnProperty.call(options, "limit") ? options.limit : 20; + const returnCount = options.returnCount || false; + + // For "get all" requests (limit: null), use the async cache + // This is the expensive operation that was blocking + if (limit === null) { + const cached = getSessionsCached(); + const totalCount = cached.length; + return returnCount ? { sessions: cached, totalCount } : cached; + } + + // For limited requests, can still use sync (fast enough) + try { + const output = runOpenClaw("sessions --json 2>/dev/null"); + const jsonStr = extractJSON(output); + if (jsonStr) { + const data = JSON.parse(jsonStr); + const totalCount = data.count || data.sessions?.length || 0; + let sessions = data.sessions || []; + if (limit != null) { + sessions = sessions.slice(0, limit); + } + const mapped = sessions.map((s) => mapSession(s)); + return returnCount ? { sessions: mapped, totalCount } : mapped; + } + } catch (e) { + console.error("Failed to get sessions:", e.message); + } + return returnCount ? { sessions: [], totalCount: 0 } : []; + } + + // Read session transcript from JSONL file + function readTranscript(sessionId) { + const openclawDir = getOpenClawDir(); + const transcriptPath = path.join( + openclawDir, + "agents", + "main", + "sessions", + `${sessionId}.jsonl`, + ); + + try { + if (!fs.existsSync(transcriptPath)) return []; + const content = fs.readFileSync(transcriptPath, "utf8"); + return content + .trim() + .split("\n") + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); + } catch (e) { + console.error("Failed to read transcript:", e.message); + return []; + } + } + + // Get detailed session info + function getSessionDetail(sessionKey) { + try { + // Get basic session info + const listOutput = runOpenClaw("sessions --json 2>/dev/null"); + let sessionInfo = null; + const jsonStr = extractJSON(listOutput); + if (jsonStr) { + const data = JSON.parse(jsonStr); + sessionInfo = data.sessions?.find((s) => s.key === sessionKey); + } + + if (!sessionInfo) { + return { error: "Session not found" }; + } + + // Read transcript directly from JSONL file + const transcript = readTranscript(sessionInfo.sessionId); + let messages = []; + let tools = {}; + let facts = []; + let needsAttention = []; + + // Aggregate token usage from transcript + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + let detectedModel = sessionInfo.model || null; + + // Process transcript entries (format: {type: "message", message: {role, content, usage}}) + transcript.forEach((entry) => { + if (entry.type !== "message" || !entry.message) return; + + const msg = entry.message; + if (!msg.role) return; + + // Extract token usage from messages (typically on assistant messages) + if (msg.usage) { + totalInputTokens += msg.usage.input || msg.usage.inputTokens || 0; + totalOutputTokens += msg.usage.output || msg.usage.outputTokens || 0; + totalCacheRead += msg.usage.cacheRead || msg.usage.cacheReadTokens || 0; + totalCacheWrite += msg.usage.cacheWrite || msg.usage.cacheWriteTokens || 0; + if (msg.usage.cost?.total) totalCost += msg.usage.cost.total; + } + + // Detect model from assistant messages + if (msg.role === "assistant" && msg.model && !detectedModel) { + detectedModel = msg.model; + } + + let text = ""; + if (typeof msg.content === "string") { + text = msg.content; + } else if (Array.isArray(msg.content)) { + const textPart = msg.content.find((c) => c.type === "text"); + if (textPart) text = textPart.text || ""; + + // Count tool calls + msg.content + .filter((c) => c.type === "toolCall" || c.type === "tool_use") + .forEach((tc) => { + const name = tc.name || tc.tool || "unknown"; + tools[name] = (tools[name] || 0) + 1; + }); + } + + if (text && msg.role !== "toolResult") { + messages.push({ role: msg.role, text, timestamp: entry.timestamp }); + } + + // Extract insights from user messages + if (msg.role === "user" && text) { + const lowerText = text.toLowerCase(); + + // Look for questions + if (text.includes("?")) { + const questions = text.match(/[^.!?\n]*\?/g) || []; + questions.slice(0, 2).forEach((q) => { + if (q.length > 15 && q.length < 200) { + needsAttention.push(`❓ ${q.trim()}`); + } + }); + } + + // Look for action items + if ( + lowerText.includes("todo") || + lowerText.includes("remind") || + lowerText.includes("need to") + ) { + const match = text.match(/(?:todo|remind|need to)[^.!?\n]*/i); + if (match) needsAttention.push(`📋 ${match[0].slice(0, 100)}`); + } + } + + // Extract facts from assistant messages + if (msg.role === "assistant" && text) { + const lowerText = text.toLowerCase(); + + // Look for completions + ["✅", "done", "created", "updated", "fixed", "deployed"].forEach((keyword) => { + if (lowerText.includes(keyword)) { + const lines = text.split("\n").filter((l) => l.toLowerCase().includes(keyword)); + lines.slice(0, 2).forEach((line) => { + if (line.length > 5 && line.length < 150) { + facts.push(line.trim().slice(0, 100)); + } + }); + } + }); + } + }); + + // Generate summary from recent messages + let summary = "No activity yet."; + const userMessages = messages.filter((m) => m.role === "user"); + const assistantMessages = messages.filter((m) => m.role === "assistant"); + let topics = []; + + if (messages.length > 0) { + summary = `${messages.length} messages (${userMessages.length} user, ${assistantMessages.length} assistant). `; + + // Identify main topics from all text using pattern matching + const allText = messages.map((m) => m.text).join(" "); + topics = detectTopics(allText); + + if (topics.length > 0) { + summary += `Topics: ${topics.join(", ")}.`; + } + } + + // Convert tools to array + const toolsArray = Object.entries(tools) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); + + // Calculate last active time + const ageMs = sessionInfo.ageMs || 0; + const lastActive = + ageMs < 60000 + ? "Just now" + : ageMs < 3600000 + ? `${Math.round(ageMs / 60000)} minutes ago` + : ageMs < 86400000 + ? `${Math.round(ageMs / 3600000)} hours ago` + : `${Math.round(ageMs / 86400000)} days ago`; + + // Determine readable channel name + // Priority: groupChannel > displayName > parsed from key > fallback + let channelDisplay = "Other"; + if (sessionInfo.groupChannel) { + channelDisplay = sessionInfo.groupChannel; + } else if (sessionInfo.displayName) { + channelDisplay = sessionInfo.displayName; + } else if (sessionKey.includes("slack")) { + // Try to parse channel name from key + const parts = sessionKey.split(":"); + const channelIdx = parts.indexOf("channel"); + if (channelIdx >= 0 && parts[channelIdx + 1]) { + const channelId = parts[channelIdx + 1].toLowerCase(); + channelDisplay = CHANNEL_MAP[channelId] || `#${channelId}`; + } else { + channelDisplay = "Slack"; + } + } else if (sessionKey.includes("telegram")) { + channelDisplay = "Telegram"; + } + + // Use parsed totals or fallback to session info + const finalTotalTokens = totalInputTokens + totalOutputTokens || sessionInfo.totalTokens || 0; + const finalInputTokens = totalInputTokens || sessionInfo.inputTokens || 0; + const finalOutputTokens = totalOutputTokens || sessionInfo.outputTokens || 0; + + // Format model name (strip prefix) + const modelDisplay = (detectedModel || sessionInfo.model || "-") + .replace("anthropic/", "") + .replace("openai/", ""); + + return { + key: sessionKey, + kind: sessionInfo.kind, + channel: channelDisplay, + groupChannel: sessionInfo.groupChannel || channelDisplay, + model: modelDisplay, + tokens: finalTotalTokens, + inputTokens: finalInputTokens, + outputTokens: finalOutputTokens, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + estCost: totalCost > 0 ? `$${totalCost.toFixed(4)}` : null, + lastActive, + summary, + topics, // Array of detected topics + facts: [...new Set(facts)].slice(0, 8), + needsAttention: [...new Set(needsAttention)].slice(0, 5), + tools: toolsArray.slice(0, 10), + messages: messages + .slice(-15) + .reverse() + .map((m) => ({ + role: m.role, + text: m.text.slice(0, 500), + })), + }; + } catch (e) { + console.error("Failed to get session detail:", e.message); + return { error: e.message }; + } + } + + return { + getSessionOriginator, + getSessionTopic, + mapSession, + refreshSessionsCache, + getSessionsCached, + getSessions, + readTranscript, + getSessionDetail, + parseSessionLabel, + }; +} + +module.exports = { createSessionsModule, CHANNEL_MAP }; diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..54f9eb7 --- /dev/null +++ b/src/state.js @@ -0,0 +1,664 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { formatBytes, formatTimeAgo } = require("./utils"); + +/** + * Creates a state management module with injected dependencies. + * + * @param {object} deps + * @param {object} deps.CONFIG - config object (for paths, billing) + * @param {function} deps.getOpenClawDir - returns the OpenClaw directory path + * @param {function} deps.getSessions - function from sessions module + * @param {function} deps.getSystemVitals - function from vitals module + * @param {function} deps.getCronJobs - function from cron module + * @param {function} deps.loadOperators - function from operators module + * @param {function} deps.getLlmUsage - function from llm-usage module + * @param {function} deps.getDailyTokenUsage - function from tokens module + * @param {function} deps.getTokenStats - function from tokens module + * @param {function} deps.getCerebroTopics - function from cerebro module + * @param {function} deps.getMemoryStats - function (defined in this module, uses CONFIG.paths) + * @param {function} deps.runOpenClaw - function from openclaw module + * @param {function} deps.extractJSON - function from openclaw module + * @param {function} deps.readTranscript - function from sessions module + */ +function createStateModule(deps) { + const { + CONFIG, + getOpenClawDir, + getSessions, + getSystemVitals, + getCronJobs, + loadOperators, + getLlmUsage, + getDailyTokenUsage, + getTokenStats, + getCerebroTopics, + runOpenClaw, + extractJSON, + readTranscript, + } = deps; + + const PATHS = CONFIG.paths; + + // Module-level state + let cachedState = null; + let lastStateUpdate = 0; + const STATE_CACHE_TTL = 30000; // 30 seconds - reduce blocking from CLI calls + let stateRefreshInterval = null; + + // Get system status + function getSystemStatus() { + const hostname = os.hostname(); + let uptime = "\u2014"; + try { + const uptimeRaw = execFileSync("uptime", [], { encoding: "utf8" }); + const match = uptimeRaw.match(/up\s+([^,]+)/); + if (match) uptime = match[1].trim(); + } catch (e) {} + + let gateway = "Unknown"; + try { + const status = runOpenClaw("gateway status 2>/dev/null"); + if (status && status.includes("running")) { + gateway = "Running"; + } else if (status && status.includes("stopped")) { + gateway = "Stopped"; + } + } catch (e) {} + + return { + hostname, + gateway, + model: "claude-opus-4-5", + uptime, + }; + } + + // Get recent activity from memory files + function getRecentActivity() { + const activities = []; + const today = new Date().toISOString().split("T")[0]; + const memoryFile = path.join(PATHS.memory, `${today}.md`); + + try { + if (fs.existsSync(memoryFile)) { + const content = fs.readFileSync(memoryFile, "utf8"); + const lines = content.split("\n").filter((l) => l.startsWith("- ")); + lines.slice(-5).forEach((line) => { + const text = line.replace(/^- /, "").slice(0, 80); + activities.push({ + icon: text.includes("\u2705") + ? "\u2705" + : text.includes("\u274C") + ? "\u274C" + : "\uD83D\uDCDD", + text: text.replace(/[\u2705\u274C\uD83D\uDCDD\uD83D\uDD27]/g, "").trim(), + time: today, + }); + }); + } + } catch (e) { + console.error("Failed to read activity:", e.message); + } + + return activities.reverse(); + } + + // Get capacity info from gateway config and active sessions + function getCapacity() { + const result = { + main: { active: 0, max: 12 }, + subagent: { active: 0, max: 24 }, + }; + + // Determine OpenClaw directory (respects OPENCLAW_PROFILE) + const openclawDir = getOpenClawDir(); + + // Read max capacity from openclaw config + try { + const configPath = path.join(openclawDir, "openclaw.json"); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + if (config?.agents?.defaults?.maxConcurrent) { + result.main.max = config.agents.defaults.maxConcurrent; + } + if (config?.agents?.defaults?.subagents?.maxConcurrent) { + result.subagent.max = config.agents.defaults.subagents.maxConcurrent; + } + } + } catch (e) { + // Fall back to defaults + } + + // Try to get active counts from sessions (preferred - has full session keys) + try { + const output = runOpenClaw("sessions --json 2>/dev/null"); + const jsonStr = extractJSON(output); + if (jsonStr) { + const data = JSON.parse(jsonStr); + const sessions = data.sessions || []; + const fiveMinMs = 5 * 60 * 1000; + + for (const s of sessions) { + // Only count sessions active in last 5 minutes + if (s.ageMs > fiveMinMs) continue; + + const key = s.key || ""; + // Session key patterns: + // agent:main:slack:... = main (human-initiated) + // agent:main:telegram:... = main + // agent:main:discord:... = main + // agent:main:subagent:... = subagent (spawned task) + // agent:main:cron:... = cron job (count as subagent) + if (key.includes(":subagent:") || key.includes(":cron:")) { + result.subagent.active++; + } else { + result.main.active++; + } + } + return result; + } + } catch (e) { + console.error("Failed to get capacity from sessions, falling back to filesystem:", e.message); + } + + // Count active sessions from filesystem (workaround for CLI returning styled text) + // Sessions active in last 5 minutes are considered "active" + try { + const sessionsDir = path.join(openclawDir, "agents", "main", "sessions"); + if (fs.existsSync(sessionsDir)) { + const fiveMinAgo = Date.now() - 5 * 60 * 1000; + const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl")); + + let mainActive = 0; + let subActive = 0; + + for (const file of files) { + try { + const filePath = path.join(sessionsDir, file); + const stat = fs.statSync(filePath); + + // Only count files modified in last 5 minutes as "active" + if (stat.mtimeMs < fiveMinAgo) continue; + + // Read the first line to get the session key + // Session keys indicate session type: + // agent:main:slack:... = main (human-initiated slack) + // agent:main:telegram:... = main (human-initiated telegram) + // agent:main:discord:... = main (human-initiated discord) + // agent:main:subagent:... = subagent (spawned autonomous task) + // agent:main:cron:... = cron job (automated, count as subagent) + // Filenames are just UUIDs, so we must read the content + let isSubagent = false; + try { + const fd = fs.openSync(filePath, "r"); + const buffer = Buffer.alloc(512); // First 512 bytes is enough for the first line + fs.readSync(fd, buffer, 0, 512, 0); + fs.closeSync(fd); + const firstLine = buffer.toString("utf8").split("\n")[0]; + const parsed = JSON.parse(firstLine); + const key = parsed.key || parsed.id || ""; + // Subagent and cron sessions are not human-initiated + isSubagent = key.includes(":subagent:") || key.includes(":cron:"); + } catch (parseErr) { + // If we can't parse, fall back to checking filename (legacy) + isSubagent = file.includes("subagent"); + } + + if (isSubagent) { + subActive++; + } else { + mainActive++; + } + } catch (e) { + // Skip unreadable files + } + } + + result.main.active = mainActive; + result.subagent.active = subActive; + } + } catch (e) { + console.error("Failed to count active sessions from filesystem:", e.message); + } + + return result; + } + + // Get memory stats + function getMemoryStats() { + const memoryDir = PATHS.memory; + const memoryFile = path.join(PATHS.workspace, "MEMORY.md"); + + const stats = { + totalFiles: 0, + totalSize: 0, + totalSizeFormatted: "0 B", + memoryMdSize: 0, + memoryMdSizeFormatted: "0 B", + memoryMdLines: 0, + recentFiles: [], + oldestFile: null, + newestFile: null, + }; + + try { + const collectMemoryFiles = (dir, baseDir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectMemoryFiles(entryPath, baseDir)); + } else if ( + entry.isFile() && + (entry.name.endsWith(".md") || entry.name.endsWith(".json")) + ) { + const stat = fs.statSync(entryPath); + const relativePath = path.relative(baseDir, entryPath); + files.push({ + name: relativePath, + size: stat.size, + sizeFormatted: formatBytes(stat.size), + modified: stat.mtime, + }); + } + } + + return files; + }; + + // MEMORY.md stats + if (fs.existsSync(memoryFile)) { + const memStat = fs.statSync(memoryFile); + stats.memoryMdSize = memStat.size; + stats.memoryMdSizeFormatted = formatBytes(memStat.size); + const content = fs.readFileSync(memoryFile, "utf8"); + stats.memoryMdLines = content.split("\n").length; + stats.totalSize += memStat.size; + stats.totalFiles++; + } + + // Memory directory stats + if (fs.existsSync(memoryDir)) { + const files = collectMemoryFiles(memoryDir, memoryDir).sort( + (a, b) => b.modified - a.modified, + ); + + stats.totalFiles += files.length; + files.forEach((f) => (stats.totalSize += f.size)); + stats.recentFiles = files.slice(0, 5).map((f) => ({ + name: f.name, + sizeFormatted: f.sizeFormatted, + age: formatTimeAgo(f.modified), + })); + + if (files.length > 0) { + stats.newestFile = files[0].name; + stats.oldestFile = files[files.length - 1].name; + } + } + + stats.totalSizeFormatted = formatBytes(stats.totalSize); + } catch (e) { + console.error("Failed to get memory stats:", e.message); + } + + return stats; + } + + // Get all data for dashboard (legacy endpoint) + function getData() { + // Get ALL sessions for accurate counts, then slice for display + const allSessions = getSessions({ limit: null }); + const pageSize = 20; + const displaySessions = allSessions.slice(0, pageSize); + const tokenStats = getTokenStats(allSessions); + const capacity = getCapacity(); + const memory = getMemoryStats(); + + // Calculate status counts based on ALL sessions (not just current page) + const statusCounts = { + all: allSessions.length, + live: allSessions.filter((s) => s.active).length, + recent: allSessions.filter((s) => !s.active && s.recentlyActive).length, + idle: allSessions.filter((s) => !s.active && !s.recentlyActive).length, + }; + + // Calculate real pagination + const totalPages = Math.ceil(allSessions.length / pageSize); + + return { + sessions: displaySessions, + tokenStats: tokenStats, + capacity: capacity, + memory: memory, + pagination: { + page: 1, + pageSize: pageSize, + total: allSessions.length, + totalPages: totalPages, + hasPrev: false, + hasNext: totalPages > 1, + }, + statusCounts: statusCounts, + }; + } + + // Unified state for dashboard (single source of truth) + function getFullState() { + const now = Date.now(); + + // Return cached state if fresh + if (cachedState && now - lastStateUpdate < STATE_CACHE_TTL) { + return cachedState; + } + + // Gather all data with error handling for each section + let sessions = []; + let tokenStats = {}; + let statusCounts = { all: 0, live: 0, recent: 0, idle: 0 }; + let vitals = {}; + let capacity = {}; + let operators = { operators: [], roles: {} }; + let llmUsage = {}; + let cron = []; + let memory = {}; + let cerebro = {}; + let subagents = []; + + // Get ALL sessions first for accurate statusCounts, then slice for display + let allSessions = []; + let totalSessionCount = 0; + try { + allSessions = getSessions({ limit: null }); // Get all for counting + totalSessionCount = allSessions.length; + sessions = allSessions.slice(0, 20); // Display only first 20 + } catch (e) { + console.error("[State] sessions:", e.message); + } + + try { + vitals = getSystemVitals(); + } catch (e) { + console.error("[State] vitals:", e.message); + } + // Use filesystem-based capacity (no CLI calls, won't block) + try { + capacity = getCapacity(); + } catch (e) { + console.error("[State] capacity:", e.message); + } + // Pass capacity to tokenStats so it can use the same active counts + try { + tokenStats = getTokenStats(allSessions, capacity, CONFIG); + } catch (e) { + console.error("[State] tokenStats:", e.message); + } + // Calculate statusCounts from ALL sessions (not just current page) for accurate filter counts + try { + const liveSessions = allSessions.filter((s) => s.active); + const recentSessions = allSessions.filter((s) => !s.active && s.recentlyActive); + const idleSessions = allSessions.filter((s) => !s.active && !s.recentlyActive); + statusCounts = { + all: totalSessionCount, + live: liveSessions.length, + recent: recentSessions.length, + idle: idleSessions.length, + }; + } catch (e) { + console.error("[State] statusCounts:", e.message); + } + try { + const operatorData = loadOperators(); + // Add stats to each operator (same as /api/operators endpoint) + const operatorsWithStats = operatorData.operators.map((op) => { + const userSessions = allSessions.filter( + (s) => s.originator?.userId === op.id || s.originator?.userId === op.metadata?.slackId, + ); + return { + ...op, + stats: { + activeSessions: userSessions.filter((s) => s.active).length, + totalSessions: userSessions.length, + lastSeen: + userSessions.length > 0 + ? new Date( + Date.now() - Math.min(...userSessions.map((s) => s.minutesAgo)) * 60000, + ).toISOString() + : op.lastSeen, + }, + }; + }); + operators = { ...operatorData, operators: operatorsWithStats }; + } catch (e) { + console.error("[State] operators:", e.message); + } + try { + llmUsage = getLlmUsage(); + } catch (e) { + console.error("[State] llmUsage:", e.message); + } + try { + cron = getCronJobs(); + } catch (e) { + console.error("[State] cron:", e.message); + } + try { + memory = getMemoryStats(); + } catch (e) { + console.error("[State] memory:", e.message); + } + try { + cerebro = getCerebroTopics(); + } catch (e) { + console.error("[State] cerebro:", e.message); + } + // Derive subagents from allSessions (no extra CLI call needed) + // Configurable retention: SUBAGENT_RETENTION_HOURS env var (default 12h) + try { + const retentionHours = parseInt(process.env.SUBAGENT_RETENTION_HOURS || "12", 10); + const retentionMs = retentionHours * 60 * 60 * 1000; + subagents = allSessions + .filter((s) => s.sessionKey && s.sessionKey.includes(":subagent:")) + .filter((s) => (s.minutesAgo || 0) * 60000 < retentionMs) + .map((s) => { + const match = s.sessionKey.match(/:subagent:([a-f0-9-]+)$/); + const subagentId = match ? match[1] : s.sessionId; + return { + id: subagentId, + shortId: subagentId.slice(0, 8), + task: s.label || s.displayName || "Sub-agent task", + tokens: s.tokens || 0, + ageMs: (s.minutesAgo || 0) * 60000, + active: s.active, + recentlyActive: s.recentlyActive, + }; + }); + } catch (e) { + console.error("[State] subagents:", e.message); + } + + cachedState = { + vitals, + sessions, + tokenStats, + statusCounts, + capacity, + operators, + llmUsage, + cron, + memory, + cerebro, + subagents, + pagination: { + page: 1, + pageSize: 20, + total: totalSessionCount, + totalPages: Math.max(1, Math.ceil(totalSessionCount / 20)), + hasPrev: false, + hasNext: totalSessionCount > 20, + }, + timestamp: now, + }; + + lastStateUpdate = now; + return cachedState; + } + + // Force refresh the cached state + function refreshState() { + lastStateUpdate = 0; + return getFullState(); + } + + // Background state refresh and SSE broadcast + function startStateRefresh(broadcastSSE, intervalMs = 30000) { + if (stateRefreshInterval) return; + + stateRefreshInterval = setInterval(() => { + try { + const newState = refreshState(); + broadcastSSE("update", newState); + } catch (e) { + console.error("[State] Refresh error:", e.message); + } + }, intervalMs); + + console.log(`[State] Background refresh started (${intervalMs}ms interval)`); + } + + // Stop background refresh + function stopStateRefresh() { + if (stateRefreshInterval) { + clearInterval(stateRefreshInterval); + stateRefreshInterval = null; + console.log("[State] Background refresh stopped"); + } + } + + // Get detailed sub-agent status + function getSubagentStatus() { + const subagents = []; + try { + const output = runOpenClaw("sessions --json 2>/dev/null"); + const jsonStr = extractJSON(output); + if (jsonStr) { + const data = JSON.parse(jsonStr); + const subagentSessions = (data.sessions || []).filter( + (s) => s.key && s.key.includes(":subagent:"), + ); + + for (const s of subagentSessions) { + const ageMs = s.ageMs || Infinity; + const isActive = ageMs < 5 * 60 * 1000; // Active if < 5 min + const isRecent = ageMs < 30 * 60 * 1000; // Recent if < 30 min + + // Extract subagent ID from key + const match = s.key.match(/:subagent:([a-f0-9-]+)$/); + const subagentId = match ? match[1] : s.sessionId; + const shortId = subagentId.slice(0, 8); + + // Try to get task info from transcript + let taskSummary = "Unknown task"; + let label = null; + const transcript = readTranscript(s.sessionId); + + // Look for task description in first 15 messages (subagent context can be deep) + for (const entry of transcript.slice(0, 15)) { + if (entry.type === "message" && entry.message?.role === "user") { + const content = entry.message.content; + let text = ""; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + const textPart = content.find((c) => c.type === "text"); + if (textPart) text = textPart.text || ""; + } + + if (!text) continue; + + // Extract label from subagent context + const labelMatch = text.match(/Label:\s*([^\n]+)/i); + if (labelMatch) { + label = labelMatch[1].trim(); + } + + // Extract task summary - try multiple patterns + // Pattern 1: "You were created to handle: **TASK**" + let taskMatch = text.match(/You were created to handle:\s*\*\*([^*]+)\*\*/i); + if (taskMatch) { + taskSummary = taskMatch[1].trim(); + break; + } + + // Pattern 2: Linear issue format "**JON-XXX: Description**" + taskMatch = text.match(/\*\*([A-Z]{2,5}-\d+:\s*[^*]+)\*\*/); + if (taskMatch) { + taskSummary = taskMatch[1].trim(); + break; + } + + // Pattern 3: First meaningful line of user message + const firstLine = text + .split("\n")[0] + .replace(/^\*\*|\*\*$/g, "") + .trim(); + if (firstLine.length > 10 && firstLine.length < 100) { + taskSummary = firstLine; + break; + } + } + } + + // Count messages + const messageCount = transcript.filter( + (e) => e.type === "message" && e.message?.role, + ).length; + + subagents.push({ + id: subagentId, + shortId, + sessionId: s.sessionId, + label: label || shortId, + task: taskSummary, + model: s.model?.replace("anthropic/", "") || "unknown", + status: isActive ? "active" : isRecent ? "idle" : "stale", + ageMs, + ageFormatted: + ageMs < 60000 + ? "Just now" + : ageMs < 3600000 + ? `${Math.round(ageMs / 60000)}m ago` + : `${Math.round(ageMs / 3600000)}h ago`, + messageCount, + tokens: s.totalTokens || 0, + }); + } + } + } catch (e) { + console.error("Failed to get subagent status:", e.message); + } + + // Sort by age (most recent first) + return subagents.sort((a, b) => a.ageMs - b.ageMs); + } + + return { + getSystemStatus, + getRecentActivity, + getCapacity, + getMemoryStats, + getFullState, + refreshState, + startStateRefresh, + stopStateRefresh, + getData, + getSubagentStatus, + }; +} + +module.exports = { createStateModule }; diff --git a/src/tokens.js b/src/tokens.js new file mode 100644 index 0000000..059e964 --- /dev/null +++ b/src/tokens.js @@ -0,0 +1,458 @@ +const fs = require("fs"); +const path = require("path"); +const { formatNumber, formatTokens } = require("./utils"); + +// Claude Opus 4 pricing (per 1M tokens) +const TOKEN_RATES = { + input: 15.0, // $15/1M input tokens + output: 75.0, // $75/1M output tokens + cacheRead: 1.5, // $1.50/1M (90% discount from input) + cacheWrite: 18.75, // $18.75/1M (25% premium on input) +}; + +// Token usage cache with async background refresh +let tokenUsageCache = { data: null, timestamp: 0, refreshing: false }; +const TOKEN_USAGE_CACHE_TTL = 30000; // 30 seconds + +// Reference to background refresh interval (set by startTokenUsageRefresh) +let refreshInterval = null; + +// Create empty usage bucket +function emptyUsageBucket() { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, requests: 0 }; +} + +// Async token usage refresh - runs in background, doesn't block +async function refreshTokenUsageAsync(getOpenClawDir) { + if (tokenUsageCache.refreshing) return; + tokenUsageCache.refreshing = true; + + try { + const sessionsDir = path.join(getOpenClawDir(), "agents", "main", "sessions"); + const files = await fs.promises.readdir(sessionsDir); + const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")); + + const now = Date.now(); + const oneDayAgo = now - 24 * 60 * 60 * 1000; + const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + + // Track usage for each time window + const usage24h = emptyUsageBucket(); + const usage3d = emptyUsageBucket(); + const usage7d = emptyUsageBucket(); + + // Process files in batches to avoid overwhelming the system + const batchSize = 50; + for (let i = 0; i < jsonlFiles.length; i += batchSize) { + const batch = jsonlFiles.slice(i, i + batchSize); + + await Promise.all( + batch.map(async (file) => { + const filePath = path.join(sessionsDir, file); + try { + const stat = await fs.promises.stat(filePath); + // Skip files not modified in the last 7 days + if (stat.mtimeMs < sevenDaysAgo) return; + + const content = await fs.promises.readFile(filePath, "utf8"); + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line) continue; + try { + const entry = JSON.parse(line); + const entryTime = entry.timestamp ? new Date(entry.timestamp).getTime() : 0; + + // Skip entries older than 7 days + if (entryTime < sevenDaysAgo) continue; + + if (entry.message?.usage) { + const u = entry.message.usage; + const input = u.input || 0; + const output = u.output || 0; + const cacheRead = u.cacheRead || 0; + const cacheWrite = u.cacheWrite || 0; + const cost = u.cost?.total || 0; + + // Add to appropriate buckets (cumulative - 24h is subset of 3d is subset of 7d) + if (entryTime >= oneDayAgo) { + usage24h.input += input; + usage24h.output += output; + usage24h.cacheRead += cacheRead; + usage24h.cacheWrite += cacheWrite; + usage24h.cost += cost; + usage24h.requests++; + } + if (entryTime >= threeDaysAgo) { + usage3d.input += input; + usage3d.output += output; + usage3d.cacheRead += cacheRead; + usage3d.cacheWrite += cacheWrite; + usage3d.cost += cost; + usage3d.requests++; + } + // Always add to 7d (already filtered above) + usage7d.input += input; + usage7d.output += output; + usage7d.cacheRead += cacheRead; + usage7d.cacheWrite += cacheWrite; + usage7d.cost += cost; + usage7d.requests++; + } + } catch (e) { + // Skip invalid lines + } + } + } catch (e) { + // Skip unreadable files + } + }), + ); + + // Yield to event loop between batches + await new Promise((resolve) => setImmediate(resolve)); + } + + // Helper to finalize bucket with computed fields + const finalizeBucket = (bucket) => ({ + ...bucket, + tokensNoCache: bucket.input + bucket.output, + tokensWithCache: bucket.input + bucket.output + bucket.cacheRead + bucket.cacheWrite, + }); + + const result = { + // Primary (24h) for backward compatibility + ...finalizeBucket(usage24h), + // All three windows + windows: { + "24h": finalizeBucket(usage24h), + "3d": finalizeBucket(usage3d), + "7d": finalizeBucket(usage7d), + }, + }; + + tokenUsageCache = { data: result, timestamp: Date.now(), refreshing: false }; + console.log( + `[Token Usage] Cached: 24h=${usage24h.requests} 3d=${usage3d.requests} 7d=${usage7d.requests} requests`, + ); + } catch (e) { + console.error("[Token Usage] Refresh error:", e.message); + tokenUsageCache.refreshing = false; + } +} + +// Returns cached token usage, triggers async refresh if stale +function getDailyTokenUsage(getOpenClawDir) { + const now = Date.now(); + const isStale = now - tokenUsageCache.timestamp > TOKEN_USAGE_CACHE_TTL; + + // Trigger async refresh if stale (don't await) + if (isStale && !tokenUsageCache.refreshing && getOpenClawDir) { + refreshTokenUsageAsync(getOpenClawDir); + } + + const emptyResult = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + requests: 0, + tokensNoCache: 0, + tokensWithCache: 0, + windows: { + "24h": { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + requests: 0, + tokensNoCache: 0, + tokensWithCache: 0, + }, + "3d": { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + requests: 0, + tokensNoCache: 0, + tokensWithCache: 0, + }, + "7d": { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + requests: 0, + tokensNoCache: 0, + tokensWithCache: 0, + }, + }, + }; + + // Always return cache (may be stale or null on cold start) + return tokenUsageCache.data || emptyResult; +} + +// Calculate cost for a usage bucket +function calculateCostForBucket(bucket, rates = TOKEN_RATES) { + const inputCost = (bucket.input / 1_000_000) * rates.input; + const outputCost = (bucket.output / 1_000_000) * rates.output; + const cacheReadCost = (bucket.cacheRead / 1_000_000) * rates.cacheRead; + const cacheWriteCost = (bucket.cacheWrite / 1_000_000) * rates.cacheWrite; + return { + inputCost, + outputCost, + cacheReadCost, + cacheWriteCost, + totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost, + }; +} + +// Get detailed cost breakdown for the modal +function getCostBreakdown(config, getSessions, getOpenClawDir) { + const usage = getDailyTokenUsage(getOpenClawDir); + if (!usage) { + return { error: "Failed to get usage data" }; + } + + // Calculate costs for 24h (primary display) + const costs = calculateCostForBucket(usage); + + // Get plan info from config + const planCost = config.billing?.claudePlanCost || 200; + const planName = config.billing?.claudePlanName || "Claude Code Max"; + + // Calculate moving averages for each window + const windowConfigs = { + "24h": { days: 1, label: "24h" }, + "3d": { days: 3, label: "3dma" }, + "7d": { days: 7, label: "7dma" }, + }; + + const windows = {}; + for (const [key, windowConfig] of Object.entries(windowConfigs)) { + const bucket = usage.windows?.[key] || usage; + const bucketCosts = calculateCostForBucket(bucket); + const dailyAvg = bucketCosts.totalCost / windowConfig.days; + const monthlyProjected = dailyAvg * 30; + const monthlySavings = monthlyProjected - planCost; + + windows[key] = { + label: windowConfig.label, + days: windowConfig.days, + totalCost: bucketCosts.totalCost, + dailyAvg, + monthlyProjected, + monthlySavings, + savingsPercent: + monthlySavings > 0 ? Math.round((monthlySavings / monthlyProjected) * 100) : 0, + requests: bucket.requests, + tokens: { + input: bucket.input, + output: bucket.output, + cacheRead: bucket.cacheRead, + cacheWrite: bucket.cacheWrite, + }, + }; + } + + return { + // Raw token counts (24h for backward compatibility) + inputTokens: usage.input, + outputTokens: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + requests: usage.requests, + + // Pricing rates + rates: { + input: TOKEN_RATES.input.toFixed(2), + output: TOKEN_RATES.output.toFixed(2), + cacheRead: TOKEN_RATES.cacheRead.toFixed(2), + cacheWrite: TOKEN_RATES.cacheWrite.toFixed(2), + }, + + // Cost calculation breakdown (24h) + calculation: { + inputCost: costs.inputCost, + outputCost: costs.outputCost, + cacheReadCost: costs.cacheReadCost, + cacheWriteCost: costs.cacheWriteCost, + }, + + // Totals (24h for backward compatibility) + totalCost: costs.totalCost, + planCost, + planName, + + // Period + period: "24 hours", + + // Multi-window data for moving averages + windows, + + // Top sessions by tokens + topSessions: getTopSessionsByTokens(5, getSessions), + }; +} + +// Get top sessions sorted by token usage +function getTopSessionsByTokens(limit = 5, getSessions) { + try { + const sessions = getSessions({ limit: null }); + return sessions + .filter((s) => s.tokens > 0) + .sort((a, b) => b.tokens - a.tokens) + .slice(0, limit) + .map((s) => ({ + label: s.label, + tokens: s.tokens, + channel: s.channel, + active: s.active, + })); + } catch (e) { + console.error("[TopSessions] Error:", e.message); + return []; + } +} + +// Calculate aggregate token stats +function getTokenStats(sessions, capacity, config = {}) { + // Use capacity data if provided, otherwise compute from sessions + let activeMainCount = capacity?.main?.active ?? 0; + let activeSubagentCount = capacity?.subagent?.active ?? 0; + let activeCount = activeMainCount + activeSubagentCount; + let mainLimit = capacity?.main?.max ?? 12; + let subagentLimit = capacity?.subagent?.max ?? 24; + + // Fallback: count from sessions if capacity not provided + if (!capacity && sessions && sessions.length > 0) { + activeCount = 0; + activeMainCount = 0; + activeSubagentCount = 0; + sessions.forEach((s) => { + if (s.active) { + activeCount++; + if (s.key && s.key.includes(":subagent:")) { + activeSubagentCount++; + } else { + activeMainCount++; + } + } + }); + } + + // Get accurate usage from JSONL files (includes all windows) + const usage = getDailyTokenUsage(); + const totalInput = usage?.input || 0; + const totalOutput = usage?.output || 0; + const total = totalInput + totalOutput; + + // Calculate cost using shared helper + const costs = calculateCostForBucket(usage); + const estCost = costs.totalCost; + + // Calculate savings vs plan cost (compare monthly to monthly) + const planCost = config?.billing?.claudePlanCost ?? 200; + const planName = config?.billing?.claudePlanName ?? "Claude Code Max"; + const monthlyApiCost = estCost * 30; // Project daily to monthly + const monthlySavings = monthlyApiCost - planCost; + const savingsPositive = monthlySavings > 0; + + // Calculate per-session averages + const sessionCount = sessions?.length || 1; + const avgTokensPerSession = Math.round(total / sessionCount); + const avgCostPerSession = estCost / sessionCount; + + // Calculate savings for all windows (24h, 3dma, 7dma) + const windowConfigs = { + "24h": { days: 1, label: "24h" }, + "3dma": { days: 3, label: "3dma" }, + "7dma": { days: 7, label: "7dma" }, + }; + + const savingsWindows = {}; + for (const [key, windowConfig] of Object.entries(windowConfigs)) { + // Map '3dma' -> '3d' for bucket lookup + const bucketKey = key.replace("dma", "d").replace("24h", "24h"); + const bucket = usage.windows?.[bucketKey === "24h" ? "24h" : bucketKey] || usage; + const bucketCosts = calculateCostForBucket(bucket); + const dailyAvg = bucketCosts.totalCost / windowConfig.days; + const monthlyProjected = dailyAvg * 30; + const windowSavings = monthlyProjected - planCost; + const windowSavingsPositive = windowSavings > 0; + + savingsWindows[key] = { + label: windowConfig.label, + estCost: `$${formatNumber(dailyAvg)}`, + estMonthlyCost: `$${Math.round(monthlyProjected).toLocaleString()}`, + estSavings: windowSavingsPositive ? `$${formatNumber(windowSavings)}/mo` : null, + savingsPercent: windowSavingsPositive + ? Math.round((windowSavings / monthlyProjected) * 100) + : 0, + requests: bucket.requests, + }; + } + + return { + total: formatTokens(total), + input: formatTokens(totalInput), + output: formatTokens(totalOutput), + cacheRead: formatTokens(usage?.cacheRead || 0), + cacheWrite: formatTokens(usage?.cacheWrite || 0), + requests: usage?.requests || 0, + activeCount, + activeMainCount, + activeSubagentCount, + mainLimit, + subagentLimit, + estCost: `$${formatNumber(estCost)}`, + planCost: `$${planCost.toFixed(0)}`, + planName, + // 24h savings (backward compatible) + estSavings: savingsPositive ? `$${formatNumber(monthlySavings)}/mo` : null, + savingsPercent: savingsPositive ? Math.round((monthlySavings / monthlyApiCost) * 100) : 0, + estMonthlyCost: `$${Math.round(monthlyApiCost).toLocaleString()}`, + // Multi-window savings (24h, 3da, 7da) + savingsWindows, + // Per-session averages + avgTokensPerSession: formatTokens(avgTokensPerSession), + avgCostPerSession: `$${avgCostPerSession.toFixed(2)}`, + sessionCount, + }; +} + +// Start background token usage refresh on an interval +// Call this once during server startup instead of auto-starting on module load +function startTokenUsageRefresh(getOpenClawDir) { + // Do an initial refresh + refreshTokenUsageAsync(getOpenClawDir); + + // Set up periodic refresh + if (refreshInterval) { + clearInterval(refreshInterval); + } + refreshInterval = setInterval(() => { + refreshTokenUsageAsync(getOpenClawDir); + }, TOKEN_USAGE_CACHE_TTL); + + return refreshInterval; +} + +module.exports = { + TOKEN_RATES, + emptyUsageBucket, + refreshTokenUsageAsync, + getDailyTokenUsage, + calculateCostForBucket, + getCostBreakdown, + getTopSessionsByTokens, + getTokenStats, + startTokenUsageRefresh, +}; diff --git a/src/topics.js b/src/topics.js new file mode 100644 index 0000000..073dfa6 --- /dev/null +++ b/src/topics.js @@ -0,0 +1,81 @@ +const TOPIC_PATTERNS = { + dashboard: ["dashboard", "command center", "ui", "interface", "status page"], + scheduling: ["cron", "schedule", "timer", "reminder", "alarm", "periodic", "interval"], + heartbeat: [ + "heartbeat", + "heartbeat_ok", + "poll", + "health check", + "ping", + "keepalive", + "monitoring", + ], + memory: ["memory", "remember", "recall", "notes", "journal", "log", "context"], + Slack: ["slack", "channel", "#cc-", "thread", "mention", "dm", "workspace"], + email: ["email", "mail", "inbox", "gmail", "send email", "unread", "compose"], + calendar: ["calendar", "event", "meeting", "appointment", "schedule", "gcal"], + coding: [ + "code", + "script", + "function", + "debug", + "error", + "bug", + "implement", + "refactor", + "programming", + ], + git: [ + "git", + "commit", + "branch", + "merge", + "push", + "pull", + "repository", + "pr", + "pull request", + "github", + ], + "file editing": ["file", "edit", "write", "read", "create", "delete", "modify", "save"], + API: ["api", "endpoint", "request", "response", "webhook", "integration", "rest", "graphql"], + research: ["search", "research", "lookup", "find", "investigate", "learn", "study"], + browser: ["browser", "webpage", "website", "url", "click", "navigate", "screenshot", "web_fetch"], + "Quip export": ["quip", "export", "document", "spreadsheet"], + finance: ["finance", "investment", "stock", "money", "budget", "bank", "trading", "portfolio"], + home: ["home", "automation", "lights", "thermostat", "smart home", "iot", "homekit"], + health: ["health", "fitness", "workout", "exercise", "weight", "sleep", "nutrition"], + travel: ["travel", "flight", "hotel", "trip", "vacation", "booking", "airport"], + food: ["food", "recipe", "restaurant", "cooking", "meal", "order", "delivery"], + subagent: ["subagent", "spawn", "sub-agent", "delegate", "worker", "parallel"], + tools: ["tool", "exec", "shell", "command", "terminal", "bash", "run"], +}; + +function detectTopics(text) { + if (!text) return []; + const lowerText = text.toLowerCase(); + const scores = {}; + for (const [topic, keywords] of Object.entries(TOPIC_PATTERNS)) { + let score = 0; + for (const keyword of keywords) { + if (keyword.length <= 3) { + const regex = new RegExp(`\\b${keyword}\\b`, "i"); + if (regex.test(lowerText)) score++; + } else if (lowerText.includes(keyword)) { + score++; + } + } + if (score > 0) { + scores[topic] = score; + } + } + if (Object.keys(scores).length === 0) return []; + const bestScore = Math.max(...Object.values(scores)); + const threshold = Math.max(2, bestScore * 0.5); + return Object.entries(scores) + .filter(([_, score]) => score >= threshold || (score >= 1 && bestScore <= 2)) + .sort((a, b) => b[1] - a[1]) + .map(([topic, _]) => topic); +} + +module.exports = { TOPIC_PATTERNS, detectTopics }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..ac291c5 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,78 @@ +/** + * Utility functions shared across modules + */ + +const { exec } = require("child_process"); +const path = require("path"); +const { promisify } = require("util"); +const execAsync = promisify(exec); + +const pkg = require(path.join(__dirname, "..", "package.json")); + +function getVersion() { + return pkg.version; +} + +/** + * Run a shell command with sensible defaults + * @param {string} cmd - Command to execute + * @param {object} options - Options (timeout, fallback, etc.) + * @returns {Promise} - Command output + */ +async function runCmd(cmd, options = {}) { + const systemPath = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; + const envPath = process.env.PATH || ""; + const opts = { + encoding: "utf8", + timeout: 10000, + env: { + ...process.env, + PATH: envPath.includes("/usr/sbin") ? envPath : `${systemPath}:${envPath}`, + }, + ...options, + }; + try { + const { stdout } = await execAsync(cmd, opts); + return stdout.trim(); + } catch (e) { + if (options.fallback !== undefined) return options.fallback; + throw e; + } +} + +function formatBytes(bytes) { + if (bytes >= 1099511627776) return (bytes / 1099511627776).toFixed(1) + " TB"; + if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + " GB"; + if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + " MB"; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; +} + +function formatTimeAgo(date) { + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.round(diffMs / 60000); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`; + return `${Math.round(diffMins / 1440)}d ago`; +} + +function formatNumber(n) { + return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function formatTokens(n) { + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return n.toString(); +} + +module.exports = { + getVersion, + runCmd, + formatBytes, + formatTimeAgo, + formatNumber, + formatTokens, +}; diff --git a/src/vitals.js b/src/vitals.js new file mode 100644 index 0000000..1080aab --- /dev/null +++ b/src/vitals.js @@ -0,0 +1,448 @@ +/** + * System vitals collection for OpenClaw Command Center + * Collects CPU, memory, disk, and temperature metrics + */ + +const { runCmd, formatBytes } = require("./utils"); + +// Vitals cache to reduce blocking +let cachedVitals = null; +let lastVitalsUpdate = 0; +const VITALS_CACHE_TTL = 30000; // 30 seconds - vitals don't change fast +let vitalsRefreshing = false; + +// Async background refresh of system vitals (non-blocking) +async function refreshVitalsAsync() { + if (vitalsRefreshing) return; + vitalsRefreshing = true; + + const vitals = { + hostname: "", + uptime: "", + disk: { used: 0, free: 0, total: 0, percent: 0, kbPerTransfer: 0, iops: 0, throughputMBps: 0 }, + cpu: { loadAvg: [0, 0, 0], cores: 0, usage: 0 }, + memory: { used: 0, free: 0, total: 0, percent: 0, pressure: "normal" }, + temperature: null, + }; + + // Detect platform for cross-platform support + const isLinux = process.platform === "linux"; + const isMacOS = process.platform === "darwin"; + + try { + // Platform-specific commands + const coresCmd = isLinux ? "nproc" : "sysctl -n hw.ncpu"; + const memCmd = isLinux + ? "cat /proc/meminfo | grep MemTotal | awk '{print $2}'" + : "sysctl -n hw.memsize"; + const topCmd = isLinux + ? "top -bn1 | head -3 | grep -E '^%?Cpu|^ ?CPU' || echo ''" + : 'top -l 1 -n 0 2>/dev/null | grep "CPU usage" || echo ""'; + + // Linux: prefer mpstat (1s average) to avoid spiky single-frame top parsing. + const mpstatCmd = isLinux + ? "(command -v mpstat >/dev/null 2>&1 && mpstat 1 1 | tail -1 | sed 's/^Average: *//') || echo ''" + : ""; + + // Run commands in parallel for speed + const [hostname, uptimeRaw, coresRaw, memTotalRaw, memInfoRaw, dfRaw, topOutput, mpstatOutput] = + await Promise.all([ + runCmd("hostname", { fallback: "unknown" }), + runCmd("uptime", { fallback: "" }), + runCmd(coresCmd, { fallback: "1" }), + runCmd(memCmd, { fallback: "0" }), + isLinux + ? runCmd("cat /proc/meminfo", { fallback: "" }) + : runCmd("vm_stat", { fallback: "" }), + runCmd("df -k ~ | tail -1", { fallback: "" }), + runCmd(topCmd, { fallback: "" }), + isLinux ? runCmd(mpstatCmd, { fallback: "" }) : Promise.resolve(""), + ]); + + vitals.hostname = hostname; + + // Parse uptime + const uptimeMatch = uptimeRaw.match(/up\s+([^,]+)/); + if (uptimeMatch) vitals.uptime = uptimeMatch[1].trim(); + const loadMatch = uptimeRaw.match(/load averages?:\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/); + if (loadMatch) + vitals.cpu.loadAvg = [ + parseFloat(loadMatch[1]), + parseFloat(loadMatch[2]), + parseFloat(loadMatch[3]), + ]; + + // CPU + vitals.cpu.cores = parseInt(coresRaw, 10) || 1; + vitals.cpu.usage = Math.min(100, Math.round((vitals.cpu.loadAvg[0] / vitals.cpu.cores) * 100)); + + // CPU percent (platform-specific) + // Linux: prefer mpstat output (averaged over 1 second). Fallback to parsing top. + if (isLinux) { + // mpstat: ... %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle + if (mpstatOutput) { + // After sed, mpstatOutput should look like: + // "all 7.69 0.00 2.05 ... 89.74" (CPU %usr %nice %sys ... %idle) + const parts = mpstatOutput.trim().split(/\s+/); + const user = parts.length > 1 ? parseFloat(parts[1]) : NaN; + const sys = parts.length > 3 ? parseFloat(parts[3]) : NaN; + const idle = parts.length ? parseFloat(parts[parts.length - 1]) : NaN; + if (!Number.isNaN(user)) vitals.cpu.userPercent = user; + if (!Number.isNaN(sys)) vitals.cpu.sysPercent = sys; + if (!Number.isNaN(idle)) { + vitals.cpu.idlePercent = idle; + vitals.cpu.usage = Math.max(0, Math.min(100, Math.round(100 - idle))); + } + } + + if (topOutput && (vitals.cpu.idlePercent === null || vitals.cpu.idlePercent === undefined)) { + // Linux top: %Cpu(s): 5.9 us, 2.0 sy, 0.0 ni, 91.5 id, 0.5 wa, ... + const userMatch = topOutput.match(/([\d.]+)\s*us/); + const sysMatch = topOutput.match(/([\d.]+)\s*sy/); + const idleMatch = topOutput.match(/([\d.]+)\s*id/); + vitals.cpu.userPercent = userMatch ? parseFloat(userMatch[1]) : null; + vitals.cpu.sysPercent = sysMatch ? parseFloat(sysMatch[1]) : null; + vitals.cpu.idlePercent = idleMatch ? parseFloat(idleMatch[1]) : null; + if (vitals.cpu.userPercent !== null && vitals.cpu.sysPercent !== null) { + vitals.cpu.usage = Math.round(vitals.cpu.userPercent + vitals.cpu.sysPercent); + } + } + } else if (topOutput) { + // macOS: CPU usage: 5.9% user, 2.0% sys, 91.5% idle + const userMatch = topOutput.match(/([\d.]+)%\s*user/); + const sysMatch = topOutput.match(/([\d.]+)%\s*sys/); + const idleMatch = topOutput.match(/([\d.]+)%\s*idle/); + vitals.cpu.userPercent = userMatch ? parseFloat(userMatch[1]) : null; + vitals.cpu.sysPercent = sysMatch ? parseFloat(sysMatch[1]) : null; + vitals.cpu.idlePercent = idleMatch ? parseFloat(idleMatch[1]) : null; + if (vitals.cpu.userPercent !== null && vitals.cpu.sysPercent !== null) { + vitals.cpu.usage = Math.round(vitals.cpu.userPercent + vitals.cpu.sysPercent); + } + } + + // Disk + const dfParts = dfRaw.split(/\s+/); + if (dfParts.length >= 4) { + vitals.disk.total = parseInt(dfParts[1], 10) * 1024; + vitals.disk.used = parseInt(dfParts[2], 10) * 1024; + vitals.disk.free = parseInt(dfParts[3], 10) * 1024; + vitals.disk.percent = Math.round((parseInt(dfParts[2], 10) / parseInt(dfParts[1], 10)) * 100); + } + + // Memory (platform-specific) + if (isLinux) { + const memTotalKB = parseInt(memTotalRaw, 10) || 0; + const memAvailableMatch = memInfoRaw.match(/MemAvailable:\s+(\d+)/); + const memFreeMatch = memInfoRaw.match(/MemFree:\s+(\d+)/); + + vitals.memory.total = memTotalKB * 1024; + const memAvailable = parseInt(memAvailableMatch?.[1] || memFreeMatch?.[1] || 0, 10) * 1024; + + vitals.memory.used = vitals.memory.total - memAvailable; + vitals.memory.free = memAvailable; + vitals.memory.percent = + vitals.memory.total > 0 ? Math.round((vitals.memory.used / vitals.memory.total) * 100) : 0; + } else { + const pageSizeMatch = memInfoRaw.match(/page size of (\d+) bytes/); + const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1], 10) : 4096; + const activePages = parseInt((memInfoRaw.match(/Pages active:\s+(\d+)/) || [])[1] || 0, 10); + const wiredPages = parseInt( + (memInfoRaw.match(/Pages wired down:\s+(\d+)/) || [])[1] || 0, + 10, + ); + const compressedPages = parseInt( + (memInfoRaw.match(/Pages occupied by compressor:\s+(\d+)/) || [])[1] || 0, + 10, + ); + vitals.memory.total = parseInt(memTotalRaw, 10) || 0; + vitals.memory.used = (activePages + wiredPages + compressedPages) * pageSize; + vitals.memory.free = vitals.memory.total - vitals.memory.used; + vitals.memory.percent = + vitals.memory.total > 0 ? Math.round((vitals.memory.used / vitals.memory.total) * 100) : 0; + } + vitals.memory.pressure = + vitals.memory.percent > 90 ? "critical" : vitals.memory.percent > 75 ? "warning" : "normal"; + + // Secondary async calls (chip info, iostat) + // NOTE: iostat needs an explicit count, otherwise it runs forever. + // IMPORTANT: Avoid shell pipelines (e.g. `| tail -1`) — when Node kills + // the shell on timeout, pipeline children like `iostat` survive as orphans. + // We wrap with timeout/gtimeout as a belt-and-suspenders safeguard on top of runCmd timeout. + const timeoutPrefix = isLinux + ? "timeout 5" + : "$(command -v gtimeout >/dev/null 2>&1 && echo gtimeout 5)"; + const iostatArgs = isLinux ? "-d -o JSON 1 2" : "-d -c 2 2"; + const iostatCmd = `${timeoutPrefix} iostat ${iostatArgs} 2>/dev/null || echo ''`; + const [perfCores, effCores, chip, iostatRaw] = await Promise.all([ + isMacOS + ? runCmd("sysctl -n hw.perflevel0.logicalcpu 2>/dev/null || echo 0", { fallback: "0" }) + : Promise.resolve("0"), + isMacOS + ? runCmd("sysctl -n hw.perflevel1.logicalcpu 2>/dev/null || echo 0", { fallback: "0" }) + : Promise.resolve("0"), + isMacOS + ? runCmd( + 'system_profiler SPHardwareDataType 2>/dev/null | grep "Chip:" | cut -d: -f2 || echo ""', + { fallback: "" }, + ) + : Promise.resolve(""), + runCmd(iostatCmd, { fallback: "", timeout: 5000 }), + ]); + + if (isLinux) { + const cpuBrand = await runCmd( + "cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2", + { fallback: "" }, + ); + if (cpuBrand) vitals.cpu.brand = cpuBrand.trim(); + } + + vitals.cpu.pCores = parseInt(perfCores, 10) || null; + vitals.cpu.eCores = parseInt(effCores, 10) || null; + if (chip) vitals.cpu.chip = chip; + if (isLinux) { + try { + const iostatJson = JSON.parse(iostatRaw); + const samples = iostatJson.sysstat.hosts[0].statistics; + const disks = samples[samples.length - 1].disk; + const disk = disks + .filter((d) => !d.disk_device.startsWith("loop")) + .sort((a, b) => b.tps - a.tps)[0]; + if (disk) { + const kbReadPerSec = disk["kB_read/s"] || 0; + const kbWrtnPerSec = disk["kB_wrtn/s"] || 0; + vitals.disk.iops = disk.tps || 0; + vitals.disk.throughputMBps = (kbReadPerSec + kbWrtnPerSec) / 1024; + vitals.disk.kbPerTransfer = disk.tps > 0 ? (kbReadPerSec + kbWrtnPerSec) / disk.tps : 0; + } + } catch { + // JSON parse failed + } + } else { + // iostat output has multiple lines (header + samples); take the last non-empty line + const iostatLines = iostatRaw.split("\n").filter((l) => l.trim()); + const lastLine = iostatLines.length > 0 ? iostatLines[iostatLines.length - 1] : ""; + const iostatParts = lastLine.split(/\s+/).filter(Boolean); + if (iostatParts.length >= 3) { + vitals.disk.kbPerTransfer = parseFloat(iostatParts[0]) || 0; + vitals.disk.iops = parseFloat(iostatParts[1]) || 0; + vitals.disk.throughputMBps = parseFloat(iostatParts[2]) || 0; + } + } + // Temperature + vitals.temperature = null; + vitals.temperatureNote = null; + const isAppleSilicon = vitals.cpu.chip && /apple/i.test(vitals.cpu.chip); + + if (isAppleSilicon) { + vitals.temperatureNote = "Apple Silicon (requires elevated access)"; + try { + const pmOutput = await runCmd( + 'sudo -n powermetrics --samplers smc -i 1 -n 1 2>/dev/null | grep -i "die temp" | head -1', + { fallback: "", timeout: 5000 }, + ); + const tempMatch = pmOutput.match(/([\d.]+)/); + if (tempMatch) { + vitals.temperature = parseFloat(tempMatch[1]); + vitals.temperatureNote = null; + } + } catch (e) {} + } else if (isMacOS) { + const home = require("os").homedir(); + try { + const temp = await runCmd( + `osx-cpu-temp 2>/dev/null || ${home}/bin/osx-cpu-temp 2>/dev/null`, + { fallback: "" }, + ); + if (temp && temp.includes("\u00b0")) { + const tempMatch = temp.match(/([\d.]+)/); + if (tempMatch && parseFloat(tempMatch[1]) > 0) { + vitals.temperature = parseFloat(tempMatch[1]); + } + } + } catch (e) {} + if (!vitals.temperature) { + try { + const ioregRaw = await runCmd( + "ioreg -r -n AppleSmartBattery 2>/dev/null | grep Temperature", + { fallback: "" }, + ); + const tempMatch = ioregRaw.match(/"Temperature"\s*=\s*(\d+)/); + if (tempMatch) { + vitals.temperature = Math.round(parseInt(tempMatch[1], 10) / 100); + } + } catch (e) {} + } + } else if (isLinux) { + try { + const temp = await runCmd("cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null", { + fallback: "", + }); + if (temp) { + vitals.temperature = Math.round(parseInt(temp, 10) / 1000); + } + } catch (e) {} + } + } catch (e) { + console.error("[Vitals] Async refresh failed:", e.message); + } + + // Formatted versions + vitals.memory.usedFormatted = formatBytes(vitals.memory.used); + vitals.memory.totalFormatted = formatBytes(vitals.memory.total); + vitals.memory.freeFormatted = formatBytes(vitals.memory.free); + vitals.disk.usedFormatted = formatBytes(vitals.disk.used); + vitals.disk.totalFormatted = formatBytes(vitals.disk.total); + vitals.disk.freeFormatted = formatBytes(vitals.disk.free); + + cachedVitals = vitals; + lastVitalsUpdate = Date.now(); + vitalsRefreshing = false; + console.log("[Vitals] Cache refreshed async"); +} + +// Start background vitals refresh on startup +setTimeout(() => refreshVitalsAsync(), 500); +setInterval(() => refreshVitalsAsync(), VITALS_CACHE_TTL); + +function getSystemVitals() { + const now = Date.now(); + if (!cachedVitals || now - lastVitalsUpdate > VITALS_CACHE_TTL) { + refreshVitalsAsync(); + } + if (cachedVitals) return cachedVitals; + + return { + hostname: "loading...", + uptime: "", + disk: { + used: 0, + free: 0, + total: 0, + percent: 0, + usedFormatted: "-", + totalFormatted: "-", + freeFormatted: "-", + }, + cpu: { loadAvg: [0, 0, 0], cores: 0, usage: 0 }, + memory: { + used: 0, + free: 0, + total: 0, + percent: 0, + pressure: "normal", + usedFormatted: "-", + totalFormatted: "-", + freeFormatted: "-", + }, + temperature: null, + }; +} + +/** + * Check for optional system dependencies. + * Returns structured results and logs hints once at startup. + */ +let cachedDeps = null; + +async function checkOptionalDeps() { + const isLinux = process.platform === "linux"; + const isMacOS = process.platform === "darwin"; + const platform = isLinux ? "linux" : isMacOS ? "darwin" : null; + const results = []; + + if (!platform) { + cachedDeps = results; + return results; + } + + const fs = require("fs"); + const path = require("path"); + const depsFile = path.join(__dirname, "..", "config", "system-deps.json"); + let depsConfig; + try { + depsConfig = JSON.parse(fs.readFileSync(depsFile, "utf8")); + } catch { + cachedDeps = results; + return results; + } + + const deps = depsConfig[platform] || []; + const home = require("os").homedir(); + + // Detect package manager + let pkgManager = null; + if (isLinux) { + for (const pm of ["apt", "dnf", "yum", "pacman", "apk"]) { + const has = await runCmd(`which ${pm}`, { fallback: "" }); + if (has) { + pkgManager = pm; + break; + } + } + } else if (isMacOS) { + const hasBrew = await runCmd("which brew", { fallback: "" }); + if (hasBrew) pkgManager = "brew"; + } + + // Detect chip for condition filtering + let isAppleSilicon = false; + if (isMacOS) { + const chip = await runCmd("sysctl -n machdep.cpu.brand_string", { fallback: "" }); + isAppleSilicon = /apple/i.test(chip); + } + + for (const dep of deps) { + // Skip deps that don't apply to this hardware + if (dep.condition === "intel" && isAppleSilicon) continue; + + let installed = false; + const hasBinary = await runCmd(`which ${dep.binary} 2>/dev/null`, { fallback: "" }); + if (hasBinary) { + installed = true; + } else if (isMacOS && dep.binary === "osx-cpu-temp") { + const homebin = await runCmd(`test -x ${home}/bin/osx-cpu-temp && echo ok`, { + fallback: "", + }); + if (homebin) installed = true; + } + + const installCmd = dep.install[pkgManager] || null; + + results.push({ + id: dep.id, + name: dep.name, + purpose: dep.purpose, + affects: dep.affects, + installed, + installCmd, + url: dep.url || null, + }); + } + + cachedDeps = results; + + // Log hints for missing deps + const missing = results.filter((d) => !d.installed); + if (missing.length > 0) { + console.log("[Startup] Optional dependencies for enhanced vitals:"); + for (const dep of missing) { + const action = dep.installCmd || dep.url || "see docs"; + console.log(` \u{1F4A1} ${dep.name} \u2014 ${dep.purpose}: ${action}`); + } + } + + return results; +} + +function getOptionalDeps() { + return cachedDeps; +} + +module.exports = { + refreshVitalsAsync, + getSystemVitals, + checkOptionalDeps, + getOptionalDeps, + VITALS_CACHE_TTL, +}; diff --git a/tests/actions.test.js b/tests/actions.test.js new file mode 100644 index 0000000..41511df --- /dev/null +++ b/tests/actions.test.js @@ -0,0 +1,79 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { executeAction } = require("../src/actions"); + +describe("actions module", () => { + describe("executeAction()", () => { + const mockDeps = { + runOpenClaw: (args) => `mock output for: ${args}`, + extractJSON: (output) => output, + PORT: 3333, + }; + + it("handles gateway-status action", () => { + const result = executeAction("gateway-status", mockDeps); + assert.strictEqual(result.success, true); + assert.strictEqual(result.action, "gateway-status"); + assert.ok(result.output.includes("gateway status")); + }); + + it("handles gateway-restart action with safety message", () => { + const result = executeAction("gateway-restart", mockDeps); + assert.strictEqual(result.success, true); + assert.ok(result.note.includes("safety")); + }); + + it("handles sessions-list action", () => { + const result = executeAction("sessions-list", mockDeps); + assert.strictEqual(result.success, true); + }); + + it("handles cron-list action", () => { + const result = executeAction("cron-list", mockDeps); + assert.strictEqual(result.success, true); + }); + + it("handles health-check action", () => { + const result = executeAction("health-check", mockDeps); + assert.strictEqual(result.success, true); + assert.ok(result.output.includes("Dashboard")); + assert.ok(result.output.includes("3333")); + }); + + it("handles clear-stale-sessions action", () => { + const deps = { + ...mockDeps, + runOpenClaw: () => '{"sessions": []}', + extractJSON: (o) => o, + }; + const result = executeAction("clear-stale-sessions", deps); + assert.strictEqual(result.success, true); + assert.ok(result.output.includes("stale sessions")); + }); + + it("returns error for unknown action", () => { + const result = executeAction("nonexistent-action", mockDeps); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes("Unknown action")); + }); + + it("handles runOpenClaw returning null", () => { + const deps = { ...mockDeps, runOpenClaw: () => null }; + const result = executeAction("gateway-status", deps); + assert.strictEqual(result.success, true); + assert.strictEqual(result.output, "Unknown"); + }); + + it("catches exceptions and returns error", () => { + const deps = { + ...mockDeps, + runOpenClaw: () => { + throw new Error("command failed"); + }, + }; + const result = executeAction("gateway-status", deps); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes("command failed")); + }); + }); +}); diff --git a/tests/auth.test.js b/tests/auth.test.js new file mode 100644 index 0000000..256787a --- /dev/null +++ b/tests/auth.test.js @@ -0,0 +1,191 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { checkAuth, AUTH_HEADERS, getUnauthorizedPage } = require("../src/auth"); + +describe("auth module", () => { + describe("AUTH_HEADERS", () => { + it("exports tailscale header names", () => { + assert.strictEqual(AUTH_HEADERS.tailscale.login, "tailscale-user-login"); + assert.strictEqual(AUTH_HEADERS.tailscale.name, "tailscale-user-name"); + assert.strictEqual(AUTH_HEADERS.tailscale.pic, "tailscale-user-profile-pic"); + }); + + it("exports cloudflare header names", () => { + assert.strictEqual(AUTH_HEADERS.cloudflare.email, "cf-access-authenticated-user-email"); + }); + }); + + describe("checkAuth()", () => { + function mockReq(remoteAddress, headers = {}) { + return { socket: { remoteAddress }, headers }; + } + + it("allows localhost (127.0.0.1) regardless of auth mode", () => { + const result = checkAuth(mockReq("127.0.0.1"), { mode: "token", token: "secret" }); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user.type, "localhost"); + }); + + it("allows localhost (::1) regardless of auth mode", () => { + const result = checkAuth(mockReq("::1"), { mode: "tailscale", allowedUsers: [] }); + assert.strictEqual(result.authorized, true); + }); + + it("allows localhost (::ffff:127.0.0.1)", () => { + const result = checkAuth(mockReq("::ffff:127.0.0.1"), { mode: "token", token: "x" }); + assert.strictEqual(result.authorized, true); + }); + + it("allows all when mode is 'none'", () => { + const result = checkAuth(mockReq("192.168.1.100"), { mode: "none" }); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user, null); + }); + + describe("token mode", () => { + const authConfig = { mode: "token", token: "my-secret-token" }; + + it("allows valid bearer token", () => { + const req = mockReq("10.0.0.1", { authorization: "Bearer my-secret-token" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user.type, "token"); + }); + + it("rejects invalid token", () => { + const req = mockReq("10.0.0.1", { authorization: "Bearer wrong-token" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("Invalid")); + }); + + it("rejects missing authorization header", () => { + const req = mockReq("10.0.0.1", {}); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + }); + }); + + describe("tailscale mode", () => { + const authConfig = { mode: "tailscale", allowedUsers: ["user@example.com", "*@corp.com"] }; + + it("allows user in allowlist", () => { + const req = mockReq("100.64.0.1", { "tailscale-user-login": "user@example.com" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user.type, "tailscale"); + assert.strictEqual(result.user.login, "user@example.com"); + }); + + it("allows wildcard domain match", () => { + const req = mockReq("100.64.0.1", { "tailscale-user-login": "anyone@corp.com" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + }); + + it("rejects user not in allowlist", () => { + const req = mockReq("100.64.0.1", { "tailscale-user-login": "hacker@evil.com" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("not in allowlist")); + }); + + it("rejects when no tailscale header present", () => { + const req = mockReq("10.0.0.1", {}); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("Tailscale")); + }); + + it("allows wildcard (*) user", () => { + const config = { mode: "tailscale", allowedUsers: ["*"] }; + const req = mockReq("100.64.0.1", { "tailscale-user-login": "anyone@anywhere.com" }); + const result = checkAuth(req, config); + assert.strictEqual(result.authorized, true); + }); + }); + + describe("cloudflare mode", () => { + const authConfig = { mode: "cloudflare", allowedUsers: ["user@example.com"] }; + + it("allows user in allowlist", () => { + const req = mockReq("172.16.0.1", { + "cf-access-authenticated-user-email": "user@example.com", + }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user.type, "cloudflare"); + }); + + it("rejects user not in allowlist", () => { + const req = mockReq("172.16.0.1", { + "cf-access-authenticated-user-email": "other@example.com", + }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + }); + + it("rejects when no cloudflare header present", () => { + const req = mockReq("172.16.0.1", {}); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("Cloudflare")); + }); + }); + + describe("allowlist mode", () => { + const authConfig = { mode: "allowlist", allowedIPs: ["10.0.0.5", "192.168.1.0/24"] }; + + it("allows exact IP match", () => { + const req = mockReq("10.0.0.5"); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + assert.strictEqual(result.user.type, "ip"); + }); + + it("allows /24 subnet match", () => { + const req = mockReq("192.168.1.42"); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + }); + + it("rejects IP not in allowlist", () => { + const req = mockReq("10.0.0.99"); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("not in allowlist")); + }); + + it("uses x-forwarded-for header when present", () => { + const req = mockReq("172.16.0.1", { "x-forwarded-for": "10.0.0.5, 172.16.0.1" }); + const result = checkAuth(req, authConfig); + assert.strictEqual(result.authorized, true); + }); + }); + + it("rejects unknown auth mode", () => { + const result = checkAuth(mockReq("10.0.0.1"), { mode: "kerberos" }); + assert.strictEqual(result.authorized, false); + assert.ok(result.reason.includes("Unknown")); + }); + }); + + describe("getUnauthorizedPage()", () => { + it("returns HTML string", () => { + const html = getUnauthorizedPage("test reason", null, { mode: "token" }); + assert.ok(html.includes("")); + assert.ok(html.includes("Access Denied")); + assert.ok(html.includes("test reason")); + }); + + it("includes user info when provided", () => { + const html = getUnauthorizedPage("denied", { login: "user@test.com" }, { mode: "tailscale" }); + assert.ok(html.includes("user@test.com")); + }); + + it("includes auth mode in output", () => { + const html = getUnauthorizedPage("denied", null, { mode: "cloudflare" }); + assert.ok(html.includes("cloudflare")); + }); + }); +}); diff --git a/tests/config.test.js b/tests/config.test.js new file mode 100644 index 0000000..113c55f --- /dev/null +++ b/tests/config.test.js @@ -0,0 +1,154 @@ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert"); +const os = require("os"); +const path = require("path"); + +describe("config module", () => { + // Save original env to restore after tests + const originalEnv = { ...process.env }; + + afterEach(() => { + // Restore env vars after each test + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + + // Clear require cache so config reloads fresh + for (const key of Object.keys(require.cache)) { + if (key.includes("config.js")) { + delete require.cache[key]; + } + } + }); + + describe("expandPath()", () => { + it("expands ~ to home directory", () => { + const { expandPath } = require("../src/config"); + const result = expandPath("~/some/path"); + assert.strictEqual(result, path.join(os.homedir(), "some", "path")); + }); + + it("expands $HOME to home directory", () => { + const { expandPath } = require("../src/config"); + const result = expandPath("$HOME/docs"); + assert.strictEqual(result, path.join(os.homedir(), "docs")); + }); + + it("expands ${HOME} to home directory", () => { + const { expandPath } = require("../src/config"); + const result = expandPath("${HOME}/docs"); + assert.strictEqual(result, path.join(os.homedir(), "docs")); + }); + + it("returns null/undefined as-is", () => { + const { expandPath } = require("../src/config"); + assert.strictEqual(expandPath(null), null); + assert.strictEqual(expandPath(undefined), undefined); + }); + + it("returns path unchanged when no expansion needed", () => { + const { expandPath } = require("../src/config"); + assert.strictEqual(expandPath("/absolute/path"), "/absolute/path"); + }); + }); + + describe("detectWorkspace()", () => { + it("returns a string path", () => { + const { detectWorkspace } = require("../src/config"); + const result = detectWorkspace(); + assert.strictEqual(typeof result, "string"); + assert.ok(result.length > 0, "workspace path should not be empty"); + }); + + it("returns an absolute path", () => { + const { detectWorkspace } = require("../src/config"); + const result = detectWorkspace(); + assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`); + }); + }); + + describe("loadConfig()", () => { + it("returns an object with all required top-level keys", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.ok(config.server, "config should have server"); + assert.ok(config.paths, "config should have paths"); + assert.ok(config.auth, "config should have auth"); + assert.ok(config.branding, "config should have branding"); + assert.ok(config.integrations, "config should have integrations"); + }); + + it("has default port of 3333", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.server.port, 3333); + }); + + it("has default auth mode of 'none'", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.auth.mode, "none"); + }); + + it("has default host of localhost", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.server.host, "localhost"); + }); + + it("has workspace path set", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.ok(config.paths.workspace, "workspace path should be set"); + assert.strictEqual(typeof config.paths.workspace, "string"); + }); + + it("has memory path set", () => { + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.ok(config.paths.memory, "memory path should be set"); + }); + }); + + describe("environment variable overrides", () => { + it("PORT env var overrides default port", () => { + process.env.PORT = "9999"; + // Clear cache to force re-require + for (const key of Object.keys(require.cache)) { + if (key.includes("config.js")) { + delete require.cache[key]; + } + } + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.server.port, 9999); + }); + + it("HOST env var overrides default host", () => { + process.env.HOST = "0.0.0.0"; + for (const key of Object.keys(require.cache)) { + if (key.includes("config.js")) { + delete require.cache[key]; + } + } + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.server.host, "0.0.0.0"); + }); + + it("DASHBOARD_AUTH_MODE env var overrides auth mode", () => { + process.env.DASHBOARD_AUTH_MODE = "token"; + for (const key of Object.keys(require.cache)) { + if (key.includes("config.js")) { + delete require.cache[key]; + } + } + const { loadConfig } = require("../src/config"); + const config = loadConfig(); + assert.strictEqual(config.auth.mode, "token"); + }); + }); +}); diff --git a/tests/cron.test.js b/tests/cron.test.js new file mode 100644 index 0000000..91db75b --- /dev/null +++ b/tests/cron.test.js @@ -0,0 +1,79 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { cronToHuman } = require("../src/cron"); + +describe("cron module", () => { + describe("cronToHuman()", () => { + it("returns null for null input", () => { + assert.strictEqual(cronToHuman(null), null); + }); + + it("returns null for dash", () => { + assert.strictEqual(cronToHuman("—"), null); + }); + + it("returns null for too few parts", () => { + assert.strictEqual(cronToHuman("* *"), null); + }); + + it("converts every-minute cron", () => { + assert.strictEqual(cronToHuman("* * * * *"), "Every minute"); + }); + + it("converts every-N-minutes cron", () => { + assert.strictEqual(cronToHuman("*/5 * * * *"), "Every 5 minutes"); + assert.strictEqual(cronToHuman("*/15 * * * *"), "Every 15 minutes"); + }); + + it("converts every-N-hours cron", () => { + assert.strictEqual(cronToHuman("0 */2 * * *"), "Every 2 hours"); + }); + + it("converts hourly at specific minute", () => { + assert.strictEqual(cronToHuman("30 * * * *"), "Hourly at :30"); + assert.strictEqual(cronToHuman("0 * * * *"), "Hourly at :00"); + }); + + it("converts daily at specific time", () => { + assert.strictEqual(cronToHuman("0 9 * * *"), "Daily at 9am"); + assert.strictEqual(cronToHuman("30 14 * * *"), "Daily at 2:30pm"); + assert.strictEqual(cronToHuman("0 0 * * *"), "Daily at 12am"); + assert.strictEqual(cronToHuman("0 12 * * *"), "Daily at 12pm"); + }); + + it("converts weekday cron", () => { + assert.strictEqual(cronToHuman("0 9 * * 1-5"), "Weekdays at 9am"); + assert.strictEqual(cronToHuman("0 9 * * MON-FRI"), "Weekdays at 9am"); + }); + + it("converts weekend cron", () => { + assert.strictEqual(cronToHuman("0 10 * * 0,6"), "Weekends at 10am"); + assert.strictEqual(cronToHuman("0 10 * * 6,0"), "Weekends at 10am"); + }); + + it("converts specific day of week", () => { + const result = cronToHuman("0 8 * * 1"); + assert.strictEqual(result, "Monday at 8am"); + }); + + it("converts specific day of month", () => { + const result = cronToHuman("0 9 1 * *"); + assert.strictEqual(result, "1st of month at 9am"); + }); + + it("handles ordinal suffixes correctly", () => { + assert.ok(cronToHuman("0 9 2 * *").includes("2nd")); + assert.ok(cronToHuman("0 9 3 * *").includes("3rd")); + assert.ok(cronToHuman("0 9 4 * *").includes("4th")); + assert.ok(cronToHuman("0 9 21 * *").includes("21st")); + assert.ok(cronToHuman("0 9 22 * *").includes("22nd")); + assert.ok(cronToHuman("0 9 23 * *").includes("23rd")); + }); + + it("returns original expression as fallback", () => { + const expr = "* * * 6 *"; + const result = cronToHuman(expr); + assert.strictEqual(typeof result, "string"); + }); + }); +}); diff --git a/tests/data.test.js b/tests/data.test.js new file mode 100644 index 0000000..f2de57d --- /dev/null +++ b/tests/data.test.js @@ -0,0 +1,67 @@ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { migrateDataDir } = require("../src/data"); + +describe("data module", () => { + let tmpDir; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe("migrateDataDir()", () => { + it("does nothing when legacy dir does not exist", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-test-")); + const dataDir = path.join(tmpDir, "data"); + // Should not throw + migrateDataDir(dataDir, "/nonexistent/legacy"); + assert.ok(!fs.existsSync(dataDir)); + }); + + it("copies files from legacy dir to data dir", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-test-")); + const legacyDir = path.join(tmpDir, "legacy"); + const dataDir = path.join(tmpDir, "data"); + fs.mkdirSync(legacyDir); + fs.writeFileSync(path.join(legacyDir, "settings.json"), '{"key":"value"}'); + + migrateDataDir(dataDir, legacyDir); + + assert.ok(fs.existsSync(path.join(dataDir, "settings.json"))); + const content = fs.readFileSync(path.join(dataDir, "settings.json"), "utf8"); + assert.strictEqual(content, '{"key":"value"}'); + }); + + it("does not overwrite existing files in data dir", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-test-")); + const legacyDir = path.join(tmpDir, "legacy"); + const dataDir = path.join(tmpDir, "data"); + fs.mkdirSync(legacyDir); + fs.mkdirSync(dataDir); + fs.writeFileSync(path.join(legacyDir, "config.json"), "legacy"); + fs.writeFileSync(path.join(dataDir, "config.json"), "current"); + + migrateDataDir(dataDir, legacyDir); + + const content = fs.readFileSync(path.join(dataDir, "config.json"), "utf8"); + assert.strictEqual(content, "current"); + }); + + it("does nothing when legacy dir is empty", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-test-")); + const legacyDir = path.join(tmpDir, "legacy"); + const dataDir = path.join(tmpDir, "data"); + fs.mkdirSync(legacyDir); + + migrateDataDir(dataDir, legacyDir); + + // data dir should not be created for empty legacy + // Actually the function creates it, let's check it doesn't crash + }); + }); +}); diff --git a/tests/iostat-leak.test.js b/tests/iostat-leak.test.js new file mode 100644 index 0000000..e8150aa --- /dev/null +++ b/tests/iostat-leak.test.js @@ -0,0 +1,127 @@ +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert"); +const http = require("http"); +const { spawn, execSync } = require("child_process"); +const os = require("os"); +const path = require("path"); + +const isLinux = os.platform() === "linux"; + +/** + * Count running iostat processes using pgrep (avoids self-match issues with ps|grep). + * Returns 0 if pgrep finds no matches (exit code 1). + */ +function countIostatProcesses() { + try { + return parseInt(execSync("pgrep -c iostat", { encoding: "utf8" }).trim(), 10) || 0; + } catch { + return 0; // pgrep exits 1 when no matches + } +} + +/** + * Simple HTTP GET helper that returns a promise + */ +function httpGet(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body, + }), + ); + }) + .on("error", reject); + }); +} + +describe( + "iostat resource leak (#31)", + { skip: !isLinux && "Linux-only test", timeout: 90000 }, + () => { + const TEST_PORT = 10000 + Math.floor(Math.random() * 50000); + let serverProcess; + + before(async () => { + // Kill any stale iostat processes from prior runs + try { + execSync("pkill iostat 2>/dev/null", { encoding: "utf8" }); + } catch { + // No stale processes — expected + } + + // Start the server + serverProcess = spawn(process.execPath, [path.join(__dirname, "..", "lib", "server.js")], { + env: { ...process.env, PORT: String(TEST_PORT) }, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Wait for server to be ready by polling the health endpoint + const maxWait = 15000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + try { + await httpGet(`http://localhost:${TEST_PORT}/api/health`); + return; // Server is ready + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + throw new Error(`Server did not start within ${maxWait}ms`); + }); + + after(() => { + if (serverProcess) { + serverProcess.kill("SIGTERM"); + serverProcess = null; + } + }); + + it("does not accumulate iostat processes over multiple vitals refreshes", async () => { + // Vitals refresh every 30s (plus once at startup). Wait long enough for + // at least two cycles, then sample multiple times to catch the peak count. + // With the fix, each iostat exits in ~1s so we should never see more than + // 1 running at a time. Without the fix, each cycle spawns an immortal + // process and the count grows unboundedly. + await new Promise((resolve) => setTimeout(resolve, 35000)); + + // Sample several times over 5s to get a reliable peak + let peak = 0; + for (let i = 0; i < 5; i++) { + peak = Math.max(peak, countIostatProcesses()); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + // At most 2 concurrent: one finishing from a prior cycle, one just started + assert.ok(peak <= 2, `Peak iostat process count was ${peak} — leak detected`); + }); + + it("leaves no orphaned iostat processes after shutdown", async () => { + // Snapshot before shutdown (other test suites may also have servers running + // that spawn iostat, so we compare relative to this baseline) + const baseline = countIostatProcesses(); + + // Kill the server + if (serverProcess) { + serverProcess.kill("SIGTERM"); + serverProcess = null; + } + + // Give processes time to clean up (iostat -d 1 2 takes ~1s, plus timeout margin) + await new Promise((resolve) => setTimeout(resolve, 6000)); + + const remaining = countIostatProcesses(); + assert.ok( + remaining <= baseline, + `iostat count grew after shutdown: ${baseline} before, ${remaining} after`, + ); + }); + }, +); diff --git a/tests/jobs.test.js b/tests/jobs.test.js new file mode 100644 index 0000000..f291a2f --- /dev/null +++ b/tests/jobs.test.js @@ -0,0 +1,98 @@ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert"); + +// We import the module to test its exports and pure functions. +// The jobs module relies on dynamic ESM import of external jobs API, +// so we focus on testing what's available without that dependency. +const { handleJobsRequest, isJobsRoute, _resetForTesting } = require("../src/jobs"); + +describe("jobs module", () => { + describe("exports", () => { + it("exports handleJobsRequest function", () => { + assert.strictEqual(typeof handleJobsRequest, "function"); + }); + + it("exports isJobsRoute function", () => { + assert.strictEqual(typeof isJobsRoute, "function"); + }); + }); + + describe("isJobsRoute()", () => { + it("returns true for /api/jobs", () => { + assert.strictEqual(isJobsRoute("/api/jobs"), true); + }); + + it("returns true for /api/jobs/some-job", () => { + assert.strictEqual(isJobsRoute("/api/jobs/some-job"), true); + }); + + it("returns true for /api/jobs/some-job/history", () => { + assert.strictEqual(isJobsRoute("/api/jobs/some-job/history"), true); + }); + + it("returns true for /api/jobs/scheduler/status", () => { + assert.strictEqual(isJobsRoute("/api/jobs/scheduler/status"), true); + }); + + it("returns true for /api/jobs/stats", () => { + assert.strictEqual(isJobsRoute("/api/jobs/stats"), true); + }); + + it("returns false for /api/health", () => { + assert.strictEqual(isJobsRoute("/api/health"), false); + }); + + it("returns false for /api/sessions", () => { + assert.strictEqual(isJobsRoute("/api/sessions"), false); + }); + + it("returns false for /api/job (no trailing s)", () => { + assert.strictEqual(isJobsRoute("/api/job"), false); + }); + + it("returns false for empty string", () => { + assert.strictEqual(isJobsRoute(""), false); + }); + + it("returns false for /jobs (no /api prefix)", () => { + assert.strictEqual(isJobsRoute("/jobs"), false); + }); + }); + + describe("handleJobsRequest()", () => { + afterEach(() => { + // Reset API state after each test + _resetForTesting(); + }); + + it("returns 500 when jobs API is not available", async () => { + // Force API to be unavailable for this test + _resetForTesting({ forceUnavailable: true }); + + let statusCode = null; + let body = null; + + const mockRes = { + writeHead(code, _headers) { + statusCode = code; + }, + end(data) { + body = data; + }, + }; + + const mockReq = {}; + const query = new URLSearchParams(); + + await handleJobsRequest(mockReq, mockRes, "/api/jobs", query, "GET"); + + assert.strictEqual(statusCode, 500); + const parsed = JSON.parse(body); + assert.ok(parsed.error, "should have an error message"); + assert.ok( + parsed.error.includes("not available"), + `Error should mention not available: ${parsed.error}`, + ); + }); + }); +}); diff --git a/tests/llm-usage.test.js b/tests/llm-usage.test.js new file mode 100644 index 0000000..75e1b2c --- /dev/null +++ b/tests/llm-usage.test.js @@ -0,0 +1,88 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { transformLiveUsageData } = require("../src/llm-usage"); + +describe("llm-usage module", () => { + describe("transformLiveUsageData()", () => { + it("transforms valid usage data with anthropic provider", () => { + const usage = { + providers: [ + { + provider: "anthropic", + windows: [ + { label: "5h", usedPercent: 25, resetAt: Date.now() + 3600000 }, + { label: "Week", usedPercent: 10, resetAt: Date.now() + 86400000 * 3 }, + { label: "Sonnet", usedPercent: 5, resetAt: Date.now() + 86400000 * 5 }, + ], + }, + ], + }; + + const result = transformLiveUsageData(usage); + assert.strictEqual(result.source, "live"); + assert.strictEqual(result.claude.session.usedPct, 25); + assert.strictEqual(result.claude.session.remainingPct, 75); + assert.strictEqual(result.claude.weekly.usedPct, 10); + assert.strictEqual(result.claude.sonnet.usedPct, 5); + }); + + it("handles auth error from provider", () => { + const usage = { + providers: [{ provider: "anthropic", error: "403 Forbidden" }], + }; + + const result = transformLiveUsageData(usage); + assert.strictEqual(result.source, "error"); + assert.strictEqual(result.errorType, "auth"); + assert.ok(result.error.includes("403")); + assert.strictEqual(result.claude.session.usedPct, null); + }); + + it("handles missing windows gracefully", () => { + const usage = { providers: [{ provider: "anthropic", windows: [] }] }; + const result = transformLiveUsageData(usage); + assert.strictEqual(result.source, "live"); + assert.strictEqual(result.claude.session.usedPct, 0); + assert.strictEqual(result.claude.weekly.usedPct, 0); + }); + + it("handles codex provider data", () => { + const usage = { + providers: [ + { provider: "anthropic", windows: [] }, + { + provider: "openai-codex", + windows: [ + { label: "5h", usedPercent: 30 }, + { label: "Day", usedPercent: 15 }, + ], + }, + ], + }; + + const result = transformLiveUsageData(usage); + assert.strictEqual(result.codex.usage5hPct, 30); + assert.strictEqual(result.codex.usageDayPct, 15); + }); + + it("handles missing providers gracefully", () => { + const usage = { providers: [] }; + const result = transformLiveUsageData(usage); + assert.strictEqual(result.source, "live"); + assert.strictEqual(result.codex.usage5hPct, 0); + }); + + it("formats reset time correctly", () => { + const usage = { + providers: [ + { + provider: "anthropic", + windows: [{ label: "5h", usedPercent: 50, resetAt: Date.now() + 30 * 60000 }], + }, + ], + }; + const result = transformLiveUsageData(usage); + assert.ok(result.claude.session.resetsIn.includes("m")); + }); + }); +}); diff --git a/tests/openclaw.test.js b/tests/openclaw.test.js new file mode 100644 index 0000000..5121b30 --- /dev/null +++ b/tests/openclaw.test.js @@ -0,0 +1,39 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { extractJSON } = require("../src/openclaw"); + +describe("openclaw module", () => { + describe("extractJSON()", () => { + it("returns null for null input", () => { + assert.strictEqual(extractJSON(null), null); + }); + + it("returns null for empty string", () => { + assert.strictEqual(extractJSON(""), null); + }); + + it("returns null for non-JSON text", () => { + assert.strictEqual(extractJSON("no json here"), null); + }); + + it("extracts JSON object from clean input", () => { + const result = extractJSON('{"key": "value"}'); + assert.strictEqual(result, '{"key": "value"}'); + }); + + it("extracts JSON array from clean input", () => { + const result = extractJSON("[1, 2, 3]"); + assert.strictEqual(result, "[1, 2, 3]"); + }); + + it("strips non-JSON prefix from output", () => { + const result = extractJSON('Some warning text\n{"key": "value"}'); + assert.strictEqual(result, '{"key": "value"}'); + }); + + it("handles prefix with special characters", () => { + const result = extractJSON('Doctor warnings: OK\n[{"id": 1}]'); + assert.strictEqual(result, '[{"id": 1}]'); + }); + }); +}); diff --git a/tests/privacy.test.js b/tests/privacy.test.js new file mode 100644 index 0000000..2326881 --- /dev/null +++ b/tests/privacy.test.js @@ -0,0 +1,78 @@ +const { describe, it, afterEach } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { loadPrivacySettings, savePrivacySettings } = require("../src/privacy"); + +describe("privacy module", () => { + let tmpDir; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe("loadPrivacySettings()", () => { + it("returns defaults when file does not exist", () => { + const settings = loadPrivacySettings("/nonexistent/path"); + assert.strictEqual(settings.version, 1); + assert.deepStrictEqual(settings.hiddenTopics, []); + assert.deepStrictEqual(settings.hiddenSessions, []); + assert.deepStrictEqual(settings.hiddenCrons, []); + assert.strictEqual(settings.hideHostname, false); + assert.strictEqual(settings.updatedAt, null); + }); + + it("loads settings from file", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "privacy-test-")); + const data = { + version: 1, + hiddenTopics: ["secret"], + hiddenSessions: [], + hiddenCrons: [], + hideHostname: true, + updatedAt: "2024-01-01", + }; + fs.writeFileSync(path.join(tmpDir, "privacy-settings.json"), JSON.stringify(data)); + const settings = loadPrivacySettings(tmpDir); + assert.deepStrictEqual(settings.hiddenTopics, ["secret"]); + assert.strictEqual(settings.hideHostname, true); + }); + }); + + describe("savePrivacySettings()", () => { + it("saves settings to file", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "privacy-test-")); + const data = { + version: 1, + hiddenTopics: ["topic1"], + hiddenSessions: [], + hiddenCrons: [], + hideHostname: false, + }; + const result = savePrivacySettings(tmpDir, data); + assert.strictEqual(result, true); + + const saved = JSON.parse(fs.readFileSync(path.join(tmpDir, "privacy-settings.json"), "utf8")); + assert.deepStrictEqual(saved.hiddenTopics, ["topic1"]); + assert.ok(saved.updatedAt); + }); + + it("creates directory if it does not exist", () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "privacy-test-")); + const nestedDir = path.join(tmpDir, "nested", "dir"); + const data = { + version: 1, + hiddenTopics: [], + hiddenSessions: [], + hiddenCrons: [], + hideHostname: false, + }; + const result = savePrivacySettings(nestedDir, data); + assert.strictEqual(result, true); + assert.ok(fs.existsSync(path.join(nestedDir, "privacy-settings.json"))); + }); + }); +}); diff --git a/tests/server.test.js b/tests/server.test.js new file mode 100644 index 0000000..f6859b1 --- /dev/null +++ b/tests/server.test.js @@ -0,0 +1,95 @@ +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert"); +const http = require("http"); +const { spawn } = require("child_process"); +const path = require("path"); + +describe("server", () => { + // Use a random high port to avoid conflicts + const TEST_PORT = 10000 + Math.floor(Math.random() * 50000); + let serverProcess; + + before(async () => { + // Start the server as a child process with a custom PORT + serverProcess = spawn(process.execPath, [path.join(__dirname, "..", "lib", "server.js")], { + env: { ...process.env, PORT: String(TEST_PORT) }, + stdio: ["pipe", "pipe", "pipe"], + }); + + // Wait for server to be ready by polling the health endpoint + const maxWait = 10000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + try { + await httpGet(`http://localhost:${TEST_PORT}/api/health`); + return; // Server is ready + } catch (_e) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + throw new Error(`Server did not start within ${maxWait}ms`); + }); + + after(() => { + if (serverProcess) { + serverProcess.kill("SIGTERM"); + serverProcess = null; + } + }); + + it("responds to /api/health with status ok", async () => { + const { statusCode, body } = await httpGet(`http://localhost:${TEST_PORT}/api/health`); + assert.strictEqual(statusCode, 200); + const data = JSON.parse(body); + assert.strictEqual(data.status, "ok"); + assert.strictEqual(data.port, TEST_PORT); + assert.ok(data.timestamp, "should have timestamp"); + }); + + it("responds to /api/about with project info", async () => { + const { statusCode, body } = await httpGet(`http://localhost:${TEST_PORT}/api/about`); + assert.strictEqual(statusCode, 200); + const data = JSON.parse(body); + assert.ok(data.name || data.version, "should have project info"); + }); + + it("returns JSON content type for API endpoints", async () => { + const { headers } = await httpGet(`http://localhost:${TEST_PORT}/api/health`); + assert.ok( + headers["content-type"].includes("application/json"), + `Expected JSON content type, got: ${headers["content-type"]}`, + ); + }); + + it("serves static files for root path", async () => { + const { statusCode } = await httpGet(`http://localhost:${TEST_PORT}/`); + // Should return 200 (index.html) or similar + assert.ok( + statusCode >= 200 && statusCode < 500, + `Expected 2xx/3xx/4xx status for root, got: ${statusCode}`, + ); + }); +}); + +/** + * Simple HTTP GET helper that returns a promise + */ +function httpGet(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => + resolve({ + statusCode: res.statusCode, + headers: res.headers, + body, + }), + ); + }) + .on("error", reject); + }); +} diff --git a/tests/tokens.test.js b/tests/tokens.test.js new file mode 100644 index 0000000..01fcd83 --- /dev/null +++ b/tests/tokens.test.js @@ -0,0 +1,80 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { TOKEN_RATES, emptyUsageBucket, calculateCostForBucket } = require("../src/tokens"); + +describe("tokens module", () => { + describe("TOKEN_RATES", () => { + it("has input rate", () => { + assert.strictEqual(TOKEN_RATES.input, 15.0); + }); + + it("has output rate", () => { + assert.strictEqual(TOKEN_RATES.output, 75.0); + }); + + it("has cache read rate", () => { + assert.strictEqual(TOKEN_RATES.cacheRead, 1.5); + }); + + it("has cache write rate", () => { + assert.strictEqual(TOKEN_RATES.cacheWrite, 18.75); + }); + }); + + describe("emptyUsageBucket()", () => { + it("returns object with zero values", () => { + const bucket = emptyUsageBucket(); + assert.strictEqual(bucket.input, 0); + assert.strictEqual(bucket.output, 0); + assert.strictEqual(bucket.cacheRead, 0); + assert.strictEqual(bucket.cacheWrite, 0); + assert.strictEqual(bucket.cost, 0); + assert.strictEqual(bucket.requests, 0); + }); + + it("returns a new object each time", () => { + const a = emptyUsageBucket(); + const b = emptyUsageBucket(); + assert.notStrictEqual(a, b); + a.input = 100; + assert.strictEqual(b.input, 0); + }); + }); + + describe("calculateCostForBucket()", () => { + it("calculates cost for given token counts", () => { + const bucket = { + input: 1_000_000, + output: 1_000_000, + cacheRead: 1_000_000, + cacheWrite: 1_000_000, + }; + const result = calculateCostForBucket(bucket); + assert.strictEqual(result.inputCost, 15.0); + assert.strictEqual(result.outputCost, 75.0); + assert.strictEqual(result.cacheReadCost, 1.5); + assert.strictEqual(result.cacheWriteCost, 18.75); + assert.strictEqual(result.totalCost, 15.0 + 75.0 + 1.5 + 18.75); + }); + + it("returns zero cost for empty bucket", () => { + const bucket = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const result = calculateCostForBucket(bucket); + assert.strictEqual(result.totalCost, 0); + }); + + it("accepts custom rates", () => { + const bucket = { input: 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0 }; + const customRates = { input: 10, output: 0, cacheRead: 0, cacheWrite: 0 }; + const result = calculateCostForBucket(bucket, customRates); + assert.strictEqual(result.inputCost, 10.0); + assert.strictEqual(result.totalCost, 10.0); + }); + + it("calculates proportionally for partial token counts", () => { + const bucket = { input: 500_000, output: 0, cacheRead: 0, cacheWrite: 0 }; + const result = calculateCostForBucket(bucket); + assert.strictEqual(result.inputCost, 7.5); + }); + }); +}); diff --git a/tests/topic-classifier.test.js b/tests/topic-classifier.test.js new file mode 100644 index 0000000..06a0c1d --- /dev/null +++ b/tests/topic-classifier.test.js @@ -0,0 +1,263 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); + +// Import the module under test (avoid side-effect heavy parts by importing functions directly) +const { + classifyAndSuggestTopics, + extractKeyTerms, + matchTopics, + CONFIG, + TOPIC_PATTERNS, +} = require("../scripts/topic-classifier"); + +describe("topic-classifier module", () => { + describe("exports", () => { + it("exports classifyAndSuggestTopics function", () => { + assert.strictEqual(typeof classifyAndSuggestTopics, "function"); + }); + + it("exports extractKeyTerms function", () => { + assert.strictEqual(typeof extractKeyTerms, "function"); + }); + + it("exports matchTopics function", () => { + assert.strictEqual(typeof matchTopics, "function"); + }); + + it("exports CONFIG object", () => { + assert.ok(CONFIG, "CONFIG should be exported"); + assert.strictEqual(typeof CONFIG.matchThreshold, "number"); + assert.strictEqual(typeof CONFIG.minTermScore, "number"); + }); + + it("exports TOPIC_PATTERNS object", () => { + assert.ok(TOPIC_PATTERNS, "TOPIC_PATTERNS should be exported"); + assert.strictEqual(typeof TOPIC_PATTERNS, "object"); + assert.ok(Object.keys(TOPIC_PATTERNS).length > 0, "should have patterns"); + }); + }); + + describe("extractKeyTerms()", () => { + it("returns an array", () => { + const result = extractKeyTerms("some text about kubernetes deployment"); + assert.ok(Array.isArray(result)); + }); + + it("returns empty array for empty string", () => { + const result = extractKeyTerms(""); + assert.deepStrictEqual(result, []); + }); + + it("returns empty array for null input", () => { + const result = extractKeyTerms(null); + assert.deepStrictEqual(result, []); + }); + + it("returns empty array for undefined input", () => { + const result = extractKeyTerms(undefined); + assert.deepStrictEqual(result, []); + }); + + it("filters out stop words", () => { + const result = extractKeyTerms("the and or but kubernetes kubernetes deployment deployment"); + const terms = result.map((t) => t.term); + assert.ok(!terms.includes("the")); + assert.ok(!terms.includes("and")); + }); + + it("each result has term and score properties", () => { + const result = extractKeyTerms( + "docker container docker container kubernetes kubernetes pod pod", + ); + for (const entry of result) { + assert.ok("term" in entry, `entry should have 'term' property: ${JSON.stringify(entry)}`); + assert.ok("score" in entry, `entry should have 'score' property: ${JSON.stringify(entry)}`); + assert.strictEqual(typeof entry.term, "string"); + assert.strictEqual(typeof entry.score, "number"); + } + }); + + it("scores are sorted descending", () => { + const result = extractKeyTerms( + "kubernetes kubernetes kubernetes docker docker terraform terraform terraform terraform deploy deploy deploy deploy", + ); + for (let i = 1; i < result.length; i++) { + assert.ok( + result[i - 1].score >= result[i].score, + `Score at index ${i - 1} (${result[i - 1].score}) should be >= score at index ${i} (${result[i].score})`, + ); + } + }); + + it("strips code blocks from text", () => { + const result = extractKeyTerms( + "kubernetes kubernetes ```const x = kubernetes;``` kubernetes deployment deployment", + ); + // The code block content should be stripped, so only tokens from outside code blocks + const terms = result.map((t) => t.term); + // 'const' from code block should not appear + assert.ok(!terms.includes("const"), "should not include tokens from code blocks"); + }); + + it("strips URLs from text", () => { + const result = extractKeyTerms( + "kubernetes kubernetes https://example.com/kubernetes kubernetes deployment deployment", + ); + const terms = result.map((t) => t.term); + assert.ok(!terms.includes("https"), "should not include URL protocol as token"); + }); + }); + + describe("matchTopics()", () => { + const existingTopics = [ + "version-control", + "deployment", + "database", + "testing", + "ai-ml", + "containers", + ]; + + it("returns an array", () => { + const result = matchTopics("some text about deploying code", existingTopics); + assert.ok(Array.isArray(result)); + }); + + it("returns empty array for empty text", () => { + const result = matchTopics("", existingTopics); + assert.deepStrictEqual(result, []); + }); + + it("matches deployment topic for deploy-related text", () => { + const result = matchTopics( + "deploying to production staging pipeline deploy deploy", + existingTopics, + ); + const topics = result.map((r) => r.topic); + assert.ok( + topics.includes("deployment"), + `Expected 'deployment' in ${JSON.stringify(topics)}`, + ); + }); + + it("matches database topic for SQL-related text", () => { + const result = matchTopics( + "postgres database query sql optimization postgres query", + existingTopics, + ); + const topics = result.map((r) => r.topic); + assert.ok(topics.includes("database"), `Expected 'database' in ${JSON.stringify(topics)}`); + }); + + it("matches containers topic for docker/k8s text", () => { + const result = matchTopics( + "docker container kubernetes pod k8s container docker", + existingTopics, + ); + const topics = result.map((r) => r.topic); + assert.ok( + topics.includes("containers"), + `Expected 'containers' in ${JSON.stringify(topics)}`, + ); + }); + + it("results have topic and confidence properties", () => { + const result = matchTopics("git commit branch merge pull push github", existingTopics); + for (const entry of result) { + assert.ok("topic" in entry); + assert.ok("confidence" in entry); + assert.strictEqual(typeof entry.confidence, "number"); + assert.ok(entry.confidence >= 0 && entry.confidence <= 1); + } + }); + + it("results are sorted by confidence descending", () => { + const result = matchTopics( + "git commit branch merge deploy production staging", + existingTopics, + ); + for (let i = 1; i < result.length; i++) { + assert.ok( + result[i - 1].confidence >= result[i].confidence, + `Confidence at index ${i - 1} should be >= index ${i}`, + ); + } + }); + }); + + describe("classifyAndSuggestTopics()", () => { + it("returns object with matched, suggested, keyTerms", () => { + const result = classifyAndSuggestTopics( + "kubernetes deployment docker container kubernetes docker deployment", + ["containers", "deployment"], + { persist: false }, + ); + assert.ok(Array.isArray(result.matched)); + assert.ok(Array.isArray(result.suggested)); + assert.ok(Array.isArray(result.keyTerms)); + }); + + it("returns empty results for very short text", () => { + const result = classifyAndSuggestTopics("hi", [], { persist: false }); + assert.deepStrictEqual(result.matched, []); + assert.deepStrictEqual(result.suggested, []); + assert.deepStrictEqual(result.keyTerms, []); + }); + + it("returns empty results for null input", () => { + const result = classifyAndSuggestTopics(null, [], { persist: false }); + assert.deepStrictEqual(result.matched, []); + }); + + it("handles array transcript input", () => { + const transcript = [ + "kubernetes deployment docker container", + "kubernetes docker deployment staging production", + "more kubernetes docker content here deploy", + ]; + const result = classifyAndSuggestTopics(transcript, ["deployment"], { + persist: false, + }); + assert.ok(Array.isArray(result.matched)); + }); + + it("handles array of message objects", () => { + const transcript = [ + { text: "kubernetes deployment docker container" }, + { text: "kubernetes docker deployment staging" }, + { text: "more content about kubernetes docker" }, + ]; + const result = classifyAndSuggestTopics(transcript, ["deployment"], { + persist: false, + }); + assert.ok(Array.isArray(result.matched)); + }); + + it("provides confidence score", () => { + const result = classifyAndSuggestTopics( + "kubernetes deployment docker container kubernetes docker deployment pod staging", + ["containers", "deployment"], + { persist: false }, + ); + assert.strictEqual(typeof result.confidence, "number"); + }); + }); + + describe("TOPIC_PATTERNS", () => { + it("maps git to version-control", () => { + assert.strictEqual(TOPIC_PATTERNS["git"], "version-control"); + }); + + it("maps docker to containers", () => { + assert.strictEqual(TOPIC_PATTERNS["docker"], "containers"); + }); + + it("maps claude to ai-ml", () => { + assert.strictEqual(TOPIC_PATTERNS["claude"], "ai-ml"); + }); + + it("maps postgres to database", () => { + assert.strictEqual(TOPIC_PATTERNS["postgres"], "database"); + }); + }); +}); diff --git a/tests/topics.test.js b/tests/topics.test.js new file mode 100644 index 0000000..e09c97f --- /dev/null +++ b/tests/topics.test.js @@ -0,0 +1,85 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { TOPIC_PATTERNS, detectTopics } = require("../src/topics"); + +describe("topics module", () => { + describe("TOPIC_PATTERNS", () => { + it("is an object with topic keys", () => { + assert.strictEqual(typeof TOPIC_PATTERNS, "object"); + assert.ok(Object.keys(TOPIC_PATTERNS).length > 0); + }); + + it("each topic has an array of keywords", () => { + for (const [topic, keywords] of Object.entries(TOPIC_PATTERNS)) { + assert.ok(Array.isArray(keywords), `${topic} should have array of keywords`); + assert.ok(keywords.length > 0, `${topic} should have at least one keyword`); + } + }); + + it("contains expected topics", () => { + const topics = Object.keys(TOPIC_PATTERNS); + assert.ok(topics.includes("dashboard")); + assert.ok(topics.includes("coding")); + assert.ok(topics.includes("git")); + assert.ok(topics.includes("Slack")); + }); + }); + + describe("detectTopics()", () => { + it("returns empty array for null input", () => { + assert.deepStrictEqual(detectTopics(null), []); + }); + + it("returns empty array for empty string", () => { + assert.deepStrictEqual(detectTopics(""), []); + }); + + it("returns empty array for undefined", () => { + assert.deepStrictEqual(detectTopics(undefined), []); + }); + + it("detects git topic from git-related text", () => { + const topics = detectTopics("git commit branch merge push pull"); + assert.ok(topics.includes("git"), `Expected 'git' in ${JSON.stringify(topics)}`); + }); + + it("detects coding topic", () => { + const topics = detectTopics("debug the function and fix the error in the code"); + assert.ok(topics.includes("coding"), `Expected 'coding' in ${JSON.stringify(topics)}`); + }); + + it("detects Slack topic", () => { + const topics = detectTopics("send a slack message to the channel thread"); + assert.ok(topics.includes("Slack"), `Expected 'Slack' in ${JSON.stringify(topics)}`); + }); + + it("returns topics sorted by score descending", () => { + // Heavily git-focused text with a minor coding mention + const topics = detectTopics("git commit branch merge push pull repository github code"); + if (topics.length >= 2) { + // git should score higher than coding since more keywords match + const gitIdx = topics.indexOf("git"); + assert.ok(gitIdx >= 0, "git should be detected"); + } + }); + + it("returns array of strings", () => { + const topics = detectTopics("kubernetes docker container deploy"); + assert.ok(Array.isArray(topics)); + topics.forEach((t) => assert.strictEqual(typeof t, "string")); + }); + + it("detects scheduling topic", () => { + const topics = detectTopics("set up a cron schedule timer for periodic interval"); + assert.ok( + topics.includes("scheduling"), + `Expected 'scheduling' in ${JSON.stringify(topics)}`, + ); + }); + + it("detects subagent topic", () => { + const topics = detectTopics("spawn a subagent to delegate the work in parallel"); + assert.ok(topics.includes("subagent"), `Expected 'subagent' in ${JSON.stringify(topics)}`); + }); + }); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js new file mode 100644 index 0000000..a7afdcb --- /dev/null +++ b/tests/utils.test.js @@ -0,0 +1,85 @@ +const { describe, it } = require("node:test"); +const assert = require("node:assert"); +const { formatBytes, formatTimeAgo, formatNumber, formatTokens } = require("../src/utils"); + +describe("utils module", () => { + describe("formatBytes()", () => { + it("formats bytes", () => { + assert.strictEqual(formatBytes(500), "500 B"); + }); + + it("formats kilobytes", () => { + assert.strictEqual(formatBytes(1024), "1.0 KB"); + assert.strictEqual(formatBytes(1536), "1.5 KB"); + }); + + it("formats megabytes", () => { + assert.strictEqual(formatBytes(1048576), "1.0 MB"); + }); + + it("formats gigabytes", () => { + assert.strictEqual(formatBytes(1073741824), "1.0 GB"); + }); + + it("formats terabytes", () => { + assert.strictEqual(formatBytes(1099511627776), "1.0 TB"); + }); + }); + + describe("formatTimeAgo()", () => { + it("formats just now", () => { + assert.strictEqual(formatTimeAgo(new Date()), "just now"); + }); + + it("formats minutes ago", () => { + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + assert.strictEqual(formatTimeAgo(fiveMinAgo), "5m ago"); + }); + + it("formats hours ago", () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + assert.strictEqual(formatTimeAgo(twoHoursAgo), "2h ago"); + }); + + it("formats days ago", () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); + assert.strictEqual(formatTimeAgo(threeDaysAgo), "3d ago"); + }); + }); + + describe("formatNumber()", () => { + it("formats with 2 decimal places", () => { + assert.strictEqual(formatNumber(1234.5), "1,234.50"); + }); + + it("formats zero", () => { + assert.strictEqual(formatNumber(0), "0.00"); + }); + + it("formats small numbers", () => { + assert.strictEqual(formatNumber(0.1), "0.10"); + }); + }); + + describe("formatTokens()", () => { + it("formats millions", () => { + assert.strictEqual(formatTokens(1500000), "1.5M"); + }); + + it("formats thousands", () => { + assert.strictEqual(formatTokens(2500), "2.5k"); + }); + + it("formats small numbers as-is", () => { + assert.strictEqual(formatTokens(42), "42"); + }); + + it("formats exactly 1M", () => { + assert.strictEqual(formatTokens(1000000), "1.0M"); + }); + + it("formats exactly 1k", () => { + assert.strictEqual(formatTokens(1000), "1.0k"); + }); + }); +});