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.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
High
Do not use without reviewing
"""Unified drive-block codec — machine-state writer and multi-shape reader.
One codec for every drive block the engine writes, replacing the two legacy codecs
(flight-assist `<!--fadrive:-->` and drive-planner `<!--dp:-->`).
Where the machine state lives
-----------------------------
State rides in the event's `extendedProperties.private` (the #178 target): a flat
`dengine_*`-namespaced string map, the only value type the field accepts, one key
per state field. `build_extended_properties` is the schema's source of truth and
what `calendar_apply` writes on create/patch. The human line stays in the
description on purpose — it is what the operator sees in the calendar UI; only the
machine state lives in `extendedProperties`.
Leg identity follows #156 C1 / G4 and keys on the CANONICAL flight identity, never
the designator:
airport_departure / airport_arrival : <dep>-<arr>-<sched_dep_utc> (+ kind)
airport_transfer : <arr-flight-key>|<dep-flight-key>
meeting : the calendar meeting id
so a codeshare tracked under two designators still maps to one block. `kind`
disambiguates the departure vs arrival block on the same flight.
Multi-shape reader (#156 R4 cutover): `parse_block` reads the current unified state
from `extendedProperties.private` AND recognizes both legacy description shapes,
tagging each with its generation so the reconcile can converge prior-gen blocks
(adopt-under-new-identity + delete legacy) and delete orphans, never double-stamp.
Malformed / unrecognized events parse to None (never raise), so one bad event
cannot abort a sweep.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime
from chain import LegKind
from flight_identity import MergedFlight
from leg_anchor import ConcreteLeg
# --- schema + generations ---------------------------------------------------
UNIFIED_BLOCK_SCHEMA_VERSION = 1
GEN_UNIFIED = "unified"
GEN_LEGACY_FADRIVE = "legacy_fadrive"
GEN_LEGACY_DP = "legacy_dp"
# --- alert values -----------------------------------------------------------
ALERT_GROWTH = "growth"
ALERT_LEAVE_NOW = "leave_now"
_ALERT_VALUES = (ALERT_GROWTH, ALERT_LEAVE_NOW)
# --- extendedProperties.private state (the #178 migration target) -----------
# `extendedProperties.private` is a flat string→string map shared with any other
# tool that tags the event, so every key is `dengine_`-namespaced to avoid
# clobbering a neighbour's tag. Values are always strings (the only type the field
# accepts) — ints and datetimes are stringified on write and parsed back on read.
# The version key is spelled out per `coding-policy: stateful-artifacts`, which
# requires every record to carry an auditable `schema_version` field by that name;
# the namespace prefix keeps it collision-safe in the shared map.
_EXT_KEY_VERSION = "dengine_schema_version"
_EXT_KEY_LEG = "dengine_leg"
_EXT_KEY_KIND = "dengine_kind"
_EXT_KEY_BASELINE = "dengine_b"
_EXT_KEY_ANCHOR = "dengine_a"
_EXT_KEY_WINDOW_END = "dengine_we"
_EXT_KEY_ORIGIN = "dengine_o"
_EXT_KEY_DESTINATION = "dengine_d"
_EXT_KEY_ALERTED = "dengine_al"
# --- legacy markers (read-only, for cutover convergence) --------------------
_LEGACY_FADRIVE_MARKER_RE = re.compile(
r"\[flight-assist:flight=(?P<id>[^:\]]+):dir=(?P<dir>[^:\]]+)\]"
)
_LEGACY_FADRIVE_STATE_RE = re.compile(r"<!--fadrive:(?P<json>\{.*?\})-->", re.DOTALL)
_LEGACY_DP_MARKER_RE = re.compile(r"\[drive-planner:meeting=(?P<id>[^:\]]+):dir=(?P<dir>[^:\]]+)\]")
_LEGACY_DP_STATE_RE = re.compile(r"<!--dp:(?P<json>\{.*?\})-->", re.DOTALL)
# --- leg identity -----------------------------------------------------------
def canonical_flight_key(flight: MergedFlight) -> str:
"""Stable per-flight identity string: route + scheduled-departure UTC instant.
Excludes the designator (#156 W3): two codeshare records for one physical
flight share this key. Minute precision — the identity tolerance lives in the
merge, so by here the flight is already one canonical instant.
"""
stamp = flight.scheduled_dep.strftime("%Y%m%dT%H%MZ")
return f"{flight.dep_airport}-{flight.arr_airport}-{stamp}"
def leg_identity(leg: ConcreteLeg) -> str:
"""The marker identity for a concrete airport leg (#156 C1 / G4).
Departure / arrival key on the single flight; transfer keys on the ordered
pair `<arrival-flight>|<departure-flight>` so the between-flights block has its
own identity distinct from either flight's own legs.
"""
if leg.kind is LegKind.AIRPORT_TRANSFER:
if leg.partner_flight is None:
raise ValueError("transfer leg missing partner_flight for identity")
return f"{canonical_flight_key(leg.flight)}|{canonical_flight_key(leg.partner_flight)}"
return canonical_flight_key(leg.flight)
# --- alerts -----------------------------------------------------------------
def serialize_alerted(alerted: frozenset | set) -> str:
"""Serialize an alert set to the stable comma-joined record."""
return ",".join(value for value in _ALERT_VALUES if value in alerted)
def parse_alerted(raw: object) -> frozenset:
"""Parse the comma-joined alert record into a set. Tolerant: unknown tokens
dropped, non-string yields empty (a corrupt record must never crash a sweep)."""
if not isinstance(raw, str):
return frozenset()
return frozenset(token.strip() for token in raw.split(",") if token.strip() in _ALERT_VALUES)
# --- build ------------------------------------------------------------------
def build_extended_properties(
*,
identity: str,
kind: str,
baseline_seconds: int,
anchor: datetime,
origin: str,
destination: str,
window_end: datetime | None = None,
alerted: frozenset | set = frozenset(),
) -> dict:
"""The `extendedProperties` field VALUE carrying the block's machine state —
the #178 migration target and the schema's source of truth.
Returns `{"private": {...}}`, the value of the event resource's
`extendedProperties` field — NOT a full event body. A writer nests it under
the top-level key: `events.insert` / `events.patch` receive
`{..., "extendedProperties": build_extended_properties(...)}`. The writer owns
these events exclusively and re-asserts the COMPLETE `dengine_*` set every
write, so whether Calendar merges or replaces the private map on patch does not
matter. Every value is a string — the only type `extendedProperties.private`
accepts — so `baseline_seconds` and the datetimes are stringified here and
parsed back in `parse_block`. `window_end` is emitted only for transfer legs.
Both the reader (`parse_block`) and the writer (`calendar_apply` create/patch)
use this. It carries no human line — the description keeps that, since it is
what the operator sees; only the machine state lives here.
"""
private: dict[str, str] = {
_EXT_KEY_VERSION: str(UNIFIED_BLOCK_SCHEMA_VERSION),
_EXT_KEY_LEG: identity,
_EXT_KEY_KIND: kind,
_EXT_KEY_BASELINE: str(baseline_seconds),
_EXT_KEY_ANCHOR: anchor.isoformat(),
_EXT_KEY_ORIGIN: origin,
_EXT_KEY_DESTINATION: destination,
_EXT_KEY_ALERTED: serialize_alerted(alerted),
}
if window_end is not None:
private[_EXT_KEY_WINDOW_END] = window_end.isoformat()
return {"private": private}
# --- parse (extendedProperties + legacy-description reader) ------------------
@dataclass(frozen=True)
class ParsedBlock:
"""A drive block read off a calendar event, tagged with its generation.
`generation` is `unified` for a new block, or a legacy tag the cutover
reconcile uses to converge/delete. For unified blocks, `identity` + `kind`
carry the leg identity and the state fields are populated. For legacy blocks,
`legacy_id` + `legacy_direction` carry the old marker's identity so reconcile
can match or orphan them; their state fields are best-effort.
"""
generation: str
event_id: str | None = None
identity: str | None = None
kind: str | None = None
legacy_id: str | None = None
legacy_direction: str | None = None
baseline_seconds: int | None = None
anchor: datetime | None = None
window_end: datetime | None = None
origin: str | None = None
destination: str | None = None
alerted: frozenset = field(default_factory=frozenset)
def _event_description(event: object) -> str | None:
if not isinstance(event, dict):
return None
desc = event.get("description")
return desc if isinstance(desc, str) else None
def _event_id(event: object) -> str | None:
if isinstance(event, dict):
eid = event.get("id")
if isinstance(eid, str):
return eid
return None
def _parse_dt(raw: object) -> datetime | None:
if not isinstance(raw, str):
return None
try:
return datetime.fromisoformat(raw)
except ValueError:
return None
def _parse_int(raw: object) -> int | None:
"""Parse a baseline that may be a real int (description JSON) or a numeric
string (`extendedProperties`, which stores every value as a string)."""
if isinstance(raw, bool):
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str):
try:
return int(raw)
except ValueError:
return None
return None
def _event_extended_private(event: object) -> dict | None:
"""The event's `extendedProperties.private` map, or None when absent/malformed."""
if not isinstance(event, dict):
return None
ext = event.get("extendedProperties")
if not isinstance(ext, dict):
return None
private = ext.get("private")
return private if isinstance(private, dict) else None
def _parse_extended_block(private: dict, event_id: str | None) -> ParsedBlock | None:
"""Read a unified block off `extendedProperties.private`, or None to fall back.
Returns None when the map carries no current-version `dengine_*` state or lacks
a usable leg identity — the caller then tries the two legacy description readers
(fadrive / dp). A version other than the current one reads as "no unified state
here" and returns None too; the writer and reader ship together, so a mismatch
is only a transient rollout artifact that the next same-version sweep rewrites.
"""
if private.get(_EXT_KEY_VERSION) != str(UNIFIED_BLOCK_SCHEMA_VERSION):
return None
identity = private.get(_EXT_KEY_LEG)
kind = private.get(_EXT_KEY_KIND)
if not (isinstance(identity, str) and identity and isinstance(kind, str) and kind):
return None
origin = private.get(_EXT_KEY_ORIGIN)
destination = private.get(_EXT_KEY_DESTINATION)
return ParsedBlock(
generation=GEN_UNIFIED,
event_id=event_id,
identity=identity,
kind=kind,
baseline_seconds=_parse_int(private.get(_EXT_KEY_BASELINE)),
anchor=_parse_dt(private.get(_EXT_KEY_ANCHOR)),
window_end=_parse_dt(private.get(_EXT_KEY_WINDOW_END)),
origin=origin if isinstance(origin, str) else None,
destination=destination if isinstance(destination, str) else None,
alerted=parse_alerted(private.get(_EXT_KEY_ALERTED)),
)
def parse_block(event: object) -> ParsedBlock | None:
"""Read a drive block off a calendar event; extendedProperties + legacy (#178, R4).
Reads the current unified state from `extendedProperties.private` (the #178
migration target), falling back to the two legacy description markers. Returns a
ParsedBlock tagged with its generation, or None for an event that carries no
unified extended-properties state AND neither legacy description marker (or is
malformed). Never raises — a single bad event must not abort a sweep.
"""
eid = _event_id(event)
private = _event_extended_private(event)
if private is not None:
extended = _parse_extended_block(private, eid)
if extended is not None:
return extended
desc = _event_description(event)
if desc is None:
return None
fadrive = _LEGACY_FADRIVE_MARKER_RE.search(desc)
if fadrive and _LEGACY_FADRIVE_STATE_RE.search(desc):
return ParsedBlock(
generation=GEN_LEGACY_FADRIVE,
event_id=eid,
legacy_id=fadrive["id"],
legacy_direction=fadrive["dir"],
)
dp = _LEGACY_DP_MARKER_RE.search(desc)
if dp and _LEGACY_DP_STATE_RE.search(desc):
return ParsedBlock(
generation=GEN_LEGACY_DP,
event_id=eid,
legacy_id=dp["id"],
legacy_direction=dp["dir"],
)
return None.tessl-plugin
skills
check-travel-bookings
drive-engine
flight-assist
references
nightly-travel-sync
sync-tripit
travel-core