diff --git a/docs/flight-finder.md b/docs/flight-finder.md new file mode 100644 index 0000000..80c5c99 --- /dev/null +++ b/docs/flight-finder.md @@ -0,0 +1,55 @@ +# flight-finder + +Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery. + +## Core behavior + +`flight-finder` is designed to: + +- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose +- support split returns, flexible dates, passenger groups, and exclusions +- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check +- normalize pricing to USD before ranking +- produce a report payload first, then render PDF/email only when the report is complete +- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets + +## Important rules + +- Recipient email is a delivery gate, not a search gate. +- `marketCountry` is explicit-only in this implementation pass. + - It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`. + - If present, it activates VPN only for the bounded search phase. + - If omitted, no VPN change happens. +- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging. +- Direct-airline cross-checking is currently best-effort, not a hard blocker. + +## Helper package + +From `skills/flight-finder/`: + +```bash +npm install +npm run normalize-request -- --legacy-dfw-blq +npm run normalize-request -- --input "" +npm run report-status -- --input "" +npm run render-report -- --input "" --output "" +npm run delivery-plan -- --to "" --subject "" --body "" --attach "" +``` + +## Delivery + +- sender identity: `luke@fiorinis.com` +- send path: + - `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "" --subject "" --body "" --attach ""` +- 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. diff --git a/skills/flight-finder/SKILL.md b/skills/flight-finder/SKILL.md index d5f0b8e..31b3a56 100644 --- a/skills/flight-finder/SKILL.md +++ b/skills/flight-finder/SKILL.md @@ -101,6 +101,25 @@ Rules: - 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 "" +npm run report-status -- --input "" +npm run render-report -- --input "" --output "" +npm run delivery-plan -- --to "" --subject "" --body "" --attach "" +``` + +Rules: + +- `normalize-request` should report missing search inputs separately from delivery-only email gaps +- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email +- `render-report` must reject incomplete report payloads +- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender + ## Prompt migration rule The old DFW↔BLQ prompt is legacy input only. Do not treat it as the execution engine. Convert its hardcoded logic into the typed request model and run the skill workflow instead. diff --git a/skills/flight-finder/package-lock.json b/skills/flight-finder/package-lock.json index 0192d72..6c6aa2d 100644 --- a/skills/flight-finder/package-lock.json +++ b/skills/flight-finder/package-lock.json @@ -8,9 +8,11 @@ "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" @@ -467,6 +469,13 @@ "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", @@ -656,6 +665,15 @@ "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", diff --git a/skills/flight-finder/package.json b/skills/flight-finder/package.json index eaf49a7..af0df85 100644 --- a/skills/flight-finder/package.json +++ b/skills/flight-finder/package.json @@ -4,12 +4,18 @@ "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" diff --git a/skills/flight-finder/src/cli.ts b/skills/flight-finder/src/cli.ts new file mode 100644 index 0000000..3e39168 --- /dev/null +++ b/skills/flight-finder/src/cli.ts @@ -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 "" + flight-finder normalize-request --legacy-dfw-blq + flight-finder report-status --input "" + flight-finder render-report --input "" --output "" + flight-finder delivery-plan --to "" --subject "" --body "" --attach ""`); +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as T; +} + +async function main(): Promise { + 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(argv.input || usage()); + console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2)); + return; + } + + if (command === "report-status") { + const payload = await readJsonFile(argv.input || usage()); + console.log( + JSON.stringify( + getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload), + null, + 2 + ) + ); + return; + } + + if (command === "render-report") { + const payload = await readJsonFile(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; +}); diff --git a/skills/flight-finder/src/input-validation.ts b/skills/flight-finder/src/input-validation.ts new file mode 100644 index 0000000..c5c413c --- /dev/null +++ b/skills/flight-finder/src/input-validation.ts @@ -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); +} diff --git a/skills/flight-finder/src/price-normalization.ts b/skills/flight-finder/src/price-normalization.ts new file mode 100644 index 0000000..6753ddc --- /dev/null +++ b/skills/flight-finder/src/price-normalization.ts @@ -0,0 +1,36 @@ +export type NormalizePriceInput = { + amount: number; + currency: string; + exchangeRates?: Record; +}; + +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}.`] + }; +} diff --git a/skills/flight-finder/src/report-delivery.ts b/skills/flight-finder/src/report-delivery.ts new file mode 100644 index 0000000..2ce6dc8 --- /dev/null +++ b/skills/flight-finder/src/report-delivery.ts @@ -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 + }; +} diff --git a/skills/flight-finder/src/report-pdf.ts b/skills/flight-finder/src/report-pdf.ts new file mode 100644 index 0000000..45aae32 --- /dev/null +++ b/skills/flight-finder/src/report-pdf.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; +import PDFDocument from "pdfkit"; + +import { getFlightReportStatus } from "./report-status.js"; +import { normalizeFlightReportRequest } from "./request-normalizer.js"; +import type { FlightReportPayload } from "./types.js"; + +export class ReportValidationError extends Error {} + +function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] { + return value?.length ? value : [fallback]; +} + +export async function renderFlightReportPdf( + payload: FlightReportPayload, + outputPath: string +): Promise { + const status = getFlightReportStatus( + normalizeFlightReportRequest(payload.request), + payload + ); + + if (!status.pdfReady) { + throw new ReportValidationError( + "The flight report payload is still incomplete. Finish the report before generating the PDF." + ); + } + + await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); + + const doc = new PDFDocument({ + size: "LETTER", + margin: 50 + }); + + const stream = fs.createWriteStream(outputPath); + doc.pipe(stream); + + try { + doc.fontSize(20).text(payload.request.tripName || "Flight report", { + align: "left" + }); + doc.moveDown(0.25); + doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`); + doc.fillColor("#000"); + doc.moveDown(); + + doc.fontSize(14).text("Executive Summary"); + bulletLines(payload.executiveSummary).forEach((line) => { + doc.fontSize(10).text(`• ${line}`); + }); + + doc.moveDown(); + doc.fontSize(14).text("Recommended Options"); + payload.rankedOptions.forEach((option) => { + doc.fontSize(11).text( + `${option.backup ? "Backup" : "Primary"}: ${option.title} — $${option.totalPriceUsd.toFixed(2)}` + ); + doc.fontSize(10).text(option.rationale); + doc.moveDown(0.5); + }); + + doc.moveDown(); + doc.fontSize(14).text("Source Findings"); + payload.sourceFindings.forEach((finding) => { + doc.fontSize(11).text(`${finding.source}: ${finding.status}`); + bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`• ${line}`)); + doc.moveDown(0.25); + }); + + doc.moveDown(); + doc.fontSize(14).text("Warnings And Degraded Conditions"); + bulletLines( + [...payload.reportWarnings, ...payload.degradedReasons], + "No additional warnings." + ).forEach((line) => { + doc.fontSize(10).text(`• ${line}`); + }); + + doc.end(); + + await new Promise((resolve, reject) => { + stream.on("finish", () => resolve()); + stream.on("error", reject); + }); + } catch (error) { + stream.destroy(); + await fs.promises.unlink(outputPath).catch(() => {}); + throw error; + } + + return outputPath; +} diff --git a/skills/flight-finder/src/report-status.ts b/skills/flight-finder/src/report-status.ts new file mode 100644 index 0000000..570c34c --- /dev/null +++ b/skills/flight-finder/src/report-status.ts @@ -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." + }; +} diff --git a/skills/flight-finder/src/request-normalizer.ts b/skills/flight-finder/src/request-normalizer.ts index 99da1cc..b2fff3e 100644 --- a/skills/flight-finder/src/request-normalizer.ts +++ b/skills/flight-finder/src/request-normalizer.ts @@ -6,6 +6,7 @@ import type { FlightSearchPreferences, NormalizedFlightReportRequest } from "./types.js"; +import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js"; const DEFAULT_PREFERENCES: FlightSearchPreferences = { preferOneStop: true, @@ -34,30 +35,6 @@ function uniqueStrings(values: Array | undefined): st return cleaned.length ? Array.from(new Set(cleaned)) : undefined; } -function normalizeMarketCountry(value: string | null | undefined): string | null | undefined { - const cleaned = cleanString(value); - 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; -} - -function isPlausibleEmail(value: string | null | undefined): boolean { - if (!value) { - return false; - } - - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); -} - function normalizePassengerGroups( groups: FlightReportRequestDraft["passengerGroups"] ): FlightPassengerGroup[] { diff --git a/skills/flight-finder/src/run-state.ts b/skills/flight-finder/src/run-state.ts new file mode 100644 index 0000000..5ce0401 --- /dev/null +++ b/skills/flight-finder/src/run-state.ts @@ -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 { + 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 { + 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 { + const statePath = getFlightFinderStatePath(baseDir); + try { + await fs.unlink(statePath); + } catch (error) { + const maybeNodeError = error as NodeJS.ErrnoException; + if (maybeNodeError.code !== "ENOENT") { + throw error; + } + } +} diff --git a/skills/flight-finder/src/search-orchestration.ts b/skills/flight-finder/src/search-orchestration.ts new file mode 100644 index 0000000..fadfa52 --- /dev/null +++ b/skills/flight-finder/src/search-orchestration.ts @@ -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 + }; +} diff --git a/skills/flight-finder/src/types.ts b/skills/flight-finder/src/types.ts index ae46aae..738119d 100644 --- a/skills/flight-finder/src/types.ts +++ b/skills/flight-finder/src/types.ts @@ -51,6 +51,37 @@ export type FlightReportStatus = { 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" @@ -67,6 +98,28 @@ export type FlightSearchSourceFinding = { 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 | null | undefined> | null; @@ -93,3 +146,42 @@ export type NormalizedFlightReportRequest = { missingDeliveryInputs: string[]; warnings: string[]; }; + +export type FlightReportPayload = { + request: FlightReportRequest; + sourceFindings: FlightSearchSourceFinding[]; + quotes: FlightQuote[]; + rankedOptions: FlightRecommendationOption[]; + executiveSummary: string[]; + reportWarnings: string[]; + degradedReasons: string[]; + comparisonCurrency: "USD"; + marketCountryUsed?: string | null; + lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery"; + renderedPdfPath?: string | null; + deliveredTo?: string | null; + emailAuthorized?: boolean; + generatedAt: string; +}; + +export type FlightReportStatusResult = FlightReportStatus & { + chatSummaryReady: boolean; + terminalOutcome: + | "ready" + | "missing-inputs" + | "all-sources-failed" + | "report-incomplete"; + degraded: boolean; + degradedReasons: string[]; + blockingReason?: string; +}; + +export type FlightRunState = { + request: FlightReportRequest; + lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery"; + updatedAt: string; + reportWarnings: string[]; + degradedReasons: string[]; + sourceFindings: FlightSearchSourceFinding[]; + comparisonCurrency: "USD"; +}; diff --git a/skills/flight-finder/tests/price-normalization.test.ts b/skills/flight-finder/tests/price-normalization.test.ts new file mode 100644 index 0000000..55bec10 --- /dev/null +++ b/skills/flight-finder/tests/price-normalization.test.ts @@ -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 + ); +}); diff --git a/skills/flight-finder/tests/report-delivery.test.ts b/skills/flight-finder/tests/report-delivery.test.ts new file mode 100644 index 0000000..edd04cc --- /dev/null +++ b/skills/flight-finder/tests/report-delivery.test.ts @@ -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'/); +}); diff --git a/skills/flight-finder/tests/report-pdf.test.ts b/skills/flight-finder/tests/report-pdf.test.ts new file mode 100644 index 0000000..231f1cc --- /dev/null +++ b/skills/flight-finder/tests/report-pdf.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +import { renderFlightReportPdf, ReportValidationError } from "../src/report-pdf.js"; +import type { FlightReportPayload } from "../src/types.js"; +import { + DFW_BLQ_2026_PROMPT_DRAFT, + normalizeFlightReportRequest +} from "../src/request-normalizer.js"; + +function samplePayload(): FlightReportPayload { + return { + request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request, + sourceFindings: [ + { + source: "kayak", + status: "viable", + checkedAt: "2026-03-30T21:00:00Z", + notes: ["Returned usable options."] + } + ], + quotes: [ + { + id: "quote-1", + source: "kayak", + legId: "outbound", + passengerGroupIds: ["pair", "solo"], + bookingLink: "https://example.com/quote-1", + itinerarySummary: "DFW -> BLQ via LHR", + totalPriceUsd: 2847, + displayPriceUsd: "$2,847" + } + ], + rankedOptions: [ + { + id: "primary", + title: "Best overall itinerary", + quoteIds: ["quote-1"], + totalPriceUsd: 2847, + rationale: "Best price-to-convenience tradeoff." + } + ], + executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."], + reportWarnings: [], + degradedReasons: [], + comparisonCurrency: "USD", + generatedAt: "2026-03-30T21:00:00Z", + lastCompletedPhase: "ranking" + }; +} + +test("renderFlightReportPdf writes a non-empty PDF", async () => { + const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`); + await renderFlightReportPdf(samplePayload(), outputPath); + assert.ok(fs.existsSync(outputPath)); + assert.ok(fs.statSync(outputPath).size > 0); +}); + +test("renderFlightReportPdf rejects incomplete report payloads", async () => { + const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`); + await assert.rejects( + () => + renderFlightReportPdf( + { + ...samplePayload(), + rankedOptions: [], + executiveSummary: [] + }, + outputPath + ), + ReportValidationError + ); +}); diff --git a/skills/flight-finder/tests/report-status.test.ts b/skills/flight-finder/tests/report-status.test.ts new file mode 100644 index 0000000..68918bd --- /dev/null +++ b/skills/flight-finder/tests/report-status.test.ts @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DFW_BLQ_2026_PROMPT_DRAFT, + normalizeFlightReportRequest +} from "../src/request-normalizer.js"; +import { getFlightReportStatus } from "../src/report-status.js"; +import type { FlightReportPayload } from "../src/types.js"; + +function buildPayload(): FlightReportPayload { + return { + request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request, + sourceFindings: [ + { + source: "kayak", + status: "viable", + checkedAt: "2026-03-30T21:00:00Z", + notes: ["Returned usable options."] + } + ], + quotes: [ + { + id: "quote-1", + source: "kayak", + legId: "outbound", + passengerGroupIds: ["pair", "solo"], + bookingLink: "https://example.com/quote-1", + itinerarySummary: "DFW -> BLQ via LHR", + totalPriceUsd: 2847, + displayPriceUsd: "$2,847", + crossCheckStatus: "not-available" + } + ], + rankedOptions: [ + { + id: "primary", + title: "Best overall itinerary", + quoteIds: ["quote-1"], + totalPriceUsd: 2847, + rationale: "Best price-to-convenience tradeoff." + } + ], + executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."], + reportWarnings: [], + degradedReasons: [], + comparisonCurrency: "USD", + generatedAt: "2026-03-30T21:00:00Z", + lastCompletedPhase: "ranking" + }; +} + +test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => { + const normalized = normalizeFlightReportRequest({ + ...DFW_BLQ_2026_PROMPT_DRAFT, + recipientEmail: null + }); + const payload = { + ...buildPayload(), + request: normalized.request + }; + + const status = getFlightReportStatus(normalized, payload); + + assert.equal(status.readyToSearch, true); + assert.equal(status.pdfReady, true); + assert.equal(status.emailReady, false); + assert.deepEqual(status.needsMissingInputs, ["recipient email"]); +}); + +test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => { + const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT); + const status = getFlightReportStatus(normalized, { + ...buildPayload(), + sourceFindings: [ + { + source: "kayak", + status: "blocked", + checkedAt: "2026-03-30T21:00:00Z", + notes: ["Timed out."] + }, + { + source: "skyscanner", + status: "blocked", + checkedAt: "2026-03-30T21:00:00Z", + notes: ["Timed out."] + } + ], + quotes: [], + rankedOptions: [], + executiveSummary: [], + degradedReasons: ["All configured travel sources failed."] + }); + + assert.equal(status.terminalOutcome, "all-sources-failed"); + assert.equal(status.chatSummaryReady, true); + assert.equal(status.pdfReady, false); + assert.equal(status.degraded, true); +}); diff --git a/skills/flight-finder/tests/run-state.test.ts b/skills/flight-finder/tests/run-state.test.ts new file mode 100644 index 0000000..65c466f --- /dev/null +++ b/skills/flight-finder/tests/run-state.test.ts @@ -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); +}); diff --git a/skills/flight-finder/tests/search-orchestration.test.ts b/skills/flight-finder/tests/search-orchestration.test.ts new file mode 100644 index 0000000..7f47545 --- /dev/null +++ b/skills/flight-finder/tests/search-orchestration.test.ts @@ -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); +});