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
"""Persist user "skip this meeting" decisions with auto-expiry — the skip store.
When drive-engine asks "drive or skip?" and the user says skip, that answer
has to stick: re-asking about the same skipped meeting every sweep is the
trust-eroding nag LoMBot hit (Epic #59 §5 #49). This module is the on-disk
store of those skips. `scan.py` consumes the `{meeting_id: expiry}` mapping
this module returns and buckets a still-active skip as `skipped` instead of
`needs_decision`.
Skips expire. A skip is meaningless once its meeting is over, so the writer
sets each skip's expiry to the meeting's end; once that passes,
`load_active_skips` drops it and the id would re-enter `needs_decision` if it
ever recurred. Expiry is also the safety valve against a skip file that
silently suppresses a meeting forever.
State file (per `coding-policy: stateful-artifacts`; see `state-schema.md`):
<state_dir>/skip-state.json
{"schema_version": 1, "skips": {"<meeting_id>": "<ISO-8601 expiry>"}}
Owner / contract:
This module owns the SHAPE — only it migrates `schema_version`. Its writer
and reader are both co-bundled: `skip_drive.py` WRITES via `add_skip` /
`clear_skip` / `prune`, and `reconcile_sweep.py` READS via
`load_active_skips`, feeding the result to `scan(skip_state=...)`. The
drive-planner sweep that used to write is retired (#156) and its bundle is
folded into this one (#181).
stdlib-only per `coding-policy: dependency-management` (Stdlib First).
Public API:
from skip_state import add_skip, load_active_skips, clear_skip, prune
add_skip("evt_42", expires=meeting_end, now=now) # user said skip
active = load_active_skips(now) # → {"evt_42": "..."}
scan(events, now=now, home_address=home, skip_state=active)
"""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime
from pathlib import Path
SKIP_SCHEMA_VERSION = 1
_DEFAULT_STATE_DIR = "/workspace/state/drive-planner"
_STATE_DIR_ENV = "DRIVE_PLANNER_STATE_DIR"
_SKIP_FILE = "skip-state.json"
class SkipStateError(ValueError):
"""Raised on a malformed skip-state file or a bad argument the caller must fix.
A ValueError subclass — the fix is "pass a tz-aware datetime / repair the
state file", not "retry". A *missing* file is never an error (it is
indistinguishable from "no skips yet"); only a present-but-corrupt file
or a future `schema_version` raises.
"""
def state_dir() -> Path:
"""The skip-store state directory.
Defaults to `/workspace/state/drive-planner`; overridable via the
`DRIVE_PLANNER_STATE_DIR` env var (tests point it at a tmp_path). The
directory is created on first write, not here.
The `drive-planner` name in both is deployed state, not a live reference:
the store predates the #181 fold into drive-engine and renaming either
would strand the skips already on disk. Rename only behind a migration.
"""
return Path(os.environ.get(_STATE_DIR_ENV, _DEFAULT_STATE_DIR))
def _skip_path() -> Path:
return state_dir() / _SKIP_FILE
def _require_aware(value: object, name: str) -> datetime:
if not isinstance(value, datetime) or value.tzinfo is None:
raise SkipStateError(f"`{name}` must be a timezone-aware datetime (got {value!r})")
return value
def _require_meeting_id(meeting_id: object) -> str:
if not isinstance(meeting_id, str) or not meeting_id:
raise SkipStateError(f"`meeting_id` must be a non-empty string (got {meeting_id!r})")
return meeting_id
def _atomic_write(path: Path, payload: dict) -> None:
"""Write JSON to `path` atomically (temp file + rename) so a crash mid-write
never leaves a half-written, unparseable state file."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
os.replace(tmp, path)
finally:
# On success the rename consumed `tmp`; on any failure (including an
# interrupt) a partial temp file is left behind — remove it so a
# crash mid-write never strands a `.tmp` beside the real file.
if os.path.exists(tmp):
os.unlink(tmp)
def _read_skips() -> dict[str, str]:
"""Read the skip map from disk, validating the schema.
Returns the raw `{meeting_id: expiry}` mapping (no pruning). A missing
file returns an empty map. A *corrupt* file — unparseable, not an object,
or missing/invalid `schema_version` — raises SkipStateError; a corrupt
skip file must not be silently treated as "no skips" (that would
resurrect every skipped meeting as a nag).
Schema version handling (per `coding-policy: stateful-artifacts`):
- newer than this plugin → raise, on BOTH the read and write paths
(#184). Reading it as "no usable prior state" would drop every
active skip, and an empty skip map is not inert: the sweep re-plans
each skipped meeting and pings the operator about drives they
declined — the nag `stateful-artifacts` forbids a fallback from
escalating into, and the exact lombot #49 scar this module exists
to prevent. Raising instead surfaces at `reconcile_sweep.main`'s
fail-closed boundary as a clean no-wake skip — the same whole-cycle
skip any sweep error takes: no partial plan, no nag. The fix is to
upgrade the plugin. A write additionally must not proceed because it
would rewrite the file as v1 and clobber a newer writer.
- below the current floor → owner-side migration point. v1 is the
first and only version, so any lower value is corrupt, not an older
record to migrate — refuse explicitly. A future bump adds the
v(N-1)→vN upgrade-and-rewrite here instead of refusing.
"""
path = _skip_path()
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise SkipStateError(
f"skip-state file {path} is unreadable / not valid JSON ({exc}) — repair or delete it"
) from exc
if not isinstance(payload, dict):
raise SkipStateError(f"skip-state file {path} must contain a JSON object")
version = payload.get("schema_version")
if not isinstance(version, int) or isinstance(version, bool):
raise SkipStateError(f"skip-state file {path} is missing a valid integer schema_version")
if version > SKIP_SCHEMA_VERSION:
raise SkipStateError(
f"skip-state file {path} has schema_version {version}, newer than this "
f"plugin supports ({SKIP_SCHEMA_VERSION}) — upgrade the nanoclaw-travel "
"plugin; refusing to read it as empty (that would drop every active skip "
"and re-ask about meetings already declined) or to overwrite it as v1"
)
if version < SKIP_SCHEMA_VERSION:
raise SkipStateError(
f"skip-state file {path} has schema_version {version}, below the current "
f"floor ({SKIP_SCHEMA_VERSION}) with no migration path — repair or delete it"
)
skips = payload.get("skips")
if not isinstance(skips, dict):
raise SkipStateError(f"skip-state file {path} `skips` must be a JSON object")
# Coerce to the {str: str} contract; drop any malformed entry rather than
# let it crash a downstream expiry parse.
return {
str(mid): expiry
for mid, expiry in skips.items()
if isinstance(mid, str) and isinstance(expiry, str)
}
def _write_skips(skips: dict[str, str]) -> None:
_atomic_write(_skip_path(), {"schema_version": SKIP_SCHEMA_VERSION, "skips": skips})
def _is_active(expiry_iso: str, now: datetime) -> bool:
"""True when `expiry_iso` parses to a tz-aware time strictly after `now`.
A malformed or naive expiry is treated as inactive (expired) — an
unusable expiry must not pin a skip forever.
"""
text = expiry_iso.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
return expiry > now
def add_skip(meeting_id: str, *, expires: datetime, now: datetime) -> None:
"""Record a skip for `meeting_id`, expiring at `expires`.
Idempotent — re-skipping the same meeting updates its expiry. Expired
entries are pruned in the same write, so the file does not grow without
bound.
Raises:
SkipStateError: on an empty meeting_id or a naive `expires` / `now`.
"""
meeting_id = _require_meeting_id(meeting_id)
expires = _require_aware(expires, "expires")
now = _require_aware(now, "now")
skips = {mid: exp for mid, exp in _read_skips().items() if _is_active(exp, now)}
skips[meeting_id] = expires.isoformat()
_write_skips(skips)
def load_active_skips(now: datetime) -> dict[str, str]:
"""Return the `{meeting_id: expiry}` mapping of skips still active at `now`.
This is the shape `scan(skip_state=...)` consumes. Read-only — it filters
expired entries out of the returned mapping but does not rewrite the file
(call `prune` to reclaim disk). A missing file returns an empty mapping.
Raises:
SkipStateError: on a naive `now` or a corrupt skip file.
"""
now = _require_aware(now, "now")
return {mid: exp for mid, exp in _read_skips().items() if _is_active(exp, now)}
def clear_skip(meeting_id: str, *, now: datetime) -> bool:
"""Remove a skip for `meeting_id`. Returns True if one was present.
Also prunes expired entries in the rewrite. A no-op (id absent) does not
rewrite the file.
Raises:
SkipStateError: on an empty meeting_id or a naive `now`.
"""
meeting_id = _require_meeting_id(meeting_id)
now = _require_aware(now, "now")
current = _read_skips()
if meeting_id not in current:
return False
survivors = {
mid: exp for mid, exp in current.items() if mid != meeting_id and _is_active(exp, now)
}
_write_skips(survivors)
return True
def prune(now: datetime) -> int:
"""Drop every expired skip and rewrite the file. Returns the count removed.
A no-op (nothing expired, or no file) does not rewrite.
Raises:
SkipStateError: on a naive `now` or a corrupt skip file.
"""
now = _require_aware(now, "now")
current = _read_skips()
survivors = {mid: exp for mid, exp in current.items() if _is_active(exp, now)}
removed = len(current) - len(survivors)
if removed:
_write_skips(survivors)
return removed.tessl-plugin
skills
check-travel-bookings
drive-engine
flight-assist
references
nightly-travel-sync
sync-tripit
travel-core