8 Commits

28 changed files with 3364 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.worktrees/ .worktrees/
node_modules/

View File

@@ -17,6 +17,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
| Skill | What it does | Path | | 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` | | `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 Lukes sender path. | `skills/flight-finder` |
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` | | `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` | | `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` | | `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |

View File

@@ -5,6 +5,7 @@ This folder contains detailed docs for each skill in this repository.
## Skills ## Skills
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text - [`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) - [`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 - [`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) - [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)

77
docs/flight-finder.md Normal file
View File

@@ -0,0 +1,77 @@
# 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
- render booking links in the PDF for each recommended fare, including airline-direct links when they were actually captured
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
- require a fresh bounded search run for every report instead of reusing earlier captures
## Important rules
- Recipient email is a delivery gate, not a search gate.
- Cached flight-search data is forbidden as primary evidence for a new run.
- same-day workspace captures must not be reused
- previously rendered PDFs must not be treated as fresh search output
- if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts
- `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.
- The PDF should show:
- the captured booking/search link for every quoted fare
- the airline-direct link only when `directBookingUrl` was actually captured
- an explicit “not captured in this run” note when direct-airline booking was unavailable
## DFW ↔ BLQ daily automation
- The 6 AM DFW ↔ BLQ automation should invoke `flight-finder` directly with the structured JSON payload.
- The legacy file at `workspace/docs/prompts/dfw-blq-2026.md` should mirror that exact direct skill invocation, not old prose instructions.
## 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>"
```
Expected report-payload provenance fields:
- `searchExecution.freshSearch: true`
- `searchExecution.startedAt`
- `searchExecution.completedAt`
- `searchExecution.artifactsRoot`
## 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.

View File

@@ -0,0 +1,159 @@
---
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.
PDF output rule:
- Render aggregator booking links for every quoted fare.
- Render direct-airline booking links when they were actually captured.
- If a direct-airline link was not captured for a quote, say so explicitly in the report instead of implying one exists.
Freshness rule:
- Never reuse cached flight-search captures, prior same-day report payloads, earlier workspace artifacts, or previously rendered PDFs as the primary evidence for a new run.
- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery.
- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures.
## 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.
- An interim commentary/status line is not completion.
- For isolated cron-style runs, do not leave the run with only a phase-update summary. If PDF render or email send did not happen, the terminal result must say so explicitly as a failed or incomplete outcome.
## 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
- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report
- 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
## Long-running command rule
- Do not fire a long-running local command and then abandon it.
- If an `exec` call returns a running session/process handle instead of a completed result, immediately follow it with the relevant process polling/log calls until the command finishes, fails, or times out.
- Treat VPN connect, fresh search sweeps, PDF render, and Gmail send as critical-path commands. They must be observed to completion; do not assume they succeeded just because the process started.
- If a critical-path command cannot be observed to completion, stop claiming progress and report the run as incomplete.
## Isolated cron rule
- In isolated cron runs, success means all of the following are true:
- fresh search artifacts exist for the current run
- final `report-payload.json` exists for the current run
- PDF render completed
- Gmail send completed to the requested recipient
- If the model/provider times out, the VPN step flakes, or the run ends before those artifacts exist, report a failure/incomplete outcome rather than an `ok`-sounding completion summary.
- Do not end an isolated cron run on the VPN/connect phase alone.
## 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
- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run
## 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 Lukes 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
- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run
- `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 freeform execution prose. Its file should contain a literal `use flight finder skill with the following json payload` request so manual runs and scheduled runs stay aligned.

View File

@@ -0,0 +1,168 @@
{
"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"
}
},
"searchExecution": {
"freshSearch": true,
"startedAt": "2026-03-30T21:00:00Z",
"completedAt": "2026-03-30T21:20:00Z",
"artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
"notes": [
"Fresh bounded Thailand-market search executed for this report payload.",
"Prior workspace captures must not be reused across flight-finder runs."
]
},
"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"
}

View 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
View 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"
}
}
}
}

View 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"
}
}

View 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

View 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;
});

View 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);
}

View 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}.`]
};
}

View 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
};
}

View File

@@ -0,0 +1,407 @@
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 {
FlightLegWindow,
FlightPassengerGroup,
FlightQuote,
FlightRecommendationOption,
FlightReportPayload
} from "./types.js";
export class ReportValidationError extends Error {}
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
return value?.length ? value : [fallback];
}
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
}
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
return new Map(
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
);
}
function formatUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0
}).format(value);
}
function formatDateWindow(leg: FlightLegWindow): string {
if (leg.earliest && leg.latest) {
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
}
if (leg.relativeToLegId) {
const min = leg.minDaysAfter ?? "?";
const max = leg.maxDaysAfter ?? "?";
return `${min}-${max} days after ${leg.relativeToLegId}`;
}
return "Not provided";
}
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
const bottomLimit = doc.page.height - doc.page.margins.bottom;
if (doc.y + requiredHeight > bottomLimit) {
doc.addPage();
}
}
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
export function formatArtifactsRoot(value: string): string {
const trimmed = String(value || "").trim();
if (!trimmed) return "Not provided";
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
}
return path.basename(trimmed) || trimmed;
}
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
ensurePageSpace(doc, 28);
const x = doc.page.margins.left;
const y = doc.y;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const height = 18;
doc.save();
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(12)
.text(title, x + 8, y + 4, { width: width - 16 });
doc.restore();
doc.moveDown(1.2);
}
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
const lines = bulletLines(items, fallback);
for (const line of lines) {
const lineHeight = doc.heightOfString(line, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
ensurePageSpace(doc, lineHeight + 12);
const startY = doc.y;
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10.5)
.text(line, doc.page.margins.left + 14, startY, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2
});
doc.moveDown(0.35);
}
doc.moveDown(0.2);
}
type QuoteBookingLink = {
label: string;
text: string;
url: string | null;
};
export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] {
return [
{
label: "Search / book this fare",
text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`,
url: quote.bookingLink
},
{
label: "Direct airline booking",
text: quote.directBookingUrl
? `Direct airline booking: ${quote.airlineName || "Airline site"}`
: "Direct airline booking: not captured in this run",
url: quote.directBookingUrl || null
}
];
}
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
for (const linkItem of links) {
const lineHeight = Math.max(
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
width: width - 132,
lineGap: 1
})
);
ensurePageSpace(doc, lineHeight + 8);
const startY = doc.y;
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(`${linkItem.label}:`, left, startY, { width: 130 });
doc
.fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66")
.font("Helvetica")
.fontSize(9.5)
.text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, {
width: width - 132,
lineGap: 1,
...(linkItem.url ? { link: linkItem.url, underline: true } : {})
});
doc.moveDown(0.25);
}
}
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
const left = doc.page.margins.left;
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const keyWidth = 170;
const valueWidth = totalWidth - keyWidth;
for (const [key, value] of rows) {
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
ensurePageSpace(doc, rowHeight + 8);
const startY = doc.y;
doc.save();
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
doc
.lineWidth(0.5)
.strokeColor("#C7D0D9")
.rect(left, startY, totalWidth, rowHeight)
.stroke();
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
doc.restore();
doc
.fillColor("#1E2329")
.font("Helvetica-Bold")
.fontSize(9.5)
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(9.5)
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
doc.y = startY + rowHeight;
}
doc.moveDown(0.8);
}
function ensureFreshSearch(payload: FlightReportPayload): void {
const searchExecution = payload.searchExecution;
if (!searchExecution?.freshSearch) {
throw new ReportValidationError(
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
);
}
const startedAt = Date.parse(searchExecution.startedAt);
const completedAt = Date.parse(searchExecution.completedAt);
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
throw new ReportValidationError(
"The flight report payload must include a valid fresh-search time window."
);
}
if (!String(searchExecution.artifactsRoot || "").trim()) {
throw new ReportValidationError(
"The flight report payload must include the artifacts root for the fresh search run."
);
}
}
function drawItineraryCard(
doc: PDFKit.PDFDocument,
option: FlightRecommendationOption,
quotesById: Map<string, FlightQuote>,
legLabels: Map<string, string>,
passengerLabels: Map<string, string>
): void {
const left = doc.page.margins.left;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const badge = option.backup ? "Backup option" : "Primary recommendation";
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
const quote = quotesById.get(quoteId);
if (!quote) {
return [
{
heading: `Missing quote reference: ${quoteId}`,
details: [],
links: []
}
];
}
const passengerLine = quote.passengerGroupIds
.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ");
const legLine = legLabels.get(quote.legId) || quote.legId;
return [
{
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
details: [
`Passengers: ${passengerLine}`,
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`,
`Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`,
...(quote.notes || []).map((note) => `Note: ${note}`)
],
links: describeQuoteBookingLinks(quote)
}
];
});
ensurePageSpace(doc, 28);
const top = doc.y;
doc.save();
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(10.5)
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
doc.restore();
doc.y = top + 28;
drawKeyValueTable(doc, [
["Recommendation total", formatUsd(option.totalPriceUsd)],
["Why it ranked here", option.rationale]
]);
for (const block of quoteBlocks) {
ensurePageSpace(doc, 22);
doc
.fillColor("#10384E")
.font("Helvetica-Bold")
.fontSize(10)
.text(block.heading, left, doc.y, {
width,
lineGap: 2
});
doc.moveDown(0.2);
drawBulletList(doc, block.details);
drawQuoteBookingLinks(doc, block.links, left, width);
doc.moveDown(0.5);
}
doc.moveDown(0.2);
}
export async function renderFlightReportPdf(
payload: FlightReportPayload,
outputPath: string
): Promise<string> {
const normalizedRequest = normalizeFlightReportRequest(payload.request);
const status = getFlightReportStatus(normalizedRequest, payload);
if (!status.pdfReady) {
throw new ReportValidationError(
"The flight report payload is still incomplete. Finish the report before generating the PDF."
);
}
ensureFreshSearch(payload);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50,
info: {
Title: payload.request.tripName || "Flight report",
Author: "OpenClaw flight-finder"
}
});
const stream = fs.createWriteStream(outputPath);
doc.pipe(stream);
const request = normalizedRequest.request;
const legLabels = asLegLabelMap(request.legs);
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
try {
doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
doc.moveDown(0.2);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(`Generated: ${payload.generatedAt}`);
doc
.fillColor("#4F5B66")
.font("Helvetica")
.fontSize(10)
.text(
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
);
doc.moveDown();
drawSectionHeader(doc, "Trip Snapshot");
drawKeyValueTable(doc, [
["Recipient", request.recipientEmail || "Missing"],
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
["Preferences", [
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
].join(" | ")],
["Market-country mode", request.preferences.marketCountry || "Off"],
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
]);
drawSectionHeader(doc, "Legs And Travelers");
for (const leg of request.legs) {
const assignedGroups = request.legAssignments
.find((assignment) => assignment.legId === leg.id)
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
.join(", ") || "Not assigned";
drawKeyValueTable(doc, [
["Leg", leg.label || leg.id],
["Route", `${leg.origin} -> ${leg.destination}`],
["Window", formatDateWindow(leg)],
["Travelers", assignedGroups]
]);
}
drawSectionHeader(doc, "Executive Summary");
drawBulletList(doc, payload.executiveSummary);
drawSectionHeader(doc, "Recommended Options");
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
drawSectionHeader(doc, "Source Findings");
payload.sourceFindings.forEach((finding) => {
drawKeyValueTable(doc, [
["Source", finding.source],
["Status", finding.status],
["Checked", finding.checkedAt]
]);
drawBulletList(doc, finding.notes, "No source notes.");
});
drawSectionHeader(doc, "Warnings And Degraded Conditions");
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
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;
}

View 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."
};
}

View 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"
}
};

View 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;
}
}
}

View 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
};
}

View File

@@ -0,0 +1,194 @@
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;
searchExecution: {
freshSearch: boolean;
startedAt: string;
completedAt: string;
artifactsRoot: string;
notes?: string[];
};
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";
};

View 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
);
});

View 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'/);
});

View File

@@ -0,0 +1,273 @@
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 {
describeQuoteBookingLinks,
formatArtifactsRoot,
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,
searchExecution: {
freshSearch: true,
startedAt: "2026-03-30T20:45:00Z",
completedAt: "2026-03-30T21:00:00Z",
artifactsRoot: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
notes: ["Fresh bounded search executed for this report."]
},
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"
};
}
function countPdfPages(outputPath: string): number {
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
}
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 embeds booking links and only includes direct-airline URLs when captured", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-links-${Date.now()}.pdf`);
const payload = samplePayload();
payload.quotes = [
{
...payload.quotes[0],
bookingLink: "https://example.com/quote-1",
directBookingUrl: null
},
{
id: "quote-2",
source: "airline-direct",
legId: "return-pair",
passengerGroupIds: ["pair"],
bookingLink: "https://example.com/quote-2",
directBookingUrl: "https://www.britishairways.com/",
airlineName: "British Airways",
itinerarySummary: "BLQ -> DFW via LHR",
totalPriceUsd: 1100,
displayPriceUsd: "$1,100"
}
];
payload.rankedOptions = [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1", "quote-2"],
totalPriceUsd: 3947,
rationale: "Best price-to-convenience tradeoff."
}
];
await renderFlightReportPdf(payload, outputPath);
const pdfContents = fs.readFileSync(outputPath, "latin1");
assert.match(pdfContents, /https:\/\/example\.com\/quote-1/);
assert.match(pdfContents, /https:\/\/example\.com\/quote-2/);
assert.match(pdfContents, /https:\/\/www\.britishairways\.com\//);
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
});
test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
const payload = samplePayload();
payload.searchExecution.artifactsRoot =
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
payload.quotes = [
{
...payload.quotes[0],
id: "quote-1",
notes: [
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
"Skyscanner TH/USD corroborated the same Madrid pattern.",
"Expedia also found a nearby fare band."
]
},
{
id: "quote-2",
source: "kayak",
legId: "return-pair",
passengerGroupIds: ["pair"],
bookingLink: "https://example.com/quote-2",
itinerarySummary: "BLQ -> DFW via LHR",
totalPriceUsd: 1316,
displayPriceUsd: "$1,316 total ($658 pp x 2)",
notes: [
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
"Expedia hit a bot challenge on the Jun 8 paired-return check."
]
},
{
id: "quote-3",
source: "kayak",
legId: "return-solo",
passengerGroupIds: ["solo"],
bookingLink: "https://example.com/quote-3",
itinerarySummary: "BLQ -> DFW via MAD",
totalPriceUsd: 722,
displayPriceUsd: "$722 total",
notes: [
"Lowest valid solo-return fare in the fresh Jul window capture.",
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
"Expedia surfaced a cheaper Turkish option, but it was excluded."
]
}
];
payload.rankedOptions = [
{
id: "primary",
title: "Best overall itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4420,
rationale: "Best price-to-convenience tradeoff."
},
{
id: "backup",
title: "Backup itinerary",
quoteIds: ["quote-1", "quote-2", "quote-3"],
totalPriceUsd: 4515,
rationale: "Slightly higher price but still viable."
}
];
payload.executiveSummary = [
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
"Lowest valid total observed was $4,420.",
"Cross-source corroboration is strongest for the outbound and solo return."
];
await renderFlightReportPdf(payload, outputPath);
const pdfContents = fs.readFileSync(outputPath, "latin1");
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
assert.ok(countPdfPages(outputPath) <= 4);
});
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
);
});
test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => {
const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderFlightReportPdf(
{
...samplePayload(),
searchExecution: {
...samplePayload().searchExecution,
freshSearch: false
}
},
outputPath
),
ReportValidationError
);
});
test("describeQuoteBookingLinks includes both aggregator and airline-direct links when captured", () => {
const payload = samplePayload();
const quote = {
...payload.quotes[0],
airlineName: "British Airways",
directBookingUrl: "https://www.britishairways.com/"
};
assert.deepEqual(describeQuoteBookingLinks(quote), [
{
label: "Search / book this fare",
text: "Search / book this fare: $2,847 via kayak",
url: "https://example.com/quote-1"
},
{
label: "Direct airline booking",
text: "Direct airline booking: British Airways",
url: "https://www.britishairways.com/"
}
]);
});
test("describeQuoteBookingLinks calls out missing direct-airline links explicitly", () => {
const payload = samplePayload();
assert.deepEqual(describeQuoteBookingLinks(payload.quotes[0]), [
{
label: "Search / book this fare",
text: "Search / book this fare: $2,847 via kayak",
url: "https://example.com/quote-1"
},
{
label: "Direct airline booking",
text: "Direct airline booking: not captured in this run",
url: null
}
]);
});
test("formatArtifactsRoot only shortens known workspace report paths", () => {
assert.equal(
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
);
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
});

View File

@@ -0,0 +1,106 @@
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,
searchExecution: {
freshSearch: true,
startedAt: "2026-03-30T20:45:00Z",
completedAt: "2026-03-30T21:00:00Z",
artifactsRoot: "/tmp/flight-finder-report-status",
notes: ["Fresh bounded search executed for this report."]
},
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);
});

View 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);
});

View 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);
});

View 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);
});

View 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"]
}