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.

75

Quality

94%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

trip_origin.pyskills/flight-assist/

"""Trip-aware drive-anchor resolution — TripIt truth over the static home.

Every drive leg the travel skills plan is anchored somewhere: the sweep's
outbound/return legs at the operator's residence, flight-assist's
time-to-leave and drive-home legs at `home_address`. That anchor was always
the static home, with no awareness of whether the operator is on a trip —
which is how a UK dinner reservation drew a 39-minute drive block from a
Tennessee origin (issue #122). This module resolves the anchor from the
TripIt-derived `travel-schedule.json` (written nightly by
`nightly-travel-sync`'s refresh-travel-schedule.py):

1. No active `Trip` segment covers the anchor time → the static home
   (today's behavior, unchanged).
2. An active `Trip` covers it → the `location` of the most recent `Lodging`
   event (check-in OR check-out) within the trip's span at or before the
   anchor time. In a check-out→check-in gap the latest event is the prior
   check-out, so its lodging wins; after the next check-in, that lodging
   wins. The event's `location` field carries the address (`address` is
   null in the feed).
3. On a trip but before its first lodging event → the `Trip` segment's own
   `location` when present, else unresolved (`address=None`) — the caller
   surfaces "no drivable origin" instead of planning from home. The static
   home is NEVER the anchor while a trip is active.

The schedule file is host-group state owned by `nightly-travel-sync` (see
its state-schema.md); this module is a non-owner READER per
`coding-policy: stateful-artifacts` — a missing, unreadable, malformed, or
forward-incompatible file resolves to "no usable schedule" (static-home
behavior), never an exception, and never a migration.

Shared across bundles: drive-planner's sweep precheck imports this module
cross-bundle the same way it already imports `maps_client` from this skill.

stdlib-only per `coding-policy: dependency-management` (Stdlib First).

Public API:
    from trip_origin import TripAnchor, load_travel_schedule, resolve_anchor

    schedule = load_travel_schedule()            # list | None, tolerant
    anchor = resolve_anchor(schedule, at=meeting_start, home_address=home)
    anchor.address    # drivable anchor, or None (unresolved mid-trip)
    anchor.source     # "home" | "lodging" | "trip_location" | "unresolved"
"""

from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path

SCHEDULE_PATH = "/workspace/group/travel-schedule.json"

# Highest travel-schedule.json record schema this reader accepts. Bump in
# lock-step with refresh-travel-schedule.py's SCHEMA_VERSION per
# `coding-policy: stateful-artifacts`. Records without a schema_version are
# legacy pre-versioned records (written before the field existed) that this
# reader treats as v1; any record carrying a HIGHER version marks the whole
# file forward-incompatible — this reader is lagging, so it takes the
# no-usable-schedule path rather than guessing at a shape it doesn't know.
SCHEDULE_SCHEMA_VERSION = 1


@dataclass(frozen=True)
class TripAnchor:
    """Where home-anchored drive legs start/end at a given moment.

    Fields:
        address: the drivable anchor (static home off-trip, lodging or trip
            location on-trip), or None when on a trip with nothing resolvable
            — the caller must surface that, not fall back to home.
        source: which rule produced the address — "home", "lodging",
            "trip_location", or "unresolved" (address is None).
        detail: human-readable context (the lodging event or trip summary,
            or the reason nothing resolved) for diagnostics and operator
            messaging.
    """

    address: str | None
    source: str
    detail: str | None = None


def load_travel_schedule(path: str | None = None) -> list[dict] | None:
    """Read travel-schedule.json, or None when no usable schedule exists.

    None (missing / unreadable / malformed / non-list root /
    forward-incompatible record version) means "resolve anchors as if not
    traveling" — the pre-#122 static-home behavior. That degraded mode is
    deliberate: the schedule's own alerting surface is `nightly-travel-sync`
    (freshness probe + failure branch), so a broken file must not take the
    drive planners down with it. A stderr diagnostic records the cause.
    """
    schedule_path = Path(path if path is not None else SCHEDULE_PATH)
    try:
        payload = json.loads(schedule_path.read_text(encoding="utf-8"))
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
        # OSError covers missing + unreadable; UnicodeDecodeError a
        # non-UTF-8 half-write; JSONDecodeError a truncated refresh.
        print(
            f"trip_origin: no usable travel schedule at {schedule_path} "
            f"({type(exc).__name__}) — resolving drive anchors as not traveling",
            file=sys.stderr,
        )
        return None
    if not isinstance(payload, list):
        print(
            f"trip_origin: travel schedule at {schedule_path} has a non-list "
            "root — resolving drive anchors as not traveling",
            file=sys.stderr,
        )
        return None
    records = [record for record in payload if isinstance(record, dict)]
    for record in records:
        version = record.get("schema_version")
        if version is None:
            continue  # legacy pre-versioned record — read as v1
        if not isinstance(version, int) or isinstance(version, bool):
            continue  # malformed version on one record — the record set still reads
        if version > SCHEDULE_SCHEMA_VERSION:
            print(
                f"trip_origin: travel schedule carries schema_version={version} "
                f"(this reader supports v{SCHEDULE_SCHEMA_VERSION}) — resolving "
                "drive anchors as not traveling until the plugin is upgraded",
                file=sys.stderr,
            )
            return None
    return records


def _parse_when(value) -> datetime | None:
    """A schedule `start`/`end` string as a tz-aware UTC datetime, else None.

    The feed emits `YYYY-MM-DDTHH:MM:SSZ` for timed VEVENTs and `YYYY-MM-DD`
    for date-only wrappers (see refresh-travel-schedule.py); a date-only
    value reads as midnight UTC. A naive datetime string (not a shape the
    feed writes) is tolerated as UTC rather than rejected.
    """
    if not isinstance(value, str) or not value:
        return None
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _parse_day(value) -> date | None:
    """A schedule `start`/`end` string as a UTC calendar date, else None."""
    parsed = _parse_when(value)
    return parsed.date() if parsed is not None else None


def _active_trip(records: list[dict], on_day: date) -> dict | None:
    """The `Trip` record whose date span covers `on_day`, else None.

    Trip wrappers are date-only; the span is inclusive of both endpoint
    dates. TripIt's date-only DTEND is nominally exclusive, so inclusive
    reading may extend trip awareness one day past the return — the safe
    direction: anchoring a landing-day evening at the last lodging beats
    planning a mid-trip drive from home, which is the failure #122 exists
    to stop. Multiple covering trips (overlapping wrappers) resolve to the
    latest-starting one.
    """
    active = None
    active_start = None
    for record in records:
        if record.get("type") != "Trip":
            continue
        start_day = _parse_day(record.get("start"))
        end_day = _parse_day(record.get("end"))
        if start_day is None or end_day is None:
            continue
        if start_day <= on_day <= end_day and (active_start is None or start_day > active_start):
            active = record
            active_start = start_day
    return active


def resolve_anchor(
    schedule: list[dict] | None,
    *,
    at: datetime,
    home_address: str | None,
) -> TripAnchor:
    """Resolve the drive anchor for time `at` per the #122 rules. Pure.

    Args:
        schedule: the record list from `load_travel_schedule` (None means
            no usable schedule — anchor at home).
        at: the tz-aware moment the anchor applies to (a meeting start, or
            "now" for flight-assist's cycle origin). Naive raises ValueError
            — comparing it to the schedule's UTC instants would be wrong,
            not just an exception.
        home_address: the static residence used off-trip. May be None
            (flight-assist's config leaves it unset), in which case the
            off-trip anchor is None with source "home" — same "no origin
            configured" contract callers already handle.

    Returns:
        TripAnchor — see the class docstring for the source ladder.
    """
    if at.tzinfo is None or at.utcoffset() is None:
        raise ValueError("resolve_anchor: `at` must be timezone-aware (UTC)")
    if not schedule:
        return TripAnchor(address=home_address, source="home")

    at_utc = at.astimezone(timezone.utc)
    trip = _active_trip(schedule, at_utc.date())
    if trip is None:
        return TripAnchor(address=home_address, source="home")

    trip_start = _parse_day(trip.get("start"))
    trip_end = _parse_day(trip.get("end"))
    best = None
    best_when = None
    for record in schedule:
        if record.get("type") != "Lodging":
            continue
        location = record.get("location")
        if not isinstance(location, str) or not location.strip():
            continue
        when = _parse_when(record.get("start"))
        if when is None or when > at_utc:
            continue
        # Bound lodging to the active trip's span so a prior trip's
        # straggler check-out (retained by the refresh's live-stay pairing)
        # can't anchor this trip's meetings in the wrong city.
        if trip_start is not None and trip_end is not None:
            if not (trip_start <= when.date() <= trip_end):
                continue
        if best_when is None or when >= best_when:
            best = record
            best_when = when
    if best is not None:
        location = best.get("location")
        assert isinstance(location, str)  # filtered non-str/empty above
        return TripAnchor(
            address=location.strip(),
            source="lodging",
            detail=best.get("summary") or None,
        )

    trip_location = trip.get("location")
    trip_summary = trip.get("summary") or "active trip"
    if isinstance(trip_location, str) and trip_location.strip():
        return TripAnchor(
            address=trip_location.strip(),
            source="trip_location",
            detail=trip_summary,
        )
    return TripAnchor(
        address=None,
        source="unresolved",
        detail=(
            f"on {trip_summary!r} with no lodging event at or before "
            f"{at_utc.isoformat()} and no trip location — no drivable anchor"
        ),
    )


def resolve_effective_home(home_address: str | None, *, now: datetime) -> str | None:
    """The trip-aware stand-in for the static `home_address` at `now`.

    The I/O convenience over `load_travel_schedule` + `resolve_anchor` for
    callers that treat "home" as a single per-cycle value (flight-assist's
    time-to-leave origin and drive-home destination): off-trip it's the
    static home; on-trip it's the current lodging (or the trip location);
    on-trip with nothing resolvable it's None — the callers' existing
    "no home_address configured" handling then skips routing, which beats
    routing to a residence an ocean away.
    """
    anchor = resolve_anchor(load_travel_schedule(), at=now, home_address=home_address)
    return anchor.address

README.md

tile.json