Compare commits
4 Commits
57f6b132b2
...
e2657f4850
| Author | SHA1 | Date | |
|---|---|---|---|
| e2657f4850 | |||
| c30ad85e0d | |||
| ba5b0e4e67 | |||
| 9c7103770a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.worktrees/
|
||||
node_modules/
|
||||
|
||||
@@ -17,6 +17,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
||||
| Skill | What it does | Path |
|
||||
|---|---|---|
|
||||
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
||||
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Luke’s sender path. | `skills/flight-finder` |
|
||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
||||
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
||||
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
||||
|
||||
@@ -5,6 +5,7 @@ This folder contains detailed docs for each skill in this repository.
|
||||
## Skills
|
||||
|
||||
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
||||
- [`flight-finder`](flight-finder.md) — Typed flight-search intake, bounded source orchestration, PDF report rendering, and Luke-sender email delivery
|
||||
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
||||
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
|
||||
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
||||
|
||||
55
docs/flight-finder.md
Normal file
55
docs/flight-finder.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# flight-finder
|
||||
|
||||
Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery.
|
||||
|
||||
## Core behavior
|
||||
|
||||
`flight-finder` is designed to:
|
||||
|
||||
- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose
|
||||
- support split returns, flexible dates, passenger groups, and exclusions
|
||||
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
|
||||
- normalize pricing to USD before ranking
|
||||
- produce a report payload first, then render PDF/email only when the report is complete
|
||||
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
|
||||
|
||||
## Important rules
|
||||
|
||||
- Recipient email is a delivery gate, not a search gate.
|
||||
- `marketCountry` is explicit-only in this implementation pass.
|
||||
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
|
||||
- If present, it activates VPN only for the bounded search phase.
|
||||
- If omitted, no VPN change happens.
|
||||
- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging.
|
||||
- Direct-airline cross-checking is currently best-effort, not a hard blocker.
|
||||
|
||||
## Helper package
|
||||
|
||||
From `skills/flight-finder/`:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run normalize-request -- --legacy-dfw-blq
|
||||
npm run normalize-request -- --input "<request.json>"
|
||||
npm run report-status -- --input "<report-payload.json>"
|
||||
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||
```
|
||||
|
||||
## Delivery
|
||||
|
||||
- sender identity: `luke@fiorinis.com`
|
||||
- send path:
|
||||
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||
- if the user already provided the destination email, that counts as delivery authorization once the report is ready
|
||||
|
||||
## Source viability for implementation pass 1
|
||||
|
||||
Bounded checks on Stefano's MacBook Air showed:
|
||||
|
||||
- `KAYAK`: viable
|
||||
- `Skyscanner`: viable
|
||||
- `Expedia`: viable
|
||||
- airline direct-booking cross-check: degraded / best-effort
|
||||
|
||||
That means the first implementation pass should rely primarily on the three aggregator sources and treat direct-airline confirmation as additive evidence when it succeeds.
|
||||
125
skills/flight-finder/SKILL.md
Normal file
125
skills/flight-finder/SKILL.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: flight-finder
|
||||
description: Use when a user wants a flight-search report with explicit trip constraints, ranked options, and PDF/email delivery. Prefer when the request may include split returns, flexible dates, passenger groups, market-localized pricing, or a WhatsApp-safe workflow that should keep going after status nudges instead of hanging behind a long inline prompt.
|
||||
---
|
||||
|
||||
# Flight Finder
|
||||
|
||||
Use this skill instead of the old `docs/prompts/dfw-blq-2026.md` prompt when the user wants a flight-search report.
|
||||
|
||||
The deliverable target is always a PDF report plus email delivery when the report is complete.
|
||||
|
||||
## Inputs
|
||||
|
||||
Accept either:
|
||||
|
||||
- a natural-language trip request
|
||||
- a structured helper payload
|
||||
- a route plus partial constraints that still need follow-up questions
|
||||
|
||||
Collect these explicitly when missing:
|
||||
|
||||
- origin and destination
|
||||
- outbound date window
|
||||
- whether there are one or more return legs
|
||||
- passenger groups
|
||||
- which passenger groups travel on which legs
|
||||
- flexibility rules
|
||||
- cabin / stop / layover preferences when relevant
|
||||
- exclusions such as airlines, countries, or airports when relevant
|
||||
- recipient email before final render/send
|
||||
|
||||
Optional explicit input:
|
||||
|
||||
- `marketCountry`
|
||||
- if present, it must be a validated ISO 3166-1 alpha-2 uppercase country code such as `TH` or `DE`
|
||||
- if present, it activates the VPN / market-localized search path for the bounded search phase only
|
||||
- if absent, do not change VPN in this implementation pass
|
||||
|
||||
Do not silently infer or reuse missing trip-shape details from earlier runs just because the route looks similar.
|
||||
If the user already explicitly said where to email the finished PDF, that counts as delivery authorization once the report is complete. Do not ask for a second send confirmation unless the destination changed or the user sounded uncertain.
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Normalize the trip request into typed legs, passenger groups, assignments, and preferences.
|
||||
2. Ask targeted follow-up questions for missing search-critical inputs.
|
||||
3. Run the bounded search phase across the configured travel sources.
|
||||
4. Rank the viable combinations in USD.
|
||||
5. Assemble the final report payload.
|
||||
6. Render the PDF.
|
||||
7. Send the email from Luke to the user-specified recipient.
|
||||
|
||||
Completion rule:
|
||||
|
||||
- A partial source result is not completion.
|
||||
- A partial helper result is not completion.
|
||||
- The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted.
|
||||
|
||||
## WhatsApp-safe behavior
|
||||
|
||||
Follow the same operational style as `property-assessor`:
|
||||
|
||||
- missing required input should trigger a direct question, not an invented assumption
|
||||
- `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going”
|
||||
- a silent helper/source path is a failed path and should be abandoned quickly
|
||||
- keep progress state by phase so a dropped session can resume or at least report the last completed phase
|
||||
- do not start email-tool exploration before the report payload is complete and the PDF is ready to render
|
||||
- if PDF render fails, return the completed report summary in chat and report delivery failure separately
|
||||
- if email send fails after render, say so clearly and keep the rendered PDF path
|
||||
|
||||
## Search-source contract
|
||||
|
||||
Use the proven travel-source order from the local viability findings:
|
||||
|
||||
1. KAYAK
|
||||
2. Skyscanner
|
||||
3. Expedia
|
||||
4. airline direct-booking cross-check
|
||||
|
||||
Rules:
|
||||
|
||||
- treat source viability as evidence-based, not assumed
|
||||
- if a source is blocked or degraded, continue with the remaining sources and record the issue in the report
|
||||
- keep the bounded search phase observable with concise updates
|
||||
- normalize compared prices to USD before ranking
|
||||
- do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded
|
||||
|
||||
## VPN / market-country rules
|
||||
|
||||
- Use VPN only when `marketCountry` is explicitly provided in the typed request for this implementation pass.
|
||||
- Connect VPN only for the bounded search phase.
|
||||
- Bounded search phase means: from first travel-site navigation until the last search-result capture for the report.
|
||||
- If VPN connect or verification fails within the configured timeout, continue on the default market and mark the report as degraded.
|
||||
- Disconnect VPN immediately after the bounded search phase, before ranking/render/delivery.
|
||||
- On rollback or failure, attempt disconnect immediately.
|
||||
|
||||
## Delivery rules
|
||||
|
||||
- Sender must be Luke: `luke@fiorinis.com`
|
||||
- Delivery path must be Luke’s wrapper:
|
||||
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||
- If recipient email is missing, ask for it before the render/send phase.
|
||||
- If the user already provided the recipient email, do not ask for a redundant “send it” confirmation.
|
||||
|
||||
## Helper commands
|
||||
|
||||
From `~/.openclaw/workspace/skills/flight-finder/` or the repo mirror copy:
|
||||
|
||||
```bash
|
||||
npm run normalize-request -- --legacy-dfw-blq
|
||||
npm run normalize-request -- --input "<request.json>"
|
||||
npm run report-status -- --input "<report-payload.json>"
|
||||
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `normalize-request` should report missing search inputs separately from delivery-only email gaps
|
||||
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
|
||||
- `render-report` must reject incomplete report payloads
|
||||
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
|
||||
|
||||
## Prompt migration rule
|
||||
|
||||
The old DFW↔BLQ prompt is legacy input only. Do not treat it as the execution engine. Convert its hardcoded logic into the typed request model and run the skill workflow instead.
|
||||
158
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
158
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"request": {
|
||||
"tripName": "DFW ↔ BLQ flight report",
|
||||
"legs": [
|
||||
{
|
||||
"id": "outbound",
|
||||
"origin": "DFW",
|
||||
"destination": "BLQ",
|
||||
"earliest": "2026-05-30",
|
||||
"latest": "2026-06-07",
|
||||
"label": "Outbound"
|
||||
},
|
||||
{
|
||||
"id": "return-pair",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"relativeToLegId": "outbound",
|
||||
"minDaysAfter": 6,
|
||||
"maxDaysAfter": 10,
|
||||
"label": "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
"id": "return-solo",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"earliest": "2026-06-28",
|
||||
"latest": "2026-07-05",
|
||||
"label": "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
"passengerGroups": [
|
||||
{
|
||||
"id": "pair",
|
||||
"adults": 2,
|
||||
"label": "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
"id": "solo",
|
||||
"adults": 1,
|
||||
"label": "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
"legAssignments": [
|
||||
{
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
"legId": "return-pair",
|
||||
"passengerGroupIds": ["pair"]
|
||||
},
|
||||
{
|
||||
"legId": "return-solo",
|
||||
"passengerGroupIds": ["solo"]
|
||||
}
|
||||
],
|
||||
"recipientEmail": "stefano@fiorinis.com",
|
||||
"preferences": {
|
||||
"preferOneStop": true,
|
||||
"maxStops": 1,
|
||||
"maxLayoverHours": 6,
|
||||
"flexibleDates": true,
|
||||
"excludeAirlines": ["Turkish Airlines"],
|
||||
"excludeCountries": ["TR"],
|
||||
"requireAirlineDirectCrossCheck": true,
|
||||
"specialConstraints": [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
"geoPricingMarket": "Thailand",
|
||||
"marketCountry": "TH",
|
||||
"normalizeCurrencyTo": "USD"
|
||||
}
|
||||
},
|
||||
"sourceFindings": [
|
||||
{
|
||||
"source": "kayak",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:00:00Z",
|
||||
"notes": [
|
||||
"KAYAK returned multiple one-stop DFW -> BLQ options and exposed direct-booking hints for British Airways."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "skyscanner",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:05:00Z",
|
||||
"notes": [
|
||||
"Skyscanner returned one-stop and multi-stop DFW -> BLQ results with total USD pricing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "expedia",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:10:00Z",
|
||||
"notes": [
|
||||
"Expedia returned one-way DFW -> BLQ options with clear per-traveler pricing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "airline-direct",
|
||||
"status": "degraded",
|
||||
"checkedAt": "2026-03-30T21:15:00Z",
|
||||
"notes": [
|
||||
"United's direct booking shell loaded with the route/date context but failed to complete the search."
|
||||
]
|
||||
}
|
||||
],
|
||||
"quotes": [
|
||||
{
|
||||
"id": "kayak-outbound-ba",
|
||||
"source": "kayak",
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"],
|
||||
"bookingLink": "https://www.kayak.com/flights/DFW-BLQ/2026-05-30?adults=3&sort=bestflight_a&fs=stops=-2",
|
||||
"itinerarySummary": "British Airways via London Heathrow",
|
||||
"airlineName": "British Airways",
|
||||
"departureTimeLocal": "10:59 PM",
|
||||
"arrivalTimeLocal": "11:45 PM +1",
|
||||
"stopsText": "1 stop",
|
||||
"layoverText": "6h 15m in London Heathrow",
|
||||
"totalDurationText": "17h 46m",
|
||||
"totalPriceUsd": 2631,
|
||||
"displayPriceUsd": "$2,631 total",
|
||||
"directBookingUrl": "https://www.britishairways.com/",
|
||||
"crossCheckStatus": "failed",
|
||||
"notes": [
|
||||
"Observed on KAYAK at $877 per traveler for 3 adults.",
|
||||
"Airline-direct cross-check remains degraded in this implementation pass."
|
||||
]
|
||||
}
|
||||
],
|
||||
"rankedOptions": [
|
||||
{
|
||||
"id": "primary-outbound",
|
||||
"title": "Best observed outbound baseline",
|
||||
"quoteIds": ["kayak-outbound-ba"],
|
||||
"totalPriceUsd": 2631,
|
||||
"rationale": "Lowest observed one-stop fare in the bounded spike while still meeting the layover constraint."
|
||||
}
|
||||
],
|
||||
"executiveSummary": [
|
||||
"The bounded implementation-pass smoke test produced a viable report payload from real DFW -> BLQ search evidence.",
|
||||
"KAYAK, Skyscanner, and Expedia all returned usable results on this machine.",
|
||||
"Direct-airline cross-checking is currently best-effort and should be reported honestly when it fails."
|
||||
],
|
||||
"reportWarnings": [
|
||||
"This smoke-test payload captures the bounded implementation-pass workflow, not the full old prompt's multi-leg optimization."
|
||||
],
|
||||
"degradedReasons": [
|
||||
"Airline direct-booking cross-check is still degraded and must be treated as best-effort in this pass."
|
||||
],
|
||||
"comparisonCurrency": "USD",
|
||||
"marketCountryUsed": "TH",
|
||||
"lastCompletedPhase": "ranking",
|
||||
"generatedAt": "2026-03-30T21:20:00Z"
|
||||
}
|
||||
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"tripName": "DFW ↔ BLQ flight report",
|
||||
"legs": [
|
||||
{
|
||||
"id": "outbound",
|
||||
"origin": "DFW",
|
||||
"destination": "BLQ",
|
||||
"earliest": "2026-05-30",
|
||||
"latest": "2026-06-07",
|
||||
"label": "Outbound"
|
||||
},
|
||||
{
|
||||
"id": "return-pair",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"relativeToLegId": "outbound",
|
||||
"minDaysAfter": 6,
|
||||
"maxDaysAfter": 10,
|
||||
"label": "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
"id": "return-solo",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"earliest": "2026-06-28",
|
||||
"latest": "2026-07-05",
|
||||
"label": "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
"passengerGroups": [
|
||||
{
|
||||
"id": "pair",
|
||||
"adults": 2,
|
||||
"label": "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
"id": "solo",
|
||||
"adults": 1,
|
||||
"label": "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
"legAssignments": [
|
||||
{
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
"legId": "return-pair",
|
||||
"passengerGroupIds": ["pair"]
|
||||
},
|
||||
{
|
||||
"legId": "return-solo",
|
||||
"passengerGroupIds": ["solo"]
|
||||
}
|
||||
],
|
||||
"recipientEmail": "stefano@fiorinis.com",
|
||||
"preferences": {
|
||||
"preferOneStop": true,
|
||||
"maxStops": 1,
|
||||
"maxLayoverHours": 6,
|
||||
"flexibleDates": true,
|
||||
"excludeAirlines": ["Turkish Airlines"],
|
||||
"excludeCountries": ["TR"],
|
||||
"requireAirlineDirectCrossCheck": true,
|
||||
"specialConstraints": [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
"geoPricingMarket": "Thailand",
|
||||
"marketCountry": "TH",
|
||||
"normalizeCurrencyTo": "USD"
|
||||
}
|
||||
}
|
||||
791
skills/flight-finder/package-lock.json
generated
Normal file
791
skills/flight-finder/package-lock.json
generated
Normal file
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
skills/flight-finder/package.json
Normal file
23
skills/flight-finder/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"normalize-request": "tsx src/cli.ts normalize-request",
|
||||
"report-status": "tsx src/cli.ts report-status",
|
||||
"render-report": "tsx src/cli.ts render-report",
|
||||
"delivery-plan": "tsx src/cli.ts delivery-plan",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
75
skills/flight-finder/references/source-viability.md
Normal file
75
skills/flight-finder/references/source-viability.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Flight Finder Source Viability
|
||||
|
||||
This note records bounded live checks for the first implementation pass.
|
||||
|
||||
It is intentionally operational, not aspirational: if a source is blocked here, the skill must treat it as blocked unless later evidence replaces this note.
|
||||
|
||||
## Required sources
|
||||
|
||||
- KAYAK
|
||||
- Skyscanner
|
||||
- Expedia
|
||||
- airline direct-booking cross-check
|
||||
|
||||
## Status
|
||||
|
||||
Checked on `2026-03-30` from Stefano's MacBook Air, with no VPN active, using the existing local `web-automation` / CloakBrowser probe scripts.
|
||||
|
||||
Route used for bounded checks:
|
||||
|
||||
- `DFW -> BLQ`
|
||||
- outbound date: `2026-05-30`
|
||||
- travelers: `3 adults`
|
||||
|
||||
### KAYAK
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node flight_kayak_sweep.mjs DFW BLQ 3 /tmp/flight-finder-kayak-dates.json ...`
|
||||
- Evidence:
|
||||
- title returned as `DFW to BLQ, 5/30`
|
||||
- results included multiple one-stop itineraries with parsed USD fares such as `$877`, `$949`, `$955`
|
||||
- direct-booking hints were visible for British Airways on at least some results
|
||||
- Implementation note:
|
||||
- KAYAK can be a primary source in this first implementation pass
|
||||
- parsed text is workable, but still brittle enough that bounded retries and status fallback remain necessary
|
||||
|
||||
### Skyscanner
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.skyscanner.com/transport/flights/dfw/blq/260530/?adultsv2=3&cabinclass=economy&rtn=0'`
|
||||
- Evidence:
|
||||
- title returned as `Cheap flights from Dallas to Bologna on Skyscanner`
|
||||
- results page exposed concrete prices, total trip prices, stops, and itinerary text
|
||||
- one-stop and multi-stop options were visible in the captured text
|
||||
- Implementation note:
|
||||
- Skyscanner is viable for bounded result capture in this first pass
|
||||
- itinerary extraction should still be treated as text-scrape, not a stable API
|
||||
|
||||
### Expedia
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node tmp_expedia_probe.mjs 'https://www.expedia.com/Flights-Search?...'`
|
||||
- Evidence:
|
||||
- title returned as `DFW to BLQ flights`
|
||||
- results page exposed current lowest price, airline/stops filters, and concrete per-traveler options such as `$877`, `$949`, `$961`
|
||||
- Expedia text already surfaced some itinerary summaries in a report-friendly format
|
||||
- Implementation note:
|
||||
- Expedia is viable for bounded result capture in this first pass
|
||||
- as with the other aggregators, source-specific timeouts and fallback rules are still required
|
||||
|
||||
### Airline direct-booking cross-check
|
||||
|
||||
- Status: `degraded`
|
||||
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.united.com/en/us/fsr/choose-flights?...'`
|
||||
- Evidence:
|
||||
- United's booking shell loaded and recognized the route / date context
|
||||
- the search then returned `united.com was unable to complete your request. Please try again later.`
|
||||
- Implementation note:
|
||||
- direct-airline cross-checking remains in scope, but it should be treated as best-effort in the first pass
|
||||
- when a direct site fails or refuses completion, the skill should record the failure explicitly instead of hanging or pretending a clean cross-check happened
|
||||
|
||||
## Scope decision for implementation pass 1
|
||||
|
||||
- Primary bounded search sources: `KAYAK`, `Skyscanner`, `Expedia`
|
||||
- Direct-airline cross-check: `best-effort / degraded`
|
||||
- The skill should continue if the direct-airline step fails, but the report must say that the direct cross-check was not fully completed
|
||||
86
skills/flight-finder/src/cli.ts
Normal file
86
skills/flight-finder/src/cli.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs/promises";
|
||||
import minimist from "minimist";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "./request-normalizer.js";
|
||||
import { getFlightReportStatus } from "./report-status.js";
|
||||
import { renderFlightReportPdf } from "./report-pdf.js";
|
||||
import { buildLukeDeliveryPlan } from "./report-delivery.js";
|
||||
import type { FlightReportPayload, FlightReportRequestDraft } from "./types.js";
|
||||
|
||||
function usage(): never {
|
||||
throw new Error(`Usage:
|
||||
flight-finder normalize-request --input "<request.json>"
|
||||
flight-finder normalize-request --legacy-dfw-blq
|
||||
flight-finder report-status --input "<report-payload.json>"
|
||||
flight-finder render-report --input "<report-payload.json>" --output "<report.pdf>"
|
||||
flight-finder delivery-plan --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"`);
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ["input", "output", "to", "subject", "body", "attach"],
|
||||
boolean: ["legacy-dfw-blq"]
|
||||
});
|
||||
|
||||
const command = argv._[0];
|
||||
if (!command) {
|
||||
usage();
|
||||
}
|
||||
|
||||
if (command === "normalize-request") {
|
||||
const draft = argv["legacy-dfw-blq"]
|
||||
? DFW_BLQ_2026_PROMPT_DRAFT
|
||||
: await readJsonFile<FlightReportRequestDraft>(argv.input || usage());
|
||||
console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "report-status") {
|
||||
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "render-report") {
|
||||
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||
const rendered = await renderFlightReportPdf(payload, argv.output || usage());
|
||||
console.log(rendered);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "delivery-plan") {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
buildLukeDeliveryPlan({
|
||||
recipientEmail: argv.to,
|
||||
subject: argv.subject || "",
|
||||
body: argv.body || "",
|
||||
attachmentPath: argv.attach || usage()
|
||||
}),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
usage();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
25
skills/flight-finder/src/input-validation.ts
Normal file
25
skills/flight-finder/src/input-validation.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function normalizeMarketCountry(
|
||||
value: string | null | undefined
|
||||
): string | null | undefined {
|
||||
const cleaned = typeof value === "string" ? value.trim() : undefined;
|
||||
if (!cleaned) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = cleaned.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
throw new Error(
|
||||
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isPlausibleEmail(value: string | null | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
36
skills/flight-finder/src/price-normalization.ts
Normal file
36
skills/flight-finder/src/price-normalization.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type NormalizePriceInput = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRates?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type NormalizedPrice = {
|
||||
currency: "USD";
|
||||
amountUsd: number;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export function normalizePriceToUsd(
|
||||
input: NormalizePriceInput
|
||||
): NormalizedPrice {
|
||||
const currency = input.currency.trim().toUpperCase();
|
||||
if (currency === "USD") {
|
||||
return {
|
||||
currency: "USD",
|
||||
amountUsd: input.amount,
|
||||
notes: ["Price already in USD."]
|
||||
};
|
||||
}
|
||||
|
||||
const rate = input.exchangeRates?.[currency];
|
||||
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Missing usable exchange rate for ${currency}.`);
|
||||
}
|
||||
|
||||
const amountUsd = Math.round(input.amount * rate * 100) / 100;
|
||||
return {
|
||||
currency: "USD",
|
||||
amountUsd,
|
||||
notes: [`Converted ${currency} to USD using rate ${rate}.`]
|
||||
};
|
||||
}
|
||||
50
skills/flight-finder/src/report-delivery.ts
Normal file
50
skills/flight-finder/src/report-delivery.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { isPlausibleEmail } from "./input-validation.js";
|
||||
|
||||
type DeliveryPlanInput = {
|
||||
recipientEmail?: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachmentPath: string;
|
||||
};
|
||||
|
||||
export type DeliveryPlan = {
|
||||
ready: boolean;
|
||||
needsRecipientEmail: boolean;
|
||||
command: string | null;
|
||||
sender: "luke@fiorinis.com";
|
||||
recipientEmail: string | null;
|
||||
};
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function buildLukeDeliveryPlan(input: DeliveryPlanInput): DeliveryPlan {
|
||||
const recipientEmail = input.recipientEmail?.trim() || null;
|
||||
if (!recipientEmail || !isPlausibleEmail(recipientEmail)) {
|
||||
return {
|
||||
ready: false,
|
||||
needsRecipientEmail: true,
|
||||
command: null,
|
||||
sender: "luke@fiorinis.com",
|
||||
recipientEmail
|
||||
};
|
||||
}
|
||||
|
||||
const validRecipientEmail: string = recipientEmail;
|
||||
const command = [
|
||||
"zsh ~/.openclaw/workspace/bin/gog-luke gmail send",
|
||||
`--to ${shellQuote(validRecipientEmail)}`,
|
||||
`--subject ${shellQuote(input.subject)}`,
|
||||
`--body ${shellQuote(input.body)}`,
|
||||
`--attach ${shellQuote(input.attachmentPath)}`
|
||||
].join(" ");
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
needsRecipientEmail: false,
|
||||
command,
|
||||
sender: "luke@fiorinis.com",
|
||||
recipientEmail: validRecipientEmail
|
||||
};
|
||||
}
|
||||
94
skills/flight-finder/src/report-pdf.ts
Normal file
94
skills/flight-finder/src/report-pdf.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
import { getFlightReportStatus } from "./report-status.js";
|
||||
import { normalizeFlightReportRequest } from "./request-normalizer.js";
|
||||
import type { FlightReportPayload } from "./types.js";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
|
||||
return value?.length ? value : [fallback];
|
||||
}
|
||||
|
||||
export async function renderFlightReportPdf(
|
||||
payload: FlightReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const status = getFlightReportStatus(
|
||||
normalizeFlightReportRequest(payload.request),
|
||||
payload
|
||||
);
|
||||
|
||||
if (!status.pdfReady) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload is still incomplete. Finish the report before generating the PDF."
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
try {
|
||||
doc.fontSize(20).text(payload.request.tripName || "Flight report", {
|
||||
align: "left"
|
||||
});
|
||||
doc.moveDown(0.25);
|
||||
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`);
|
||||
doc.fillColor("#000");
|
||||
doc.moveDown();
|
||||
|
||||
doc.fontSize(14).text("Executive Summary");
|
||||
bulletLines(payload.executiveSummary).forEach((line) => {
|
||||
doc.fontSize(10).text(`• ${line}`);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Recommended Options");
|
||||
payload.rankedOptions.forEach((option) => {
|
||||
doc.fontSize(11).text(
|
||||
`${option.backup ? "Backup" : "Primary"}: ${option.title} — $${option.totalPriceUsd.toFixed(2)}`
|
||||
);
|
||||
doc.fontSize(10).text(option.rationale);
|
||||
doc.moveDown(0.5);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Source Findings");
|
||||
payload.sourceFindings.forEach((finding) => {
|
||||
doc.fontSize(11).text(`${finding.source}: ${finding.status}`);
|
||||
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`• ${line}`));
|
||||
doc.moveDown(0.25);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Warnings And Degraded Conditions");
|
||||
bulletLines(
|
||||
[...payload.reportWarnings, ...payload.degradedReasons],
|
||||
"No additional warnings."
|
||||
).forEach((line) => {
|
||||
doc.fontSize(10).text(`• ${line}`);
|
||||
});
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
await fs.promises.unlink(outputPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
83
skills/flight-finder/src/report-status.ts
Normal file
83
skills/flight-finder/src/report-status.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
FlightReportPayload,
|
||||
FlightReportStatusResult,
|
||||
NormalizedFlightReportRequest
|
||||
} from "./types.js";
|
||||
import { isPlausibleEmail } from "./input-validation.js";
|
||||
|
||||
export function getFlightReportStatus(
|
||||
normalizedRequest: NormalizedFlightReportRequest,
|
||||
payload?: FlightReportPayload | null
|
||||
): FlightReportStatusResult {
|
||||
if (normalizedRequest.missingSearchInputs.length) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingSearchInputs,
|
||||
readyToSearch: false,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: false,
|
||||
terminalOutcome: "missing-inputs",
|
||||
degraded: false,
|
||||
degradedReasons: [],
|
||||
blockingReason: "Missing search-critical trip inputs."
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: false,
|
||||
terminalOutcome: "report-incomplete",
|
||||
degraded: false,
|
||||
degradedReasons: [],
|
||||
lastCompletedPhase: "intake",
|
||||
blockingReason: "Search and report assembly have not completed yet."
|
||||
};
|
||||
}
|
||||
|
||||
const allSourcesFailed =
|
||||
payload.sourceFindings.length > 0 &&
|
||||
payload.sourceFindings.every((finding) => finding.status === "blocked") &&
|
||||
payload.quotes.length === 0;
|
||||
|
||||
if (allSourcesFailed) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: true,
|
||||
terminalOutcome: "all-sources-failed",
|
||||
degraded: true,
|
||||
degradedReasons: payload.degradedReasons,
|
||||
lastCompletedPhase: payload.lastCompletedPhase || "search",
|
||||
blockingReason: "All configured travel sources failed or were blocked."
|
||||
};
|
||||
}
|
||||
|
||||
const reportComplete = Boolean(
|
||||
payload.quotes.length &&
|
||||
payload.rankedOptions.length &&
|
||||
payload.executiveSummary.length
|
||||
);
|
||||
const validRecipient = isPlausibleEmail(normalizedRequest.request.recipientEmail);
|
||||
|
||||
return {
|
||||
needsMissingInputs: validRecipient ? [] : normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: reportComplete,
|
||||
emailReady: reportComplete && validRecipient,
|
||||
chatSummaryReady:
|
||||
payload.quotes.length > 0 ||
|
||||
payload.rankedOptions.length > 0 ||
|
||||
payload.executiveSummary.length > 0,
|
||||
terminalOutcome: reportComplete ? "ready" : "report-incomplete",
|
||||
degraded: payload.degradedReasons.length > 0,
|
||||
degradedReasons: payload.degradedReasons,
|
||||
lastCompletedPhase: payload.lastCompletedPhase,
|
||||
blockingReason: reportComplete ? undefined : "The report payload is still incomplete."
|
||||
};
|
||||
}
|
||||
309
skills/flight-finder/src/request-normalizer.ts
Normal file
309
skills/flight-finder/src/request-normalizer.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type {
|
||||
FlightLegWindow,
|
||||
FlightPassengerGroup,
|
||||
FlightReportRequest,
|
||||
FlightReportRequestDraft,
|
||||
FlightSearchPreferences,
|
||||
NormalizedFlightReportRequest
|
||||
} from "./types.js";
|
||||
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
|
||||
|
||||
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
||||
preferOneStop: true,
|
||||
maxLayoverHours: 6,
|
||||
normalizeCurrencyTo: "USD"
|
||||
};
|
||||
|
||||
function cleanString(value: string | null | undefined): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined> | undefined): string[] | undefined {
|
||||
if (!values?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cleaned = values
|
||||
.map((value) => cleanString(value))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
|
||||
}
|
||||
|
||||
function normalizePassengerGroups(
|
||||
groups: FlightReportRequestDraft["passengerGroups"]
|
||||
): FlightPassengerGroup[] {
|
||||
return (groups || [])
|
||||
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||
.map((group, index) => ({
|
||||
id: cleanString(group.id) || `group-${index + 1}`,
|
||||
adults: Number(group.adults || 0),
|
||||
children: Number(group.children || 0) || undefined,
|
||||
infants: Number(group.infants || 0) || undefined,
|
||||
label: cleanString(group.label)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeLegs(legs: FlightReportRequestDraft["legs"]): FlightLegWindow[] {
|
||||
return (legs || [])
|
||||
.filter((leg): leg is NonNullable<typeof leg> => Boolean(leg))
|
||||
.map((leg, index) => ({
|
||||
id: cleanString(leg.id) || `leg-${index + 1}`,
|
||||
origin: cleanString(leg.origin) || "",
|
||||
destination: cleanString(leg.destination) || "",
|
||||
earliest: cleanString(leg.earliest),
|
||||
latest: cleanString(leg.latest),
|
||||
relativeToLegId: cleanString(leg.relativeToLegId),
|
||||
minDaysAfter:
|
||||
typeof leg.minDaysAfter === "number" && Number.isFinite(leg.minDaysAfter)
|
||||
? leg.minDaysAfter
|
||||
: undefined,
|
||||
maxDaysAfter:
|
||||
typeof leg.maxDaysAfter === "number" && Number.isFinite(leg.maxDaysAfter)
|
||||
? leg.maxDaysAfter
|
||||
: undefined,
|
||||
label: cleanString(leg.label)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAssignments(
|
||||
draft: FlightReportRequestDraft["legAssignments"]
|
||||
): FlightReportRequest["legAssignments"] {
|
||||
return (draft || [])
|
||||
.filter((assignment): assignment is NonNullable<typeof assignment> => Boolean(assignment))
|
||||
.map((assignment) => ({
|
||||
legId: cleanString(assignment.legId) || "",
|
||||
passengerGroupIds:
|
||||
uniqueStrings(
|
||||
(assignment.passengerGroupIds || []).map((value) =>
|
||||
typeof value === "string" ? value : undefined
|
||||
)
|
||||
) || []
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePreferences(
|
||||
preferences: FlightReportRequestDraft["preferences"]
|
||||
): FlightSearchPreferences {
|
||||
const draft = preferences || {};
|
||||
return {
|
||||
...DEFAULT_PREFERENCES,
|
||||
cabin: draft.cabin,
|
||||
maxStops: typeof draft.maxStops === "number" ? draft.maxStops : undefined,
|
||||
preferOneStop:
|
||||
typeof draft.preferOneStop === "boolean"
|
||||
? draft.preferOneStop
|
||||
: DEFAULT_PREFERENCES.preferOneStop,
|
||||
maxLayoverHours:
|
||||
typeof draft.maxLayoverHours === "number"
|
||||
? draft.maxLayoverHours
|
||||
: DEFAULT_PREFERENCES.maxLayoverHours,
|
||||
excludeAirlines: uniqueStrings(draft.excludeAirlines),
|
||||
excludeCountries: uniqueStrings(draft.excludeCountries),
|
||||
excludeAirports: uniqueStrings(draft.excludeAirports),
|
||||
flexibleDates:
|
||||
typeof draft.flexibleDates === "boolean" ? draft.flexibleDates : undefined,
|
||||
requireAirlineDirectCrossCheck:
|
||||
typeof draft.requireAirlineDirectCrossCheck === "boolean"
|
||||
? draft.requireAirlineDirectCrossCheck
|
||||
: undefined,
|
||||
specialConstraints: uniqueStrings(draft.specialConstraints),
|
||||
geoPricingMarket: cleanString(draft.geoPricingMarket),
|
||||
marketCountry: normalizeMarketCountry(draft.marketCountry),
|
||||
normalizeCurrencyTo: "USD"
|
||||
};
|
||||
}
|
||||
|
||||
function collectMissingSearchInputs(request: FlightReportRequest): string[] {
|
||||
const missing = new Set<string>();
|
||||
|
||||
if (!request.legs.length) {
|
||||
missing.add("trip legs");
|
||||
}
|
||||
|
||||
if (!request.passengerGroups.length) {
|
||||
missing.add("passenger groups");
|
||||
}
|
||||
|
||||
for (const group of request.passengerGroups) {
|
||||
const total = group.adults + (group.children || 0) + (group.infants || 0);
|
||||
if (total <= 0) {
|
||||
missing.add(`traveler count for passenger group ${group.label || group.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const legIds = new Set(request.legs.map((leg) => leg.id));
|
||||
const groupIds = new Set(request.passengerGroups.map((group) => group.id));
|
||||
|
||||
for (const leg of request.legs) {
|
||||
const label = leg.label || leg.id;
|
||||
if (!leg.origin || !leg.destination) {
|
||||
missing.add(`origin and destination for ${label}`);
|
||||
}
|
||||
|
||||
const hasAbsoluteWindow = Boolean(leg.earliest || leg.latest);
|
||||
const hasRelativeWindow = Boolean(
|
||||
leg.relativeToLegId &&
|
||||
(typeof leg.minDaysAfter === "number" || typeof leg.maxDaysAfter === "number")
|
||||
);
|
||||
|
||||
if (!hasAbsoluteWindow && !hasRelativeWindow) {
|
||||
missing.add(`date window for ${label}`);
|
||||
}
|
||||
|
||||
if (leg.relativeToLegId && !legIds.has(leg.relativeToLegId)) {
|
||||
missing.add(`valid reference leg for ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.legAssignments.length) {
|
||||
missing.add("passenger assignments for every leg");
|
||||
}
|
||||
|
||||
for (const leg of request.legs) {
|
||||
const label = leg.label || leg.id;
|
||||
const assignment = request.legAssignments.find((entry) => entry.legId === leg.id);
|
||||
if (!assignment) {
|
||||
missing.add(`passenger assignments for ${label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!assignment.passengerGroupIds.length) {
|
||||
missing.add(`assigned travelers for ${label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.passengerGroupIds.some((groupId) => !groupIds.has(groupId))) {
|
||||
missing.add(`valid passenger-group references for ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(missing);
|
||||
}
|
||||
|
||||
export function normalizeFlightReportRequest(
|
||||
draft: FlightReportRequestDraft
|
||||
): NormalizedFlightReportRequest {
|
||||
const normalizedRecipientEmail = cleanString(draft.recipientEmail) || null;
|
||||
const request: FlightReportRequest = {
|
||||
tripName: cleanString(draft.tripName),
|
||||
legs: normalizeLegs(draft.legs),
|
||||
passengerGroups: normalizePassengerGroups(draft.passengerGroups),
|
||||
legAssignments: normalizeAssignments(draft.legAssignments),
|
||||
recipientEmail: normalizedRecipientEmail,
|
||||
preferences: normalizePreferences(draft.preferences)
|
||||
};
|
||||
|
||||
const missingSearchInputs = collectMissingSearchInputs(request);
|
||||
const missingDeliveryInputs = !request.recipientEmail
|
||||
? ["recipient email"]
|
||||
: isPlausibleEmail(request.recipientEmail)
|
||||
? []
|
||||
: ["valid recipient email"];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!request.recipientEmail) {
|
||||
warnings.push(
|
||||
"Recipient email is still missing. Ask for it before rendering or sending the PDF report."
|
||||
);
|
||||
} else if (!isPlausibleEmail(request.recipientEmail)) {
|
||||
warnings.push(
|
||||
"Recipient email looks malformed. Ask for a corrected email address before rendering or sending the PDF report."
|
||||
);
|
||||
}
|
||||
|
||||
if (request.preferences.marketCountry && !request.preferences.geoPricingMarket) {
|
||||
warnings.push(
|
||||
`Market-localized search is explicit for this run. Connect VPN to ${request.preferences.marketCountry} only for the bounded search phase, then disconnect before ranking/render/delivery.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
readyToSearch: missingSearchInputs.length === 0,
|
||||
missingInputs: [...missingSearchInputs, ...missingDeliveryInputs],
|
||||
missingSearchInputs,
|
||||
missingDeliveryInputs,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
export const DFW_BLQ_2026_PROMPT_DRAFT: FlightReportRequestDraft = {
|
||||
tripName: "DFW ↔ BLQ flight report",
|
||||
legs: [
|
||||
{
|
||||
id: "outbound",
|
||||
origin: "DFW",
|
||||
destination: "BLQ",
|
||||
earliest: "2026-05-30",
|
||||
latest: "2026-06-07",
|
||||
label: "Outbound"
|
||||
},
|
||||
{
|
||||
id: "return-pair",
|
||||
origin: "BLQ",
|
||||
destination: "DFW",
|
||||
relativeToLegId: "outbound",
|
||||
minDaysAfter: 6,
|
||||
maxDaysAfter: 10,
|
||||
label: "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
id: "return-solo",
|
||||
origin: "BLQ",
|
||||
destination: "DFW",
|
||||
earliest: "2026-06-28",
|
||||
latest: "2026-07-05",
|
||||
label: "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
passengerGroups: [
|
||||
{
|
||||
id: "pair",
|
||||
adults: 2,
|
||||
label: "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
id: "solo",
|
||||
adults: 1,
|
||||
label: "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
legAssignments: [
|
||||
{
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
legId: "return-pair",
|
||||
passengerGroupIds: ["pair"]
|
||||
},
|
||||
{
|
||||
legId: "return-solo",
|
||||
passengerGroupIds: ["solo"]
|
||||
}
|
||||
],
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
preferences: {
|
||||
preferOneStop: true,
|
||||
maxStops: 1,
|
||||
maxLayoverHours: 6,
|
||||
flexibleDates: true,
|
||||
excludeAirlines: ["Turkish Airlines"],
|
||||
excludeCountries: ["TR"],
|
||||
requireAirlineDirectCrossCheck: true,
|
||||
specialConstraints: [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
geoPricingMarket: "Thailand",
|
||||
marketCountry: "TH",
|
||||
normalizeCurrencyTo: "USD"
|
||||
}
|
||||
};
|
||||
59
skills/flight-finder/src/run-state.ts
Normal file
59
skills/flight-finder/src/run-state.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { FlightRunState } from "./types.js";
|
||||
|
||||
const DEFAULT_STATE_DIR = path.join(
|
||||
os.homedir(),
|
||||
".openclaw",
|
||||
"workspace",
|
||||
"state",
|
||||
"flight-finder"
|
||||
);
|
||||
|
||||
export function getFlightFinderStatePath(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): string {
|
||||
return path.join(baseDir, "flight-finder-run.json");
|
||||
}
|
||||
|
||||
export async function saveFlightFinderRunState(
|
||||
state: FlightRunState,
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
||||
return statePath;
|
||||
}
|
||||
|
||||
export async function loadFlightFinderRunState(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<FlightRunState | null> {
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf8");
|
||||
return JSON.parse(raw) as FlightRunState;
|
||||
} catch (error) {
|
||||
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||
if (maybeNodeError.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFlightFinderRunState(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<void> {
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
try {
|
||||
await fs.unlink(statePath);
|
||||
} catch (error) {
|
||||
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||
if (maybeNodeError.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
skills/flight-finder/src/search-orchestration.ts
Normal file
81
skills/flight-finder/src/search-orchestration.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
FlightReportRequest,
|
||||
FlightSearchPlan,
|
||||
FlightSearchSourceFinding,
|
||||
FlightSearchSourceName,
|
||||
FlightSearchSourceViability
|
||||
} from "./types.js";
|
||||
|
||||
export const SOURCE_SILENT_TIMEOUT_MS = 45_000;
|
||||
export const SOURCE_TOTAL_TIMEOUT_MS = 180_000;
|
||||
export const RUN_TOTAL_TIMEOUT_MS = 900_000;
|
||||
export const VPN_CONNECT_TIMEOUT_MS = 30_000;
|
||||
export const VPN_DISCONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
const DEFAULT_SOURCE_ORDER: FlightSearchSourceName[] = [
|
||||
"kayak",
|
||||
"skyscanner",
|
||||
"expedia",
|
||||
"airline-direct"
|
||||
];
|
||||
|
||||
function findingFor(
|
||||
source: FlightSearchSourceName,
|
||||
findings: FlightSearchSourceFinding[]
|
||||
): FlightSearchSourceFinding | undefined {
|
||||
return findings.find((entry) => entry.source === source);
|
||||
}
|
||||
|
||||
function sourceStatus(
|
||||
source: FlightSearchSourceName,
|
||||
findings: FlightSearchSourceFinding[]
|
||||
): FlightSearchSourceViability {
|
||||
return findingFor(source, findings)?.status || "viable";
|
||||
}
|
||||
|
||||
export function buildFlightSearchPlan(
|
||||
request: FlightReportRequest,
|
||||
findings: FlightSearchSourceFinding[] = []
|
||||
): FlightSearchPlan {
|
||||
const sourceOrder = DEFAULT_SOURCE_ORDER.map((source) => {
|
||||
const status = sourceStatus(source, findings);
|
||||
const finding = findingFor(source, findings);
|
||||
const required = source !== "airline-direct";
|
||||
const enabled = status !== "blocked";
|
||||
const reason =
|
||||
finding?.notes[0] ||
|
||||
(source === "airline-direct"
|
||||
? "Best-effort airline direct cross-check."
|
||||
: "Primary bounded search source.");
|
||||
|
||||
return {
|
||||
source,
|
||||
enabled,
|
||||
required,
|
||||
status,
|
||||
silentTimeoutMs: SOURCE_SILENT_TIMEOUT_MS,
|
||||
totalTimeoutMs: SOURCE_TOTAL_TIMEOUT_MS,
|
||||
reason
|
||||
};
|
||||
});
|
||||
|
||||
const degradedReasons = sourceOrder
|
||||
.filter((source) => source.status !== "viable")
|
||||
.map((source) =>
|
||||
source.enabled
|
||||
? `${source.source} is degraded for this run: ${source.reason}`
|
||||
: `${source.source} is blocked for this run: ${source.reason}`
|
||||
);
|
||||
|
||||
return {
|
||||
sourceOrder,
|
||||
vpn: {
|
||||
enabled: Boolean(request.preferences.marketCountry),
|
||||
marketCountry: request.preferences.marketCountry || null,
|
||||
connectTimeoutMs: VPN_CONNECT_TIMEOUT_MS,
|
||||
disconnectTimeoutMs: VPN_DISCONNECT_TIMEOUT_MS,
|
||||
fallbackMode: "default-market"
|
||||
},
|
||||
degradedReasons
|
||||
};
|
||||
}
|
||||
187
skills/flight-finder/src/types.ts
Normal file
187
skills/flight-finder/src/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
export type FlightPassengerGroup = {
|
||||
id: string;
|
||||
adults: number;
|
||||
children?: number;
|
||||
infants?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FlightLegWindow = {
|
||||
id: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
earliest?: string;
|
||||
latest?: string;
|
||||
relativeToLegId?: string;
|
||||
minDaysAfter?: number;
|
||||
maxDaysAfter?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FlightSearchPreferences = {
|
||||
cabin?: "economy" | "premium-economy" | "business" | "first";
|
||||
maxStops?: number;
|
||||
preferOneStop?: boolean;
|
||||
maxLayoverHours?: number;
|
||||
excludeAirlines?: string[];
|
||||
excludeCountries?: string[];
|
||||
excludeAirports?: string[];
|
||||
flexibleDates?: boolean;
|
||||
requireAirlineDirectCrossCheck?: boolean;
|
||||
specialConstraints?: string[];
|
||||
geoPricingMarket?: string | null;
|
||||
marketCountry?: string | null;
|
||||
normalizeCurrencyTo?: "USD";
|
||||
};
|
||||
|
||||
export type FlightReportRequest = {
|
||||
tripName?: string;
|
||||
legs: FlightLegWindow[];
|
||||
passengerGroups: FlightPassengerGroup[];
|
||||
legAssignments: Array<{ legId: string; passengerGroupIds: string[] }>;
|
||||
recipientEmail?: string | null;
|
||||
preferences: FlightSearchPreferences;
|
||||
};
|
||||
|
||||
export type FlightReportStatus = {
|
||||
needsMissingInputs: string[];
|
||||
readyToSearch: boolean;
|
||||
pdfReady: boolean;
|
||||
emailReady: boolean;
|
||||
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
};
|
||||
|
||||
export type FlightQuote = {
|
||||
id: string;
|
||||
source: FlightSearchSourceName;
|
||||
legId: string;
|
||||
passengerGroupIds: string[];
|
||||
bookingLink: string;
|
||||
itinerarySummary: string;
|
||||
airlineName?: string;
|
||||
departureTimeLocal?: string;
|
||||
arrivalTimeLocal?: string;
|
||||
stopsText?: string;
|
||||
layoverText?: string;
|
||||
totalDurationText?: string;
|
||||
totalPriceUsd: number;
|
||||
displayPriceUsd: string;
|
||||
originalCurrency?: string;
|
||||
originalTotalPrice?: number;
|
||||
directBookingUrl?: string | null;
|
||||
crossCheckStatus?: "verified" | "not-available" | "failed";
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type FlightRecommendationOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
quoteIds: string[];
|
||||
totalPriceUsd: number;
|
||||
rationale: string;
|
||||
backup?: boolean;
|
||||
};
|
||||
|
||||
export type FlightSearchSourceName =
|
||||
| "kayak"
|
||||
| "skyscanner"
|
||||
| "expedia"
|
||||
| "airline-direct";
|
||||
|
||||
export type FlightSearchSourceViability = "viable" | "degraded" | "blocked";
|
||||
|
||||
export type FlightSearchSourceFinding = {
|
||||
source: FlightSearchSourceName;
|
||||
status: FlightSearchSourceViability;
|
||||
checkedAt: string;
|
||||
notes: string[];
|
||||
evidence?: string[];
|
||||
};
|
||||
|
||||
export type FlightSearchPlanSource = {
|
||||
source: FlightSearchSourceName;
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
status: FlightSearchSourceViability;
|
||||
silentTimeoutMs: number;
|
||||
totalTimeoutMs: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type FlightSearchPlan = {
|
||||
sourceOrder: FlightSearchPlanSource[];
|
||||
vpn: {
|
||||
enabled: boolean;
|
||||
marketCountry: string | null;
|
||||
connectTimeoutMs: number;
|
||||
disconnectTimeoutMs: number;
|
||||
fallbackMode: "default-market";
|
||||
};
|
||||
degradedReasons: string[];
|
||||
};
|
||||
|
||||
export type FlightReportRequestDraft = {
|
||||
tripName?: string | null;
|
||||
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
|
||||
passengerGroups?: Array<Partial<FlightPassengerGroup> | null | undefined> | null;
|
||||
legAssignments?:
|
||||
| Array<
|
||||
| {
|
||||
legId?: string | null;
|
||||
passengerGroupIds?: Array<string | null | undefined> | null;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
| null;
|
||||
recipientEmail?: string | null;
|
||||
preferences?: Partial<FlightSearchPreferences> | null;
|
||||
};
|
||||
|
||||
export type NormalizedFlightReportRequest = {
|
||||
request: FlightReportRequest;
|
||||
readyToSearch: boolean;
|
||||
missingInputs: string[];
|
||||
missingSearchInputs: string[];
|
||||
missingDeliveryInputs: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type FlightReportPayload = {
|
||||
request: FlightReportRequest;
|
||||
sourceFindings: FlightSearchSourceFinding[];
|
||||
quotes: FlightQuote[];
|
||||
rankedOptions: FlightRecommendationOption[];
|
||||
executiveSummary: string[];
|
||||
reportWarnings: string[];
|
||||
degradedReasons: string[];
|
||||
comparisonCurrency: "USD";
|
||||
marketCountryUsed?: string | null;
|
||||
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
renderedPdfPath?: string | null;
|
||||
deliveredTo?: string | null;
|
||||
emailAuthorized?: boolean;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type FlightReportStatusResult = FlightReportStatus & {
|
||||
chatSummaryReady: boolean;
|
||||
terminalOutcome:
|
||||
| "ready"
|
||||
| "missing-inputs"
|
||||
| "all-sources-failed"
|
||||
| "report-incomplete";
|
||||
degraded: boolean;
|
||||
degradedReasons: string[];
|
||||
blockingReason?: string;
|
||||
};
|
||||
|
||||
export type FlightRunState = {
|
||||
request: FlightReportRequest;
|
||||
lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
updatedAt: string;
|
||||
reportWarnings: string[];
|
||||
degradedReasons: string[];
|
||||
sourceFindings: FlightSearchSourceFinding[];
|
||||
comparisonCurrency: "USD";
|
||||
};
|
||||
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizePriceToUsd } from "../src/price-normalization.js";
|
||||
|
||||
test("normalizePriceToUsd passes through USD values", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 2847,
|
||||
currency: "USD"
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 2847);
|
||||
assert.equal(result.currency, "USD");
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
exchangeRates: {
|
||||
EUR: 1.08
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 108);
|
||||
assert.match(result.notes.join(" "), /EUR/i);
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd rejects missing exchange rates", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
}),
|
||||
/exchange rate/i
|
||||
);
|
||||
});
|
||||
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
|
||||
|
||||
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "invalid-email",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, false);
|
||||
assert.equal(plan.needsRecipientEmail, true);
|
||||
assert.equal(plan.command, null);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /gog-luke gmail send/);
|
||||
assert.match(String(plan.command), /stefano@fiorinis.com/);
|
||||
assert.match(String(plan.command), /report\.pdf/);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "Luke's flight report; rm -rf /",
|
||||
body: "It's ready.",
|
||||
attachmentPath: "/tmp/report's-final.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
|
||||
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
|
||||
});
|
||||
76
skills/flight-finder/tests/report-pdf.test.ts
Normal file
76
skills/flight-finder/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
function samplePayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||
assert.ok(fs.existsSync(outputPath));
|
||||
assert.ok(fs.statSync(outputPath).size > 0);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderFlightReportPdf(
|
||||
{
|
||||
...samplePayload(),
|
||||
rankedOptions: [],
|
||||
executiveSummary: []
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
99
skills/flight-finder/tests/report-status.test.ts
Normal file
99
skills/flight-finder/tests/report-status.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import { getFlightReportStatus } from "../src/report-status.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
|
||||
function buildPayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847",
|
||||
crossCheckStatus: "not-available"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
|
||||
const normalized = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: null
|
||||
});
|
||||
const payload = {
|
||||
...buildPayload(),
|
||||
request: normalized.request
|
||||
};
|
||||
|
||||
const status = getFlightReportStatus(normalized, payload);
|
||||
|
||||
assert.equal(status.readyToSearch, true);
|
||||
assert.equal(status.pdfReady, true);
|
||||
assert.equal(status.emailReady, false);
|
||||
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
|
||||
});
|
||||
|
||||
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
|
||||
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||
const status = getFlightReportStatus(normalized, {
|
||||
...buildPayload(),
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
},
|
||||
{
|
||||
source: "skyscanner",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
}
|
||||
],
|
||||
quotes: [],
|
||||
rankedOptions: [],
|
||||
executiveSummary: [],
|
||||
degradedReasons: ["All configured travel sources failed."]
|
||||
});
|
||||
|
||||
assert.equal(status.terminalOutcome, "all-sources-failed");
|
||||
assert.equal(status.chatSummaryReady, true);
|
||||
assert.equal(status.pdfReady, false);
|
||||
assert.equal(status.degraded, true);
|
||||
});
|
||||
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
test("normalizeFlightReportRequest preserves the legacy DFW-BLQ split-return structure", () => {
|
||||
const result = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingSearchInputs, []);
|
||||
assert.deepEqual(result.missingDeliveryInputs, []);
|
||||
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||
assert.equal(result.request.preferences.normalizeCurrencyTo, "USD");
|
||||
assert.equal(result.request.legs.length, 3);
|
||||
assert.equal(result.request.passengerGroups.length, 2);
|
||||
assert.deepEqual(
|
||||
result.request.legAssignments.find((entry) => entry.legId === "outbound")?.passengerGroupIds,
|
||||
["pair", "solo"]
|
||||
);
|
||||
assert.equal(
|
||||
result.request.preferences.requireAirlineDirectCrossCheck,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest keeps email missing as a delivery gate, not a search blocker", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: null
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingSearchInputs, []);
|
||||
assert.deepEqual(result.missingDeliveryInputs, ["recipient email"]);
|
||||
assert.match(result.warnings.join(" "), /recipient email/i);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest reports missing search-critical inputs explicitly", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
tripName: "Incomplete flight request",
|
||||
legs: [
|
||||
{
|
||||
id: "outbound",
|
||||
origin: "DFW",
|
||||
destination: "",
|
||||
label: "Outbound"
|
||||
}
|
||||
],
|
||||
passengerGroups: [],
|
||||
legAssignments: []
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, false);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /origin and destination/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /date window/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /passenger groups/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /assignments/i);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest validates marketCountry as an ISO alpha-2 code", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: "Germany"
|
||||
}
|
||||
}),
|
||||
/ISO 3166-1 alpha-2/i
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest uppercases a lowercase marketCountry code", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: "th"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest flags a malformed recipient email without blocking search readiness", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: "stefano-at-example"
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingDeliveryInputs, ["valid recipient email"]);
|
||||
assert.match(result.warnings.join(" "), /malformed/i);
|
||||
});
|
||||
42
skills/flight-finder/tests/run-state.test.ts
Normal file
42
skills/flight-finder/tests/run-state.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
clearFlightFinderRunState,
|
||||
loadFlightFinderRunState,
|
||||
saveFlightFinderRunState
|
||||
} from "../src/run-state.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "flight-finder-run-state-")
|
||||
);
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
|
||||
await saveFlightFinderRunState(
|
||||
{
|
||||
request,
|
||||
lastCompletedPhase: "search",
|
||||
updatedAt: "2026-03-30T21:00:00Z",
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
sourceFindings: [],
|
||||
comparisonCurrency: "USD"
|
||||
},
|
||||
baseDir
|
||||
);
|
||||
|
||||
const loaded = await loadFlightFinderRunState(baseDir);
|
||||
assert.equal(loaded?.lastCompletedPhase, "search");
|
||||
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
|
||||
|
||||
await clearFlightFinderRunState(baseDir);
|
||||
assert.equal(await loadFlightFinderRunState(baseDir), null);
|
||||
});
|
||||
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import {
|
||||
VPN_CONNECT_TIMEOUT_MS,
|
||||
VPN_DISCONNECT_TIMEOUT_MS,
|
||||
buildFlightSearchPlan
|
||||
} from "../src/search-orchestration.js";
|
||||
|
||||
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
const plan = buildFlightSearchPlan(request);
|
||||
|
||||
assert.equal(plan.vpn.enabled, true);
|
||||
assert.equal(plan.vpn.marketCountry, "TH");
|
||||
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
|
||||
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
|
||||
const request = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: null
|
||||
}
|
||||
}).request;
|
||||
const plan = buildFlightSearchPlan(request, [
|
||||
{
|
||||
source: "airline-direct",
|
||||
status: "degraded",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Direct booking shell loads but search completion is unreliable."]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(plan.vpn.enabled, false);
|
||||
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
|
||||
assert.equal(directSource?.enabled, true);
|
||||
assert.equal(directSource?.required, false);
|
||||
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
|
||||
});
|
||||
17
skills/flight-finder/tsconfig.json
Normal file
17
skills/flight-finder/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user