CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

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

90%

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

postarrival.pyskills/drive-engine/

"""Post-arrival meeting composition — flight-aware handoff before reconcile.

A meeting still going after landing replaces airport-to-lodging with
airport-to-venue; the return goes to lodging. No drive may begin inside a
flight's clearance / flight / post-arrival interval. Ended or unreachable
meetings get no drive. Pure: no I/O, no clock.
"""

from __future__ import annotations

import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from pathlib import Path

_BUNDLE_DIR = Path(__file__).resolve().parent
if str(_BUNDLE_DIR) not in sys.path:
    sys.path.insert(0, str(_BUNDLE_DIR))

from block_codec import GEN_UNIFIED, ParsedBlock  # noqa: E402
from meeting_source import DEFAULT_MAX_REASONABLE_DRIVE, MIN_MEETING_DRIVE  # noqa: E402
from reconcile import DesiredBlock, HeldLeg  # noqa: E402
from scan import DEFAULT_TIGHT_GAP_SECONDS, same_place  # noqa: E402

# Matches engine.MAX_AIRPORT_DRIVE: a longer airport-to-venue is not a real drive.
_MAX_HANDOFF_DRIVE = timedelta(hours=6)

_MEETING_KINDS = frozenset({"meeting_outbound", "meeting_return"})

# Scan buckets that represent a real in-person meeting the composer may hand off.
# filtered / skipped stay out; past is included because an in-flight sweep sees
# an already-started meeting as past (scan assigns that after declined/cancelled/
# virtual/anchor filters, and before the skip check).
_ELIGIBLE_BUCKETS = frozenset({"needs_decision", "bridge", "back_to_back", "has_block", "past"})

RouteFn = Callable[[str, str], "timedelta | None"]
AddressLookup = Callable[[datetime], str | None]


@dataclass(frozen=True)
class FlightGuard:
    """Protected bounds and arrival routing for one flight."""

    identity: str
    guard_start: datetime
    ready_at: datetime
    airport_code: str
    airport_point: str
    airport_label: str
    timezone: str | None = None
    chain_closing: bool = False


@dataclass(frozen=True)
class PostarrivalMeeting:
    """Meeting intent taken from a scan `MeetingClass`, without re-parsing events."""

    identity: str
    summary: str
    location: str | None
    start: datetime
    end: datetime
    timezone: str | None = None
    return_suppressed: bool = False
    driving_to: bool = False


def _skip_is_active(skip_state: dict[str, str] | None, identity: str, now: datetime | None) -> bool:
    """True when `skip_state` still suppresses `identity` at `now`.

    Mirrors scan's skip predicate so a `past` meeting (classified before the
    skip check) cannot enter the composer while an active skip remains.
    """
    if not skip_state:
        return False
    expiry_raw = skip_state.get(identity)
    if not isinstance(expiry_raw, str):
        return False
    text = expiry_raw.strip()
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        expiry = datetime.fromisoformat(text)
    except ValueError:
        return False
    if expiry.tzinfo is None:
        return False
    if now is None:
        return True
    return expiry > now


def _has_return_leg(meeting: object) -> bool:
    legs = getattr(meeting, "legs", ()) or ()
    return any(getattr(leg, "direction", None) == "return" for leg in legs)


def _has_any_leg(meeting: object) -> bool:
    legs = getattr(meeting, "legs", ()) or ()
    return bool(legs)


def _next_starts_within(meeting: object, others: Sequence[object], tight_gap_seconds: int) -> bool:
    end = getattr(meeting, "end", None)
    identity = getattr(meeting, "meeting_id", None)
    if end is None:
        return False
    for other in others:
        if getattr(other, "meeting_id", None) == identity:
            continue
        start = getattr(other, "start", None)
        if start is None:
            continue
        gap = (start - end).total_seconds()
        if 0 <= gap <= tight_gap_seconds:
            return True
    return False


def postarrival_meetings_from(
    scan_results: Sequence[object],
    *,
    skip_state: dict[str, str] | None = None,
    now: datetime | None = None,
    tight_gap_seconds: int = DEFAULT_TIGHT_GAP_SECONDS,
    driving_to: frozenset[str] = frozenset(),
) -> list[PostarrivalMeeting]:
    """Lift scan results that are eligible in-person meeting intents.

    Declined, cancelled, skipped, virtual, at-anchor, planner-block, and other
    filtered results stay out even when they still carry a timed location. An
    active skip excludes a `past` meeting (scan buckets past before it checks
    skip). A legitimate in-flight `past` in-person meeting remains eligible.
    """
    eligible: list[object] = []
    for meeting in scan_results:
        identity = getattr(meeting, "meeting_id", None)
        start = getattr(meeting, "start", None)
        end = getattr(meeting, "end", None)
        bucket = getattr(meeting, "bucket", None)
        if not isinstance(identity, str) or start is None or end is None:
            continue
        if bucket not in _ELIGIBLE_BUCKETS:
            continue
        if _skip_is_active(skip_state, identity, now):
            continue
        eligible.append(meeting)

    out: list[PostarrivalMeeting] = []
    for meeting in eligible:
        if getattr(meeting, "attendance_id", None) is not None:
            # Keep on-site events as physical neighbors above, but never
            # regenerate their suppressed airport-to-meeting/return drives.
            continue
        identity = getattr(meeting, "meeting_id", None)
        start = getattr(meeting, "start", None)
        end = getattr(meeting, "end", None)
        location = getattr(meeting, "location", None)
        if not isinstance(identity, str) or start is None or end is None:
            continue
        if _has_any_leg(meeting):
            return_suppressed = not _has_return_leg(meeting)
        else:
            return_suppressed = _next_starts_within(meeting, eligible, tight_gap_seconds)
        out.append(
            PostarrivalMeeting(
                identity=identity,
                summary=str(getattr(meeting, "summary", "") or ""),
                location=location if isinstance(location, str) and location.strip() else None,
                start=start,
                end=end,
                timezone=getattr(meeting, "timezone", None),
                return_suppressed=return_suppressed,
                driving_to=identity in driving_to,
            )
        )
    return out


def _key(block: DesiredBlock) -> tuple[str, str]:
    return (block.identity, block.kind)


def _in_protected(start: datetime, guards: Sequence[FlightGuard]) -> bool:
    return any(guard.guard_start <= start < guard.ready_at for guard in guards)


def _meeting_keys(identity: str) -> tuple[tuple[str, str], tuple[str, str]]:
    return ((identity, "meeting_outbound"), (identity, "meeting_return"))


def _drop_meeting(
    desired: list[DesiredBlock],
    unsafe: set[tuple[str, str]],
    identity: str,
) -> list[DesiredBlock]:
    unsafe.update(_meeting_keys(identity))
    return [
        block
        for block in desired
        if not (block.kind in _MEETING_KINDS and block.identity == identity)
    ]


def _ongoing(meeting: PostarrivalMeeting, ready_at: datetime) -> bool:
    return meeting.start < ready_at < meeting.end


def _unreachable_note(
    guard: FlightGuard,
    meeting: PostarrivalMeeting,
    drive: timedelta | None,
    max_handoff_drive: timedelta,
) -> str | None:
    """Why `meeting` is proven unreachable from `guard`, or None.

    A failed or negative route proves nothing: reachability is unknown, so the
    candidate stays in play and the fail-safe applies later.
    """
    if drive is None or drive < timedelta(0):
        return None
    if drive > max_handoff_drive:
        return f"postarrival {meeting.identity}: airport-to-venue drive exceeds cap"
    if drive > DEFAULT_MAX_REASONABLE_DRIVE and not meeting.driving_to:
        return (
            f"postarrival {meeting.identity}: airport-to-venue drive "
            "implausible — suppressed (away?)"
        )
    if guard.ready_at + drive >= meeting.end:
        return (
            f"postarrival {meeting.identity}: not reachable from {guard.identity} "
            f"by {guard.ready_at.isoformat()}"
        )
    return None


def _remaining_continuations(
    meeting: PostarrivalMeeting,
    meetings: Sequence[PostarrivalMeeting],
    excluded: frozenset[str],
    tight_gap_seconds: int,
    desired: Sequence[DesiredBlock],
    held: Sequence[HeldLeg],
    current_blocks: Sequence[ParsedBlock],
) -> tuple[bool, set[tuple[str, str]]]:
    """Validate the actual surviving path after an unreachable neighbour is dropped.

    A chronological neighbour alone cannot suppress the hotel return. Its
    inbound must come from the surviving venue and fit the remaining gap, or
    both meetings must share that venue. A provider-failure hold counts only
    when the existing block proves the same valid continuation.
    """
    continuing = False
    invalid: set[tuple[str, str]] = set()
    held_keys = {item.key for item in held}
    for other in meetings:
        gap = (other.start - meeting.end).total_seconds()
        if (
            other.identity == meeting.identity
            or other.identity in excluded
            or other.location is None
            or not 0 <= gap <= tight_gap_seconds
        ):
            continue
        if same_place(meeting.location, other.location):
            continuing = True
            continue
        key = (other.identity, "meeting_outbound")
        planned = any(
            _key(block) == key
            and same_place(block.origin, meeting.location)
            and same_place(block.destination, other.location)
            and meeting.end <= block.start <= block.end <= other.start
            for block in desired
        )
        retained = key in held_keys and any(
            block.generation == GEN_UNIFIED
            and (block.identity, block.kind) == key
            and same_place(block.origin, meeting.location)
            and same_place(block.destination, other.location)
            and block.anchor is not None
            and (native_start := _current_drive_start(block)) is not None
            and meeting.end <= native_start
            and (block.end or block.anchor) <= other.start
            for block in current_blocks
        )
        if planned or retained:
            continuing = True
        else:
            invalid.add(key)
    return continuing, invalid


def _current_drive_start(block: ParsedBlock) -> datetime | None:
    """When an existing unified meeting block's drive begins, or None if unknown.

    Prefer complete native Calendar bounds. Older projections may use their
    native span, then the route baseline. EV time never redefines that baseline.
    """
    if block.start is not None:
        return block.start
    if block.anchor is None:
        return None
    if block.kind == "meeting_return":
        return block.anchor
    duration = (
        block.duration_seconds if block.duration_seconds is not None else block.baseline_seconds
    )
    if block.kind == "meeting_outbound" and duration is not None and duration >= 0:
        return block.anchor - timedelta(seconds=duration)
    return None


def _held_is_unsafe(
    item: HeldLeg,
    current_by_key: dict[tuple[str, str], list[ParsedBlock]],
    guards: Sequence[FlightGuard],
) -> bool:
    """Whether the drive a held meeting leg keeps alive begins inside a guard.

    A hold keeps an EXISTING block, so the block's own drive start decides. A
    return hold's `when` is also its drive start. An outbound hold's `when` is the
    arrival deadline, never the drive start: a safe outbound that leaves before
    `guard_start` may arrive inside the guard, and a failed route must not delete
    it. An outbound hold with no readable block has no known drive start.
    """
    starts = [
        start
        for block in current_by_key.get(item.key, ())
        if (start := _current_drive_start(block)) is not None
    ]
    if item.kind == "meeting_return" and item.when is not None:
        starts.append(item.when)
    return any(_in_protected(start, guards) for start in starts)


def compose_postarrival(
    *,
    guards: Sequence[FlightGuard],
    meetings: Sequence[PostarrivalMeeting],
    desired: Sequence[DesiredBlock],
    held: Sequence[HeldLeg],
    lodging_lookup: AddressLookup,
    fallback_lookup: AddressLookup,
    route: RouteFn,
    current_blocks: Sequence[ParsedBlock] = (),
    max_handoff_drive: timedelta = _MAX_HANDOFF_DRIVE,
) -> tuple[list[DesiredBlock], list[HeldLeg], list[str]]:
    """Compose airport and meeting desired blocks around inbound arrivals.

    Returns `(desired, held, diagnostics)`. Unsafe meeting keys are removed from
    both desired and held so reconcile deletes a stale airborne or centroid block
    instead of preserving it under a provider-failure hold. `current_blocks` are the
    calendar's existing blocks: a held leg is judged by the drive start of the
    block it keeps alive.
    """
    desired_list = list(desired)
    diagnostics: list[str] = []
    unsafe: set[tuple[str, str]] = set()
    used_meetings: set[str] = set()
    arrival_ids = {block.identity for block in desired_list if block.kind == "airport_arrival"}

    for guard in guards:
        if guard.identity not in arrival_ids:
            continue
        candidates = [
            meeting
            for meeting in meetings
            if meeting.identity not in used_meetings
            and meeting.location is not None
            and _ongoing(meeting, guard.ready_at)
        ]
        # Proven-unreachable candidates (ended, too far) take the unreachable path
        # and never count toward ambiguity. A failed route is unknown and stays.
        in_play: list[tuple[PostarrivalMeeting, timedelta | None]] = []
        dropped: set[str] = set()
        for meeting in candidates:
            assert meeting.location is not None
            drive = route(guard.airport_point, meeting.location)
            note = _unreachable_note(guard, meeting, drive, max_handoff_drive)
            if note is not None:
                diagnostics.append(note)
                desired_list = _drop_meeting(desired_list, unsafe, meeting.identity)
                used_meetings.add(meeting.identity)
                dropped.add(meeting.identity)
                continue
            in_play.append((meeting, drive))
        if len(in_play) > 1:
            diagnostics.append(
                f"postarrival {guard.identity}: {len(in_play)} overlapping meetings; "
                "no automatic choice"
            )
            continue
        if not in_play:
            continue
        meeting, drive = in_play[0]
        if meeting.return_suppressed and dropped:
            # Scan may have routed a later meeting from the dropped neighbour.
            # Keep suppression only when a surviving continuation is usable.
            continuing, invalid = _remaining_continuations(
                meeting,
                meetings,
                frozenset(dropped),
                DEFAULT_TIGHT_GAP_SECONDS,
                desired_list,
                held,
                current_blocks,
            )
            unsafe.update(invalid)
            desired_list = [block for block in desired_list if _key(block) not in invalid]
            meeting = replace(meeting, return_suppressed=continuing)
        desired_list, extra_diags = _apply_handoff(
            guard=guard,
            meeting=meeting,
            drive=drive,
            desired=desired_list,
            unsafe=unsafe,
            lodging_lookup=lodging_lookup,
            fallback_lookup=fallback_lookup,
            route=route,
        )
        diagnostics.extend(extra_diags)
        used_meetings.add(meeting.identity)

    kept: list[DesiredBlock] = []
    protected_outbound: set[str] = set()
    protected_return: set[str] = set()
    for block in desired_list:
        if not _in_protected(block.start, guards):
            kept.append(block)
            continue
        unsafe.add(_key(block))
        if block.kind == "meeting_outbound":
            protected_outbound.add(block.identity)
            diagnostics.append(
                f"postarrival {block.identity}: drive would start inside a flight "
                "interval — suppressed"
            )
        elif block.kind == "meeting_return":
            protected_return.add(block.identity)
            diagnostics.append(
                f"postarrival {block.identity}: drive would start inside a flight "
                "interval — suppressed"
            )

    # A held meeting leg (in-flight `past` bucket, or a failed route) keeps an
    # existing block alive. It is unsafe only when that block's drive begins inside
    # a protected interval; meetings the handoff already rewrote own their
    # post-arrival return and their stale outbound is already unsafe.
    current_by_key: dict[tuple[str, str], list[ParsedBlock]] = {}
    for block in current_blocks:
        if block.generation == GEN_UNIFIED and block.identity is not None and block.kind:
            current_by_key.setdefault((block.identity, block.kind), []).append(block)
    for item in held:
        if item.identity in used_meetings or item.kind not in _MEETING_KINDS:
            continue
        if not _held_is_unsafe(item, current_by_key, guards):
            continue
        if item.kind == "meeting_outbound":
            protected_outbound.add(item.identity)
        else:
            protected_return.add(item.identity)
        diagnostics.append(
            f"postarrival {item.identity}: drive would start inside a flight interval — suppressed"
        )

    for identity in protected_outbound:
        kept = _drop_meeting(kept, unsafe, identity)
    for identity in protected_return - protected_outbound:
        unsafe.add((identity, "meeting_return"))
        kept = [
            block
            for block in kept
            if not (block.identity == identity and block.kind == "meeting_return")
        ]

    held_list = [item for item in held if item.key not in unsafe]
    return kept, held_list, diagnostics


def _apply_handoff(
    *,
    guard: FlightGuard,
    meeting: PostarrivalMeeting,
    drive: timedelta | None,
    desired: list[DesiredBlock],
    unsafe: set[tuple[str, str]],
    lodging_lookup: AddressLookup,
    fallback_lookup: AddressLookup,
    route: RouteFn,
) -> tuple[list[DesiredBlock], list[str]]:
    """Rewrite the arrival to `meeting`; `drive` is its already-screened route."""
    venue = meeting.location
    assert venue is not None
    diagnostics: list[str] = []
    if drive is None:
        diagnostics.append(f"postarrival {meeting.identity}: airport-to-venue route failed")
        return _drop_meeting(desired, unsafe, meeting.identity), diagnostics
    if drive < timedelta(0):
        diagnostics.append(
            f"postarrival {meeting.identity}: negative route duration — check route provider"
        )
        return _drop_meeting(desired, unsafe, meeting.identity), diagnostics

    lodging = lodging_lookup(guard.ready_at)
    return_dest = lodging if lodging is not None else fallback_lookup(guard.ready_at)
    need_return = not meeting.return_suppressed
    ret_drive = route(venue, return_dest) if need_return and return_dest is not None else None
    if need_return and (ret_drive is None or return_dest is None):
        diagnostics.append(f"postarrival {meeting.identity}: venue-to-lodging route failed")
        return _drop_meeting(desired, unsafe, meeting.identity), diagnostics
    if ret_drive is not None and ret_drive < timedelta(0):
        diagnostics.append(
            f"postarrival {meeting.identity}: negative route duration — check route provider"
        )
        return _drop_meeting(desired, unsafe, meeting.identity), diagnostics

    if ret_drive is not None and ret_drive <= MIN_MEETING_DRIVE:
        need_return = False
        diagnostics.append(
            f"postarrival {meeting.identity}: return at or below one minute — no drive"
        )

    if (
        ret_drive is not None
        and ret_drive > DEFAULT_MAX_REASONABLE_DRIVE
        and not meeting.driving_to
    ):
        diagnostics.append(
            f"postarrival {meeting.identity}: venue-to-lodging drive "
            "implausible — suppressed (away?)"
        )
        return _drop_meeting(desired, unsafe, meeting.identity), diagnostics

    no_arrival_drive = drive <= MIN_MEETING_DRIVE
    if no_arrival_drive:
        unsafe.add((guard.identity, "airport_arrival"))
        diagnostics.append(
            f"postarrival {meeting.identity}: arrival at or below one minute — no drive"
        )
    rewritten: list[DesiredBlock] = []
    found_return = False
    for block in desired:
        if block.kind == "meeting_outbound" and block.identity == meeting.identity:
            unsafe.add(_key(block))
            continue
        if block.kind == "airport_arrival" and block.identity == guard.identity:
            if no_arrival_drive:
                continue
            rewritten.append(
                replace(
                    block,
                    summary=f"Drive: {guard.airport_code} → {venue}",
                    start=guard.ready_at,
                    end=guard.ready_at + drive,
                    origin=guard.airport_label,
                    destination=venue,
                    baseline_seconds=int(drive.total_seconds()),
                    anchor=guard.ready_at,
                )
            )
            continue
        if block.kind == "meeting_return" and block.identity == meeting.identity:
            found_return = True
            if not need_return:
                unsafe.add(_key(block))
                continue
            assert ret_drive is not None and return_dest is not None
            rewritten.append(
                replace(
                    block,
                    start=meeting.end,
                    end=meeting.end + ret_drive,
                    origin=venue,
                    destination=return_dest,
                    baseline_seconds=int(ret_drive.total_seconds()),
                    anchor=meeting.end,
                )
            )
            continue
        rewritten.append(block)

    if need_return and not found_return:
        assert ret_drive is not None and return_dest is not None
        rewritten.append(
            DesiredBlock(
                identity=meeting.identity,
                kind="meeting_return",
                summary=f"Drive: {meeting.summary}",
                start=meeting.end,
                end=meeting.end + ret_drive,
                origin=venue,
                destination=return_dest,
                baseline_seconds=int(ret_drive.total_seconds()),
                anchor=meeting.end,
                timezone=meeting.timezone,
            )
        )
    elif not need_return:
        unsafe.add((meeting.identity, "meeting_return"))

    unsafe.add((meeting.identity, "meeting_outbound"))
    return rewritten, diagnostics

skills

README.md

tile.json