Initial commit: OpenClaw ops workspace
This commit is contained in:
67
skills/capmetro-monitor/SKILL.md
Normal file
67
skills/capmetro-monitor/SKILL.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: capmetro-monitor
|
||||
description: Monitor CapMetro (Austin, TX) service changes for specific routes. Checks tri-annual service change pages for Route 5 (Bus) and Route 500 (MetroRail), translates transit operator language into plain English summaries. Use for weekly monitoring of commute-relevant transit updates.
|
||||
---
|
||||
|
||||
# CapMetro Service Change Monitor
|
||||
|
||||
Weekly monitoring of Austin transit route changes with plain-English summaries.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Checks CapMetro service change pages (tri-annual: Jan, Jun, Aug)
|
||||
2. Filters for Route 5 (Bus) and Route 500 (MetroRail)
|
||||
3. Detects new changes since last check
|
||||
4. Returns structured JSON for processing
|
||||
|
||||
## Monitored Routes
|
||||
|
||||
- **Route 5** - Woodrow/East 12th (Bus)
|
||||
- **Route 500** - MetroRail (Red Line)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
bash skills/capmetro-monitor/scripts/check-changes.sh
|
||||
```
|
||||
|
||||
**Output when nothing new:**
|
||||
```json
|
||||
{"hasNew":false}
|
||||
```
|
||||
|
||||
**Output with new changes:**
|
||||
```json
|
||||
{
|
||||
"hasNew": true,
|
||||
"newChanges": [
|
||||
{
|
||||
"url": "https://www.capmetro.org/servicechange/june-2026",
|
||||
"title": "June 2026 Proposed Service Changes",
|
||||
"id": "https://www.capmetro.org/servicechange/june-2026"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
Designed for weekly cron job that:
|
||||
1. Runs check script
|
||||
2. If `hasNew: true`, fetch full details and summarize in plain English
|
||||
3. Translate transit terminology (timepoint, alignment, turnaround) for clarity
|
||||
|
||||
## State Tracking
|
||||
|
||||
State stored in `memory/capmetro-check-state.json`:
|
||||
```json
|
||||
{
|
||||
"lastCheck": "2026-02-04T17:30:00Z",
|
||||
"seenChanges": ["url1", "url2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- `curl` for web requests
|
||||
- `jq` for JSON processing
|
||||
63
skills/capmetro-monitor/scripts/check-changes.sh
Executable file
63
skills/capmetro-monitor/scripts/check-changes.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Check CapMetro service changes for Route 5 and Route 500
|
||||
# Returns JSON with new changes since last check
|
||||
|
||||
set -e
|
||||
|
||||
STATE_FILE="${STATE_FILE:-memory/capmetro-check-state.json}"
|
||||
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# Initialize state if missing
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
echo '{"lastCheck":"1970-01-01T00:00:00Z","seenChanges":[]}' > "$STATE_FILE"
|
||||
fi
|
||||
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Fetch current service changes page
|
||||
CHANGES=$(curl -s "https://www.capmetro.org/servicechange" | \
|
||||
grep -oP 'href="/servicechange/[^"]+' | \
|
||||
sed 's/href="//' | \
|
||||
sort -u)
|
||||
|
||||
# Check each change period for Route 5 or Route 500
|
||||
RELEVANT_CHANGES='[]'
|
||||
|
||||
for change_url in $CHANGES; do
|
||||
FULL_URL="https://www.capmetro.org$change_url"
|
||||
CONTENT=$(curl -s "$FULL_URL")
|
||||
|
||||
# Check if Route 5 or Route 500 mentioned
|
||||
if echo "$CONTENT" | grep -qiE "(Route 5[^0-9]|Route 500)"; then
|
||||
TITLE=$(echo "$CONTENT" | grep -oP '<title>\K[^<]+' | head -1)
|
||||
RELEVANT_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --arg url "$FULL_URL" --arg title "$TITLE" \
|
||||
'. += [{"url":$url, "title":$title, "id":$url}]')
|
||||
fi
|
||||
done
|
||||
|
||||
# Load seen changes
|
||||
SEEN=$(jq -r '.seenChanges // []' "$STATE_FILE")
|
||||
|
||||
# Find new changes
|
||||
NEW_CHANGES=$(echo "$RELEVANT_CHANGES" | jq --argjson seen "$SEEN" '[
|
||||
.[] | select(.id as $id | $seen | index($id) | not)
|
||||
]')
|
||||
|
||||
NEW_COUNT=$(echo "$NEW_CHANGES" | jq 'length')
|
||||
|
||||
# Update state
|
||||
ALL_IDS=$(echo "$RELEVANT_CHANGES" | jq -r '[.[].id]')
|
||||
jq -n \
|
||||
--arg now "$NOW" \
|
||||
--argjson ids "$ALL_IDS" \
|
||||
'{lastCheck:$now, seenChanges:$ids}' > "$STATE_FILE"
|
||||
|
||||
# Output
|
||||
if [ "$NEW_COUNT" -eq 0 ]; then
|
||||
echo '{"hasNew":false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -n --argjson changes "$NEW_CHANGES" '{hasNew:true, newChanges:$changes}'
|
||||
182
skills/capmetro-monitor/scripts/monitor-route5.js
Normal file
182
skills/capmetro-monitor/scripts/monitor-route5.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// Route 5 bus monitor — parses GTFS-RT feed for real-time vehicle positions
|
||||
// Usage: node monitor-route5.js
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const protobuf = require('/tmp/gtfs-rt/node_modules/protobufjs');
|
||||
|
||||
const FEED_URL = 'https://data.texas.gov/download/i5qp-g5fd/application/octet-stream';
|
||||
const ROUTE_ID = '5';
|
||||
const FIRST_STOP = '5854'; // Anderson/Northcross
|
||||
const USER_STOP = '964'; // Woodrow/Choquette
|
||||
const TRAVEL_SECS = 446; // 7 min 26 sec from first stop to user stop
|
||||
|
||||
// GTFS-RT proto definition (minimal, inline)
|
||||
const PROTO = `
|
||||
syntax = "proto2";
|
||||
package transit_realtime;
|
||||
|
||||
message FeedMessage {
|
||||
required FeedHeader header = 1;
|
||||
repeated FeedEntity entity = 2;
|
||||
}
|
||||
message FeedHeader {
|
||||
required string gtfs_realtime_version = 1;
|
||||
optional uint64 timestamp = 2;
|
||||
}
|
||||
message FeedEntity {
|
||||
required string id = 1;
|
||||
optional TripUpdate trip_update = 3;
|
||||
optional VehiclePosition vehicle = 4;
|
||||
optional Alert alert = 5;
|
||||
}
|
||||
message TripUpdate {
|
||||
optional TripDescriptor trip = 1;
|
||||
optional VehicleDescriptor vehicle = 3;
|
||||
repeated StopTimeUpdate stop_time_update = 2;
|
||||
optional uint64 timestamp = 4;
|
||||
message StopTimeUpdate {
|
||||
optional uint32 stop_sequence = 1;
|
||||
optional string stop_id = 4;
|
||||
optional StopTimeEvent arrival = 2;
|
||||
optional StopTimeEvent departure = 3;
|
||||
enum ScheduleRelationship { SCHEDULED = 0; SKIPPED = 1; NO_DATA = 2; }
|
||||
optional ScheduleRelationship schedule_relationship = 5;
|
||||
}
|
||||
}
|
||||
message StopTimeEvent {
|
||||
optional int32 delay = 1;
|
||||
optional int64 time = 2;
|
||||
optional int32 uncertainty = 3;
|
||||
}
|
||||
message VehiclePosition {
|
||||
optional TripDescriptor trip = 1;
|
||||
optional VehicleDescriptor vehicle = 8;
|
||||
optional Position position = 2;
|
||||
optional uint32 current_stop_sequence = 3;
|
||||
optional string stop_id = 7;
|
||||
enum VehicleStopStatus { INCOMING_AT = 0; STOPPED_AT = 1; IN_TRANSIT_TO = 2; }
|
||||
optional VehicleStopStatus current_status = 4;
|
||||
optional uint64 timestamp = 5;
|
||||
enum CongestionLevel { UNKNOWN = 0; RUNNING_SMOOTHLY = 1; STOP_AND_GO = 2; CONGESTION = 3; SEVERE_CONGESTION = 4; }
|
||||
optional CongestionLevel congestion_level = 6;
|
||||
enum OccupancyStatus { EMPTY = 0; MANY_SEATS = 1; FEW_SEATS = 2; STANDING_ROOM = 3; CRUSHED = 4; FULL = 5; NOT_ACCEPTING = 6; }
|
||||
optional OccupancyStatus occupancy_status = 9;
|
||||
}
|
||||
message TripDescriptor {
|
||||
optional string trip_id = 1;
|
||||
optional string route_id = 5;
|
||||
optional uint32 direction_id = 6;
|
||||
optional string start_time = 2;
|
||||
optional string start_date = 3;
|
||||
enum ScheduleRelationship { SCHEDULED = 0; ADDED = 1; UNSCHEDULED = 2; CANCELED = 3; }
|
||||
optional ScheduleRelationship schedule_relationship = 4;
|
||||
}
|
||||
message VehicleDescriptor {
|
||||
optional string id = 1;
|
||||
optional string label = 2;
|
||||
optional string license_plate = 3;
|
||||
}
|
||||
message Position {
|
||||
required float latitude = 1;
|
||||
required float longitude = 2;
|
||||
optional float bearing = 3;
|
||||
optional double odometer = 4;
|
||||
optional float speed = 5;
|
||||
}
|
||||
message Alert {
|
||||
repeated TimeRange active_period = 1;
|
||||
repeated EntitySelector informed_entity = 5;
|
||||
optional TranslatedString header_text = 10;
|
||||
optional TranslatedString description_text = 11;
|
||||
}
|
||||
message TimeRange { optional uint64 start = 1; optional uint64 end = 2; }
|
||||
message EntitySelector { optional string agency_id = 1; optional string route_id = 3; optional TripDescriptor trip = 4; optional string stop_id = 6; }
|
||||
message TranslatedString { repeated Translation translation = 1; message Translation { required string text = 1; optional string language = 2; } }
|
||||
`;
|
||||
|
||||
function fetch(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = protobuf.parse(PROTO, { keepCase: true }).root;
|
||||
const FeedMessage = root.lookupType('transit_realtime.FeedMessage');
|
||||
|
||||
const buf = await fetch(FEED_URL);
|
||||
const feed = FeedMessage.decode(buf);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cst = new Date((now - 6*3600) * 1000); // CST offset
|
||||
const timeStr = cst.toISOString().replace('T', ' ').substring(0, 19) + ' CST';
|
||||
|
||||
// Filter Route 5 vehicles
|
||||
const vehicles = feed.entity
|
||||
.filter(e => e.vehicle && e.vehicle.trip && e.vehicle.trip.route_id === ROUTE_ID)
|
||||
.map(e => {
|
||||
const v = e.vehicle;
|
||||
const age = now - (v.timestamp?.low || v.timestamp || 0);
|
||||
const status = ['INCOMING_AT', 'STOPPED_AT', 'IN_TRANSIT_TO'][v.current_status] || 'UNKNOWN';
|
||||
return {
|
||||
vehicleId: v.vehicle?.label || v.vehicle?.id || 'unknown',
|
||||
tripId: v.trip?.trip_id,
|
||||
directionId: v.trip?.direction_id,
|
||||
stopId: v.stop_id,
|
||||
stopSequence: v.current_stop_sequence,
|
||||
status,
|
||||
lat: v.position?.latitude,
|
||||
lon: v.position?.longitude,
|
||||
speed: v.position?.speed,
|
||||
bearing: v.position?.bearing,
|
||||
ageSec: age,
|
||||
timestamp: v.timestamp
|
||||
};
|
||||
});
|
||||
|
||||
// Filter Route 5 trip updates
|
||||
const tripUpdates = feed.entity
|
||||
.filter(e => e.trip_update && e.trip_update.trip && e.trip_update.trip.route_id === ROUTE_ID)
|
||||
.map(e => {
|
||||
const tu = e.trip_update;
|
||||
const userStopUpdate = tu.stop_time_update?.find(s => s.stop_id === USER_STOP);
|
||||
const firstStopUpdate = tu.stop_time_update?.find(s => s.stop_id === FIRST_STOP);
|
||||
return {
|
||||
tripId: tu.trip?.trip_id,
|
||||
directionId: tu.trip?.direction_id,
|
||||
vehicleId: tu.vehicle?.label || tu.vehicle?.id,
|
||||
userStopDelay: userStopUpdate?.departure?.delay || userStopUpdate?.arrival?.delay || null,
|
||||
userStopTime: userStopUpdate?.arrival?.time || userStopUpdate?.departure?.time || null,
|
||||
firstStopDelay: firstStopUpdate?.departure?.delay || null,
|
||||
firstStopTime: firstStopUpdate?.departure?.time || null,
|
||||
totalStops: tu.stop_time_update?.length || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Eastbound (direction 0) only
|
||||
const ebVehicles = vehicles.filter(v => v.directionId === 0);
|
||||
const ebUpdates = tripUpdates.filter(t => t.directionId === 0);
|
||||
|
||||
const result = {
|
||||
timestamp: timeStr,
|
||||
feedTimestamp: feed.header?.timestamp?.toString(),
|
||||
route5_eastbound: {
|
||||
activeVehicles: ebVehicles.length,
|
||||
vehicles: ebVehicles,
|
||||
tripUpdates: ebUpdates.filter(t => t.userStopDelay !== null || t.userStopTime !== null)
|
||||
},
|
||||
route5_all: {
|
||||
totalVehicles: vehicles.length,
|
||||
totalTripUpdates: tripUpdates.length
|
||||
}
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch(e => console.error(JSON.stringify({ error: e.message })));
|
||||
89
skills/capmetro-monitor/scripts/monitor.sh
Executable file
89
skills/capmetro-monitor/scripts/monitor.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# Route 5 smart monitor — determines direction by time of day, launches background watcher
|
||||
# Usage: bash monitor.sh [channel_id]
|
||||
# Before 11 AM CST → Eastbound (morning commute)
|
||||
# After 11 AM CST → Westbound (evening commute)
|
||||
set -eo pipefail
|
||||
|
||||
CHANNEL="${1:-1467247377743347953}"
|
||||
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
|
||||
|
||||
STATE_DIR="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory"
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
NOW=$(date +%s)
|
||||
CST_HOUR=$(TZ=America/Chicago date +%H)
|
||||
|
||||
if [ "$CST_HOUR" -lt 11 ]; then
|
||||
DIRECTION=0
|
||||
DIR_NAME="Eastbound"
|
||||
FIRST_STOP="5854"
|
||||
FIRST_STOP_NAME="Anderson/Northcross"
|
||||
USER_STOP="964"
|
||||
USER_STOP_NAME="Woodrow/Choquette"
|
||||
TRAVEL_FIRST_TO_USER=446 # 7m26s
|
||||
WALK_LEAD=0 # already near stop
|
||||
else
|
||||
DIRECTION=1
|
||||
DIR_NAME="Westbound"
|
||||
FIRST_STOP="4606"
|
||||
FIRST_STOP_NAME="Tannehill/Webberville"
|
||||
USER_STOP="5499"
|
||||
USER_STOP_NAME="6th/West"
|
||||
TRAVEL_FIRST_TO_USER=2384 # 39m44s
|
||||
WALK_LEAD=900 # 15 min walk from office
|
||||
HOME_STOP="1072"
|
||||
HOME_STOP_NAME="Woodrow/Dwyce"
|
||||
TRAVEL_USER_TO_HOME=1344 # 22m24s
|
||||
fi
|
||||
|
||||
# Find next departure from first stop
|
||||
TU=$(curl -sL --max-time 10 "$TU_URL" 2>/dev/null)
|
||||
NEXT=$(echo "$TU" | jq --arg dir "$DIRECTION" --arg fs "$FIRST_STOP" --arg now "$NOW" '
|
||||
[.entity[] |
|
||||
select(.tripUpdate.trip.routeId == "5" and (.tripUpdate.trip.directionId | tostring) == $dir) |
|
||||
{
|
||||
tripId: .tripUpdate.trip.tripId,
|
||||
depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == $fs) | (.departure.time // .arrival.time)] | .[0])
|
||||
}
|
||||
] | [.[] | select(.depart != null and (.depart | tonumber) > ($now | tonumber))]
|
||||
| sort_by(.depart | tonumber) | .[0]' 2>/dev/null)
|
||||
|
||||
TRIP_ID=$(echo "$NEXT" | jq -r '.tripId // empty')
|
||||
DEPART_TS=$(echo "$NEXT" | jq -r '.depart // empty')
|
||||
|
||||
if [ -z "$TRIP_ID" ] || [ -z "$DEPART_TS" ]; then
|
||||
echo '{"ok":false,"error":"No upcoming Route 5 departures found"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEPART_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
|
||||
MINS_AWAY=$(( (DEPART_TS - NOW) / 60 ))
|
||||
|
||||
# Export config for the watcher
|
||||
export DIRECTION DIR_NAME FIRST_STOP FIRST_STOP_NAME USER_STOP USER_STOP_NAME
|
||||
export TRAVEL_FIRST_TO_USER WALK_LEAD TRIP_ID DEPART_TS CHANNEL
|
||||
export HOME_STOP HOME_STOP_NAME TRAVEL_USER_TO_HOME
|
||||
|
||||
echo "{\"ok\":true,\"direction\":\"$DIR_NAME\",\"tripId\":\"$TRIP_ID\",\"firstStopDepart\":\"$DEPART_CST\",\"minsUntilDepart\":$MINS_AWAY,\"userStop\":\"$USER_STOP_NAME\"}"
|
||||
|
||||
# Kill any existing watcher
|
||||
PID_FILE="$STATE_DIR/watcher.pid"
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
OLD_PID=$(cat "$PID_FILE" 2>/dev/null)
|
||||
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
# Dynamic timeout: time until departure + 5 min buffer for delays
|
||||
WAIT_SECS=$(( DEPART_TS - NOW + 300 ))
|
||||
[ "$WAIT_SECS" -lt 900 ] && WAIT_SECS=900 # minimum 15 min
|
||||
|
||||
# Calculate MAX_POLLS for the watcher (poll every 20s)
|
||||
export MAX_POLLS=$(( WAIT_SECS / 20 ))
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
nohup timeout "$WAIT_SECS" bash "$SCRIPT_DIR/watch-departure-v2.sh" > /dev/null 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
58
skills/capmetro-monitor/scripts/route5-status.sh
Executable file
58
skills/capmetro-monitor/scripts/route5-status.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Route 5 on-demand monitor — checks real-time bus status
|
||||
# Usage: bash route5-status.sh
|
||||
set -eo pipefail
|
||||
|
||||
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
|
||||
TU_URL="https://data.texas.gov/download/mqtr-wwpy/application%2Fjson"
|
||||
|
||||
# Fetch both feeds in parallel
|
||||
VP=$(curl -sL --max-time 10 "$VP_URL") &
|
||||
TU=$(curl -sL --max-time 10 "$TU_URL") &
|
||||
VP=$(curl -sL --max-time 10 "$VP_URL")
|
||||
TU=$(curl -sL --max-time 10 "$TU_URL")
|
||||
|
||||
NOW=$(date +%s)
|
||||
|
||||
# Route 5 eastbound vehicles
|
||||
VEHICLES=$(echo "$VP" | jq -c '[.entity[] | select(.vehicle.trip.routeId == "5" and .vehicle.trip.directionId == 0) | {
|
||||
vehicleId: .vehicle.vehicle.label,
|
||||
tripId: .vehicle.trip.tripId,
|
||||
stopId: .vehicle.stopId,
|
||||
status: .vehicle.currentStatus,
|
||||
lat: .vehicle.position.latitude,
|
||||
lon: .vehicle.position.longitude,
|
||||
speed: .vehicle.position.speed
|
||||
}]')
|
||||
|
||||
# Route 5 eastbound trip updates
|
||||
TRIPS=$(echo "$TU" | jq -c --arg now "$NOW" '[.entity[] | select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) | {
|
||||
tripId: .tripUpdate.trip.tripId,
|
||||
firstStopDepart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0]),
|
||||
userStopArrive: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.time // .departure.time)] | .[0]),
|
||||
userStopDelay: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "964") | (.arrival.delay // .departure.delay)] | .[0])
|
||||
}] | [.[] | select(.firstStopDepart != null)] | sort_by(.firstStopDepart)')
|
||||
|
||||
# Format output
|
||||
jq -n \
|
||||
--argjson vehicles "$VEHICLES" \
|
||||
--argjson trips "$TRIPS" \
|
||||
--arg now "$NOW" '{
|
||||
timestampUTC: ($now | tonumber | todate),
|
||||
timestampCST: (($now | tonumber - 21600) | todate),
|
||||
route: "5 - Woodrow/Lamar",
|
||||
direction: "Eastbound → Downtown",
|
||||
firstStop: "Anderson/Northcross (5854)",
|
||||
userStop: "Woodrow/Choquette (964)",
|
||||
scheduledTravel: "7m 26s",
|
||||
activeVehicles: ($vehicles | length),
|
||||
vehicles: $vehicles,
|
||||
nextDepartures: [$trips[] | {
|
||||
tripId,
|
||||
firstStopDepart: (if .firstStopDepart then (.firstStopDepart | tonumber | todate) else null end),
|
||||
userStopArrive: (if .userStopArrive then (.userStopArrive | tonumber | todate) else null end),
|
||||
delaySec: .userStopDelay,
|
||||
minsUntilDepart: (if .firstStopDepart then (((.firstStopDepart | tonumber) - ($now | tonumber)) / 60 | floor) else null end),
|
||||
minsUntilArrive: (if .userStopArrive then (((.userStopArrive | tonumber) - ($now | tonumber)) / 60 | floor) else null end)
|
||||
}]
|
||||
}'
|
||||
84
skills/capmetro-monitor/scripts/watch-departure-v2.sh
Executable file
84
skills/capmetro-monitor/scripts/watch-departure-v2.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# Route 5 departure watcher v2 — direction-aware, runs in background
|
||||
# Called by monitor.sh with env vars set
|
||||
set -eo pipefail
|
||||
|
||||
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
|
||||
TOKEN=$(printenv DISCORD_BOT_TOKEN)
|
||||
MAX_POLLS=${MAX_POLLS:-40}
|
||||
POLL_INTERVAL=20
|
||||
PID_FILE="/home/node/.openclaw/workspace/skills/capmetro-monitor/memory/watcher.pid"
|
||||
|
||||
# Clean up PID file on exit (success, timeout, or signal)
|
||||
cleanup() { rm -f "$PID_FILE" 2>/dev/null; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
for i in $(seq 1 $MAX_POLLS); do
|
||||
VP=$(curl -sL --max-time 8 "$VP_URL" 2>/dev/null)
|
||||
|
||||
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
|
||||
|
||||
if [ -z "$VEHICLE" ]; then
|
||||
sleep $POLL_INTERVAL
|
||||
continue
|
||||
fi
|
||||
|
||||
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
|
||||
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
|
||||
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
|
||||
|
||||
# Bus has left the first stop
|
||||
if [ "$STOP_ID" != "$FIRST_STOP" ] || { [ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]; }; then
|
||||
ACTUAL_TS=$(date +%s)
|
||||
ACTUAL_CST=$(TZ=America/Chicago date +"%I:%M %p")
|
||||
DELAY=$((ACTUAL_TS - DEPART_TS))
|
||||
DELAY_MIN=$((DELAY / 60))
|
||||
|
||||
if [ "$DELAY_MIN" -le 0 ]; then
|
||||
STATUS_ICON="🟢"
|
||||
STATUS_TEXT="On time"
|
||||
elif [ "$DELAY_MIN" -le 2 ]; then
|
||||
STATUS_ICON="🟡"
|
||||
STATUS_TEXT="~${DELAY_MIN}min late"
|
||||
else
|
||||
STATUS_ICON="🔴"
|
||||
STATUS_TEXT="${DELAY_MIN}min late"
|
||||
fi
|
||||
|
||||
# Calculate ETAs
|
||||
ETA_USER=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER))
|
||||
ETA_USER_CST=$(TZ=America/Chicago date -d "@$ETA_USER" +"%I:%M %p")
|
||||
|
||||
if [ "$DIRECTION" = "0" ]; then
|
||||
# EASTBOUND: simple alert
|
||||
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n📍 ETA at %s: **%s**' \
|
||||
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
|
||||
"$STATUS_ICON" "$STATUS_TEXT" "$USER_STOP_NAME" "$ETA_USER_CST")
|
||||
else
|
||||
# WESTBOUND: include leave-office time and home ETA
|
||||
LEAVE_TS=$((ETA_USER - WALK_LEAD))
|
||||
LEAVE_CST=$(TZ=America/Chicago date -d "@$LEAVE_TS" +"%I:%M %p")
|
||||
ETA_HOME=$((ACTUAL_TS + TRAVEL_FIRST_TO_USER + TRAVEL_USER_TO_HOME))
|
||||
ETA_HOME_CST=$(TZ=America/Chicago date -d "@$ETA_HOME" +"%I:%M %p")
|
||||
|
||||
MSG=$(printf '🚌 **Route 5 %s Departed!**\nBus %s left %s at %s\n%s %s\n\n🚶 **Leave office by %s** (15 min walk)\n📍 Bus arrives %s: **%s**\n🏠 Home (%s): **%s**' \
|
||||
"$DIR_NAME" "$VEH_ID" "$FIRST_STOP_NAME" "$ACTUAL_CST" \
|
||||
"$STATUS_ICON" "$STATUS_TEXT" \
|
||||
"$LEAVE_CST" "$USER_STOP_NAME" "$ETA_USER_CST" \
|
||||
"$HOME_STOP_NAME" "$ETA_HOME_CST")
|
||||
fi
|
||||
|
||||
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "$MSG" '{content: $c}')" \
|
||||
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep $POLL_INTERVAL
|
||||
done
|
||||
|
||||
# Timeout
|
||||
SCHED_CST=$(TZ=America/Chicago date -d "@$DEPART_TS" +"%I:%M %p" 2>/dev/null)
|
||||
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"content\":\"⚠️ Route 5 $DIR_NAME watcher timed out — could not confirm $SCHED_CST departure.\"}" \
|
||||
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
|
||||
103
skills/capmetro-monitor/scripts/watch-departure.sh
Executable file
103
skills/capmetro-monitor/scripts/watch-departure.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# Route 5 departure watcher — runs in background, posts to Discord when bus departs
|
||||
# Usage: bash watch-departure.sh <scheduled_time_UTC> [channel_id]
|
||||
# Example: bash watch-departure.sh "2026-02-12T13:30:00Z" 1467247377743347953
|
||||
set -eo pipefail
|
||||
|
||||
SCHED_DEPART="$1"
|
||||
CHANNEL="${2:-1467247377743347953}" # Default: DM channel
|
||||
VP_URL="https://data.texas.gov/download/cuc7-ywmd/application%2Fjson"
|
||||
FIRST_STOP="5854"
|
||||
USER_STOP="964"
|
||||
TOKEN=$(printenv DISCORD_BOT_TOKEN)
|
||||
MAX_POLLS=40 # ~13 minutes max watch time
|
||||
POLL_INTERVAL=20 # seconds between polls
|
||||
|
||||
# Find the trip matching this scheduled departure
|
||||
find_trip() {
|
||||
local TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
|
||||
echo "$TU" | jq -r --arg sched "$SCHED_DEPART" '.entity[] |
|
||||
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
|
||||
select([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") |
|
||||
((.departure.time // .arrival.time) | tonumber)] | .[0] == ($sched | sub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) |
|
||||
.tripUpdate.trip.tripId' 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
# Convert ISO to epoch
|
||||
sched_epoch() {
|
||||
date -d "$SCHED_DEPART" +%s 2>/dev/null || date -u -d "${SCHED_DEPART%Z}" +%s 2>/dev/null
|
||||
}
|
||||
|
||||
SCHED_TS=$(sched_epoch)
|
||||
SCHED_CST=$(TZ=America/Chicago date -d "@$SCHED_TS" +"%I:%M %p" 2>/dev/null)
|
||||
|
||||
# Find the trip ID
|
||||
TRIP_ID=$(find_trip)
|
||||
if [ -z "$TRIP_ID" ]; then
|
||||
# Fallback: find closest eastbound trip
|
||||
TU=$(curl -sL --max-time 10 "https://data.texas.gov/download/mqtr-wwpy/application%2Fjson" 2>/dev/null)
|
||||
TRIP_ID=$(echo "$TU" | jq -r --arg ts "$SCHED_TS" '[.entity[] |
|
||||
select(.tripUpdate.trip.routeId == "5" and .tripUpdate.trip.directionId == 0) |
|
||||
{tripId: .tripUpdate.trip.tripId, depart: ([.tripUpdate.stopTimeUpdate[] | select(.stopId == "5854") | (.departure.time // .arrival.time)] | .[0] | tonumber)}] |
|
||||
sort_by((. .depart - ($ts | tonumber)) | fabs) | .[0].tripId' 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$TRIP_ID" ]; then
|
||||
# Post error
|
||||
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"content\":\"⚠️ Could not find Route 5 trip for $SCHED_CST departure.\"}" \
|
||||
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Poll until departure
|
||||
for i in $(seq 1 $MAX_POLLS); do
|
||||
VP=$(curl -sL --max-time 8 "https://data.texas.gov/download/cuc7-ywmd/application%2Fjson" 2>/dev/null)
|
||||
|
||||
VEHICLE=$(echo "$VP" | jq -c ".entity[] | select(.vehicle.trip.tripId == \"$TRIP_ID\")" 2>/dev/null)
|
||||
|
||||
if [ -z "$VEHICLE" ]; then
|
||||
sleep $POLL_INTERVAL
|
||||
continue
|
||||
fi
|
||||
|
||||
STOP_ID=$(echo "$VEHICLE" | jq -r '.vehicle.stopId')
|
||||
STATUS=$(echo "$VEHICLE" | jq -r '.vehicle.currentStatus')
|
||||
SPEED=$(echo "$VEHICLE" | jq -r '.vehicle.position.speed')
|
||||
VEH_ID=$(echo "$VEHICLE" | jq -r '.vehicle.vehicle.label')
|
||||
|
||||
# Bus has left the first stop
|
||||
if [ "$STOP_ID" != "$FIRST_STOP" ] || ([ "$STATUS" = "IN_TRANSIT_TO" ] && [ "$STOP_ID" != "$FIRST_STOP" ]); then
|
||||
DEPART_TS=$(date +%s)
|
||||
DEPART_CST=$(TZ=America/Chicago date +"%I:%M:%S %p")
|
||||
DELAY=$((DEPART_TS - SCHED_TS))
|
||||
DELAY_MIN=$((DELAY / 60))
|
||||
|
||||
# Calculate ETA at user stop (7m26s from first stop)
|
||||
ETA_TS=$((DEPART_TS + 446))
|
||||
ETA_CST=$(TZ=America/Chicago date -d "@$ETA_TS" +"%I:%M %p" 2>/dev/null)
|
||||
|
||||
if [ "$DELAY_MIN" -le 0 ]; then
|
||||
STATUS_MSG="🟢 On time"
|
||||
elif [ "$DELAY_MIN" -le 2 ]; then
|
||||
STATUS_MSG="🟡 ~${DELAY_MIN}min late"
|
||||
else
|
||||
STATUS_MSG="🔴 ${DELAY_MIN}min late"
|
||||
fi
|
||||
|
||||
MSG="🚌 **Route 5 Departed!**\nBus ${VEH_ID} left Anderson/Northcross at ${DEPART_CST}\nScheduled: ${SCHED_CST} | ${STATUS_MSG}\n📍 ETA at Woodrow/Choquette: **${ETA_CST}**"
|
||||
|
||||
CONTENT=$(printf "$MSG")
|
||||
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "$CONTENT" '{content: $c}')" \
|
||||
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep $POLL_INTERVAL
|
||||
done
|
||||
|
||||
# Timeout — bus never departed (or we missed it)
|
||||
curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"content\":\"⚠️ Route 5 watcher timed out — could not confirm $SCHED_CST departure from Anderson/Northcross.\"}" \
|
||||
"https://discord.com/api/v10/channels/$CHANNEL/messages" > /dev/null
|
||||
127
skills/github-notifications/SKILL.md
Normal file
127
skills/github-notifications/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: github-notifications
|
||||
description: Check GitHub notifications for PR activity and major releases. Filters for PRs where user is mentioned/author, and major releases (v*.0.0) plus all Mirrowel/LLM-API-Key-Proxy dev builds. Tracks state to avoid duplicate alerts. Use for periodic GitHub notification checking via cron jobs or manual checks.
|
||||
---
|
||||
|
||||
# GitHub Notifications Checker
|
||||
|
||||
Efficiently check GitHub notifications with smart filtering and state tracking.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Fetches notifications** via GitHub CLI (`gh api`)
|
||||
2. **Filters intelligently:**
|
||||
- PRs where you're mentioned, author, or review requested
|
||||
- Major releases (v*.0.0 format)
|
||||
- ALL releases from `Mirrowel/LLM-API-Key-Proxy` (including dev builds)
|
||||
- Excludes: rc/pre/beta/alpha/nightly releases
|
||||
3. **Tracks state** to avoid duplicate notifications
|
||||
4. **Returns JSON** for easy parsing
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Check
|
||||
|
||||
```bash
|
||||
bash skills/github-notifications/scripts/check.sh
|
||||
```
|
||||
|
||||
**Output when nothing new:**
|
||||
```json
|
||||
{"hasNew":false}
|
||||
```
|
||||
|
||||
**Output with new activity:**
|
||||
```json
|
||||
{
|
||||
"hasNew": true,
|
||||
"newPRs": [
|
||||
{
|
||||
"repo": "openclaw/openclaw",
|
||||
"title": "feat: Add cron silent mode",
|
||||
"url": "https://api.github.com/repos/openclaw/openclaw/pulls/1234",
|
||||
"updated": "2026-02-03T14:30:00Z",
|
||||
"reason": "mention",
|
||||
"id": "openclaw/openclaw#feat: Add cron silent mode"
|
||||
}
|
||||
],
|
||||
"newReleases": [
|
||||
{
|
||||
"repo": "some/repo",
|
||||
"title": "v2.0.0",
|
||||
"updated": "2026-02-03T12:00:00Z",
|
||||
"id": "some/repo@v2.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `STATE_FILE` - Path to state tracking file (default: `memory/github-check-state.json`)
|
||||
- `WORKSPACE` - Workspace directory (default: `/home/node/.openclaw/workspace`)
|
||||
|
||||
### State Tracking
|
||||
|
||||
State is stored in `memory/github-check-state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"lastCheck": "2026-02-03T14:00:00Z",
|
||||
"seenPRs": ["repo#PR Title", ...],
|
||||
"seenReleases": ["repo@v1.0.0", ...]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Cron
|
||||
|
||||
This skill is designed to work with OpenClaw cron jobs. The script handles all filtering and state management, only calling the LLM when there's actual content to summarize.
|
||||
|
||||
**Recommended cron setup:**
|
||||
|
||||
1. Script runs periodically (every 4 hours)
|
||||
2. If `hasNew: false`, script exits silently - no LLM call, no message
|
||||
3. If `hasNew: true`, cron job can format the summary and deliver it
|
||||
|
||||
This approach:
|
||||
- ✅ Saves tokens (no LLM call when nothing new)
|
||||
- ✅ Handles errors gracefully (GitHub API failures logged)
|
||||
- ✅ Avoids duplicate notifications (state tracking)
|
||||
- ✅ Faster execution (no LLM parsing)
|
||||
|
||||
## Error Handling
|
||||
|
||||
If GitHub API fails, returns:
|
||||
```json
|
||||
{
|
||||
"error": "GitHub API failed",
|
||||
"details": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Check for `.error` field in output to detect failures.
|
||||
|
||||
## Auto-Dismiss Low-Value Notifications
|
||||
|
||||
```bash
|
||||
# Dry run (see what would be dismissed)
|
||||
DRY_RUN=true bash skills/github-notifications/scripts/auto-dismiss.sh
|
||||
|
||||
# Actually dismiss
|
||||
bash skills/github-notifications/scripts/auto-dismiss.sh
|
||||
```
|
||||
|
||||
**Auto-dismisses:**
|
||||
- Title matches: nightly, preview, checkpoint, pre-release, canary, alpha, beta, snapshot
|
||||
- Releases with empty release notes
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{"dismissed":3,"checked":12}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- `gh` CLI authenticated
|
||||
- `jq` for JSON parsing
|
||||
- GitHub token with `notifications` scope
|
||||
74
skills/github-notifications/scripts/auto-dismiss.sh
Executable file
74
skills/github-notifications/scripts/auto-dismiss.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# Auto-dismiss GitHub notifications matching certain patterns
|
||||
# - Nightlies, previews, checkpoints, rc, etc (by title pattern)
|
||||
# - Releases with no release notes
|
||||
# Exempts specified repos from auto-dismiss
|
||||
#
|
||||
# Dismiss = PATCH (read) + DELETE thread + DELETE subscription
|
||||
|
||||
set -e
|
||||
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
|
||||
# Repos exempt from auto-dismiss (always show these)
|
||||
EXEMPT_REPOS="Mirrowel/LLM-API-Key-Proxy|b3nw/LLM-API-Key-Proxy|pedramamini/Maestro"
|
||||
|
||||
# Patterns to auto-dismiss (case-insensitive)
|
||||
DISMISS_PATTERNS="nightly|preview|checkpoint|pre-release|canary|alpha|beta|snapshot|-rc\.|rc[0-9]"
|
||||
|
||||
# Get all unread notifications
|
||||
NOTIFICATIONS=$(gh api /notifications 2>/dev/null || echo "[]")
|
||||
|
||||
if [ "$NOTIFICATIONS" = "[]" ] || [ -z "$NOTIFICATIONS" ]; then
|
||||
echo '{"dismissed":0,"checked":0}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DISMISSED=0
|
||||
TOTAL=$(echo "$NOTIFICATIONS" | jq 'length')
|
||||
|
||||
while read -r notif; do
|
||||
ID=$(echo "$notif" | jq -r '.id')
|
||||
TITLE=$(echo "$notif" | jq -r '.subject.title')
|
||||
TYPE=$(echo "$notif" | jq -r '.subject.type')
|
||||
URL=$(echo "$notif" | jq -r '.subject.url')
|
||||
REPO=$(echo "$notif" | jq -r '.repository.full_name')
|
||||
|
||||
# Skip exempt repos
|
||||
if echo "$REPO" | grep -qiE "$EXEMPT_REPOS"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
SHOULD_DISMISS=false
|
||||
REASON=""
|
||||
|
||||
# Check title patterns
|
||||
if echo "$TITLE" | grep -qiE "$DISMISS_PATTERNS"; then
|
||||
SHOULD_DISMISS=true
|
||||
REASON="title_pattern"
|
||||
fi
|
||||
|
||||
# Check releases with no notes
|
||||
if [ "$TYPE" = "Release" ] && [ "$SHOULD_DISMISS" = "false" ]; then
|
||||
RELEASE_BODY=$(gh api "$URL" --jq '.body // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$RELEASE_BODY" ] || [ "$RELEASE_BODY" = "null" ]; then
|
||||
SHOULD_DISMISS=true
|
||||
REASON="empty_release_notes"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$SHOULD_DISMISS" = "true" ]; then
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "Would dismiss: [$REPO] $TITLE ($REASON)" >&2
|
||||
else
|
||||
# Full dismiss: mark read + delete thread + delete subscription
|
||||
gh api -X PATCH "/notifications/threads/$ID" 2>/dev/null || true
|
||||
gh api -X DELETE "/notifications/threads/$ID" 2>/dev/null || true
|
||||
gh api -X DELETE "/notifications/threads/$ID/subscription" 2>/dev/null || true
|
||||
echo "Dismissed: [$REPO] $TITLE ($REASON)" >&2
|
||||
fi
|
||||
DISMISSED=$((DISMISSED + 1))
|
||||
fi
|
||||
done < <(echo "$NOTIFICATIONS" | jq -c '.[]')
|
||||
|
||||
echo "{\"dismissed\":$DISMISSED,\"checked\":$TOTAL}"
|
||||
145
skills/github-notifications/scripts/check.sh
Executable file
145
skills/github-notifications/scripts/check.sh
Executable file
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# GitHub Notifications Checker
|
||||
# Filters PRs and releases, tracks state, returns JSON summary
|
||||
|
||||
set -e
|
||||
|
||||
STATE_FILE="${STATE_FILE:-memory/github-check-state.json}"
|
||||
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# Initialize state file if missing
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
echo '{"lastCheck":"1970-01-01T00:00:00Z","seenPRs":[],"seenReleases":[]}' > "$STATE_FILE"
|
||||
fi
|
||||
|
||||
# Load last check time
|
||||
LAST_CHECK=$(jq -r '.lastCheck' "$STATE_FILE")
|
||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Fetch notifications (PRs only)
|
||||
if ! PR_DATA=$(gh api 'notifications?all=true&per_page=100' 2>&1); then
|
||||
echo '{"error":"GitHub API failed","details":"'"${PR_DATA//\"/\\\"}"'"}' | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Filter PRs where user is mentioned/author/review requested/subscribed
|
||||
FILTERED_PRS=$(echo "$PR_DATA" | jq -r '[
|
||||
.[] |
|
||||
select(.subject.type == "PullRequest") |
|
||||
select(.reason == "mention" or .reason == "author" or .reason == "review_requested" or .reason == "subscribed") |
|
||||
{
|
||||
repo: .repository.full_name,
|
||||
title: .subject.title,
|
||||
url: .subject.url,
|
||||
updated: .updated_at,
|
||||
reason: .reason,
|
||||
id: (.repository.full_name + "#" + .subject.title)
|
||||
}
|
||||
]')
|
||||
|
||||
# Filter releases
|
||||
RELEASE_DATA=$(echo "$PR_DATA" | jq -r '[
|
||||
.[] |
|
||||
select(.subject.type == "Release") |
|
||||
{
|
||||
repo: .repository.full_name,
|
||||
title: .subject.title,
|
||||
updated: .updated_at,
|
||||
reason: .reason,
|
||||
id: (.repository.full_name + "@" + .subject.title)
|
||||
}
|
||||
]')
|
||||
|
||||
# Filter releases:
|
||||
# - include subscribed releases (user-requested)
|
||||
# - keep legacy inclusion for major releases + whitelist repos
|
||||
# - ignore dev/pre-release markers for non-whitelist repos
|
||||
# - additionally ignore GitHub prerelease=true for non-whitelist repos
|
||||
# - whitelist repos always pass through
|
||||
FILTERED_RELEASES=$(echo "$RELEASE_DATA" | jq -r '[
|
||||
.[] |
|
||||
. as $r |
|
||||
($r.repo == "Mirrowel/LLM-API-Key-Proxy" or $r.repo == "openclaw/openclaw" or $r.repo == "anomalyco/opencode") as $whitelisted |
|
||||
select(
|
||||
($r.reason == "subscribed") or
|
||||
$whitelisted or
|
||||
($r.title | test("^v[0-9]+\\.0\\.0"))
|
||||
) |
|
||||
select(
|
||||
$whitelisted or
|
||||
(($r.title | ascii_downcase) | test("(rc|pre|beta|alpha|nightly|dev|exp|canary|snapshot)") | not)
|
||||
)
|
||||
]')
|
||||
|
||||
# Enrich release candidates with GitHub prerelease flag (best-effort).
|
||||
# Notifications payload lacks prerelease metadata, so look up each candidate by repo+title.
|
||||
# For non-whitelisted repos, exclude prerelease=true.
|
||||
FILTERED_RELEASES=$(echo "$FILTERED_RELEASES" | jq -c '.[]' | while read -r rel; do
|
||||
repo=$(echo "$rel" | jq -r '.repo')
|
||||
title=$(echo "$rel" | jq -r '.title')
|
||||
|
||||
whitelisted=false
|
||||
if [ "$repo" = "Mirrowel/LLM-API-Key-Proxy" ] || [ "$repo" = "openclaw/openclaw" ] || [ "$repo" = "anomalyco/opencode" ]; then
|
||||
whitelisted=true
|
||||
fi
|
||||
|
||||
# Whitelist bypasses prerelease metadata filtering.
|
||||
if [ "$whitelisted" = "true" ]; then
|
||||
echo "$rel"
|
||||
continue
|
||||
fi
|
||||
|
||||
# URL-encode tag (title) using jq for safety.
|
||||
tag_encoded=$(jq -nr --arg s "$title" '$s|@uri')
|
||||
|
||||
# If lookup fails, keep item (fail-open) to avoid dropping potentially important notifications.
|
||||
prerelease=$(gh api "repos/$repo/releases/tags/$tag_encoded" --jq '.prerelease' 2>/dev/null || echo "lookup_failed")
|
||||
|
||||
if [ "$prerelease" = "true" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$rel"
|
||||
done | jq -s '.')
|
||||
|
||||
# Load seen items
|
||||
SEEN_PRS=$(jq -r '.seenPRs // []' "$STATE_FILE")
|
||||
SEEN_RELEASES=$(jq -r '.seenReleases // []' "$STATE_FILE")
|
||||
|
||||
# Find new items
|
||||
NEW_PRS=$(echo "$FILTERED_PRS" | jq --argjson seen "$SEEN_PRS" '[
|
||||
.[] | select(.id as $id | $seen | index($id) | not)
|
||||
]')
|
||||
|
||||
NEW_RELEASES=$(echo "$FILTERED_RELEASES" | jq --argjson seen "$SEEN_RELEASES" '[
|
||||
.[] | select(.id as $id | $seen | index($id) | not)
|
||||
]')
|
||||
|
||||
# Count new items
|
||||
NEW_PR_COUNT=$(echo "$NEW_PRS" | jq 'length')
|
||||
NEW_RELEASE_COUNT=$(echo "$NEW_RELEASES" | jq 'length')
|
||||
|
||||
# Update state
|
||||
ALL_PR_IDS=$(echo "$FILTERED_PRS" | jq -r '[.[].id]')
|
||||
ALL_RELEASE_IDS=$(echo "$FILTERED_RELEASES" | jq -r '[.[].id]')
|
||||
|
||||
jq -n \
|
||||
--arg now "$NOW" \
|
||||
--argjson prIds "$ALL_PR_IDS" \
|
||||
--argjson relIds "$ALL_RELEASE_IDS" \
|
||||
'{lastCheck:$now, seenPRs:$prIds, seenReleases:$relIds}' \
|
||||
> "$STATE_FILE"
|
||||
|
||||
# Output result
|
||||
if [ "$NEW_PR_COUNT" -eq 0 ] && [ "$NEW_RELEASE_COUNT" -eq 0 ]; then
|
||||
echo '{"hasNew":false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Return new items
|
||||
jq -n \
|
||||
--argjson prs "$NEW_PRS" \
|
||||
--argjson releases "$NEW_RELEASES" \
|
||||
'{hasNew:true, newPRs:$prs, newReleases:$releases}'
|
||||
98
skills/github-notifications/scripts/cron-wrapper.sh
Executable file
98
skills/github-notifications/scripts/cron-wrapper.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Cron wrapper for GitHub notifications
|
||||
# 1. Auto-dismisses low-value notifications (nightlies, previews, empty releases)
|
||||
# 2. Checks remaining notifications and formats for human consumption
|
||||
|
||||
set -e
|
||||
|
||||
WORKSPACE="${WORKSPACE:-/home/node/.openclaw/workspace}"
|
||||
cd "$WORKSPACE"
|
||||
|
||||
# First: auto-dismiss low-value notifications
|
||||
bash skills/github-notifications/scripts/auto-dismiss.sh >/dev/null 2>&1 || true
|
||||
|
||||
# Then: run the checker
|
||||
RESULT=$(bash skills/github-notifications/scripts/check.sh)
|
||||
|
||||
# Check for errors
|
||||
if echo "$RESULT" | jq -e '.error' > /dev/null 2>&1; then
|
||||
ERROR_MSG=$(echo "$RESULT" | jq -r '.error')
|
||||
ERROR_DETAILS=$(echo "$RESULT" | jq -r '.details')
|
||||
echo "❌ **GitHub Check Failed**"
|
||||
echo ""
|
||||
echo "Error: $ERROR_MSG"
|
||||
echo "\`\`\`"
|
||||
echo "$ERROR_DETAILS" | head -20
|
||||
echo "\`\`\`"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if there's new activity
|
||||
HAS_NEW=$(echo "$RESULT" | jq -r '.hasNew')
|
||||
|
||||
if [ "$HAS_NEW" != "true" ]; then
|
||||
# Nothing new - stay completely silent (no output = no message)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Format and output the summary
|
||||
echo "🔔 **GitHub Activity Update**"
|
||||
echo ""
|
||||
|
||||
# Process PRs
|
||||
PR_COUNT=$(echo "$RESULT" | jq '.newPRs | length')
|
||||
if [ "$PR_COUNT" -gt 0 ]; then
|
||||
echo "**Pull Requests ($PR_COUNT new):**"
|
||||
echo "$RESULT" | jq -r '.newPRs[] | "- **\(.repo)** #\(.title)\n Updated: \(.updated) | Reason: \(.reason)"'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Process Releases
|
||||
RELEASE_COUNT=$(echo "$RESULT" | jq '.newReleases | length')
|
||||
if [ "$RELEASE_COUNT" -gt 0 ]; then
|
||||
echo "**Releases ($RELEASE_COUNT new):**"
|
||||
while IFS= read -r rel; do
|
||||
repo=$(echo "$rel" | jq -r '.repo')
|
||||
title=$(echo "$rel" | jq -r '.title')
|
||||
updated=$(echo "$rel" | jq -r '.updated')
|
||||
|
||||
echo "- **$repo** \`$title\`"
|
||||
echo " Released: $updated"
|
||||
|
||||
# Best-effort major changes summary from release body.
|
||||
# Use the release API URL directly from notifications/checker output when available.
|
||||
release_url=$(echo "$rel" | jq -r '.url // empty')
|
||||
body=""
|
||||
|
||||
if [ -n "$release_url" ]; then
|
||||
body=$(gh api "$release_url" --jq '.body' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Fallback only if direct release lookup failed and title might actually equal tag.
|
||||
if [ -z "$body" ] || [ "$body" = "null" ]; then
|
||||
tag_encoded=$(jq -nr --arg s "$title" '$s|@uri')
|
||||
body=$(gh api "repos/$repo/releases/tags/$tag_encoded" --jq '.body' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -n "$body" ] && [ "$body" != "null" ]; then
|
||||
summary=$(printf '%s\n' "$body" \
|
||||
| sed 's/\r$//' \
|
||||
| awk 'NF' \
|
||||
| grep -E '^(\- |\* |[0-9]+\.|## |### )' \
|
||||
| head -3 \
|
||||
| sed 's/^/ /')
|
||||
|
||||
if [ -z "$summary" ]; then
|
||||
summary=$(printf '%s\n' "$body" | awk 'NF{print; exit}' | cut -c1-240)
|
||||
[ -n "$summary" ] && summary=" $summary"
|
||||
fi
|
||||
|
||||
if [ -n "$summary" ]; then
|
||||
echo " Major changes:"
|
||||
echo "$summary"
|
||||
fi
|
||||
else
|
||||
echo " Major changes: release details unavailable from GitHub API."
|
||||
fi
|
||||
done < <(echo "$RESULT" | jq -c '.newReleases[]')
|
||||
fi
|
||||
105
skills/model-selector/SKILL.md
Normal file
105
skills/model-selector/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: model-selector
|
||||
description: Safely change an agent's primary and fallback models by validating IDs against the live LLM proxy model list. Use for model switches, fallback chain updates, and model-availability troubleshooting.
|
||||
---
|
||||
|
||||
# Model Selector
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Never edit config files directly; always use `openclaw config` commands.
|
||||
2. Validate model IDs against both `/v1/models` and local `openclaw.json` llm-proxy catalog before proposing changes.
|
||||
3. Keep at least 2 fallback models (unless user explicitly asks otherwise).
|
||||
4. Do not remove a primary model without setting a replacement.
|
||||
5. Use exact IDs from the model catalog; do not guess.
|
||||
6. Prefer provider diversity in fallbacks.
|
||||
7. Get explicit user approval before writing config.
|
||||
8. Treat `/model` as temporary; it creates per-session overrides.
|
||||
9. After backend default changes, clear session pins and reset active sessions.
|
||||
10. Always report back the exact `openclaw config` commands executed.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1) Fetch Available Models
|
||||
|
||||
```bash
|
||||
bash {baseDir}/scripts/list-models.sh
|
||||
bash {baseDir}/scripts/list-models.sh --providers
|
||||
```
|
||||
|
||||
### 2) Validate Candidate IDs
|
||||
|
||||
```bash
|
||||
bash {baseDir}/scripts/validate-model.sh "nvidia_nim/moonshotai/kimi-k2.5"
|
||||
```
|
||||
|
||||
### 3) Inspect Current Configuration
|
||||
|
||||
```bash
|
||||
bash {baseDir}/scripts/show-current.sh
|
||||
```
|
||||
|
||||
### 4) Apply Backend Model Changes
|
||||
|
||||
Preferred (comprehensive) flow:
|
||||
|
||||
```bash
|
||||
bash {baseDir}/scripts/switch-models.sh \
|
||||
--non-main-primary "nanogpt/zai-org/glm-5" \
|
||||
--non-main-fallbacks "lightning_ai/lightning-ai/kimi-k2.5,nanogpt/zai-org/glm-4.7" \
|
||||
--clear-session-pins \
|
||||
--pattern "gemini-3-flash"
|
||||
```
|
||||
|
||||
Legacy defaults-only flow (does not migrate runtime session pins by itself):
|
||||
|
||||
```bash
|
||||
# Primary only
|
||||
bash {baseDir}/scripts/update-model.sh --primary "nanogpt/moonshotai/kimi-k2.5"
|
||||
|
||||
# Fallbacks only
|
||||
bash {baseDir}/scripts/update-model.sh --fallbacks "nvidia_nim/moonshotai/kimi-k2.5,chutes/zai-org/GLM-5-TEE"
|
||||
|
||||
# Primary + fallbacks
|
||||
bash {baseDir}/scripts/update-model.sh \
|
||||
--primary "nanogpt/moonshotai/kimi-k2.5" \
|
||||
--fallbacks "nvidia_nim/moonshotai/kimi-k2.5,chutes/zai-org/GLM-5-TEE"
|
||||
```
|
||||
|
||||
### 5) Required Rollout Sequence (Do Not Skip)
|
||||
|
||||
1. Update config with a schema-safe path (`agents.list[].model` for per-agent overrides).
|
||||
2. Clear per-session model pins so defaults/agent model can apply.
|
||||
3. Run model-state audit to confirm no stale references.
|
||||
4. Restart gateway so in-memory runtime state reloads config.
|
||||
5. In active channels/threads, run `/reset` (or `/new`) before testing.
|
||||
|
||||
Use helpers:
|
||||
|
||||
```bash
|
||||
# Clear all session model pins for an agent
|
||||
bash {baseDir}/scripts/clear-session-model-pins.sh --agent home
|
||||
|
||||
# Clear only one channel session family
|
||||
bash {baseDir}/scripts/clear-session-model-pins.sh --agent home --channel 1470162839284224184
|
||||
|
||||
# Audit config + cron + session pins for stale model refs
|
||||
bash {baseDir}/scripts/audit-model-state.sh "gemini-3-flash"
|
||||
```
|
||||
|
||||
## Model ID Format
|
||||
|
||||
- Catalog ID format: `<provider>/<model-path>`
|
||||
- Config reference format: `llm-proxy/<catalog-id>`
|
||||
|
||||
Examples:
|
||||
- `nanogpt/moonshotai/kimi-k2.5` -> `llm-proxy/nanogpt/moonshotai/kimi-k2.5`
|
||||
- `nvidia_nim/moonshotai/kimi-k2.5` -> `llm-proxy/nvidia_nim/moonshotai/kimi-k2.5`
|
||||
|
||||
For `/model` inside a session, use catalog IDs (without `llm-proxy/`).
|
||||
|
||||
## Troubleshooting Quick Checks
|
||||
|
||||
1. Model missing: rerun `list-models.sh` and validate exact ID.
|
||||
2. Old model still used: clear session pins + restart gateway + `/reset`.
|
||||
3. Unexpected fallbacks: confirm fallback chain order in `show-current.sh`.
|
||||
78
skills/model-selector/scripts/audit-model-state.sh
Executable file
78
skills/model-selector/scripts/audit-model-state.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PATTERN="${1:-gemini-3-flash}"
|
||||
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/openclaw.json"
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
for alt in \
|
||||
"${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" \
|
||||
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json" \
|
||||
"/opt/openclaw/state/openclaw.json"; do
|
||||
if [[ -f "$alt" ]]; then
|
||||
STATE_FILE="$alt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
echo "ERROR: openclaw.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Config: $STATE_FILE"
|
||||
echo "Match pattern: $PATTERN"
|
||||
|
||||
echo "\n== Config references =="
|
||||
rg -n "$PATTERN" "$STATE_FILE" || true
|
||||
|
||||
echo "\n== Cron payload model/message references =="
|
||||
openclaw cron list --json 2>/dev/null | jq -r --arg pat "$PATTERN" '
|
||||
.jobs[]
|
||||
| {
|
||||
id,
|
||||
name,
|
||||
agentId,
|
||||
payloadModel: (.payload.model // ""),
|
||||
message: (.payload.message // "")
|
||||
}
|
||||
| select((.payloadModel|test($pat;"i")) or (.message|test($pat;"i")))
|
||||
' || true
|
||||
|
||||
echo "\n== Session model pins =="
|
||||
for agent_dir in /home/node/.openclaw/agents/*; do
|
||||
[[ -d "$agent_dir" ]] || continue
|
||||
agent_id="$(basename "$agent_dir")"
|
||||
sess_file="$agent_dir/sessions/sessions.json"
|
||||
[[ -f "$sess_file" ]] || continue
|
||||
jq -r --arg aid "$agent_id" --arg pat "$PATTERN" '
|
||||
to_entries[]
|
||||
| select((.value.model // "" | tostring | test($pat;"i")))
|
||||
| "agent=" + $aid + " session=" + .key + " model=" + (.value.model|tostring)
|
||||
' "$sess_file" || true
|
||||
done
|
||||
|
||||
echo "\n== Invalid llm-proxy refs against local catalog =="
|
||||
node - <<'NODE' "$STATE_FILE"
|
||||
const fs=require('fs');
|
||||
const p=process.argv[2];
|
||||
const j=JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
const catalog=new Set((j.models?.providers?.['llm-proxy']?.models||[]).map(m=>'llm-proxy/'+m.id));
|
||||
const refs=[];
|
||||
const push=(where,val)=>{ if(!val) return; if(Array.isArray(val)){val.forEach((v,i)=>refs.push([`${where}[${i}]`,v]));} else refs.push([where,val]); };
|
||||
push('agents.defaults.model.primary', j.agents?.defaults?.model?.primary);
|
||||
push('agents.defaults.model.fallbacks', j.agents?.defaults?.model?.fallbacks);
|
||||
for (const a of (j.agents?.list||[])) {
|
||||
push(`agents.list[${a.id}].model.primary`, a.model?.primary);
|
||||
push(`agents.list[${a.id}].model.fallbacks`, a.model?.fallbacks);
|
||||
}
|
||||
let bad=0;
|
||||
for (const [where,val] of refs){
|
||||
if (typeof val==='string' && val.startsWith('llm-proxy/') && !catalog.has(val)) {
|
||||
bad++;
|
||||
console.log(`INVALID ${where} -> ${val}`);
|
||||
}
|
||||
}
|
||||
if(!bad) console.log('none');
|
||||
NODE
|
||||
97
skills/model-selector/scripts/clear-session-model-pins.sh
Executable file
97
skills/model-selector/scripts/clear-session-model-pins.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
clear-session-model-pins.sh --agent <agent-id> [--channel <channel-id>] [--sessions-file <path>]
|
||||
|
||||
Examples:
|
||||
clear-session-model-pins.sh --agent home
|
||||
clear-session-model-pins.sh --agent home --channel 1470162839284224184
|
||||
|
||||
Notes:
|
||||
- Removes per-session "model" keys so agent defaults apply again.
|
||||
- By default targets: /home/node/.openclaw/agents/<agent>/sessions/sessions.json
|
||||
EOF
|
||||
}
|
||||
|
||||
AGENT_ID=""
|
||||
CHANNEL_ID=""
|
||||
SESSIONS_FILE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agent)
|
||||
AGENT_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--channel)
|
||||
CHANNEL_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--sessions-file)
|
||||
SESSIONS_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$AGENT_ID" ]]; then
|
||||
echo "--agent is required" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$SESSIONS_FILE" ]]; then
|
||||
SESSIONS_FILE="/home/node/.openclaw/agents/${AGENT_ID}/sessions/sessions.json"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SESSIONS_FILE" ]]; then
|
||||
echo "sessions file not found: $SESSIONS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - <<PY
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(${SESSIONS_FILE@Q})
|
||||
channel = ${CHANNEL_ID@Q}
|
||||
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
|
||||
removed = 0
|
||||
scanned = 0
|
||||
|
||||
for key, value in data.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
scanned += 1
|
||||
|
||||
if channel:
|
||||
if f"channel:{channel}" not in key:
|
||||
continue
|
||||
|
||||
if "model" in value:
|
||||
del value["model"]
|
||||
removed += 1
|
||||
|
||||
with path.open("w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
print(f"scanned={scanned}")
|
||||
print(f"removed_model_pins={removed}")
|
||||
print(f"sessions_file={path}")
|
||||
PY
|
||||
74
skills/model-selector/scripts/list-models.sh
Executable file
74
skills/model-selector/scripts/list-models.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# list-models.sh — Query the LLM proxy /v1/models endpoint
|
||||
# Usage:
|
||||
# list-models.sh # List all model IDs (sorted)
|
||||
# list-models.sh --providers # List unique provider names
|
||||
# list-models.sh --json # Raw JSON response
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve proxy URL and API key from environment or defaults
|
||||
PROXY_URL="${LLM_PROXY_URL:-https://llm-proxy.ext.ben.io/v1}"
|
||||
PROXY_KEY="${PROXY_API_KEY:-${LLM_PROXY_API_KEY:-}}"
|
||||
|
||||
if [[ -z "$PROXY_KEY" ]]; then
|
||||
# Try to read from openclaw config
|
||||
for cfg_path in \
|
||||
"${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" \
|
||||
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
|
||||
if [[ -f "$cfg_path" ]]; then
|
||||
# Extract apiKey from llm-proxy provider config (handles JSON5 comments)
|
||||
key=$(grep -A5 '"llm-proxy"' "$cfg_path" | grep '"apiKey"' | head -1 | sed 's/.*"apiKey"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)
|
||||
if [[ -n "$key" && "$key" != *'${'* ]]; then
|
||||
PROXY_KEY="$key"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$PROXY_KEY" ]]; then
|
||||
echo "ERROR: No API key found. Set PROXY_API_KEY or LLM_PROXY_API_KEY environment variable." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Strip trailing /v1 from PROXY_URL if present, then always append /v1/models
|
||||
# This prevents double /v1/v1/ when LLM_PROXY_URL already includes /v1
|
||||
PROXY_BASE="${PROXY_URL%/v1}"
|
||||
PROXY_BASE="${PROXY_BASE%/}"
|
||||
|
||||
response=$(curl -s -f -H "Authorization: Bearer $PROXY_KEY" "${PROXY_BASE}/v1/models" 2>&1) || {
|
||||
echo "ERROR: Failed to query ${PROXY_BASE}/v1/models" >&2
|
||||
echo "$response" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--providers)
|
||||
echo "$response" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
providers = sorted(set(m['id'].split('/')[0] for m in data.get('data', [])))
|
||||
for p in providers:
|
||||
print(p)
|
||||
"
|
||||
;;
|
||||
--json)
|
||||
echo "$response"
|
||||
;;
|
||||
--count)
|
||||
echo "$response" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
print(len(data.get('data', [])))
|
||||
"
|
||||
;;
|
||||
*)
|
||||
echo "$response" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
models = sorted(m['id'] for m in data.get('data', []))
|
||||
for m in models:
|
||||
print(m)
|
||||
"
|
||||
;;
|
||||
esac
|
||||
97
skills/model-selector/scripts/show-current.sh
Executable file
97
skills/model-selector/scripts/show-current.sh
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# show-current.sh — Display the current model configuration from openclaw state
|
||||
# Usage: show-current.sh
|
||||
set -euo pipefail
|
||||
|
||||
# Find the state file
|
||||
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json"
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
# Try alternative locations
|
||||
for alt in \
|
||||
"/opt/openclaw/state/openclaw.json" \
|
||||
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
|
||||
if [[ -f "$alt" ]]; then
|
||||
STATE_FILE="$alt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
echo "ERROR: Cannot find openclaw.json state file" >&2
|
||||
echo "Searched:" >&2
|
||||
echo " ${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" >&2
|
||||
echo " /opt/openclaw/state/openclaw.json" >&2
|
||||
echo " ${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📁 Config file: $STATE_FILE"
|
||||
echo ""
|
||||
|
||||
python3 -c "
|
||||
import json, sys, re
|
||||
|
||||
# Read file and strip JSON5 comments for parsing
|
||||
with open('$STATE_FILE', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Strip single-line comments (// ...) but not inside strings
|
||||
lines = content.split('\n')
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
stripped = line.rstrip()
|
||||
s = stripped.lstrip()
|
||||
if s.startswith('//'):
|
||||
continue
|
||||
in_string = False
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(stripped):
|
||||
c = stripped[i]
|
||||
if c == '\"' and (i == 0 or stripped[i-1] != '\\\\'):
|
||||
in_string = not in_string
|
||||
elif c == '/' and i + 1 < len(stripped) and stripped[i+1] == '/' and not in_string:
|
||||
break
|
||||
result.append(c)
|
||||
i += 1
|
||||
cleaned.append(''.join(result))
|
||||
|
||||
# Remove trailing commas (JSON5)
|
||||
json_str = '\n'.join(cleaned)
|
||||
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
|
||||
|
||||
try:
|
||||
cfg = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
cfg = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'ERROR: Failed to parse config: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
agents = cfg.get('agents', {})
|
||||
defaults = agents.get('defaults', {})
|
||||
model = defaults.get('model', {})
|
||||
|
||||
if isinstance(model, str):
|
||||
print(f'🎯 Primary: {model}')
|
||||
print(f'⛓️ Fallbacks: (none configured)')
|
||||
else:
|
||||
primary = model.get('primary', '(not set)')
|
||||
fallbacks = model.get('fallbacks', [])
|
||||
print(f'🎯 Primary: {primary}')
|
||||
print(f'⛓️ Fallbacks ({len(fallbacks)}):')
|
||||
for i, fb in enumerate(fallbacks, 1):
|
||||
print(f' {i}. {fb}')
|
||||
|
||||
# Check for per-agent model overrides
|
||||
agent_list = agents.get('list', [])
|
||||
overrides = [(a.get('id', '?'), a.get('model', '')) for a in agent_list if 'model' in a]
|
||||
if overrides:
|
||||
print()
|
||||
print('⚠️ Per-agent model overrides:')
|
||||
for aid, amodel in overrides:
|
||||
print(f' {aid}: {amodel}')
|
||||
" 2>&1
|
||||
166
skills/model-selector/scripts/switch-models.sh
Executable file
166
skills/model-selector/scripts/switch-models.sh
Executable file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
switch-models.sh --non-main-primary <catalog-id> --non-main-fallbacks <m1,m2,...> [--clear-session-pins] [--pattern <old-model-pattern>]
|
||||
|
||||
Example:
|
||||
switch-models.sh \
|
||||
--non-main-primary "nanogpt/zai-org/glm-5" \
|
||||
--non-main-fallbacks "lightning_ai/lightning-ai/kimi-k2.5,nanogpt/zai-org/glm-4.7" \
|
||||
--clear-session-pins \
|
||||
--pattern "gemini-3-flash"
|
||||
|
||||
Notes:
|
||||
- Updates agents.list[].model for home/security/research.
|
||||
- Keeps main/default model untouched.
|
||||
- Validates candidates against live /v1/models.
|
||||
- Optionally removes matching per-session model pins.
|
||||
EOF
|
||||
}
|
||||
|
||||
PRIMARY=""
|
||||
FALLBACKS=""
|
||||
CLEAR_PINS=0
|
||||
PATTERN="gemini-3-flash"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--non-main-primary)
|
||||
PRIMARY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--non-main-fallbacks)
|
||||
FALLBACKS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--clear-session-pins)
|
||||
CLEAR_PINS=1
|
||||
shift
|
||||
;;
|
||||
--pattern)
|
||||
PATTERN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PRIMARY" || -z "$FALLBACKS" ]]; then
|
||||
echo "ERROR: --non-main-primary and --non-main-fallbacks are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IFS=',' read -ra FB <<< "$FALLBACKS"
|
||||
if [[ ${#FB[@]} -lt 1 ]]; then
|
||||
echo "ERROR: at least 1 fallback required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for m in "$PRIMARY" "${FB[@]}"; do
|
||||
m_clean="$(echo "${m#llm-proxy/}" | xargs)"
|
||||
"$SCRIPT_DIR/validate-model.sh" "$m_clean" >/dev/null
|
||||
echo "validated-live: $m_clean"
|
||||
done
|
||||
|
||||
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/openclaw.json"
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
for alt in \
|
||||
"${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json" \
|
||||
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json" \
|
||||
"/opt/openclaw/state/openclaw.json"; do
|
||||
if [[ -f "$alt" ]]; then
|
||||
STATE_FILE="$alt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[[ -f "$STATE_FILE" ]] || { echo "ERROR: openclaw.json not found" >&2; exit 1; }
|
||||
|
||||
# Validate against local configured catalog too (gateway uses this on restart)
|
||||
node - <<'NODE' "$STATE_FILE" "$PRIMARY" "$FALLBACKS"
|
||||
const fs=require('fs');
|
||||
const p=process.argv[2];
|
||||
const primary=process.argv[3].replace(/^llm-proxy\//,'');
|
||||
const fallbacks=process.argv[4].split(',').map(s=>s.trim().replace(/^llm-proxy\//,'')).filter(Boolean);
|
||||
const j=JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
const catalog=new Set((j.models?.providers?.['llm-proxy']?.models||[]).map(m=>m.id));
|
||||
const missing=[];
|
||||
if(!catalog.has(primary)) missing.push(primary);
|
||||
for (const f of fallbacks) if(!catalog.has(f)) missing.push(f);
|
||||
if (missing.length) {
|
||||
console.error('ERROR: target models missing from local llm-proxy catalog in openclaw.json');
|
||||
for (const m of [...new Set(missing)]) console.error(' - '+m);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log('validated-local-catalog: ok');
|
||||
NODE
|
||||
|
||||
primary_full="llm-proxy/${PRIMARY#llm-proxy/}"
|
||||
raw_fb="${FALLBACKS}"
|
||||
fb_json="$(node - <<'NODE' "$PRIMARY" "$raw_fb"
|
||||
const primary=process.argv[2].replace(/^llm-proxy\//,'');
|
||||
const raw=process.argv[3].split(',').map(s=>s.trim().replace(/^llm-proxy\//,'')).filter(Boolean);
|
||||
const seen=new Set();
|
||||
const out=[];
|
||||
for (const item of raw) {
|
||||
if (item===primary) continue;
|
||||
if (seen.has(item)) continue;
|
||||
seen.add(item);
|
||||
out.push(`llm-proxy/${item}`);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
NODE
|
||||
)"
|
||||
|
||||
for aid in home security research; do
|
||||
cmd1="openclaw config set agents.list[\"$aid\"].model.primary $primary_full"
|
||||
echo "$cmd1"
|
||||
eval "$cmd1"
|
||||
cmd2="openclaw config set agents.list[\"$aid\"].model.fallbacks '$fb_json' --json"
|
||||
echo "$cmd2"
|
||||
eval "$cmd2"
|
||||
done
|
||||
|
||||
if [[ "$CLEAR_PINS" -eq 1 ]]; then
|
||||
echo "clearing matching session model pins pattern=$PATTERN"
|
||||
for aid in home security research; do
|
||||
sess="/home/node/.openclaw/agents/${aid}/sessions/sessions.json"
|
||||
[[ -f "$sess" ]] || continue
|
||||
node - <<'NODE' "$sess" "$PATTERN" "$aid"
|
||||
const fs=require('fs');
|
||||
const file=process.argv[2];
|
||||
const pattern=new RegExp(process.argv[3],'i');
|
||||
const aid=process.argv[4];
|
||||
const j=JSON.parse(fs.readFileSync(file,'utf8'));
|
||||
let removed=0;
|
||||
for (const [k,v] of Object.entries(j)) {
|
||||
const model=(v&&v.model)?String(v.model):'';
|
||||
if (model && pattern.test(model)) {
|
||||
delete v.model;
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(j,null,2)+'\n');
|
||||
console.log(`agent=${aid} removed_model_pins=${removed}`);
|
||||
NODE
|
||||
done
|
||||
fi
|
||||
|
||||
echo "running post-change audit..."
|
||||
"$SCRIPT_DIR/audit-model-state.sh" "$PATTERN"
|
||||
|
||||
echo "done. restart gateway to apply runtime changes."
|
||||
216
skills/model-selector/scripts/update-model.sh
Executable file
216
skills/model-selector/scripts/update-model.sh
Executable file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-model.sh — Update model configuration in openclaw state file
|
||||
# Usage:
|
||||
# update-model.sh --primary <model-id>
|
||||
# update-model.sh --fallbacks <model1,model2,model3>
|
||||
# update-model.sh --primary <model-id> --fallbacks <model1,model2>
|
||||
#
|
||||
# All model IDs are validated against /v1/models before writing.
|
||||
# A backup of the current config is created before any changes.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
PRIMARY=""
|
||||
FALLBACKS=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--primary)
|
||||
PRIMARY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--fallbacks)
|
||||
FALLBACKS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: update-model.sh [--primary <model-id>] [--fallbacks <model1,model2,...>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --primary Set the primary model (will be prefixed with llm-proxy/)"
|
||||
echo " --fallbacks Comma-separated list of fallback models (min 2 required)"
|
||||
echo ""
|
||||
echo "All model IDs are validated against /v1/models before writing."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$PRIMARY" && -z "$FALLBACKS" ]]; then
|
||||
echo "ERROR: Must specify --primary and/or --fallbacks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the state file
|
||||
STATE_FILE="${OPENCLAW_STATE_DIR:-$HOME/.openclaw/state}/openclaw.json"
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
for alt in \
|
||||
"/opt/openclaw/state/openclaw.json" \
|
||||
"${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw/config}/openclaw.json"; do
|
||||
if [[ -f "$alt" ]]; then
|
||||
STATE_FILE="$alt"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
echo "ERROR: Cannot find openclaw.json state file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📁 Config file: $STATE_FILE"
|
||||
|
||||
# Validate all model IDs first
|
||||
echo ""
|
||||
echo "🔍 Validating model IDs against /v1/models..."
|
||||
VALIDATION_FAILED=0
|
||||
|
||||
if [[ -n "$PRIMARY" ]]; then
|
||||
# Strip llm-proxy/ prefix for validation
|
||||
PRIMARY_CLEAN="${PRIMARY#llm-proxy/}"
|
||||
if ! "$SCRIPT_DIR/validate-model.sh" "$PRIMARY_CLEAN" 2>&1; then
|
||||
VALIDATION_FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$FALLBACKS" ]]; then
|
||||
IFS=',' read -ra FB_ARRAY <<< "$FALLBACKS"
|
||||
|
||||
if [[ ${#FB_ARRAY[@]} -lt 2 ]]; then
|
||||
echo "❌ ERROR: Minimum 2 fallback models required (got ${#FB_ARRAY[@]})" >&2
|
||||
VALIDATION_FAILED=1
|
||||
fi
|
||||
|
||||
for fb in "${FB_ARRAY[@]}"; do
|
||||
fb_clean="${fb#llm-proxy/}"
|
||||
fb_clean="$(echo "$fb_clean" | xargs)" # trim whitespace
|
||||
if ! "$SCRIPT_DIR/validate-model.sh" "$fb_clean" 2>&1; then
|
||||
VALIDATION_FAILED=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $VALIDATION_FAILED -ne 0 ]]; then
|
||||
echo ""
|
||||
echo "❌ Validation failed. No changes made." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create backup
|
||||
BACKUP="${STATE_FILE}.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$STATE_FILE" "$BACKUP"
|
||||
echo ""
|
||||
echo "💾 Backup saved: $BACKUP"
|
||||
|
||||
# Apply changes using Python for safe JSON manipulation
|
||||
python3 -c "
|
||||
import json, sys, re
|
||||
|
||||
state_file = '$STATE_FILE'
|
||||
primary = '${PRIMARY}'.strip() or None
|
||||
fallbacks_raw = '${FALLBACKS}'.strip() or None
|
||||
|
||||
# Read and parse (handle JSON5 comments)
|
||||
with open(state_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Strip comments for parsing
|
||||
lines = content.split('\n')
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
s = line.lstrip()
|
||||
if s.startswith('//'):
|
||||
continue
|
||||
in_string = False
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(line):
|
||||
c = line[i]
|
||||
if c == '\"' and (i == 0 or line[i-1] != '\\\\'):
|
||||
in_string = not in_string
|
||||
elif c == '/' and i + 1 < len(line) and line[i+1] == '/' and not in_string:
|
||||
break
|
||||
result.append(c)
|
||||
i += 1
|
||||
cleaned.append(''.join(result))
|
||||
|
||||
# Remove trailing commas before } or ] (JSON5 feature)
|
||||
json_str = '\n'.join(cleaned)
|
||||
json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
|
||||
|
||||
try:
|
||||
cfg = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
cfg = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'ERROR: Failed to parse config: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure path exists
|
||||
if 'agents' not in cfg:
|
||||
cfg['agents'] = {}
|
||||
if 'defaults' not in cfg['agents']:
|
||||
cfg['agents']['defaults'] = {}
|
||||
if 'model' not in cfg['agents']['defaults']:
|
||||
cfg['agents']['defaults']['model'] = {}
|
||||
|
||||
model = cfg['agents']['defaults']['model']
|
||||
if isinstance(model, str):
|
||||
model = {'primary': model}
|
||||
cfg['agents']['defaults']['model'] = model
|
||||
|
||||
old_primary = model.get('primary', '(none)')
|
||||
old_fallbacks = model.get('fallbacks', [])
|
||||
|
||||
# Apply primary
|
||||
if primary:
|
||||
# Ensure llm-proxy/ prefix
|
||||
if not primary.startswith('llm-proxy/'):
|
||||
primary = f'llm-proxy/{primary}'
|
||||
model['primary'] = primary
|
||||
|
||||
# Apply fallbacks
|
||||
if fallbacks_raw:
|
||||
fbs = [fb.strip() for fb in fallbacks_raw.split(',') if fb.strip()]
|
||||
fbs = [f'llm-proxy/{fb}' if not fb.startswith('llm-proxy/') else fb for fb in fbs]
|
||||
model['fallbacks'] = fbs
|
||||
|
||||
# Remove per-agent model overrides that match the old primary
|
||||
# (they were likely set by the same drift that caused the issue)
|
||||
agent_list = cfg.get('agents', {}).get('list', [])
|
||||
removed_overrides = []
|
||||
for agent in agent_list:
|
||||
if 'model' in agent:
|
||||
removed_overrides.append((agent.get('id', '?'), agent['model']))
|
||||
del agent['model']
|
||||
|
||||
# Write back
|
||||
with open(state_file, 'w') as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
f.write('\n')
|
||||
|
||||
# Print summary
|
||||
print()
|
||||
print('✅ Configuration updated:')
|
||||
print()
|
||||
print(f' Primary: {old_primary} → {model.get(\"primary\", \"(none)\")}')
|
||||
print(f' Fallbacks:')
|
||||
for i, fb in enumerate(model.get('fallbacks', []), 1):
|
||||
old_marker = '' if fb in old_fallbacks else ' (new)'
|
||||
print(f' {i}. {fb}{old_marker}')
|
||||
if removed_overrides:
|
||||
print()
|
||||
print(' 🧹 Cleared per-agent model overrides:')
|
||||
for aid, amodel in removed_overrides:
|
||||
print(f' {aid}: {amodel} → (uses default)')
|
||||
" 2>&1
|
||||
|
||||
echo ""
|
||||
echo "Done. Restart OpenClaw for changes to take effect."
|
||||
39
skills/model-selector/scripts/validate-model.sh
Executable file
39
skills/model-selector/scripts/validate-model.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-model.sh — Validate that a model ID exists in the LLM proxy
|
||||
# Usage: validate-model.sh <model-id>
|
||||
# Exit 0 if valid, 1 if not found
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: validate-model.sh <model-id>" >&2
|
||||
echo "Example: validate-model.sh nanogpt/deepseek-chat" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MODEL_ID="$1"
|
||||
|
||||
# Strip llm-proxy/ prefix if present (user might pass the openclaw.json format)
|
||||
MODEL_ID="${MODEL_ID#llm-proxy/}"
|
||||
|
||||
# Get the live model list
|
||||
available=$("$SCRIPT_DIR/list-models.sh" 2>/dev/null) || {
|
||||
echo "ERROR: Could not fetch model list from LLM proxy" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if echo "$available" | grep -qxF "$MODEL_ID"; then
|
||||
echo "✅ Model '$MODEL_ID' is available"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ Model '$MODEL_ID' NOT found in /v1/models" >&2
|
||||
# Suggest close matches
|
||||
partial=$(echo "$available" | grep -i "$(echo "$MODEL_ID" | sed 's|.*/||')" | head -5)
|
||||
if [[ -n "$partial" ]]; then
|
||||
echo "" >&2
|
||||
echo "Did you mean one of these?" >&2
|
||||
echo "$partial" | sed 's/^/ /' >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user