Skill: lewisawe/kplc-sentinel (version 1.7.3)

--- name: kplc-sentinel description: Track Kenyan prepaid electricity (KPLC) tokens, predict blackout times, and get proactive low-balance alerts — all through chat. version: 1.7.0 metadata: {"openclaw":{"emoji":"⚡","requires":{"bins":["python3"]}}} --- # KPLC Token Sentinel Track prepaid electricity for Kenyan households. Parses KPLC token SMS messages, records meter readings, calculates burn rate, and warns before power runs out. ## Setup Install Python dependencies (one-time): ``` pip install -r {baseDir}/requirements.txt ``` ## When to use this skill Activate this skill when the user's message matches ANY of these: **Auto-detect (no prefix needed):** - A forwarded SMS containing "Token:" and "Units:" (KPLC SMS format) - A forwarded SMS that mentions Kenya Power or KPLC **Direct commands (stima prefix):** - "stima" → show interactive menu - "stima help" / "stima menu" → show interactive menu - "stima 42.5" → meter reading - "stima balance" → check remaining power - "stima spending" → spending dashboard - "stima outage" → planned outage check - "stima setup" → household onboarding - "stima budget 3000" → set monthly budget - "stima budget" → check budget status - "stima insights" → week-over-week comparison and day patterns - "stima monthly" / "stima yearly" → reports - "stima price" → cost-per-unit trend - "stima profile" → show household info - "stima reset" → clear profile and re-onboard **Natural language (English, Swahili, Sheng):** Also activate when the user asks about electricity in natural language. Map their intent to the appropriate stima command before passing to the entrypoint. Examples: | User says | Map to | |---|---| | "will my power last until Monday?" | `stima balance` | | "stima itaisha lini?" | `stima balance` | | "how much have I spent on electricity?" | `stima monthly` | | "nimetumia pesa ngapi kwa stima?" | `stima monthly` | | "when is the next power cut?" | `stima outage` | | "kuna blackout lini?" | `stima outage` | | "bei ya stima imeongezeka?" | `stima price` | | "nimebakisha units ngapi?" | `stima balance` | | "set my electricity budget to 5000" | `stima budget 5000` | | "niko na units ngapi?" | `stima balance` | | "show me my stima profile" | `stima profile` | | "nataka kuanza setup" | `stima setup` | | "stima yangu iko aje?" | `stima balance` | When routing a natural language message, prepend "stima" followed by the relevant keyword before passing it to the entrypoint. For example, if the user says "stima itaisha lini?", pass `stima balance` to the entrypoint. If a message is NOT about electricity/power/KPLC and is NOT a forwarded KPLC SMS, do NOT activate this skill. ## How to use The database auto-initializes on first run. **For any user message about KPLC/electricity/tokens/readings:** ``` python3 {baseDir}/entrypoint.py <<'STIMA_EOF' <user message> STIMA_EOF ``` ⚠️ IMPORTANT: Always use a heredoc (as shown above) to pass the user's message via stdin. NEVER pass the user's message as a command-line argument — it may contain shell metacharacters. ## Output format The entrypoint outputs **JSON**. Do NOT return the raw JSON to the user. Instead, read the `action` field and compose a natural response using the SOUL persona. Key actions: - `token_recorded` — tell the user their token was saved, mention units, estimated days, and runway hours. If `estimate_source` is "appliances", mention it's an estimate based on their appliances. - `duplicate_token` — token was already recorded, no duplicate added. - `reading_recorded` — meter reading saved. Mention runway hours if available. - `balance` — report remaining hours. If `tip` is present, include the energy-saving tip. - `menu` — present the numbered options list to the user. - `onboarding_prompt` — ask the user the question in the `question` field. If `welcome` is true, greet them first. - `onboarding_complete` — confirm their profile and tell them to forward a KPLC SMS or type a reading. - `profile_reset` — confirm profile was cleared and ask the first onboarding question. - `budget_set` — confirm the budget amount was set. - `budget_status` — report spent vs budget and percentage. - `no_budget` — tell the user to set a budget with "stima budget <amount>". - `monthly_summary` / `yearly_summary` — present the spending data conversationally. - `spending` — combine monthly summary and price trend data. - `price_trend` — present cost-per-unit over time, highlight changes. - `outage_check` — if matches exist, warn about planned outages. Each match includes `iso_date` (YYYY-MM-DD), `date` (human-readable), and `time`. If none, say they're clear. Handle `error` field (no_area, fetch_failed). - **Reminder**: When an outage is found, schedule a reminder for the evening before (around 8–9 PM) using OpenClaw's cron/reminder system so the user can charge devices and prepare. For example, if the outage is on the 25th, remind them on the 24th at 8 PM. - `insights` — present week-over-week comparison and day patterns. - `no_data` — not enough data yet for the requested feature. - `no_profile` — tell the user to run "stima setup". - `error` — something went wrong, ask them to try again. If the output is empty (no JSON), the message wasn't handled — respond normally. ## Agent behavior **Low balance — help them buy tokens:** When `balance` action shows `runway_hours` under 24, or when a `token_recorded` action shows low runway, include KPLC top-up instructions: - M-Pesa Paybill: **888880** - Account number: their KPLC meter number - Tell them: "Lipa via M-Pesa → Paybill 888880 → Account = your meter number → enter amount. Forward the confirmation SMS back to me." **Outage reminders:** When `outage_check` returns matches, set a reminder for the evening before each outage (8 PM the previous day). Remind the user to charge phones, power banks, and plan around the outage window. **Multi-skill composability:** - If the user has a calendar skill installed and an outage is detected, create a calendar event for the outage window. - If a reminders/cron skill is available, use it to schedule the eve-of-outage reminder. - If a payments skill is installed and balance is critically low (under 6 hours), offer to initiate an M-Pesa payment to Paybill 888880. **For heartbeat checks (see HEARTBEAT.md):** ``` python3 {baseDir}/sentinel.py ``` ## Example KPLC SMS formats - "Accept [REDACTED_SECRET] Units: 34.5 Amount: 1000.00" - "[REDACTED_SECRET] Units: 15.2 Amt: 500.0"

path: HEARTBEAT.md
content: # Heartbeat Schedule

## Every 6 Hours — Power Status Check
- Run `python3 {baseDir}/sentinel.py` in the kplc-sentinel skill directory
- If any alerts are returned, send them to the user immediately
- LOW_BALANCE_WARNING alerts are urgent — send right away
- SPOT_CHECK_REQUEST alerts are gentle reminders
- PLANNED_OUTAGE alerts should be sent once when first detected
- BUDGET_WARNING alerts should be sent when spending exceeds 80% of monthly budget

## Every Monday 8:00 AM — Weekly KPLC Summary
- Run `python3 -c "import sys; sys.path.insert(0, '{baseDir}'); from sentinel import weekly_summary; s = weekly_summary(); print(s['message'])"` in the kplc-sentinel skill directory
- Send the full summary to the user


path: README.md
content: # KPLC Sentinel

An OpenClaw skill that tracks prepaid electricity for Kenyan households. Parses KPLC token SMS messages, predicts when you'll run out of power, tracks spending, detects price trends, and warns you about planned outages in your area.

## Requirements

- OpenClaw installed and running (`npm install -g openclaw@latest`)
- A connected messaging channel (WhatsApp, Telegram, etc.)
- Python 3 with `pdfplumber` (`pip install -r requirements.txt`)
- An LLM API key (Anthropic, OpenAI, Google Gemini, etc.)

## Install

```
clawhub install kplc-sentinel
```

Or if you don't have the ClawHub CLI:

```
openclaw skills install kplc-sentinel
```

## Setup

If you don't have OpenClaw running yet:

```bash
# Install OpenClaw
npm install -g openclaw@latest

# Run the setup wizard (configures your LLM API key and daemon)
openclaw onboard

# Connect WhatsApp (scan QR code with your phone)
openclaw channels login --channel whatsapp

# Install the skill
openclaw skills install kplc-sentinel

# Install Python dependencies
pip install -r requirements.txt

# Start the gateway
openclaw gateway
```

Message your OpenClaw agent on WhatsApp and say "stima" to see the menu, or "stima setup" to start onboarding.

## Usage

The skill responds to the `stima` prefix, forwarded KPLC SMS messages, and natural language about electricity in English, Swahili, or Sheng.

**Menu:** Type "stima" to see a numbered menu of all commands. Reply with a number to select.

**Natural language:** You can also just ask naturally:
- "stima itaisha lini?" → checks your balance
- "nimetumia pesa ngapi kwa stima?" → monthly summary
- "kuna blackout lini?" → checks for outages
- "will my power last until Monday?" → checks your balance

The agent translates your intent into the right command automatically.

**Onboarding:** Say "stima setup". The skill asks for your household size, area/estate, and appliances. Your appliance list is used to estimate consumption even before you have any meter readings.

**Track a token purchase:** Forward your KPLC SMS to the chat. The skill parses the token, units, and amount automatically, and estimates how many days it will last.

Example SMS formats it recognizes:
```
Accept [REDACTED_SECRET] Units: 34.5 Amount: 1000.00
[REDACTED_SECRET] Units: 15.2 Amt: 500.0
```

**Record a meter reading:** Press 20# on your meter and type "stima 42.5".

**Check your balance:** "stima balance" or "nimebakisha units ngapi?"

**Budget:** "stima budget 3000" to set, "stima budget" to check.

**Usage insights:** "stima insights" for week-over-week comparison and day patterns.

**Spending dashboards:** "stima monthly", "stima yearly", or "stima spending".

**Price trends:** "stima price" or "bei ya stima imeongezeka?"

**Planned outages:** "stima outage" — the skill scrapes KPLC's maintenance schedule PDF and checks for your area. When an outage is found, the agent sets a reminder for the evening before so you can charge your devices.

**Low balance top-up:** When your power is running low, the agent includes M-Pesa top-up instructions (Paybill 888880).

**Profile:** "stima profile" to see household info and budget status.

**Reset:** "stima reset" to clear your profile and re-onboard (e.g. if you move house or change appliances).

**Help:** "stima help" or "stima menu" to see all commands.

## How it works

The skill returns structured JSON data from its Python scripts. The OpenClaw agent reads this data and composes natural, conversational responses using the Stima persona defined in SOUL.md. The scripts are the data layer; the agent is the brain.

Nothing leaves your machine except a single HTTPS request to `kplc.co.ke` to fetch the planned outage PDF (cached for 1 hour).

- **Burn rate:** Weighted average across all meter readings, with exponential decay so recent usage matters more
- **Appliance estimates:** When no meter readings exist yet, the skill estimates consumption from your appliance list (wattage × typical daily usage hours) scaled by household size — useful predictions from day one
- **Blackout prediction:** Current balance divided by burn rate (or appliance estimate)
- **Token-to-days:** Estimates how long a new purchase will last
- **Budget tracking:** Set a monthly KES limit, warns at 80% and 100%
- **Usage insights:** Week-over-week comparison and heaviest/lightest day detection
- **Household tips:** When balance is low, suggests turning off heavy appliances from your profile
- **Outage alerts:** Downloads and parses KPLC's two-column maintenance PDF, matches against your area
- **Price trends:** Tracks cost-per-unit across months, shows percentage changes
- **Multi-skill composability:** Returns structured data that other OpenClaw skills can act on (calendar events for outages, M-Pesa payments for top-ups)

## Security

- All SQL queries use parameterized statements (no injection)
- User input passed via stdin heredoc (no shell injection)
- Input length capped at 5000 characters
- Profile values sanitized before display (no chat injection)
- Database file created with owner-only permissions (0600)
- Outbound requests locked to `kplc.co.ke` HTTPS only
- No credentials stored in code
- Error messages don't leak internal details

## Heartbeat (proactive alerts)

The skill checks automatically via OpenClaw's heartbeat system:

- Every 6 hours: warns if you have less than 24 hours of power left, checks for planned outages
- Every 48 hours without a reading: asks for a spot check
- Weekly on Monday: sends a consumption and spending summary

## Files

| File | Purpose |
|---|---|
| `SKILL.md` | Skill metadata, agent routing, and JSON action reference |
| `SOUL.md` | Agent persona ("Stima") and response formatting guidance |
| `HEARTBEAT.md` | Scheduled proactive checks |
| `entrypoint.py` | Message handler — returns JSON data for the agent |
| `sentinel.py` | Heartbeat alerts and weekly summary |
| `logic.py` | Burn rate, predictions, appliance estimates, spending, outage parsing |
| `parser.py` | KPLC SMS regex parser |
| `init_db.py` | SQLite schema and connection management |
| `requirements.txt` | Python dependencies |


path: SOUL.md
content: # Soul

You are Stima, a friendly Kenyan household electricity assistant. You speak in casual English with occasional Sheng/Swahili flavor (e.g., "Niaje!", "sawa", "poa", "stima"). You are practical, concise, and proactive — your job is to make sure the household never runs out of power unexpectedly.

Personality: warm, helpful, slightly cheeky. Like a neighbor who always knows when to top up.

## How to respond

The kplc-sentinel skill returns JSON data. Your job is to turn that data into a natural, conversational message. Don't dump numbers — weave them into a sentence. Use the data to be specific.

Examples of good responses:
- "Sawa! 34.5 units imeingia. That should last you about 5.7 days based on your appliances. Total runway ni 136 hours — you're good for now."
- "Stima yako iko na roughly 18 hours. That's tight — avoid running the water heater and iron to stretch it."
- "This month you've spent KES 2,500 on 3 top-ups (85 units). That's about KES 29 per unit."

Keep responses short. Use numbers and time estimates, not vague language. When power is running low, be direct and urgent. When things are fine, keep it light.

For the menu, present the numbered list cleanly so the user can reply with a number.


path: entrypoint.py
content: import json
import sys
import logging
import sqlite3
from init_db import init_db, get_db

logger = logging.getLogger(__name__)
from parser import parse_kplc_sms
from logic import (
    add_purchase, add_reading, predict_blackout, calculate_burn_rate,
    set_profile, get_profile, get_all_profile, is_onboarded, reset_profile,
    monthly_summary, yearly_summary, price_trend_data, check_outages,
    set_budget, budget_data, estimate_days, comparison_insights_data
)

MENU_OPTIONS = [
    ("balance", "Check remaining power"),
    ("budget", "Budget status"),
    ("insights", "Week-over-week usage"),
    ("monthly", "This month's summary"),
    ("yearly", "Yearly summary"),
    ("spending", "Spending + price trend"),
    ("price", "Cost-per-unit trend"),
    ("outage", "Planned outages in your area"),
    ("profile", "Household info"),
    ("setup", "Onboard / re-setup"),
]

ONBOARDING_QUESTIONS = [
    ("occupants", "How many people live in your household?"),
    ("area", "What area/estate do you live in? (e.g. Kilimani, Umoja, Kitengela)"),
    ("appliances", "List your main electric appliances (e.g. fridge, TV, iron, water heater):"),
]

_COMMANDS = None

def _get_commands():
    global _COMMANDS
    if _COMMANDS is None:
        _COMMANDS = [
            (("reset", "clear"), _cmd_reset),
            (("setup", "start", "hello", "hi", "hey"), _cmd_setup),
            (("help", "menu", "?"), lambda t, l: _cmd_menu()),
            (("budget",), _cmd_budget),
            (("insight", "compare", "pattern"), _cmd_insights),
            (("monthly", "this month"), _cmd_monthly),
            (("yearly", "this year", "annual"), _cmd_yearly),
            (("spending", "spend"), _cmd_spending),
            (("price", "tariff"), _cmd_price),
            (("outage", "interruption", "maintenance"), _cmd_outage),
            (("balance", "power", "units"), _cmd_balance),
            (("profile", "household", "info"), _cmd_profile),
        ]
    return _COMMANDS


def handle_message(text):
    try:
        return _handle_message(text)
    except sqlite3.Error:
        logger.exception("Database error")
        return {"action": "error", "error": "database"}


MAX_INPUT_LEN = 5000
MAX_PROFILE_LEN = 200

def _handle_message(text):
    text = text.strip()[:MAX_INPUT_LEN]

    # Auto-detect forwarded KPLC SMS (no prefix needed)
    parsed = parse_kplc_sms(text)
    if parsed["success"]:
        is_dup = not add_purchase(parsed["token"], parsed["units"], parsed["amount"], text)
        if is_dup:
            return {"action": "duplicate_token", "token": parsed["token_display"]}
        result = {
            "action": "token_recorded",
            "token": parsed["token_display"],
            "units": parsed["units"],
            "amount": parsed["amount"],
            "estimate_source": "meter" if calculate_burn_rate() else "appliances",
        }
        days = estimate_days(parsed["units"])
        if days:
            result["estimated_days"] = round(days, 1)
        remaining = predict_blackout()
        if remaining:
            result["total_runway_hours"] = round(remaining, 1)
            result["tip"] = _get_tip(remaining)
        bdata = budget_data()
        if bdata and bdata["percent"] >= 80:
            result["budget_warning"] = bdata
        return result

    # All other messages require 'stima' prefix
    lower = text.lower()
    if not lower.startswith("stima"):
        return None

    text = text[5:].strip()
    lower = text.lower()

    # Onboarding flow
    pending = get_profile("onboarding_step")
    if pending is not None:
        if not text:
            if pending.isdigit() and int(pending) < len(ONBOARDING_QUESTIONS):
                return {"action": "onboarding_prompt", "question": ONBOARDING_QUESTIONS[int(pending)][1]}
            return None
        return _handle_onboarding(pending, text)

    if not text:
        return _cmd_menu()

    # Menu number selection
    if lower.isdigit() and get_profile("menu_pending"):
        choice = int(lower)
        if 1 <= choice <= len(MENU_OPTIONS):
            set_profile("menu_pending", None)
            cmd = MENU_OPTIONS[choice - 1][0]
            return _handle_message(f"stima {cmd}")
        set_profile("menu_pending", None)

    # Manual reading (plain number)
    try:
        balance = float(text)
        return _cmd_reading(balance)
    except ValueError:
        pass

    for prefixes, handler in _get_commands():
        if any(lower == p or lower.startswith(p + " ") for p in prefixes):
            return handler(text, lower)

    return None


# ── handlers ─────────────────────────────────────────────────────────────

def _cmd_menu():
    set_profile("menu_pending", "1")
    return {
        "action": "menu",
        "options": [{"number": i, "label": label} for i, (_, label) in enumerate(MENU_OPTIONS, 1)],
    }


def _handle_onboarding(pending, text):
    try:
        step = int(pending)
    except (ValueError, TypeError):
        set_profile("onboarding_step", None)
        return {"action": "onboarding_error"}
    if step < 0 or step >= len(ONBOARDING_QUESTIONS):
        set_profile("onboarding_step", None)
        return {"action": "onboarding_error"}
    key = ONBOARDING_QUESTIONS[step][0]
    set_profile(key, text[:MAX_PROFILE_LEN])
    step += 1
    if step < len(ONBOARDING_QUESTIONS):
        set_profile("onboarding_step", str(step))
        return {"action": "onboarding_prompt", "question": ONBOARDING_QUESTIONS[step][1]}
    set_profile("onboarding_step", None)
    return {"action": "onboarding_complete", "profile": get_all_profile()}


def _cmd_reading(balance):
    if balance < 0:
        return {"action": "error", "error": "negative_reading"}
    try:
        add_reading(balance, "Manual entry")
    except ValueError as e:
        return {"action": "error", "error": str(e)}
    remaining = predict_blackout()
    result = {"action": "reading_recorded", "balance": balance}
    if remaining:
        result["runway_hours"] = round(remaining, 1)
        result["estimate_source"] = "meter" if calculate_burn_rate() else "appliances"
        result["tip"] = _get_tip(remaining)
    return result


def _cmd_setup(text, lower):
    if not is_onboarded():
        set_profile("onboarding_step", "0")
        return {"action": "onboarding_prompt", "question": ONBOARDING_QUESTIONS[0][1], "welcome": True}
    return {"action": "already_onboarded"}


def _cmd_reset(text, lower):
    reset_profile()
    set_profile("onboarding_step", "0")
    return {"action": "profile_reset", "question": ONBOARDING_QUESTIONS[0][1]}


def _cmd_budget(text, lower):
    parts = text.split()
    if len(parts) >= 2:
        try:
            amt = float(parts[1].replace(",", ""))
            set_budget(amt)
            return {"action": "budget_set", "amount": amt}
        except ValueError:
            pass
    data = budget_data()
    if data:
        return {"action": "budget_status", **data}
    return {"action": "no_budget"}


def _cmd_insights(text, lower):
    data = comparison_insights_data()
    if data:
        return {"action": "insights", **data}
    return {"action": "no_data", "feature": "insights"}


def _cmd_monthly(text, lower):
    return {"action": "monthly_summary", **monthly_summary()}


def _cmd_yearly(text, lower):
    return {"action": "yearly_summary", **yearly_summary()}


def _cmd_spending(text, lower):
    return {"action": "spending", "monthly": monthly_summary(), "price_trend": price_trend_data()}


def _cmd_price(text, lower):
    data = price_trend_data()
    if data["months"]:
        return {"action": "price_trend", **data}
    return {"action": "no_data", "feature": "price trends"}


def _cmd_outage(text, lower):
    return {"action": "outage_check", **check_outages()}


def _cmd_balance(text, lower):
    remaining = predict_blackout()
    if remaining:
        return {
            "action": "balance",
            "runway_hours": round(remaining, 1),
            "estimate_source": "meter" if calculate_burn_rate() else "appliances",
            "tip": _get_tip(remaining),
        }
    return {"action": "no_data", "feature": "balance"}


def _cmd_profile(text, lower):
    profile = get_all_profile()
    if not profile:
        return {"action": "no_profile"}
    result = {"action": "profile", **profile}
    data = budget_data()
    if data:
        result["budget"] = data
    return result


# ── helpers ──────────────────────────────────────────────────────────────

_HEAVY_APPLIANCES = {"heater", "iron", "oven", "geyser", "water heater", "boiler"}

def _get_tip(hours_remaining):
    if hours_remaining >= 12:
        return None
    appliances = get_profile("appliances")
    if not appliances:
        return None
    import re
    items = [a.strip().lower() for a in re.split(r'[,/]|\band\b', appliances) if a.strip()]
    heavy = [a for a in items if any(h in a for h in _HEAVY_APPLIANCES)]
    return f"avoid running {', '.join(heavy)}" if heavy else None


if __name__ == "__main__":
    init_db()
    msg = sys.stdin.read().strip()
    if msg:
        result = handle_message(msg)
        if result:
            print(json.dumps(result))


path: requirements.txt
content: pdfplumber


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)