CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-travel

Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.

Quality

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

detect-new-trips.pyskills/nightly-travel-sync/scripts/

#!/usr/bin/env python3
"""
Detect trips that appeared in travel-db.json since the last nightly run
and emit one durable daily-log line per new trip (log-only, no chat).

`nightly-travel-sync` already surfaces timezone changes, OOO, trip
conflicts, and booking gaps to chat. A newly-appeared trip that is
already fully booked, with no TZ change and no conflict, enters
silently — nothing durable records it, so the main agent has no
awareness of it until it queries the travel files on demand (#204).

This script closes that gap deterministically: it diffs the current
travel-db.json trip set against a persisted snapshot
(travel-trips-seen.json) and reports the trips that are new. The
skill logs each one to the group daily memory and then calls this
script again with `--commit` to persist the snapshot. There is no
chat output by design — the owner books the trips himself and a
per-trip ping is noise; the requirement is durable *agent* awareness.

Two modes:

  detect (default)
    Read travel-db.json + travel-trips-seen.json, compute the new
    trips, print a JSON object, and touch nothing on disk:
      {"seeded": bool, "new_trips": [
        {"slug", "summary", "start", "end", "log_line"}, ...]}
    - `seeded` is true when no usable prior snapshot exists (absent,
      corrupt, or an unrecognised schema_version). On a seed run
      `new_trips` is always empty: the whole itinerary is NOT dumped
      as "new" (#204). The caller logs nothing and just commits the
      snapshot, so only trips appearing *after* the seed are new.
    - `log_line` is the semantic content the daily log records,
      WITHOUT the leading `- HH:MM UTC` timestamp — the caller
      prepends the timestamp (the trusted-memory daily-log
      convention). It carries no clock value, so this stays testable.

  --commit
    Rewrite travel-trips-seen.json to exactly the current upcoming
    trip set and print {"committed": true, "trips_tracked": N}. The
    skill runs this AFTER logging, so a logging failure leaves the
    snapshot untouched and the next nightly run retries; the daily-log
    helper's own line-dedup guards against a double entry in the
    meantime. The snapshot is pruned to the current set each commit,
    so a trip that ages out (past/cancelled) simply drops — it is not
    a "new trip", and cancellation logging is out of scope here.

Both modes scope to upcoming trips (`end` on/after today), mirroring
build-travel-db.py, so a trip dropping into the past never counts as
appearing or disappearing.

Reads:  /workspace/group/travel-db.json
        /workspace/group/travel-trips-seen.json
Writes: /workspace/group/travel-trips-seen.json  (--commit only)
"""

import json
import os
import sys
from datetime import date, datetime, timezone

DB_PATH = "/workspace/group/travel-db.json"
SEEN_PATH = "/workspace/group/travel-trips-seen.json"

# Snapshot schema — see the sibling `state-schema.md`
# (travel-trips-seen.json section). Bump in lock-step with that doc.
SCHEMA_VERSION = 1


def _parse_day(s: str) -> date:
    # travel-db.json trip-level start/end are date-only `YYYY-MM-DD`
    # (build-travel-db.py). Slice defensively in case a timed value
    # ever reaches here.
    return date.fromisoformat(s[:10])


def _load_db_trips() -> dict:
    """Return {slug: {"summary", "start", "end"}} for upcoming trips
    in travel-db.json (`end` on/after today). Exit 1 with an
    actionable diagnostic if the DB is missing or unreadable — Step 4
    (build-travel-db.py) rebuilds it immediately before this runs, so
    an absent/corrupt DB here is a real failure, not a first-run
    state."""
    try:
        with open(DB_PATH, encoding="utf-8") as f:
            db = json.load(f)
    except FileNotFoundError:
        print(
            f"ERROR: {DB_PATH} not found — run "
            "check-travel-bookings/scripts/build-travel-db.py "
            "(nightly-travel-sync Step 4) first",
            file=sys.stderr,
        )
        sys.exit(1)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        print(
            f"ERROR: cannot read {DB_PATH} as JSON "
            f"({type(exc).__name__}: {exc}) — re-run build-travel-db.py "
            "to rewrite it",
            file=sys.stderr,
        )
        sys.exit(1)

    if not isinstance(db, dict) or not isinstance(db.get("trips"), dict):
        print(
            f"ERROR: {DB_PATH} must be a JSON object with a `trips` map "
            "(the build-travel-db.py output contract) — re-run "
            "build-travel-db.py to rewrite it",
            file=sys.stderr,
        )
        sys.exit(1)

    today = date.today()
    upcoming = {}
    for slug, trip in db["trips"].items():
        try:
            trip_end = _parse_day(trip["end"])
        except (KeyError, TypeError, ValueError):
            # A malformed trip record in an otherwise-valid DB: skip it
            # rather than abort the whole run. build-travel-db.py owns
            # the shape; a bad record there is its bug to surface.
            continue
        if trip_end < today:
            continue
        upcoming[slug] = {
            "summary": trip.get("summary", slug),
            "start": trip.get("start", ""),
            "end": trip.get("end", ""),
        }
    return upcoming


def _load_prior_slugs() -> set | None:
    """Return the set of previously-seen trip slugs, or None when no
    usable prior snapshot exists (absent, corrupt, non-object, or an
    unrecognised schema_version). None triggers a seed run — the whole
    itinerary is never dumped as new (#204), and --commit rewrites a
    clean snapshot at the current version.

    An *access* failure (permission, I/O error) is NOT a seed run: it
    exits 1 loudly rather than silently reseeding, which would let the
    following --commit clobber a possibly-good snapshot and miss a new
    trip. This mirrors build-travel-db.py's content-vs-access split."""
    try:
        with open(SEEN_PATH, encoding="utf-8") as f:
            snap = json.load(f)
    except FileNotFoundError:
        return None  # absent snapshot — first-ever run, seed
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None  # corrupt content — seed; --commit rewrites clean
    except OSError as exc:
        print(
            f"ERROR: cannot read {SEEN_PATH} "
            f"({type(exc).__name__}: {exc}) — fix the file's "
            "permissions/mount, then re-run; refusing to reseed over a "
            "possibly-good snapshot",
            file=sys.stderr,
        )
        sys.exit(1)
    if not isinstance(snap, dict):
        return None
    if snap.get("schema_version") != SCHEMA_VERSION:
        # Owner-side safe fallback per `coding-policy: stateful-artifacts`:
        # an unfamiliar version is treated as "no usable prior state".
        # --commit rewrites at SCHEMA_VERSION. Only v1 exists today, so
        # this fires only on a corrupt/hand-edited version field.
        return None
    trips = snap.get("trips")
    if not isinstance(trips, dict):
        return None
    return set(trips.keys())


def _make_log_line(trip: dict) -> str:
    """Semantic daily-log content for a new trip, WITHOUT the leading
    `- HH:MM UTC` timestamp (the caller prepends it). Uses only fields
    always present on a travel-db.json trip record — summary carries
    the destination; route/city enrichment is intentionally omitted
    (travel-db.json trip records hold no reliable origin→dest)."""
    start, end = trip["start"], trip["end"]
    if start and end:
        span = f" ({start} to {end})"
    elif start or end:
        span = f" ({start or end})"
    else:
        span = ""
    return f"[travel] new trip: {trip['summary']}{span}"


def _write_snapshot(trips: dict) -> None:
    """Atomically rewrite travel-trips-seen.json to the given trip set.
    Same-dir `.tmp` + os.replace, normal open() so mode follows the
    umask (the group volume is shared across plugin readers), matching
    build-travel-db.py's write."""
    payload = {
        "schema_version": SCHEMA_VERSION,
        "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "trips": trips,
    }
    tmp_path = SEEN_PATH + ".tmp"
    with open(tmp_path, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)
    os.replace(tmp_path, SEEN_PATH)


def _commit() -> None:
    trips = _load_db_trips()
    _write_snapshot(trips)
    print(json.dumps({"committed": True, "trips_tracked": len(trips)}, ensure_ascii=False))


def _detect() -> None:
    current = _load_db_trips()
    prior = _load_prior_slugs()

    if prior is None:
        # Seed run: record nothing, report seeded so the caller skips
        # logging and just commits the snapshot.
        print(json.dumps({"seeded": True, "new_trips": []}, ensure_ascii=False))
        return

    new_slugs = [slug for slug in current if slug not in prior]
    new_trips = []
    for slug in sorted(new_slugs, key=lambda s: (current[s]["start"], s)):
        trip = current[slug]
        new_trips.append(
            {
                "slug": slug,
                "summary": trip["summary"],
                "start": trip["start"],
                "end": trip["end"],
                "log_line": _make_log_line(trip),
            }
        )

    print(json.dumps({"seeded": False, "new_trips": new_trips}, ensure_ascii=False))


def main(argv: list[str] | None = None) -> None:
    args = sys.argv[1:] if argv is None else argv
    if args == ["--commit"]:
        _commit()
    elif not args:
        _detect()
    else:
        print(
            f"ERROR: unknown arguments {args!r} — usage: detect-new-trips.py [--commit]",
            file=sys.stderr,
        )
        sys.exit(2)


if __name__ == "__main__":
    main()

README.md

tile.json