102 Commits

Author SHA1 Message Date
b9878e938c Clarify flight-finder cron completion rules 2026-04-01 10:21:14 -05:00
b2e97a3036 Polish flight-finder PDF layout 2026-03-30 19:54:35 -05:00
809a3955e5 Improve flight-finder report links and cron workflow 2026-03-30 18:26:09 -05:00
fb868b9e5f fix(flight-finder): require fresh search evidence and improve PDF layout 2026-03-30 17:45:32 -05:00
e2657f4850 feat(flight-finder): implement milestone M3 - prompt migration and smoke test fixtures 2026-03-30 17:12:52 -05:00
c30ad85e0d feat(flight-finder): implement milestone M2 - report workflow and delivery gates 2026-03-30 17:00:09 -05:00
ba5b0e4e67 chore(flight-finder): stop tracking installed dependencies 2026-03-30 16:46:06 -05:00
9c7103770a feat(flight-finder): implement milestone M1 - domain model and skill contract 2026-03-30 16:45:40 -05:00
57f6b132b2 Fix NordVPN DNS and Tailscale recovery interlock 2026-03-30 14:29:39 -05:00
b3a59b5b45 fix(nordvpn-client): validate live utun persistence before dns pinning 2026-03-30 12:08:25 -05:00
a796481875 feat(nordvpn-client): gate macos connect on stable wireguard persistence 2026-03-30 11:55:20 -05:00
8d2c162849 feat(nordvpn-client): implement milestone M1 diagnostics and classification 2026-03-30 11:34:20 -05:00
4919edcec1 Fix ACP startup guidance for managed acpx path 2026-03-30 08:05:53 -05:00
9f3d080471 Document ACP rollout caveats and parity results 2026-03-30 00:02:04 -05:00
efbdb25937 Document ACP orchestration baseline 2026-03-29 23:35:18 -05:00
b77134ced5 Use Zillow parcel hints for CAD lookup 2026-03-28 03:55:56 -05:00
ece8fc548f Trust embedded Zillow photo sets without visible count 2026-03-28 03:40:50 -05:00
446d43cc78 Prefer structured Zillow photo data before click path 2026-03-28 03:30:42 -05:00
54854edfc6 Harden assessor fallback after Zillow photo failure 2026-03-28 03:17:51 -05:00
3335e96d35 Refresh repo docs for assessor and integrations 2026-03-28 02:55:54 -05:00
a7c318aca8 Route property-assessor email through Luke 2026-03-28 02:53:34 -05:00
9caa8fa4f5 Align google-workspace helper docs 2026-03-28 02:51:32 -05:00
c3e5f669ed Fix Stefano PDF email delivery path 2026-03-28 02:50:42 -05:00
5cffd0edf9 Prefer web-automation over brave for property assessor chat runs 2026-03-28 02:38:10 -05:00
8fe451e8d0 Fix slower Zillow unit photo discovery path 2026-03-28 02:28:30 -05:00
7690dc259b Enrich property assessor with CAD detail data 2026-03-28 02:07:18 -05:00
b1722a04fa fix: require completed photo review before pdf render 2026-03-28 01:43:34 -05:00
a88d960ec9 docs: treat explicit email target as send authorization 2026-03-28 01:40:47 -05:00
d2d43df24d docs: forbid silent helper polling in whatsapp runs 2026-03-28 01:34:23 -05:00
3d7ce7617c fix: make property-assessor safer for whatsapp runs 2026-03-28 01:28:59 -05:00
2deeb31369 Clarify chat-safe property assessor behavior 2026-03-28 01:05:00 -05:00
33466079a3 Require property assessor follow-through after preliminary helper 2026-03-28 00:44:28 -05:00
be4f829704 Require explicit property assessor purpose per request 2026-03-28 00:40:35 -05:00
761bd2f083 Block preliminary property assessor PDFs 2026-03-28 00:34:28 -05:00
c68523386d Fix property assessor geocode fallback 2026-03-28 00:25:31 -05:00
7570f748f0 Clarify approval-safe property assessor flow 2026-03-27 23:47:24 -05:00
1f23eac52c Defer property assessor email gate 2026-03-27 23:27:51 -05:00
f8c998d579 Make listing discovery unit-aware 2026-03-27 23:11:10 -05:00
301986fb25 Add purpose-aware property assessor intake 2026-03-27 23:01:12 -05:00
c58a2a43c8 Add property assessor assess command 2026-03-27 22:35:57 -05:00
e6d987d725 Port property assessor helpers to TypeScript 2026-03-27 22:23:58 -05:00
954374ce48 Expand property assessor documentation 2026-03-27 21:39:06 -05:00
19adb919fc Refresh property assessor and web automation docs 2026-03-27 21:35:55 -05:00
eeea0c8ef1 Add Zillow and HAR photo extractors 2026-03-27 17:35:46 -05:00
e7c56fe760 Keep Zillow photo workflow inside web-automation 2026-03-27 16:17:45 -05:00
35d3fede49 Prefer Zillow scroller image URLs for photo review 2026-03-27 16:11:56 -05:00
f82980c4ed Require Zillow photo review via web-automation all-photos flow 2026-03-27 14:56:28 -05:00
2c5d31f85e Require action-based photo-source attempts in property-assessor 2026-03-27 14:50:14 -05:00
02cc5f8e7e Require photo-source fallback attempts in property-assessor 2026-03-27 14:43:58 -05:00
8ec0237309 Require explicit photo-review status in property assessments 2026-03-27 14:06:14 -05:00
93247a5954 Make property-assessor approval-averse for chat use 2026-03-27 13:38:53 -05:00
c2ae79ccf0 Prefer accessible all-photos views for property photo review 2026-03-27 13:15:06 -05:00
90635bf8f2 Improve gallery photo-review guidance for property assessment 2026-03-27 12:02:47 -05:00
b13f272e48 feat: add photo and make-ready analysis to property assessor 2026-03-27 10:36:42 -05:00
075f5bd9a7 feat: add property assessor skill and web automation approvals docs 2026-03-27 10:16:17 -05:00
Stefano Fiorini
b2bb07fa90 feat: make us-cpa questions retrieval-first 2026-03-15 04:40:57 -05:00
Stefano Fiorini
b4f9666560 fix: support surviving spouse filing status in us-cpa 2026-03-15 04:24:37 -05:00
Stefano Fiorini
f219672a2e fix: infer filing status from us-cpa questions 2026-03-15 04:20:43 -05:00
Stefano Fiorini
12838f7449 docs: preserve us-cpa workspace virtualenv on sync 2026-03-15 04:05:29 -05:00
Stefano Fiorini
31ed267027 docs: use python3 pip bootstrap for us-cpa install 2026-03-15 04:01:07 -05:00
Stefano Fiorini
850e89d339 docs: require pip upgrade for us-cpa editable install 2026-03-15 03:54:43 -05:00
Stefano Fiorini
b520bdc998 docs: quote us-cpa extras install command 2026-03-15 03:53:15 -05:00
7187ba9ea3 Merge pull request 'us-cpa: OpenClaw skill wrapper for U.S. federal individual tax work' (#1) from feat/us-cpa into main
Reviewed-on: #1
2026-03-15 08:48:24 +00:00
Stefano Fiorini
fdfc9f0996 docs: use home-relative us-cpa install paths 2026-03-15 03:35:24 -05:00
Stefano Fiorini
9f650faf88 docs: add us-cpa openclaw installation guide 2026-03-15 03:31:52 -05:00
Stefano Fiorini
1be0317192 test: add us-cpa module coverage and citations 2026-03-15 03:11:24 -05:00
Stefano Fiorini
fb39fe76cb fix: expand us-cpa extraction review and rendering 2026-03-15 03:01:16 -05:00
Stefano Fiorini
6c02e0b7c6 fix: add us-cpa tax year rules and package metadata 2026-03-15 02:47:14 -05:00
Stefano Fiorini
d3fd874330 docs: finalize us-cpa integration and fixtures 2026-03-15 01:34:14 -05:00
Stefano Fiorini
10a9d40f1d feat: add us-cpa review workflow 2026-03-15 01:31:43 -05:00
Stefano Fiorini
82cf3d9010 feat: add us-cpa preparation workflow 2026-03-15 01:28:22 -05:00
Stefano Fiorini
decf3132d5 feat: add us-cpa pdf renderer 2026-03-15 01:26:29 -05:00
Stefano Fiorini
c3c0d85908 feat: add us-cpa return model and calculations 2026-03-15 01:23:47 -05:00
Stefano Fiorini
8f797b3a51 feat: add us-cpa question engine 2026-03-15 01:17:14 -05:00
Stefano Fiorini
faff555757 feat: add us-cpa case intake workflow 2026-03-15 00:56:07 -05:00
Stefano Fiorini
0c2e34f2f0 feat: add us-cpa tax-year source corpus 2026-03-15 00:53:18 -05:00
Stefano Fiorini
291b729894 feat: scaffold us-cpa skill 2026-03-15 00:45:06 -05:00
Stefano Fiorini
59dbaf8a6c fix: relax CloakBrowser prerequisite check 2026-03-13 09:33:53 -05:00
Stefano Fiorini
8d5dd046a4 fix: harden mac nordvpn status inference 2026-03-13 00:30:06 -05:00
Stefano Fiorini
fc8e388c0a chore: update cloakbrowser web automation runtime 2026-03-13 00:19:54 -05:00
Stefano Fiorini
e6dccb5656 docs: expand nordvpn client setup and troubleshooting 2026-03-12 02:45:21 -05:00
Stefano Fiorini
7d8eb89911 fix: redact nordvpn path metadata by default 2026-03-12 02:37:16 -05:00
Stefano Fiorini
60f425a4fc fix: stabilize mac nordvpn state reporting 2026-03-12 02:28:57 -05:00
Stefano Fiorini
647828aa78 fix: force mac nordvpn disconnect teardown 2026-03-12 02:22:50 -05:00
Stefano Fiorini
b4c8d3fdb8 fix: clear stale nordvpn disconnect state 2026-03-12 02:08:34 -05:00
Stefano Fiorini
09b1c1e37a fix: simplify mac nordvpn tailscale coordination 2026-03-12 01:55:46 -05:00
Stefano Fiorini
916d8bf95a docs: add nordvpn tailscale coordination plan 2026-03-12 01:46:52 -05:00
Stefano Fiorini
6bc21219a7 docs: add nordvpn macos dns plan 2026-03-12 01:35:32 -05:00
Stefano Fiorini
ca33b2d74a fix: avoid mac wireguard dns rewrites 2026-03-12 01:20:02 -05:00
Stefano Fiorini
d0c50f5d8a fix: harden nordvpn wireguard verification 2026-03-12 00:50:05 -05:00
Stefano Fiorini
a8a285b356 feat: add nordvpn wireguard sudo helper 2026-03-12 00:34:04 -05:00
Stefano Fiorini
045cf6aad2 feat: add default nordvpn credential paths 2026-03-12 00:09:56 -05:00
Stefano Fiorini
78be4fc600 docs: clarify nordvpn mac setup flow 2026-03-11 23:53:15 -05:00
Stefano Fiorini
d1b4d58c5d Merge branch 'feature/nordvpn-wireguard' 2026-03-11 23:44:26 -05:00
Stefano Fiorini
4a539a33c9 feat: add mac wireguard nordvpn backend 2026-03-11 23:44:22 -05:00
Stefano Fiorini
b326153d26 fix: clarify mac nordvpn app-only mode 2026-03-11 22:59:08 -05:00
Stefano Fiorini
2612cef1dc Merge branch 'feature/nordvpn-client' 2026-03-11 22:36:03 -05:00
Stefano Fiorini
120721bbc6 feat: add nordvpn client skill 2026-03-11 22:35:50 -05:00
Stefano Fiorini
fe5b4659fe docs: add nordvpn client plan 2026-03-11 22:02:49 -05:00
Stefano Fiorini
4078073f0b refactor: migrate web-automation to cloakbrowser 2026-03-11 20:26:59 -05:00
Stefano Fiorini
c9254ed7eb docs: add cloakbrowser migration plan 2026-03-11 20:14:40 -05:00
Stefano Fiorini
49d0236a52 docs: add web automation consolidation plans 2026-03-10 19:24:59 -05:00
135 changed files with 19146 additions and 577 deletions

1
.gitignore vendored
View File

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

View File

@@ -17,17 +17,27 @@ This repository contains practical OpenClaw skills and companion integrations. I
| Skill | What it does | Path |
|---|---|---|
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Lukes sender path. | `skills/flight-finder` |
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
| `property-assessor` | Assess a residential property from an address or listing URL with CAD/public-record enrichment, Zillow-first/HAR-fallback photo review, carry-cost/risk analysis, and fixed-template PDF output. | `skills/property-assessor` |
| `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` |
| `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright + Camoufox (auth flows, extraction, bot-protected sites). | `skills/web-automation` |
| `us-cpa` | Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export. | `skills/us-cpa` |
| `web-automation` | One-shot extraction plus broader browsing/scraping with CloakBrowser, including unit-aware Zillow/HAR discovery and dedicated listing-photo extractors. | `skills/web-automation` |
## Integrations
| Integration | What it does | Path |
|---|---|---|
| `google-maps` | Traffic-aware ETA and leave-by calculations using Google Maps APIs. | `integrations/google-maps` |
| `google-workspace` | Gmail and Google Calendar helper CLI for profile, mail, calendar search, and event creation. | `integrations/google-workspace` |
| `google-workspace` | Gmail and Google Calendar helper CLI for profile, mail, attachment-capable send, calendar search, and event creation. | `integrations/google-workspace` |
## Operator docs
| Doc | What it covers |
|---|---|
| `docs/openclaw-acp-orchestration.md` | ACP orchestration setup for Codex and Claude Code on the gateway host |
## Install ideas

View File

@@ -5,12 +5,20 @@ This folder contains detailed docs for each skill in this repository.
## Skills
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
- [`flight-finder`](flight-finder.md) — Typed flight-search intake, bounded source orchestration, PDF report rendering, and Luke-sender email delivery
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
- [`property-assessor`](property-assessor.md) — Residential property assessment with CAD/public-record enrichment, Zillow/HAR photo review, valuation workflow, and PDF delivery rules
- [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance
- [`web-automation`](web-automation.md) — One-shot extraction plus Playwright + Camoufox browser automation and scraping
- [`us-cpa`](us-cpa.md) — Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export
- [`web-automation`](web-automation.md) — One-shot extraction plus CloakBrowser automation, including unit-aware Zillow/HAR discovery and dedicated photo extraction
## Integrations
- [`google-maps`](google-maps.md) — Traffic-aware ETA and leave-by calculations via Google Maps APIs
- [`google-workspace`](google-workspace.md) — Gmail and Google Calendar helper CLI
- [`google-workspace`](google-workspace.md) — Gmail and Google Calendar helper CLI with attachment-capable send support
## Operator Docs
- [`openclaw-acp-orchestration`](openclaw-acp-orchestration.md) — OpenClaw ACP orchestration for Codex and Claude Code on the gateway host

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

@@ -41,7 +41,14 @@ Optional env:
```bash
node integrations/google-workspace/gw.js whoami
node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Hello" --body "Hi there"
node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Report" --body "Attached is the PDF." --attach /tmp/report.pdf
node integrations/google-workspace/gw.js search-mail --query "from:someone@example.com newer_than:7d" --max 10
node integrations/google-workspace/gw.js search-calendar --timeMin 2026-03-17T00:00:00-05:00 --timeMax 2026-03-18T00:00:00-05:00 --max 20
node integrations/google-workspace/gw.js create-event --summary "Meeting" --start 2026-03-20T09:00:00-05:00 --end 2026-03-20T10:00:00-05:00
```
## Attachments
- `send` now supports one or more `--attach /path/to/file` arguments.
- Attachment content type is inferred from the filename extension; PDF attachments are sent as `application/pdf`.
- The default impersonation remains `stefano@fiorinis.com`, so this is the correct helper for actions explicitly performed on Stefano's behalf.

384
docs/nordvpn-client.md Normal file
View File

@@ -0,0 +1,384 @@
# nordvpn-client
Cross-platform NordVPN lifecycle skill for macOS and Linux.
## Overview
`nordvpn-client` is the operator-facing VPN control skill for OpenClaw. It can:
- detect whether the host is ready for NordVPN automation
- install or bootstrap the required backend
- validate auth
- connect to a target country or city
- verify the public exit location
- disconnect and restore normal local networking state
The skill uses different backends by platform:
- Linux: official `nordvpn` CLI
- macOS: NordLynx/WireGuard with `wireguard-go` and `wireguard-tools`
## Commands
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js status
node skills/nordvpn-client/scripts/nordvpn-client.js install
node skills/nordvpn-client/scripts/nordvpn-client.js login
node skills/nordvpn-client/scripts/nordvpn-client.js verify
node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy"
node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy" --city "Milan"
node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Italy"
node skills/nordvpn-client/scripts/nordvpn-client.js connect --city "Tokyo"
node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Japan" --city "Tokyo"
node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
```
## Credentials
Supported inputs:
- `NORDVPN_TOKEN`
- `NORDVPN_TOKEN_FILE`
- `NORDVPN_USERNAME`
- `NORDVPN_PASSWORD`
- `NORDVPN_PASSWORD_FILE`
Default OpenClaw credential paths:
- token: `~/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt`
- password: `~/.openclaw/workspace/.clawdbot/credentials/nordvpn/password.txt`
Recommended setup on macOS is a token file with strict permissions:
```bash
mkdir -p ~/.openclaw/workspace/.clawdbot/credentials/nordvpn
chmod 700 ~/.openclaw/workspace/.clawdbot/credentials/nordvpn
printf '%s\n' '<your-nordvpn-token>' > ~/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt
chmod 600 ~/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt
```
Do not commit secrets into the repo or the skill docs.
## Platform Backends
### macOS
Current macOS backend:
- NordLynx/WireGuard
- `wireguard-go`
- `wireguard-tools`
- explicit macOS DNS management on eligible physical services:
- `103.86.96.100`
- `103.86.99.100`
Important behavior:
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
- the generated WireGuard config intentionally stays free of `DNS = ...` so `wg-quick` does not rewrite every macOS network service behind the skills back.
- during `connect`, the skill first proves the tunnel is stable with a bounded persistence gate that reuses the allowed helper `probe` action and a verified public exit.
- during `connect`, the skill snapshots current DNS/search-domain settings on eligible physical services and then applies NordVPN DNS only after that stable gate, one last liveness check, and a post-DNS system-hostname-resolution check succeed.
- during `disconnect`, or after a failed/stale teardown, the skill restores the saved DNS/search-domain snapshot.
- if persistence, exit verification, or post-DNS hostname resolution fails, the skill rolls back before treating the connect as successful and resumes Tailscale if it stopped it.
- when the skill intentionally stops Tailscale for a VPN session, it writes a short-lived suppression marker so host watchdogs do not immediately run `tailscale up` and fight the VPN route change.
- The skill automatically suspends Tailscale before connect if Tailscale is active.
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
- The Homebrew NordVPN app does not need to be uninstalled.
### Linux
Current Linux backend:
- official `nordvpn` CLI
- official NordVPN installer
- token login through `nordvpn login --token ...`
## Install / Bootstrap
### macOS
Bootstrap the automation backend:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js install
```
Equivalent Homebrew command:
```bash
brew install wireguard-go wireguard-tools
```
What `install` does on macOS:
- checks whether `wireguard-go` is present
- checks whether `wg` and `wg-quick` are present
- installs missing packages through Homebrew
### Linux
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js install
```
What `install` does on Linux:
- downloads NordVPNs official installer script
- runs it
- leaves subsequent login/connect to the official `nordvpn` CLI
## macOS sudoers Setup
Automated macOS connect/disconnect requires passwordless `sudo` for the helper script that invokes `wg-quick`.
Installed OpenClaw helper path:
```text
/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh
```
Edit sudoers safely:
```bash
sudo visudo
```
Add this exact rule:
```sudoers
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
```
Do not add extra helper actions just for persistence checks unless you are also updating host sudoers. The current implementation intentionally rides the persistence check on `probe` so the existing `probe/up/down` rule remains sufficient.
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
## Common Flows
### Status
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js status
```
Use this first to answer:
- is the correct backend available?
- is the token visible?
- is `sudoReady` true?
- is the machine currently connected?
### Login
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js login
```
On macOS this validates the token and populates the local auth cache. It does not connect the VPN.
### Connect
Country:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Germany"
```
City:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Japan" --city "Tokyo"
```
Expected macOS behavior:
- stop Tailscale if active
- select a NordVPN server for the target
- bring up the WireGuard tunnel
- prove persistence of the live `utun*` runtime via the helper `probe` path
- verify the public exit location
- run one final liveness check before applying NordVPN DNS
- return JSON describing the chosen server and final verified location
### Verify
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Germany"
```
Use this after connect if you want an explicit location check without changing VPN state.
### Disconnect
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
```
Expected macOS behavior:
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
- remove stale local NordVPN state files after teardown
- restore automatic DNS when the saved DNS snapshot is obviously just NordVPN-pinned leftovers
- resume Tailscale if the skill had suspended it
## Output Model
Normal JSON is redacted by default.
Redacted fields in normal mode:
- `cliPath`
- `appPath`
- `wireguard.configPath`
- `wireguard.helperPath`
- `wireguard.authCache.tokenSource`
Operational fields preserved in normal mode:
- `connected`
- `wireguard.active`
- `wireguard.endpoint`
- `requestedTarget`
- `verification`
- public IP and location
For deeper troubleshooting, use:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
```
`--debug` keeps the internal local paths, helper-hardening diagnostics, and other low-level metadata in the JSON output.
If you also run local watchdogs such as `healthwatch.sh`, they should honor the NordVPN Tailscale suppression marker at `~/.nordvpn-client/tailscale-suppressed` and skip automatic `tailscale up` while the marker is fresh or the NordVPN WireGuard tunnel is active.
## Troubleshooting
### `Invalid authorization header`
Meaning:
- the token file was found
- the token value is not valid for NordVPNs API
Actions:
1. generate a fresh NordVPN access token
2. replace the contents of `~/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt`
3. run:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js login
```
### `sudoReady: false`
Meaning:
- the helper script is present
- the agent cannot run `wg-quick` non-interactively
Actions:
1. add the `visudo` rule shown above
2. rerun:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js status
```
Expected:
- `wireguard.sudoReady: true`
### WireGuard tools missing
Meaning:
- macOS backend is selected
- `wireguard-go`, `wg`, or `wg-quick` is missing
Actions:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js install
```
or:
```bash
brew install wireguard-go wireguard-tools
```
### Tailscale interaction
Expected behavior on macOS:
- Tailscale is suspended before the NordVPN connect
- Tailscale is resumed after disconnect or failed connect
If a connect succeeds but later traffic is wrong, check:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js status
/opt/homebrew/bin/tailscale status --json
```
Look for:
- `connected: true` and a foreign exit IP while NordVPN is up
- `connected: false` and Texas/Garland IP after disconnect
### Status says disconnected after a verified connect
This was a previous macOS false-negative path and is now normalized in the connect response.
Current expectation:
- if `connect` verifies the target location successfully
- the returned `state` snapshot should also show:
- `connected: true`
- `wireguard.active: true`
If that regresses, capture:
- `connect` JSON
- `verify` JSON
- `status --debug` JSON
### Disconnect says “no active connection” but traffic is still foreign
The current macOS disconnect path now treats residual WireGuard state as sufficient reason to attempt teardown.
Safe operator check:
```bash
node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
node skills/nordvpn-client/scripts/nordvpn-client.js verify
```
Expected after a good disconnect:
- Texas/Garland public IP again
- `wireguard.configPath: null` in normal status output
- `wireguard.lastConnection: null`
If that regresses, capture:
- `disconnect` JSON
- `verify` JSON
- `status --debug` JSON
## Recommended Agent Workflow
For VPN-routed work:
1. `status`
2. `install` if backend tooling is missing
3. `login` if token validation has not happened yet
4. `connect --country ...` or `connect --country ... --city ...`
5. `verify`
6. run the follow-up skill such as `web-automation`
7. `disconnect`
8. `verify` again if you need proof the machine returned to the normal exit path

View File

@@ -0,0 +1,375 @@
# OpenClaw ACP Orchestration
This document describes the local OpenClaw ACP setup used to orchestrate Codex and Claude Code from an OpenClaw agent on the gateway machine.
## Scope
The target workflow is:
- OpenClaw remains the orchestration brain
- natural-language requests like `use codex for this` or `run this in claude code` are routed to ACP
- the coding harness runs on the same gateway machine where the local `codex` and `claude` clients are installed
- session lifecycle is handled through OpenClaw ACP rather than sub-agents or shell relay hacks
## Local Baseline Before ACP Enablement
Captured on 2026-03-29:
- OpenClaw: `2026.3.28 (f9b1079)`
- bundled `acpx` plugin present locally but disabled and not in the plugin allowlist
- local `codex`: `/opt/homebrew/bin/codex` `0.117.0`
- local `claude`: `/opt/homebrew/bin/claude` `2.1.87`
- gateway host: 8 CPU cores, 8 GB RAM
- default OpenClaw agent workspace: `~/.openclaw/workspace`
## Architectural Decision
Primary architecture:
- OpenClaw ACP with `acpx`
Fallback architecture only if parity is not acceptable:
- `openclaw mcp serve` with Codex or Claude Code connected as external MCP clients to existing OpenClaw channel conversations
Why ACP is primary:
- this is the official OpenClaw architecture for "run this in Codex" / "start Claude Code in a thread"
- it gives durable ACP sessions, resume, bindings, and programmatic `sessions_spawn runtime:"acp"`
## Important Runtime Caveat
The bundled `acpx` runtime supports Codex and Claude, but the stock aliases are adapter commands, not necessarily the bare local terminal binaries:
- `codex -> npx -y @zed-industries/codex-acp@0.9.5`
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
That means "same as terminal" behavior has to be validated explicitly. It is not guaranteed just because ACP works.
## Baseline Configuration Applied
The current host-local OpenClaw config keeps the native `main` orchestrator and adds ACP-backed agents alongside it:
- `agents.list[0] = main` with `runtime.type = "embedded"`
- `agents.list[1] = codex` with `runtime.type = "acp"`
- `agents.list[2] = claude` with `runtime.type = "acp"`
- `acp.enabled = true`
- `acp.dispatch.enabled = true`
- `acp.backend = "acpx"`
- `acp.defaultAgent = "codex"`
- `acp.allowedAgents = ["claude", "codex"]`
- `acp.maxConcurrentSessions = 2`
- `plugins.allow += acpx`
- `plugins.entries.acpx.enabled = true`
- ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts
The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture.
## ACP Health Equivalents
The docs mention `/acp doctor`, but the operator-friendly local equivalents on this host are:
- `openclaw config validate`
- `openclaw plugins inspect acpx --json`
- `openclaw gateway status --json`
- `openclaw status --deep`
- `cd /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx && ./node_modules/.bin/acpx config show`
Healthy baseline on this machine means:
- config validates
- `acpx` plugin status is `loaded`
- gateway RPC is healthy
- `openclaw status --deep` shows `Agents 3` with `default main`
- `acpx config show` works without bootstrap errors
- `plugins.installs` does not need an `acpx` record because `acpx` is bundled with OpenClaw, not separately installed
Important health nuance:
- `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"`
- the actual readiness signal is the gateway log line `acpx runtime backend ready`
- during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths
- a later startup bug showed that pinning a custom `command` path disables the plugin-local managed install path and can leave ACP unavailable after a restart if the local `acpx` artifact is absent at boot
- the current host fix is to leave `plugins.entries.acpx.config.command` unset so the bundled plugin can manage its own plugin-local `acpx` binary
Maintenance note:
- the current host intentionally uses the managed plugin-local default command path rather than a custom override
- after any OpenClaw upgrade, re-run:
- `openclaw config validate`
- `openclaw plugins inspect acpx --json`
- `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'`
- `ls -l /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/.bin/acpx`
- if ACP comes up unavailable at startup, check whether a custom `plugins.entries.acpx.config.command` override was reintroduced before debugging deeper
## Security Review
### Why this needs a review
ACP coding sessions are headless and non-interactive. If they are allowed to write files and run shell commands, the permission mode matters a lot.
### Leading rollout candidate
- `plugins.entries.acpx.config.permissionMode = "approve-all"`
- `plugins.entries.acpx.config.nonInteractivePermissions = "deny"`
Why `deny` instead of `fail`:
- on this host, graceful degradation is better than crashing an otherwise useful ACP session at the first blocked headless permission prompt
- the live `acpx` plugin schema for OpenClaw `2026.3.28` validates `deny`, so this is an intentional runtime choice rather than a placeholder
### What `approve-all` means here
On this gateway host, an ACP coding harness may:
- write files in the configured working tree
- execute shell commands without an interactive prompt
- access network resources that are already reachable from the host
- read local home-directory configuration that the launched harness itself can reach
### Risk boundaries
This host already runs OpenClaw with:
- `tools.exec.host = "gateway"`
- `tools.exec.security = "full"`
- `tools.exec.ask = "off"`
So ACP `approve-all` does not create the first fully trusted execution path on this machine. It extends that trust to ACP-backed Codex/Claude sessions. That is still a meaningful trust expansion and should stay limited to trusted operators and trusted channels.
### First-wave rollout stance
Recommended first wave:
- enable ACP only for trusted direct operators
- prefer explicit `agentId` routing and minimal bindings
- defer broad persistent group bindings until parity and lifecycle behavior are proven
- keep the plugin-tools bridge off unless there is a proven need for ACP harnesses to call OpenClaw plugin tools from inside the session
## Observability And Recovery
Minimum required operational checks:
- `openclaw config validate`
- `openclaw plugins inspect acpx --json`
- `openclaw gateway status --json`
- `openclaw status --deep`
- `openclaw logs --follow`
- `/tmp/openclaw/openclaw-YYYY-MM-DD.log`
Operational questions this setup must answer:
- did an ACP session start
- which harness was used
- which session key is active
- where a stall or permission denial first occurred
- whether the gateway restart preserved resumable state
Current host signals:
- plugin status: `openclaw plugins inspect acpx --json`
- gateway/runtime health: `openclaw gateway status --json`
- agent inventory and active session count: `openclaw status --deep`
- ACP adapter defaults and override file discovery: `acpx config show`
- first runtime failure point: gateway log under `/tmp/openclaw/`
Claude adapter noise:
- the Claude ACP adapter currently emits `session/update` validation noise for `usage_update` after otherwise successful turns
- when filtering logs during Claude ACP troubleshooting, separate that known noise from startup failures by focusing first on:
- `acpx runtime backend ready`
- `ACP runtime backend is currently unavailable`
- `probe failed`
- actual session spawn/close lines
## Concurrency Stance
This machine has 8 CPU cores and 8 GB RAM. A conservative initial ACP concurrency cap is better than the plan's generic placeholder of `8`.
Recommended initial cap:
- `acp.maxConcurrentSessions = 2`
Reason:
- enough for one Codex and one Claude session at the same time
- low enough to reduce memory pressure and noisy contention on the same laptop-class host
- if operators start using longer-lived persistent ACP sessions heavily, revisit this only after checking real memory pressure and swap behavior on the gateway host
## Plugin Tools Bridge
The planning material discussed `plugins.entries.acpx.config.pluginToolsMcpBridge`, but the local `2026.3.28` bundled `acpx` schema does not currently expose that key in `openclaw plugins inspect acpx --json`.
Current stance:
- treat plugin-tools bridge as unsupported unless the live runtime proves otherwise
- do not add that key blindly to `openclaw.json`
## Default Workspace Root
The default ACP workspace root for this install is:
- `~/.openclaw/workspace`
Per-session or per-binding `cwd` values can narrow from there when a specific repository or skill workspace is known.
For ACP plugin/runtime config, use absolute paths instead of `~`-prefixed paths.
## Parity Results
### Codex ACP parity
Validated directly with `acpx codex` against a real project worktree.
Observed:
- correct `cwd`
- `HOME=/Users/stefano`
- access to `~/.codex`
- access to `~/.openclaw/workspace`
- access to installed Codex skills under `~/.codex/skills`
- persistent named sessions retained state across turns
- persistent named sessions retained state across an OpenClaw gateway restart
Assessment:
- Codex ACP is close enough to local terminal behavior for rollout
### Claude Code ACP parity
Validated directly with `acpx claude` against the same project worktree.
Observed:
- correct `cwd`
- `HOME=/Users/stefano`
- access to `~/.claude`
- access to `~/.codex` when explicitly tested with shell commands
- persistent named sessions retained state across turns
- persistent named sessions retained state across an OpenClaw gateway restart
Known defect:
- the Claude ACP adapter emits an extra `session/update` validation error after otherwise successful turns:
- `Invalid params`
- `sessionUpdate: 'usage_update'`
Assessment:
- Claude ACP is usable, but noisier than Codex
- this is an adapter/protocol mismatch to monitor, not a rollout blocker for trusted operators
## ACPX Override Decision
Decision:
- do **not** add `~/.acpx/config.json` agent overrides for Codex or Claude right now
Why:
- Codex parity already passes with the stock alias path
- swapping Claude from the deprecated package name to `@agentclientprotocol/claude-agent-acp@0.24.2` did **not** remove the `session/update` validation noise
- raw local `codex` and `claude` CLIs are not drop-in ACP servers, so an override would add maintenance cost without delivering materially better parity
## Natural-Language Routing Policy
The `main` agent is instructed to:
- stay native as the orchestrator
- use `sessions_spawn` with `runtime: "acp"` when the user explicitly asks for Codex or Claude Code
- choose `agentId: "codex"` or `agentId: "claude"` accordingly
- use one-shot ACP runs for single tasks
- use persistent ACP sessions only when the user clearly wants continued context
- avoid silent fallback to ordinary local exec when ACP was explicitly requested
The live messaging tool surface had to be extended to expose:
- `sessions_spawn`
- `sessions_yield`
without widening the whole profile beyond what was needed.
## Binding Policy
First-wave binding policy is intentionally conservative:
- no broad top-level persistent `bindings[]`
- no automatic permanent channel/topic binds
- prefer on-demand ACP spawn from the current conversation
- only introduce persistent binds later if there is a clear operator need
Channel-specific note:
- WhatsApp does not support ACP thread-bound spawn in the tested path
- use current-conversation or one-shot ACP behavior there, not thread-bound ACP assumptions
## Smoke-Test Findings
What worked:
- direct `acpx codex` runs
- direct `acpx claude` runs
- mixed Codex + Claude ACPX runs in parallel
- persistent ACPX named sessions
- named-session recall after a gateway restart
What failed and why:
- channel-less CLI-driven `openclaw agent` tests can fail ACP spawn with:
- `Channel is required when multiple channels are configured: telegram, whatsapp, bluebubbles`
- this is a context issue, not a backend-registration issue
- synthetic CLI sessions are not a perfect substitute for a real inbound channel conversation when testing current-conversation ACP spawn
Operational interpretation:
- ACP backend + harness parity are good enough for rollout
- final operator confidence should still come from a real inbound Telegram or WhatsApp conversation, not only a synthetic CLI turn
## Fallback Decision
Decision:
- keep ACP via `acpx` as the primary architecture
- do **not** adopt `openclaw mcp serve` as the primary mode at this stage
Why fallback was not adopted:
- Codex parity is good
- Claude parity is acceptable with one known noisy adapter defect
- OpenClaw can now expose the ACP spawn tool in the messaging profile
- the remaining limitation is real channel context for current-conversation spawn, not a fundamental mismatch between ACP and the installed gateway clients
## Rollback
Back up `~/.openclaw/openclaw.json` before any ACP change.
Current ACP implementation backup:
- `~/.openclaw/openclaw.json.bak.pre-acp-implementation-20260329-231818`
Rollback approach:
1. restore the backup config
2. validate config
3. restart the gateway
4. confirm ACP plugin status and channel health
Example rollback:
```bash
cp ~/.openclaw/openclaw.json.bak.pre-acp-implementation-20260329-231818 ~/.openclaw/openclaw.json
openclaw config validate
openclaw gateway restart
openclaw status --deep
```
## Implementation Hazards
Two local quirks were discovered during rollout:
- `openclaw config set` is not safe for parallel writes to the same config file. Concurrent `config set` calls can clobber each other.
- host-local legacy keys can reappear if a write path round-trips older config state. For this rollout, atomic file edits plus explicit validation were safer than chaining many `config set` commands.
## Implementation Notes
This document is updated milestone by milestone as the ACP rollout is implemented and verified.

View File

@@ -0,0 +1,21 @@
# Web Automation Consolidation Design
## Goal
Consolidate `playwright-safe` into `web-automation` so the repo exposes a single web skill. Keep the proven one-shot extractor behavior, rename it to `extract.js`, and remove the separate `playwright-safe` skill and docs.
## Architecture
`web-automation` remains the only published skill. It will expose two capability bands under one skill: one-shot extraction via `scripts/extract.js`, and broader stateful automation via the existing `auth.ts`, `browse.ts`, `flow.ts`, and `scrape.ts` commands. The one-shot extractor will keep the current safe Playwright behavior: single URL, JSON output, bounded stealth/anti-bot handling, and no sandbox-disabling Chromium flags.
## Migration
- Copy the working extractor into `skills/web-automation/scripts/extract.js`
- Update `skills/web-automation/SKILL.md` and `docs/web-automation.md` to describe both one-shot extraction and full automation
- Remove `skills/playwright-safe/`
- Remove `docs/playwright-safe.md`
- Remove README/doc index references to `playwright-safe`
## Verification
- `node skills/web-automation/scripts/extract.js` -> JSON error for missing URL
- `node skills/web-automation/scripts/extract.js ftp://example.com` -> JSON error for invalid scheme
- `node skills/web-automation/scripts/extract.js https://example.com` -> valid JSON result with title/status
- Repo text scan confirms no remaining published references directing users to `playwright-safe`
- Commit, push, and clean up the worktree

View File

@@ -0,0 +1,132 @@
# Web Automation Consolidation Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Consolidate the separate `playwright-safe` skill into `web-automation` and publish a single web skill with both one-shot extraction and broader automation.
**Architecture:** Move the proven safe one-shot extractor into `skills/web-automation/scripts/extract.js`, update `web-automation` docs to expose it as the simple path, and remove the separate `playwright-safe` skill and docs. Keep the extractor behavior unchanged except for its new location/name.
**Tech Stack:** Node.js, Playwright, Camoufox skill docs, git
---
### Task 1: Create isolated worktree
**Files:**
- Modify: repo git metadata only
**Step 1: Create worktree**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills worktree add /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation -b feature/web-automation-consolidation
```
**Step 2: Verify baseline**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation status --short --branch
```
Expected: clean feature branch
### Task 2: Move the extractor into web-automation
**Files:**
- Create: `skills/web-automation/scripts/extract.js`
- Read: `skills/playwright-safe/scripts/playwright-safe.js`
**Step 1: Copy the extractor**
- Copy the proven script content into `skills/web-automation/scripts/extract.js`
- Adjust only relative paths/messages if needed
**Step 2: Preserve behavior**
- Keep JSON-only output
- Keep URL validation
- Keep stealth/anti-bot behavior
- Keep sandbox enabled
### Task 3: Update skill and docs
**Files:**
- Modify: `skills/web-automation/SKILL.md`
- Modify: `docs/web-automation.md`
- Modify: `README.md`
- Modify: `docs/README.md`
- Delete: `skills/playwright-safe/SKILL.md`
- Delete: `skills/playwright-safe/package.json`
- Delete: `skills/playwright-safe/package-lock.json`
- Delete: `skills/playwright-safe/.gitignore`
- Delete: `skills/playwright-safe/scripts/playwright-safe.js`
- Delete: `docs/playwright-safe.md`
**Step 1: Update docs**
- Make `web-automation` the only published web skill
- Document `extract.js` as the one-shot extraction path
- Remove published references to `playwright-safe`
**Step 2: Remove redundant skill**
- Delete the separate `playwright-safe` skill files and doc
### Task 4: Verify behavior
**Files:**
- Test: `skills/web-automation/scripts/extract.js`
**Step 1: Missing URL check**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation && node skills/web-automation/scripts/extract.js
```
Expected: JSON error about missing URL
**Step 2: Invalid scheme check**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation && node skills/web-automation/scripts/extract.js ftp://example.com
```
Expected: JSON error about only http/https URLs allowed
**Step 3: Smoke test**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation && node skills/web-automation/scripts/extract.js https://example.com
```
Expected: JSON with title `Example Domain`, status `200`, and no sandbox-disabling flags in code
**Step 4: Reference scan**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-consolidation && rg -n "playwright-safe" README.md docs skills
```
Expected: no remaining published references, or only intentional historical plan docs
### Task 5: Commit, push, and clean up
**Files:**
- Modify: git history only
**Step 1: Commit**
Run:
```bash
git add skills/web-automation docs README.md
git commit -m "refactor: consolidate web scraping into web-automation"
```
**Step 2: Push**
Run:
```bash
git push -u origin feature/web-automation-consolidation
```
**Step 3: Merge and cleanup**
- Fast-forward or merge to `main`
- Push `main`
- Remove the worktree
- Delete the feature branch

View File

@@ -0,0 +1,40 @@
# NordVPN Client Skill Design
## Goal
Create a `nordvpn-client` skill that works on macOS and Linux gateway hosts. It should detect whether NordVPN is already installed, bootstrap it if missing, handle login/auth setup, connect to a requested country or city, verify the VPN state and public IP location, disconnect when requested, and then be usable alongside other skills like `web-automation`.
## Architecture
The skill exposes one logical interface with platform-specific backends. Linux uses the official NordVPN CLI path. macOS probes for a usable CLI first, but falls back to the official app workflow when needed. The skill is responsible only for VPN lifecycle and verification, not for wrapping arbitrary commands inside a VPN session.
## Interface
Single script entrypoint:
- `node scripts/nordvpn-client.js install`
- `node scripts/nordvpn-client.js login`
- `node scripts/nordvpn-client.js connect --country "Italy"`
- `node scripts/nordvpn-client.js connect --city "Milan"`
- `node scripts/nordvpn-client.js disconnect`
- `node scripts/nordvpn-client.js status`
## Platform Model
### Linux
- Probe for `nordvpn`
- If missing, bootstrap official NordVPN package/CLI
- Prefer token-based login for non-interactive auth
- Connect/disconnect/status through official CLI
### macOS
- Probe for `nordvpn` CLI if available
- Otherwise probe/install the official app
- Use CLI when present, otherwise automate the app/login flow
- Verify connection using app/CLI state plus external IP/geolocation
## Auth and Safety
- Do not store raw NordVPN secrets in skill docs
- Read token/credentials from env vars or a local credential file path
- Keep the skill focused on install/login/connect/disconnect/status
- After `connect`, verify both local VPN state and external IP/location before the agent proceeds to tasks like `web-automation`
## Verification
- `status` reports platform, install state, auth state, connection state, and public IP/location check
- `connect` verifies the requested target as closely as available data allows
- Local validation happens first in the OpenClaw workspace, then the proven skill is copied into `stef-openclaw-skills`, documented, committed, and pushed

View File

@@ -0,0 +1,127 @@
# NordVPN Client Skill Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a cross-platform `nordvpn-client` skill for macOS and Linux that can install/bootstrap NordVPN, log in, connect to a target country or city, verify the VPN session, disconnect, and report status.
**Architecture:** Implement one skill with one script entrypoint and platform-specific backends. Linux uses the official NordVPN CLI. macOS uses a CLI path when present and otherwise falls back to the NordVPN app workflow. The skill manages VPN state only, leaving follow-up operations like `web-automation` to separate agent steps.
**Tech Stack:** Node.js, shell/OS commands, NordVPN CLI/app integration, OpenClaw skills, git
---
### Task 1: Create isolated worktree
**Files:**
- Modify: repo git metadata only
**Step 1: Create worktree**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills worktree add /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/nordvpn-client -b feature/nordvpn-client
```
**Step 2: Verify baseline**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/nordvpn-client status --short --branch
```
Expected: clean feature branch
### Task 2: Create the local skill runtime
**Files:**
- Create: `skills/nordvpn-client/SKILL.md`
- Create: `skills/nordvpn-client/scripts/nordvpn-client.js`
- Optional Create: helper files under `skills/nordvpn-client/scripts/`
**Step 1: Write the failing checks**
- Missing command/action should fail with clear usage output
- Unsupported platform should fail clearly
**Step 2: Implement platform detection and install probe**
- detect `darwin` vs `linux`
- detect whether NordVPN CLI/app is already present
- expose `status` with install/auth/connect fields
### Task 3: Implement install and auth bootstrap
**Files:**
- Modify: `skills/nordvpn-client/scripts/nordvpn-client.js`
**Step 1: Linux install/login path**
- implement official CLI probe/install path
- implement token-based login path
**Step 2: macOS install/login path**
- probe CLI first
- if absent, probe/install NordVPN app path
- implement login/bootstrap state verification for the app workflow
**Step 3: Keep secrets external**
- env vars or local credential path only
- no raw secrets in docs or skill text
### Task 4: Implement connect/disconnect/status/verification
**Files:**
- Modify: `skills/nordvpn-client/scripts/nordvpn-client.js`
**Step 1: Connect**
- support `--country` and `--city`
- normalize target handling per platform
**Step 2: Verify**
- report local connection state
- run public IP / geolocation verification
- fail if connection target cannot be reasonably verified
**Step 3: Disconnect and status**
- implement clean disconnect
- ensure `status` emits machine-readable output for agent use
### Task 5: Validate locally in OpenClaw workspace
**Files:**
- Test: local workspace copy of `nordvpn-client`
**Step 1: Direct command validation**
- usage errors are correct
- install probe works on this host
- status output is coherent before login/connect
**Step 2: One real connect flow**
- connect to a test country/city if credentials are available
- verify local state + external IP/location
- disconnect cleanly
### Task 6: Promote to repo docs and publish
**Files:**
- Modify: `README.md`
- Modify: `docs/README.md`
- Create: `docs/nordvpn-client.md`
- Create/Modify: `skills/nordvpn-client/...`
**Step 1: Document the skill**
- install/bootstrap behavior
- auth expectations
- connect/disconnect/status commands
- macOS vs Linux notes
**Step 2: Commit and push**
Run:
```bash
git add skills/nordvpn-client docs README.md
git commit -m "feat: add nordvpn client skill"
git push -u origin feature/nordvpn-client
```
**Step 3: Merge and cleanup**
- fast-forward or merge to `main`
- push `main`
- remove the worktree
- delete the feature branch

View File

@@ -0,0 +1,34 @@
# NordVPN macOS WireGuard Backend Design
## Goal
Replace the current macOS app-manual fallback in `nordvpn-client` with a scripted WireGuard/NordLynx backend inspired by `wg-nord` and `wgnord`, while preserving the official Linux `nordvpn` CLI backend.
## Key decisions
- Keep Linux on the official `nordvpn` CLI.
- Prefer a native macOS WireGuard backend over the GUI app.
- Do not vendor third-party scripts directly; reimplement the needed logic in our own JSON-based Node skill.
- Do not require uninstalling the Homebrew `nordvpn` app. The new backend can coexist with it.
## macOS backend model
- Bootstrap via Homebrew:
- `wireguard-tools`
- `wireguard-go`
- Read NordVPN token from existing env/file inputs.
- Discover a WireGuard-capable NordVPN server via the public Nord API.
- Generate a private key locally.
- Exchange the private key for Nord-provided interface credentials using the token.
- Materialize a temporary WireGuard config under a skill-owned state directory.
- Connect and disconnect via `wg-quick`.
- Verify with public IP/geolocation after connect.
## Data/state
- Keep state under a skill-owned directory in the user's home, not `/etc`.
- Persist only what is needed for reconnect/disconnect/status.
- Never store secrets in docs.
## Rollout
1. Implement the macOS WireGuard backend in the skill.
2. Update status output so backend selection is explicit.
3. Update skill docs and repo docs.
4. Verify non-destructive flows on this host.
5. Commit, push, and then decide whether to run a live connect test.

View File

@@ -0,0 +1,11 @@
# NordVPN macOS WireGuard Backend Plan
1. Add a backend selector to `nordvpn-client`.
2. Keep Linux CLI behavior unchanged.
3. Add macOS WireGuard dependency probing and install guidance.
4. Implement token-based NordLynx config generation inspired by `wg-nord`/`wgnord`.
5. Replace the current preferred macOS control mode from `app-manual` to WireGuard when dependencies and token are available.
6. Keep app-manual as the last fallback only.
7. Update `status`, `login`, `connect`, `disconnect`, and `verify` JSON to expose the backend in use.
8. Update repo docs and skill docs to reflect the new model and required token/dependencies.
9. Verify command behavior locally without forcing a live VPN connection unless requested.

View File

@@ -0,0 +1,33 @@
# Web Automation CloakBrowser Migration Design
## Goal
Replace all Camoufox and direct Playwright Chromium usage in `web-automation` with CloakBrowser, while preserving the existing feature set: one-shot extraction, persistent browsing sessions, authenticated flows, multi-step automation, and markdown scraping. After local validation, keep the repo copy and docs as the canonical published version, then commit and push.
## Architecture
`web-automation` will become CloakBrowser-only. A single browser-launch layer in `skills/web-automation/scripts/browse.ts` will provide the canonical runtime for the other scripts. Stateful flows will use CloakBrowser persistent contexts; one-shot extraction will also use CloakBrowser instead of raw `playwright.chromium`.
## Scope
- Replace `camoufox-js` in `browse.ts` and any helper/test scripts
- Replace direct `playwright.chromium` launch in `extract.js`
- Update shared types/imports to match the CloakBrowser Playwright-compatible API
- Remove Camoufox/Chromium-specific setup instructions from skill docs
- Update package metadata and lockfile to depend on `cloakbrowser`
- Keep the user-facing command surface stable where possible
## Compatibility Strategy
To minimize user breakage:
- keep the script filenames and CLI interfaces stable
- support old `CAMOUFOX_*` env vars as temporary aliases where practical
- introduce neutral naming in docs and code for the new canonical path
## Testing Strategy
- Verify launcher setup directly through `browse.ts`
- Verify `extract.js` still handles: missing URL, invalid scheme, smoke extraction from `https://example.com`
- Verify one persistent-context path and one higher-level consumer (`scrape.ts` or `flow.ts`) still works
- Update docs only after the runtime is validated
## Rollout
1. Implement and verify locally in the repo worktree
2. Update repo docs/indexes to describe CloakBrowser-based `web-automation`
3. Commit and push the repo changes
4. If needed, sync the installed OpenClaw workspace copy from the validated repo version

View File

@@ -0,0 +1,136 @@
# Web Automation CloakBrowser Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace Camoufox and direct Chromium launches in `web-automation` with CloakBrowser and publish the updated repo/docs.
**Architecture:** Use a single CloakBrowser-backed launch path in `skills/web-automation/scripts/browse.ts`, migrate `extract.js` to the same backend, update dependent scripts and tests, then update docs and publish the repo changes.
**Tech Stack:** Node.js, TypeScript, JavaScript, Playwright-compatible browser automation, CloakBrowser, git
---
### Task 1: Create isolated worktree
**Files:**
- Modify: repo git metadata only
**Step 1: Create worktree**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills worktree add /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser -b feature/web-automation-cloakbrowser
```
**Step 2: Verify baseline**
Run:
```bash
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser status --short --branch
```
Expected: clean feature branch
### Task 2: Migrate the browser launcher
**Files:**
- Modify: `skills/web-automation/scripts/browse.ts`
- Modify: `skills/web-automation/scripts/package.json`
- Modify: `skills/web-automation/scripts/pnpm-lock.yaml`
- Optional Modify: test helper scripts under `skills/web-automation/scripts/`
**Step 1: Write the failing verification**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser/skills/web-automation/scripts && node -e "require.resolve('cloakbrowser/package.json')"
```
Expected: fail before dependency migration
**Step 2: Replace backend dependency**
- remove `camoufox-js`
- add `cloakbrowser`
- update `browse.ts` to launch CloakBrowser contexts
- preserve persistent profile support
**Step 3: Verify launcher wiring**
Run a direct browse smoke test after install/update.
### Task 3: Migrate dependent scripts
**Files:**
- Modify: `skills/web-automation/scripts/extract.js`
- Modify: `skills/web-automation/scripts/auth.ts`
- Modify: `skills/web-automation/scripts/flow.ts`
- Modify: `skills/web-automation/scripts/scrape.ts`
- Modify: `skills/web-automation/scripts/test-minimal.ts`
- Modify: `skills/web-automation/scripts/test-full.ts`
- Modify: `skills/web-automation/scripts/test-profile.ts`
**Step 1: Keep interfaces stable**
- preserve CLI usage where possible
- use CloakBrowser through shared launcher code
- keep one-shot extraction JSON output unchanged except for backend wording if needed
**Step 2: Add compatibility aliases**
- support old `CAMOUFOX_*` env vars where practical
- document new canonical naming
### Task 4: Verify behavior
**Files:**
- Test: `skills/web-automation/scripts/*`
**Step 1: Extractor error checks**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser && node skills/web-automation/scripts/extract.js
```
Expected: JSON error for missing URL
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser && node skills/web-automation/scripts/extract.js ftp://example.com
```
Expected: JSON error for invalid scheme
**Step 2: Extractor smoke test**
Run:
```bash
cd /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/web-automation-cloakbrowser && node skills/web-automation/scripts/extract.js https://example.com
```
Expected: JSON result with title `Example Domain` and status `200`
**Step 3: Stateful path verification**
- run one direct `browse.ts`, `scrape.ts`, or `flow.ts` command using the CloakBrowser backend
- confirm persistent-context code still initializes successfully
### Task 5: Update docs and publish
**Files:**
- Modify: `skills/web-automation/SKILL.md`
- Modify: `docs/web-automation.md`
- Modify: `README.md`
- Modify: `docs/README.md`
**Step 1: Update docs**
- replace Camoufox wording with CloakBrowser wording
- replace old setup/install steps
- document any compatibility env vars and new canonical names
**Step 2: Commit and push**
Run:
```bash
git add skills/web-automation docs README.md
git commit -m "refactor: migrate web-automation to cloakbrowser"
git push -u origin feature/web-automation-cloakbrowser
```
**Step 3: Merge and cleanup**
- fast-forward or merge to `main`
- push `main`
- remove the worktree
- delete the feature branch

View File

@@ -0,0 +1,76 @@
# NordVPN Client Docs Refresh Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refresh the `nordvpn-client` documentation so operators and the OpenClaw agent have complete, accurate setup and troubleshooting guidance for the current macOS and Linux backends.
**Architecture:** Expand the canonical repo doc into a full operator guide, tighten the agent-facing `SKILL.md` to match the current behavior, and lightly update summary docs only if their current one-line descriptions are materially incomplete. Sync the updated `SKILL.md` into the installed OpenClaw workspace copy so runtime guidance matches the repo.
**Tech Stack:** Markdown docs, local repo skill docs, OpenClaw workspace skill sync
---
### Task 1: Refresh canonical operator documentation
**Files:**
- Modify: `docs/nordvpn-client.md`
**Step 1: Rewrite the doc structure**
- Add sections for overview, platform backends, prerequisites, credential paths, install/bootstrap, macOS sudoers setup, command flows, output model, and troubleshooting.
**Step 2: Add exact operator setup details**
- Include the exact `visudo` entry for the helper script.
- Document default token/password file locations.
- Document Homebrew install commands for macOS tooling.
**Step 3: Add safe troubleshooting guidance**
- Include only safe operator procedures from the debugging work:
- invalid token handling
- `sudoReady: false`
- Tailscale suspend/resume expectations
- what normal redacted output includes
- how to use `--debug` when deeper inspection is needed
### Task 2: Refresh agent-facing skill documentation
**Files:**
- Modify: `skills/nordvpn-client/SKILL.md`
- Sync: `/Users/stefano/.openclaw/workspace/skills/nordvpn-client/SKILL.md`
**Step 1: Tighten the skill instructions**
- Keep the doc shorter than the canonical operator guide.
- Ensure it explicitly covers the default credential paths, macOS sudoers requirement, Tailscale suspend/resume behavior, and `--debug` usage.
**Step 2: Sync installed OpenClaw copy**
- Copy the updated repo `SKILL.md` into the installed workspace skill path.
### Task 3: Update summary docs if needed
**Files:**
- Check: `README.md`
- Check: `docs/README.md`
- Modify only if current summary text is materially missing the current backend model.
**Step 1: Review summary descriptions**
- Confirm whether the one-line descriptions already adequately describe Linux CLI + macOS NordLynx/WireGuard.
**Step 2: Update only if necessary**
- Avoid churn if the existing summaries are already sufficient.
### Task 4: Verify and publish
**Files:**
- Verify: `docs/nordvpn-client.md`
- Verify: `skills/nordvpn-client/SKILL.md`
- Verify: `/Users/stefano/.openclaw/workspace/skills/nordvpn-client/SKILL.md`
**Step 1: Run doc verification checks**
- Run: `rg -n "sudoers|visudo|--debug|Tailscale|token.txt|wireguard-helper" docs/nordvpn-client.md skills/nordvpn-client/SKILL.md`
- Expected: all required topics present
**Step 2: Confirm installed workspace skill matches repo skill**
- Run: `cmp skills/nordvpn-client/SKILL.md /Users/stefano/.openclaw/workspace/skills/nordvpn-client/SKILL.md`
- Expected: no output
**Step 3: Commit and push**
- Commit message: `docs: expand nordvpn client setup and troubleshooting`

View File

@@ -0,0 +1,40 @@
# NordVPN macOS DNS Design
## Goal
Keep NordVPN DNS while connected on macOS, but only apply it to active physical services so the WireGuard backend does not break Tailscale or other virtual interfaces.
## Behavior
- Keep the generated WireGuard config free of `DNS = ...`
- During `connect` on macOS:
- detect active physical network services
- snapshot current DNS/search-domain settings
- set NordVPN DNS only on those physical services
- During `disconnect`:
- restore the saved DNS/search-domain settings
- During failed `connect` after DNS changes:
- restore DNS before returning the error
## DNS Values
- IPv4 primary: `103.86.96.100`
- IPv4 secondary: `103.86.99.100`
- No IPv6 DNS for now
## Service Selection
Include only enabled physical services from `networksetup`.
Exclude names matching:
- Tailscale
- Bridge
- Thunderbolt Bridge
- Loopback
- VPN
- utun
## Persistence
- Save DNS snapshot under `~/.nordvpn-client`
- Overwrite on each successful connect
- Clear after successful disconnect restore
## Verification
- Unit tests for service selection and DNS snapshot/restore helpers
- Direct logic/config tests
- Avoid live connect tests from this session unless explicitly requested because they can drop connectivity

View File

@@ -0,0 +1,11 @@
# NordVPN macOS DNS Plan
1. Add macOS DNS state file support under `~/.nordvpn-client`.
2. Implement helpers to enumerate eligible physical services and snapshot existing DNS/search-domain settings.
3. Implement helpers to apply NordVPN DNS only to eligible physical services.
4. Implement helpers to restore previous DNS/search-domain settings on disconnect or failed connect.
5. Add unit tests for service filtering and DNS state transitions.
6. Update skill/docs to explain macOS physical-service DNS management.
7. Sync the installed workspace copy.
8. Run tests and non-destructive verification.
9. Commit and push.

View File

@@ -0,0 +1,26 @@
# NordVPN Tailscale Coordination Design
## Goal
Stabilize macOS NordVPN connects by explicitly stopping Tailscale before bringing up the NordVPN WireGuard tunnel, then restarting Tailscale after NordVPN disconnects.
## Behavior
- macOS only
- on `connect`:
- detect whether Tailscale is active
- if active, stop it and record that state
- bring up NordVPN
- on `disconnect`:
- tear down NordVPN
- if the skill stopped Tailscale earlier, start it again
- clear the saved state
- on connect failure after stopping Tailscale:
- attempt to start Tailscale again before returning the error
## State
- persist `tailscaleWasActive` under `~/.nordvpn-client`
- only restart Tailscale if the skill actually stopped it
## Rollback target if successful
- remove the temporary macOS physical-service DNS management patch
- restore the simpler NordVPN config path that uses NordVPN DNS directly in the WireGuard config
- keep Tailscale suspend/resume as the macOS coexistence solution

View File

@@ -0,0 +1,10 @@
# NordVPN Tailscale Coordination Plan
1. Add macOS Tailscale state file support under `~/.nordvpn-client`.
2. Implement helpers to detect, stop, and start Tailscale on macOS.
3. Add unit tests for Tailscale state transitions.
4. Wire Tailscale stop into macOS `connect` before WireGuard up.
5. Wire Tailscale restart into macOS `disconnect` and connect-failure rollback.
6. Sync the installed workspace copy.
7. Run tests and non-destructive verification.
8. Commit and push.

View File

@@ -0,0 +1,180 @@
# Property Assessor WhatsApp-Safe Runtime Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Keep WhatsApp property-assessor runs moving by failing fast on silent discovery/photo hangs, avoiding helper subprocesses during core analysis, and reserving subprocess use to the final PDF render attempt only.
**Architecture:** Add small timeout guards around the existing in-process listing discovery and photo extraction calls so one quiet browser-backed task cannot stall the entire assessment. Tighten the live skill and published docs so messaging runs treat chat-native source collection as the default path and helper commands as non-chat or final-render-only tools.
**Tech Stack:** TypeScript, Node test runner, existing OpenClaw property-assessor skill, existing OpenClaw web-automation modules, Markdown docs.
---
### Task 1: Add failing timeout tests for discovery and photo extraction
**Files:**
- Modify: `skills/property-assessor/tests/assessment.test.ts`
**Step 1: Write the failing test**
Add tests that stub discovery/photo functions with never-resolving promises and assert that:
- listing discovery returns `null` URLs and records timeout attempts
- photo extraction returns `not completed` instead of hanging forever
**Step 2: Run test to verify it fails**
Run: `npm test -- --test-name-pattern "times out"`
Expected: FAIL because current code never times out or records timeout attempts.
**Step 3: Write minimal implementation**
Implement the smallest timeout wrapper needed for the tests to pass.
**Step 4: Run test to verify it passes**
Run: `npm test -- --test-name-pattern "times out"`
Expected: PASS
**Step 5: Commit**
```bash
git add skills/property-assessor/tests/assessment.test.ts
git commit -m "test: cover stalled discovery and photo extraction"
```
### Task 2: Implement hard timeout guards in the live assessment path
**Files:**
- Create: `skills/property-assessor/src/async-timeout.ts`
- Modify: `skills/property-assessor/src/listing-discovery.ts`
- Modify: `skills/property-assessor/src/photo-review.ts`
**Step 1: Write the failing test**
Use the tests from Task 1 as the red phase.
**Step 2: Run test to verify it fails**
Run: `npm test -- --test-name-pattern "times out"`
Expected: FAIL
**Step 3: Write minimal implementation**
Add:
- a shared timeout helper for async operations
- timeout-wrapped Zillow/HAR discovery in `listing-discovery.ts`
- timeout-wrapped Zillow/HAR photo extraction in `photo-review.ts`
- clear timeout attempt messages so the assessment can continue honestly
**Step 4: Run test to verify it passes**
Run: `npm test -- --test-name-pattern "times out"`
Expected: PASS
**Step 5: Commit**
```bash
git add skills/property-assessor/src/async-timeout.ts skills/property-assessor/src/listing-discovery.ts skills/property-assessor/src/photo-review.ts skills/property-assessor/tests/assessment.test.ts
git commit -m "fix: fail fast on stalled property-assessor extraction steps"
```
### Task 3: Tighten live skill instructions for WhatsApp-safe execution
**Files:**
- Modify: `../skills/property-assessor/SKILL.md`
**Step 1: Write the failing test**
No automated test. Use the documented runtime rule as the spec:
- WhatsApp/messaging runs must avoid helper subprocesses for core analysis
- only the final PDF render attempt may use the helper subprocess path
- `update?` must remain status-only
**Step 2: Run verification to confirm current docs are wrong**
Run: `rg -n "scripts/property-assessor assess|node zillow-photos|node har-photos|Good:" ../skills/property-assessor/SKILL.md`
Expected: current doc still presents helper commands as normal chat-safe core workflow.
**Step 3: Write minimal implementation**
Update the live skill doc to:
- prefer `web_search`, `web_fetch`, and bounded `web-automation` for core assessment
- forbid `scripts/property-assessor assess`, `node zillow-photos.js`, `node har-photos.js`, and ad hoc `curl` as the default WhatsApp core path
- allow a single final PDF render attempt only after a decision-grade verdict exists
**Step 4: Run verification**
Run: `sed -n '1,220p' ../skills/property-assessor/SKILL.md`
Expected: the WhatsApp-safe runtime rules are explicit and unambiguous.
**Step 5: Commit**
```bash
git add ../skills/property-assessor/SKILL.md
git commit -m "docs: clarify whatsapp-safe property-assessor execution"
```
### Task 4: Mirror the runtime guidance into the published repo docs
**Files:**
- Modify: `docs/property-assessor.md`
- Modify: `docs/web-automation.md`
**Step 1: Write the failing test**
No automated test. The spec is consistency with the live skill instructions.
**Step 2: Run verification to confirm current docs drift**
Run: `rg -n "node zillow-photos|node har-photos|assess --address" docs/property-assessor.md docs/web-automation.md`
Expected: current docs still imply subprocess-heavy commands are the standard chat path.
**Step 3: Write minimal implementation**
Document:
- chat-native assessment first
- timeout-protected discovery/photo extraction behavior
- final-render-only subprocess attempt from messaging runs
**Step 4: Run verification**
Run: `sed -n '1,220p' docs/property-assessor.md && sed -n '1,220p' docs/web-automation.md`
Expected: published docs match the live skill behavior.
**Step 5: Commit**
```bash
git add docs/property-assessor.md docs/web-automation.md
git commit -m "docs: document whatsapp-safe property assessment flow"
```
### Task 5: Verify the focused runtime behavior
**Files:**
- Modify: `skills/property-assessor/tests/assessment.test.ts`
- Verify: `skills/property-assessor/src/*.ts`
- Verify: `../skills/property-assessor/SKILL.md`
- Verify: `docs/property-assessor.md`
- Verify: `docs/web-automation.md`
**Step 1: Run focused tests**
Run: `npm test`
Expected: all `property-assessor` tests pass, including timeout coverage.
**Step 2: Run targeted source verification**
Run: `rg -n "withTimeout|timed out|final PDF render" skills/property-assessor/src ../skills/property-assessor/SKILL.md docs/property-assessor.md docs/web-automation.md`
Expected: timeout guards and the final-render-only messaging rule are present.
**Step 3: Inspect git status**
Run: `git status --short`
Expected: only intended files are modified.
**Step 4: Commit**
```bash
git add skills/property-assessor/src/async-timeout.ts skills/property-assessor/src/listing-discovery.ts skills/property-assessor/src/photo-review.ts skills/property-assessor/tests/assessment.test.ts ../skills/property-assessor/SKILL.md docs/property-assessor.md docs/web-automation.md docs/plans/2026-03-28-property-assessor-whatsapp-safe-runtime.md
git commit -m "fix: make property-assessor safer for whatsapp runs"
```

477
docs/property-assessor.md Normal file
View File

@@ -0,0 +1,477 @@
# property-assessor
Decision-grade residential property assessment skill for OpenClaw, with official public-record enrichment and fixed-template PDF report rendering.
## Overview
`property-assessor` is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
If the subject property has an apartment / unit / suite number, include it. Discovery is now unit-aware for Zillow and HAR when unit data is present, while still supporting plain single-family addresses that have no unit.
The skill is intended to:
- normalize the property across listing sources
- review listing photos before making condition claims
- incorporate official public-record / appraisal-district context when available
- compare the property against comps and carrying costs
- produce a fixed-format PDF report, not just ad hoc chat prose
## Standalone helper usage
This skill now ships with a small TypeScript helper package for three tasks:
- assembling an address-first preliminary assessment payload
- locating official public-record jurisdiction from an address
- rendering a fixed-template PDF report
From `skills/property-assessor/`:
```bash
npm install
scripts/property-assessor --help
```
The wrapper script uses the skill-local Node dependencies under `node_modules/`.
Delivery rule:
- If the user explicitly says to email or send the finished PDF to a stated target address, that counts as delivery authorization once the report is ready.
- The agent should not ask for a second `send it` confirmation unless the user changed the destination or showed uncertainty.
- Final property-assessor delivery should be sent as Luke via the Luke Google Workspace wrapper, while the destination remains whatever address the user specified.
## Commands
```bash
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property"
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com"
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --parcel-id "14069438"
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --listing-geo-id "233290"
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
```
## Core workflow
Default operating sequence:
1. Normalize the address and property type.
2. Resolve public-record jurisdiction from the address.
3. Discover accessible listing sources for the same property.
4. Build a baseline fact set.
5. Review listing photos before making condition claims.
6. Pull same-building or nearby comps.
7. Underwrite carry costs and risk factors.
8. Render the final report as a fixed-template PDF.
Operational rule:
- The helper returning a preliminary payload is not the end of the job.
- For a user request that clearly asks for a full assessment or PDF delivery, the agent is expected to continue the missing analysis after the helper returns.
- Preliminary helper output should be treated as structured scaffolding for the remaining work, not as a reason to stop and wait for another user nudge.
- In chat/messaging runs, do not waste the turn on `npm install` or `npm ci` when the local skill dependencies are already present.
- If the user asks `update?` or `and?` mid-run, treat that as a status request and continue the same assessment rather than restarting or stopping at the last checkpoint.
- In WhatsApp or similar messaging runs, keep the core analysis on `web-automation` plus `web_fetch`. Treat `web_search` as a narrow fallback for alternate-URL discovery only, not the default path for Zillow/HAR/CAD work.
- Do not start Zillow/HAR property discovery or photo review from Brave-backed `web_search` when `web-automation` can open the candidate listing source directly.
- For CAD/public-record lookup, prefer official assessor/CAD pages via `web_fetch` first and `web-automation` second if the site needs rendered interaction.
- In Texas runs, do not use `https://www.texas.gov/propertytaxes/search/` as the CAD lookup path; use the address-first CAD/helper path or the discovered county CAD pages directly.
- In those messaging runs, reserve subprocess use for a single final `render-report` attempt after the verdict and fair-value range are complete.
- In those messaging runs, do not start Gmail/email-send skill discovery or delivery tooling until the report content is complete and the PDF is ready to render or already rendered.
- Property-assessor delivery emails should be sent as Luke from Luke's Google Workspace account, while still delivering to the user-specified destination.
- Use `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"` for final PDF delivery.
- Do not route property-assessor delivery through generic `gog` or the Stefano helper `node ~/.openclaw/workspace/integrations/google-workspace/gw.js`.
- If the agent needs to confirm Luke auth before sending, use `zsh ~/.openclaw/workspace/bin/gog-luke auth list --check --plain`.
- Treat a silent helper as a failed helper in messaging runs. If a helper produces no useful output within a short bound, abandon it and continue with the chat-native path instead of repeatedly polling it.
- If Zillow photo extraction fails, immediately continue with HAR photo fallback or the next available rendered listing/photo source rather than stopping the assessment.
- After a Zillow/HAR photo miss, continue the comp and CAD/public-record work in the same run. A photo-source miss is a fallback event, not a terminal state.
- If the original request already authorized sending the finished PDF to a stated email address, do not pause for a redundant send-confirmation prompt after rendering.
- If final PDF render/send fails, return the completed decision-grade report in chat and report delivery failure separately rather than restarting the whole assessment.
### `assess`
```bash
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>"
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>" --recipient-email "<target@example.com>"
```
Current behavior:
- starts from the address
- requires an assessment purpose for decision-grade analysis
- does not assume the assessment purpose from prior thread context unless the user explicitly says the purpose is unchanged
- automatically runs public-record / appraisal-district lookup
- keeps CAD-site selection address-driven and jurisdiction-specific; it does not hardcode one county's CAD as the global source
- when a supported official CAD detail host is found, captures direct property facts such as property ID/account, owner, legal description, assessed value, exemptions, and the official property-detail URL
- automatically tries to discover Zillow and HAR listing URLs from the address when no listing URL is provided
- starts Zillow and HAR listing discovery in parallel so HAR can already be in flight if Zillow misses or stalls
- runs Zillow photo extraction first, then HAR as fallback when available
- gives Zillow a longer source-specific discovery/photo window than the generic fallback path, because some exact-unit Zillow pages resolve more slowly than HAR or public-record lookups
- reuses the OpenClaw web-automation logic in-process instead of spawning nested helper commands
- fails fast when Zillow/HAR discovery or photo extraction stalls instead of hanging indefinitely
- returns a structured preliminary report payload
- does not require recipient email(s) for the analysis-only run
- asks for recipient email(s) only when PDF rendering is explicitly requested
- does not render/send the PDF from a preliminary helper payload with `decision: pending`
- does not render/send the PDF when `photoReview.status` is not `completed`
- only renders the fixed-template PDF after a decision-grade verdict and fair-value range are actually present
Expected agent behavior:
- if the user asked for the full assessment, continue beyond the preliminary helper output
- fill the remaining gaps with listing facts, comp work, condition interpretation, and valuation logic
- require completed subject-unit photo review before treating the report as decision-grade enough for PDF delivery
- only stop early when there is a real blocker, not merely because the helper stopped at a checkpoint
Important limitation:
- this implementation now wires the address-first intake, purpose-aware framing, public-record lookup, listing discovery, and photo-source extraction
- it still does not perform full comp analysis, pricing judgment, or completed carry underwriting inside the helper itself
- those deeper decision steps are still governed by the skill workflow after the helper assembles the enriched payload
## Source priority
Unless the user says otherwise, preferred listing/source order is:
1. Zillow
2. Redfin
3. Realtor.com
4. HAR / Homes.com / brokerage mirrors
5. county or appraisal pages
Public-record / assessor data should be linked in the final result when available.
## Public-record enrichment
The skill should not rely on listing-site geo IDs as if they were assessor record identifiers.
Correct approach:
1. start from the street address
2. resolve the address to state/county/FIPS/GEOID
3. identify the official public-record jurisdiction
4. use parcel/APN/account identifiers when available
5. link the official jurisdiction page and any direct property page used
### `locate-public-records`
```bash
scripts/property-assessor locate-public-records --address "<street-address>"
```
Current behavior:
- uses the official Census geocoder
- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates
- retries fallback geocoding without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider
- returns matched address, county/state/FIPS, and block GEOID context
- for Texas, returns:
- Texas Comptroller county directory page
- appraisal district contact/site details
- tax assessor/collector contact/site details
- official CAD property-detail facts when a supported county adapter can retrieve them from the discovered CAD site
Important rules:
- Zillow/Redfin/HAR geo IDs are hints only
- parcel/APN/account IDs are stronger search keys than listing geo IDs
- if Zillow exposes a parcel/APN/account number on the listing, capture it and use that identifier in CAD lookup before falling back to address-only matching
- official jurisdiction pages should be linked in the final report
- if a direct property detail page is accessible, its data should be labeled as official public-record evidence
### Texas support
Texas is the first-class public-record path in this implementation.
For Texas addresses, the helper resolves:
- the official Census geocoder link
- the official Texas Comptroller county directory page
- the appraisal district website
- the tax assessor/collector website
- the official CAD property-detail page when a supported adapter can identify and retrieve the subject record
That output should be used by the skill to:
- identify the correct CAD
- attempt address / parcel / account lookup on the discovered CAD site for that county
- prefer Zillow-exposed parcel/APN/account identifiers over address-only search when the listing provides them
- capture official owner / legal / assessed-value evidence when a public detail page is available
- treat county-specific CAD detail retrieval as an adapter layer on top of generic county/jurisdiction resolution
Nueces-specific note:
- when using Nueces CAD `By ID` / `Geographic ID`, insert a dash after the first 4 digits and again after the first 8 digits, for example `123456789012` -> `1234-5678-9012`
Recommended fields to capture from official records when accessible:
- account number
- owner name
- land value
- improvement value
- assessed total
- exemptions
- official property-detail URL
## Minimum data to capture
For the subject property, capture when available:
- address
- list price or last known list price
- property type
- beds / baths
- square footage
- lot size if relevant
- year built
- HOA fee and included services
- taxes
- days on market
- price history
- parking
- waterfront / flood clues
- subdivision or building name
- same-building or nearby active inventory
- listing photos and visible condition cues
- public-record jurisdiction and linked official source
- account / parcel / tax ID if confirmed
- official assessed values and exemptions if confirmed
## Photo-review rules
Photo review is mandatory when photos are exposed by a listing source.
Do not make strong condition claims from structured text alone if photos are available.
Preferred photo-access order:
1. Zillow extractor
2. HAR extractor
3. Realtor.com photo page
4. brokerage mirror or other accessible listing mirror
Use the dedicated `web-automation` extractors first:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "<zillow-listing-url>"
node har-photos.js "<har-listing-url>"
```
When those extractors return `imageUrls`, that returned set is the photo-review set.
## Approval-safe command shape
For local/manual runs, prefer file-based commands.
For chat-driven messaging runs, prefer native `web_search`, `web_fetch`, and bounded browser actions first. Treat these commands as local/manual helpers or non-chat fallbacks, not as the default core-analysis path.
Good:
- `scripts/property-assessor assess --address "..." --assessment-purpose "..."`
- `node check-install.js`
- `node zillow-discover.js "<street-address>"`
- `node har-discover.js "<street-address>"`
- `node zillow-photos.js "<url>"`
- `node har-photos.js "<url>"`
- `scripts/property-assessor locate-public-records --address "..."`
- `scripts/property-assessor render-report --input ... --output ...`
- `web_fetch` to read an official CAD / assessor page when the TypeScript helper needs a plain page fetch
Messaging default:
- `web_search` to discover listing and public-record URLs
- `web_fetch` for official CAD / assessor pages and accessible listing pages
- bounded `web-automation` actions for rendered listing/photo views
- one final `scripts/property-assessor render-report ...` attempt only after the decision-grade report is complete
Avoid when possible:
- `node -e "..."`
- `node --input-type=module -e "..."`
- `python3 - <<'PY' ... PY`
- `python -c "..."`
- raw `bash -lc '...'` or `zsh -lc '...'` probes for CAD / public-record lookup
Reason:
- OpenClaw exec approvals are path-based, and inline shell / interpreter forms are treated conservatively in allowlist mode.
- For `property-assessor`, CAD and public-record lookup should stay on the skills file-based TypeScript helper path or use `web_fetch`.
- If the workflow drifts into an ad hoc shell snippet, that is not the approved skill path and can still trigger Control UI approval prompts.
## PDF report template
The final deliverable should be a fixed-template PDF, not a one-off layout.
Template reference:
- `skills/property-assessor/references/report-template.md`
Current renderer:
```bash
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
The fixed template includes:
1. Report header
2. Verdict panel
3. Subject-property summary table
4. Snapshot
5. What I Like
6. What I Do Not Like
7. Comp View
8. Underwriting / Carry View
9. Risks and Diligence Items
10. Photo Review
11. Public Records
12. Source Links
13. Notes page
### Recipient email gate
The report must not be rendered or sent unless target recipient email address(es) are known.
This requirement applies when the operator is actually rendering or sending the PDF.
It should not interrupt a normal analysis-only `assess` run.
If the prompt does not include recipient email(s), the skill should:
- stop
- ask for target recipient email address(es)
- not finalize the PDF workflow yet
The renderer enforces this. If `recipientEmails` is missing or empty, it fails with:
`Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.`
## Example payload
Sample payload:
- `skills/property-assessor/examples/report-payload.example.json`
This is the easiest way to test the renderer without building a report payload from scratch.
## Output contract
The assessment itself should remain concise but decision-grade.
Recommended narrative structure:
1. Snapshot
2. What I like
3. What I do not like
4. Comp view
5. Underwriting / carry view
6. Risks and diligence items
7. Verdict with fair-value range and offer guidance
It must also explicitly include:
- `Photo source attempts: ...`
- `Photo review: completed via <source>` or `Photo review: not completed`
- public-record / CAD evidence and links when available
## Validation flow
### 1. Install the helper package locally
```bash
cd ~/.openclaw/workspace/skills/property-assessor
npm install
npm test
```
### 2. Run address-first assess without recipient email
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property"
```
Expected shape:
- `needsAssessmentPurpose: false`
- `needsRecipientEmails: false`
- public-record / CAD jurisdiction included in the returned payload
- `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds
- no PDF generated yet
- explicit message saying the payload is ready and email is only needed when rendering or sending the PDF
### 3. Run public-record lookup directly
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
```
Expected shape:
- state/county/FIPS/GEOID present
- official Census geocoder link present
- for Texas: Comptroller county directory link present
- for Texas: appraisal district and tax assessor/collector contacts present
### 4. Run assess with recipient email and render the PDF
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf
```
Expected result:
- `needsRecipientEmails: false`
- JSON success payload with `outputPath`
- a non-empty PDF written to `/tmp/property-assessment.pdf`
### 5. Run PDF render with the sample payload
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
```
Expected result:
- JSON success payload with `outputPath`
- a non-empty PDF written to `/tmp/property-assessment.pdf`
### 6. Verify the email gate
Run the renderer with a payload that omits `recipientEmails`.
Expected result:
- non-zero exit
- explicit message telling the operator to stop and ask for target recipient email(s)
### 7. Verify the end-to-end skill behavior
When testing `property-assessor` itself, confirm the assessment:
- starts from the address when available
- uses Zillow first for photo extraction, HAR as fallback
- frames the analysis around the stated assessment purpose
- uses official public-record jurisdiction links when available
- does not treat listing geo IDs as assessor keys
- asks for assessment purpose if it was not provided
- asks for recipient email(s) if they were not provided
- renders the final report through the fixed PDF template once recipient email(s) are known
## Related files
- skill instructions:
- `skills/property-assessor/SKILL.md`
- underwriting heuristics:
- `skills/property-assessor/references/underwriting-rules.md`
- PDF template rules:
- `skills/property-assessor/references/report-template.md`
- sample report payload:
- `skills/property-assessor/examples/report-payload.example.json`
- photo extraction docs:
- `docs/web-automation.md`

302
docs/us-cpa.md Normal file
View File

@@ -0,0 +1,302 @@
# us-cpa
`us-cpa` is a Python CLI plus OpenClaw skill wrapper for U.S. federal individual tax work.
## Standalone package usage
From `skills/us-cpa/`:
```bash
python3 -m ensurepip --upgrade
python3 -m pip install --upgrade pip setuptools wheel
python3 -m pip install -e '.[dev]'
us-cpa --help
```
Without installing, the repo-local wrapper works directly:
```bash
skills/us-cpa/scripts/us-cpa --help
```
## OpenClaw installation
To install the skill for OpenClaw itself, copy the repo skill into the workspace skill directory and install its Python dependencies there.
1. Sync the repo copy into the workspace:
```bash
rsync -a --delete --exclude '.venv' \
~/.openclaw/workspace/projects/stef-openclaw-skills/skills/us-cpa/ \
~/.openclaw/workspace/skills/us-cpa/
```
2. Create a workspace-local virtualenv and install the package:
```bash
cd ~/.openclaw/workspace/skills/us-cpa
python3 -m venv .venv
. .venv/bin/activate
python3 -m ensurepip --upgrade
python3 -m pip install --upgrade pip setuptools wheel
python3 -m pip install -e '.[dev]'
```
3. Verify the installed workspace wrapper:
```bash
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
```
The wrapper prefers `.venv/bin/python` inside the skill directory when present, so OpenClaw can run the workspace copy without relying on global Python packages.
Keep the `--exclude '.venv'` flag on future syncs, otherwise `rsync --delete` will remove the workspace virtualenv.
## Current Milestone
Current implementation now includes:
- deterministic cache layout under `~/.cache/us-cpa` by default
- `fetch-year` download flow for the bootstrap IRS corpus
- source manifest with URL, hash, authority rank, and local path traceability
- primary-law URL building for IRC and Treasury regulation escalation
- case-folder intake, document registration, and machine-usable fact extraction from JSON, text, and PDF inputs
- question workflow with conversation and memo output
- prepare workflow for the current supported multi-form 1040 package
- review workflow with findings-first output
- fillable-PDF first rendering with overlay fallback
- e-file-ready draft export payload generation
## CLI Surface
```bash
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025 --style memo --format markdown
skills/us-cpa/scripts/us-cpa prepare --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa fetch-year --tax-year 2025
skills/us-cpa/scripts/us-cpa extract-docs --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --create-case --case-label "Jane Doe" --facts-json ./facts.json
skills/us-cpa/scripts/us-cpa render-forms --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa export-efile-ready --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
```
## Tax-Year Cache
Default cache root:
```text
~/.cache/us-cpa
```
Override for isolated runs:
```bash
US_CPA_CACHE_DIR=/tmp/us-cpa-cache skills/us-cpa/scripts/us-cpa fetch-year --tax-year 2025
```
Current `fetch-year` bootstrap corpus for tax year `2025` is verified against live IRS `irs-prior` PDFs for:
- Form 1040
- Schedules 1, 2, 3, A, B, C, D, E, SE, and 8812
- Forms 8949, 4562, 4797, 6251, 8606, 8863, 8889, 8959, 8960, 8995, 8995-A, 5329, 5695, and 1116
- General Form 1040 instructions and selected schedule/form instructions
Current bundled tax-year computation data:
- 2024
- 2025
Other years fetch/source correctly, but deterministic return calculations currently stop with an explicit unsupported-year error until rate tables are added.
Adding a new supported year is a deliberate data-table change in `tax_years.py`, not an automatic runtime discovery step. That is intentional for tax-engine correctness.
## Interaction Model
- `question`
- stateless by default
- optional case context
- `prepare`
- requires a case directory
- if none exists, OpenClaw should ask whether to create one and where
- `review`
- requires a case directory
- can operate on an existing or newly-created review case
## Planned Case Layout
```text
<case-dir>/
input/
extracted/
return/
output/
reports/
issues/
sources/
```
Current implementation writes:
- `case-manifest.json`
- `extracted/facts.json`
- `issues/open-issues.json`
## Intake Flow
Current `extract-docs` supports:
- `--create-case`
- `--case-label`
- `--facts-json <path>`
- repeated `--input-file <path>`
Behavior:
- creates the full case directory layout when `--create-case` is used
- copies input documents into `input/`
- stores normalized facts with source metadata in `extracted/facts.json`
- extracts machine-usable facts from JSON/text/PDF documents where supported
- appends document registry entries to `case-manifest.json`
- stops with a structured issue and non-zero exit if a new fact conflicts with an existing stored fact
## Output Contract
- JSON by default
- markdown available with `--format markdown`
- `question` supports:
- `--style conversation`
- `--style memo`
- `question` emits answered analysis output
- `prepare` emits a prepared return package summary
- `export-efile-ready` emits a draft e-file-ready payload
- `review` emits a findings-first review result
- `fetch-year` emits a downloaded manifest location and source count
## Question Engine
Current `question` implementation:
- loads the cached tax-year corpus
- searches the downloaded IRS corpus for relevant authorities and excerpts
- returns one canonical analysis object with:
- authorities
- excerpts
- confidence / risk
- primary-law escalation only when the IRS corpus is still insufficient
- renders that analysis as:
- conversational output
- memo output
In OpenClaw, the model should answer the user from the returned IRS excerpts when `primaryLawRequired` is `false`, rather than merely repeating the CLI summary.
## Form Rendering
Current rendering path:
- official IRS PDFs from the cached tax-year corpus
- deterministic field-fill when usable AcroForm fields are present
- overlay rendering onto those official PDFs using `reportlab` + `pypdf` as fallback
- artifact manifest written to `output/artifacts.json`
Current rendered form support:
- field-fill support for known mapped fillable forms
- overlay generation for the current required-form set resolved by the return model
Current review rule:
- field-filled artifacts are not automatically flagged for review
- overlay-rendered artifacts are marked `reviewRequired: true`
Overlay coordinates are currently a fallback heuristic and are not treated as line-perfect authoritative field maps. Overlay output must be visually reviewed before any filing/export handoff.
## Preparation Workflow
Current `prepare` implementation:
- loads case facts from `extracted/facts.json`
- normalizes them into the current supported federal return model
- preserves source provenance for normalized values
- computes the current supported 1040 package
- resolves required forms across the current supported subset
- writes:
- `return/normalized-return.json`
- `output/artifacts.json`
- `reports/prepare-summary.json`
Current supported calculation inputs:
- `filingStatus`
- `spouse.fullName`
- `dependents`
- `wages`
- `taxableInterest`
- `businessIncome`
- `capitalGainLoss`
- `rentalIncome`
- `federalWithholding`
- `itemizedDeductions`
- `hsaContribution`
- `educationCredit`
- `foreignTaxCredit`
- `qualifiedBusinessIncome`
- `traditionalIraBasis`
- `additionalMedicareTax`
- `netInvestmentIncomeTax`
- `alternativeMinimumTax`
- `additionalTaxPenalty`
- `energyCredit`
- `depreciationExpense`
- `section1231GainLoss`
## E-file-ready Export
`export-efile-ready` writes:
- `output/efile-ready.json`
Current export behavior:
- draft-only
- includes required forms
- includes refund or balance due summary
- includes attachment manifest
- includes unresolved issues
## Review Workflow
Current `review` implementation:
- recomputes the return from current case facts
- compares stored normalized return values to recomputed values
- flags source-fact mismatches for key income fields
- flags likely omitted income when document-extracted facts support an amount the stored return omits
- checks whether required rendered artifacts are present
- flags high-complexity forms for specialist follow-up
- flags overlay-rendered artifacts as requiring human review
- sorts findings by severity
Current render modes:
- `--style conversation`
- `--style memo`
## Scope Rules
- U.S. federal individual returns only in v1
- official IRS artifacts are the target output for compiled forms
- conflicting facts must stop the workflow for user resolution
## Authority Ranking
Current authority classes are ranked to preserve source hierarchy:
- IRS forms
- IRS instructions
- IRS publications
- IRS FAQs
- Internal Revenue Code
- Treasury regulations
- other primary authority
Later research and review flows should consume this ranking rather than inventing their own.

View File

@@ -1,6 +1,6 @@
# web-automation
Automated web browsing and scraping using Playwright, with one-shot extraction and broader Camoufox-based automation under a single skill.
Automated web browsing and scraping using Playwright-compatible CloakBrowser, with one-shot extraction and broader persistent automation under a single skill.
## What this skill is for
@@ -15,20 +15,36 @@ Automated web browsing and scraping using Playwright, with one-shot extraction a
- Use `node skills/web-automation/scripts/extract.js "<URL>"` for one-shot extraction from a single URL
- Use `npx tsx scrape.ts ...` for markdown scraping modes
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` for interactive or authenticated flows
- Use `node skills/web-automation/scripts/zillow-discover.js "<street-address>"` or `har-discover.js` to resolve a real-estate listing URL from an address
- Use `node skills/web-automation/scripts/zillow-photos.js "<listing-url>"` or `har-photos.js` for real-estate photo extraction before attempting generic gallery automation
Messaging rule:
- For WhatsApp or similar chat-driven runs, prefer native `web_search`, `web_fetch`, and bounded browser actions over shelling out to helper scripts for every core step.
- Treat the dedicated Zillow/HAR scripts as local/manual helpers, regression checks, or non-chat fallbacks.
- If a messaging workflow needs a subprocess at all, reserve it for a single final delivery step rather than the whole assessment.
## Requirements
- Node.js 20+
- `pnpm`
- Network access to download browser binaries
- Network access to download the CloakBrowser binary on first use or via preinstall
## First-time setup
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
pnpm install
npx playwright install chromium
npx camoufox-js fetch
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Updating CloakBrowser
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
pnpm up cloakbrowser playwright-core
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
@@ -48,15 +64,66 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Without this, `browse.ts` and `scrape.ts` may fail before launch because the native bindings are missing.
Without this, helper scripts may fail before launch because the native bindings are missing.
## Prerequisite check
Before running automation, verify the local install and CloakBrowser wiring:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node check-install.js
```
If this fails, stop and fix setup before troubleshooting site automation.
## Exec approvals allowlist
If OpenClaw keeps prompting for approval when running this skill, add a local allowlist for the main agent:
```bash
openclaw approvals allowlist add --agent main "/opt/homebrew/bin/node"
openclaw approvals allowlist add --agent main "/usr/bin/env"
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/*.js"
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/node_modules/.bin/*"
```
Verify with:
```bash
openclaw approvals get
```
Notes:
- If `node` lives somewhere else, replace `/opt/homebrew/bin/node` with the output of `which node`.
- If matching is inconsistent, replace `~/.openclaw/...` with the full absolute path for the machine.
- Keep the allowlist scoped to the main agent unless there is a clear reason to widen it.
- Prefer file-based commands like `node check-install.js`, `node zillow-photos.js ...`, and `node har-photos.js ...` over inline `node -e ...`. Inline interpreter eval is more likely to trigger approval friction.
- The same applies to `zillow-discover.js` and `har-discover.js`: keep discovery file-based, not inline.
## Common commands
```bash
# Install / wiring check
cd ~/.openclaw/workspace/skills/web-automation/scripts
node check-install.js
# One-shot JSON extraction
node skills/web-automation/scripts/extract.js "https://example.com"
# Browse a page
# Zillow listing discovery from address
node skills/web-automation/scripts/zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
# HAR listing discovery from address
node skills/web-automation/scripts/har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
# Zillow photo extraction
node skills/web-automation/scripts/zillow-photos.js "https://www.zillow.com/homedetails/..."
# HAR photo extraction
node skills/web-automation/scripts/har-photos.js "https://www.har.com/homedetail/..."
# Browse a page with persistent profile
npx tsx browse.ts --url "https://example.com"
# Scrape markdown
@@ -69,6 +136,111 @@ npx tsx auth.ts --url "https://example.com/login"
npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s'
```
## Real-estate listing discovery and photo extraction
Use the dedicated Zillow and HAR discovery/photo commands before trying a free-form gallery flow.
Discovery is unit-aware when the address includes an apartment / unit / suite identifier, and still supports plain no-unit addresses for single-family homes.
### Zillow discovery
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
```
What it does:
- opens the Zillow address URL with CloakBrowser
- resolves directly to a property page when Zillow supports the address slug
- otherwise looks for a `homedetails` listing link in the rendered page
- returns the discovered listing URL as JSON
- fails fast with a timeout if the browser-backed discovery stalls
Operational note:
- when imported by `property-assessor`, Zillow discovery is allowed a longer source-specific timeout than the generic helper default, because some exact-unit Zillow pages resolve more slowly than the basic search/listing flow
### HAR discovery
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
```
What it does:
- opens the HAR address search page
- looks for a confident `homedetail` match in rendered results
- returns the discovered listing URL when HAR exposes a strong enough match
- returns `listingUrl: null` when HAR discovery is not confident enough
- fails fast with a timeout if the browser-backed discovery stalls
### Zillow
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
```
What it does:
- opens the listing page with CloakBrowser
- first checks whether the rendered listing shell already exposes a complete photo set in Zillow's embedded `__NEXT_DATA__` payload
- if the visible `See all XX photos` count is missing, still trusts the embedded set when the page metadata confirms the count or when the embedded set is already clearly substantial
- only tries the `See all photos` / `See all X photos` entry point when the initial structured data is incomplete
- returns direct `photos.zillowstatic.com` image URLs as JSON
- fails fast with a timeout if the browser-backed extraction stalls
Operational note:
- when imported by `property-assessor`, Zillow photo extraction is allowed a longer source-specific timeout than the generic helper default, because some exact-unit Zillow listings expose the correct photo set only after a slower render path
Expected success shape:
- `complete: true`
- `expectedPhotoCount` matches `photoCount`
- `imageUrls` contains the listing photo set
### Zillow identifiers
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-identifiers.js "https://www.zillow.com/homedetails/6702-Everhart-Rd-APT-T106-Corpus-Christi-TX-78413/2067445642_zpid/"
```
What it does:
- opens the Zillow listing shell without forcing the photo workflow
- inspects embedded `__NEXT_DATA__` plus visible listing text
- extracts parcel/APN-style identifiers when Zillow exposes them
- returns those identifiers so `property-assessor` can use them as stronger CAD lookup keys than listing geo IDs
### HAR
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node har-photos.js "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
```
What it does:
- opens the HAR listing page
- clicks `Show all photos` / `View all photos`
- extracts the direct `pics.harstatic.com` image URLs from the all-photos page
- fails fast with a timeout if the browser-backed extraction stalls
Expected success shape:
- `complete: true`
- `expectedPhotoCount` matches `photoCount`
- `imageUrls` contains the listing photo set
### Test commands
From `skills/web-automation/scripts`:
```bash
node check-install.js
npm run test:photos
node zillow-discover.js "<street-address>"
node har-discover.js "<street-address>"
node zillow-photos.js "<zillow-listing-url>"
node har-photos.js "<har-listing-url>"
```
Use the live Zillow and HAR URLs above for a known-good regression check.
## One-shot extraction (`extract.js`)
Use `extract.js` when the task is just: open one URL, render it, and return structured content.
@@ -104,6 +276,24 @@ USER_AGENT="Mozilla/5.0 ..." node skills/web-automation/scripts/extract.js "http
- optional `screenshot`
- optional `htmlFile`
## Persistent browsing profile
`browse.ts`, `auth.ts`, `flow.ts`, and `scrape.ts` use a persistent CloakBrowser profile so sessions survive across runs.
Canonical env vars:
- `CLOAKBROWSER_PROFILE_PATH`
- `CLOAKBROWSER_HEADLESS`
- `CLOAKBROWSER_USERNAME`
- `CLOAKBROWSER_PASSWORD`
Legacy aliases still supported for compatibility:
- `CAMOUFOX_PROFILE_PATH`
- `CAMOUFOX_HEADLESS`
- `CAMOUFOX_USERNAME`
- `CAMOUFOX_PASSWORD`
## Natural-language flow runner (`flow.ts`)
Use `flow.ts` when you want a general command style like:

View File

@@ -3,7 +3,7 @@
Google Workspace helper CLI
Commands:
whoami
send --to <email> --subject <text> --body <text> [--html]
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
search-mail --query <gmail query> [--max 10]
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
@@ -11,7 +11,6 @@
const fs = require('fs');
const path = require('path');
const { google } = require('googleapis');
const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com';
const DEFAULT_KEY_CANDIDATES = [
@@ -45,7 +44,11 @@ function parseArgs(argv) {
if (!next || next.startsWith('--')) {
out[key] = true;
} else {
out[key] = next;
if (Object.hasOwn(out, key)) {
out[key] = Array.isArray(out[key]) ? out[key].concat(next) : [out[key], next];
} else {
out[key] = next;
}
i++;
}
} else {
@@ -63,7 +66,7 @@ Env (optional):
Commands:
whoami
send --to <email> --subject <text> --body <text> [--html]
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
search-mail --query <gmail query> [--max 10]
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
@@ -77,17 +80,82 @@ function assertRequired(opts, required) {
}
}
function makeRawEmail({ from, to, subject, body, isHtml = false }) {
function toArray(value) {
if (value == null || value === false) return [];
return Array.isArray(value) ? value : [value];
}
function getAttachmentContentType(filename) {
const ext = path.extname(filename).toLowerCase();
if (ext === '.pdf') return 'application/pdf';
if (ext === '.txt') return 'text/plain; charset="UTF-8"';
if (ext === '.html' || ext === '.htm') return 'text/html; charset="UTF-8"';
if (ext === '.json') return 'application/json';
return 'application/octet-stream';
}
function wrapBase64(base64) {
return base64.match(/.{1,76}/g)?.join('\r\n') || '';
}
function loadAttachments(attachArg) {
return toArray(attachArg).map((filePath) => {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Attachment file not found: ${absolutePath}`);
}
return {
filename: path.basename(absolutePath),
contentType: getAttachmentContentType(absolutePath),
data: fs.readFileSync(absolutePath).toString('base64'),
};
});
}
function makeRawEmail({ from, to, subject, body, isHtml = false, attachments = [] }) {
const contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"';
const msg = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
'MIME-Version: 1.0',
`Content-Type: ${contentType}`,
'',
body,
].join('\r\n');
const normalizedAttachments = attachments.filter(Boolean);
let msg;
if (normalizedAttachments.length === 0) {
msg = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
'MIME-Version: 1.0',
`Content-Type: ${contentType}`,
'',
body,
].join('\r\n');
} else {
const boundary = `gw-boundary-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
msg = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
'MIME-Version: 1.0',
`Content-Type: multipart/mixed; boundary="${boundary}"`,
'',
`--${boundary}`,
`Content-Type: ${contentType}`,
'Content-Transfer-Encoding: 7bit',
'',
body,
'',
...normalizedAttachments.flatMap((attachment) => [
`--${boundary}`,
`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: attachment; filename="${attachment.filename}"`,
'',
wrapBase64(attachment.data),
'',
]),
`--${boundary}--`,
'',
].join('\r\n');
}
return Buffer.from(msg)
.toString('base64')
@@ -97,6 +165,7 @@ function makeRawEmail({ from, to, subject, body, isHtml = false }) {
}
async function getClients() {
const { google } = require('googleapis');
const keyPath = resolveKeyPath();
if (!keyPath) {
throw new Error('Service account key not found. Set GW_KEY_PATH or place the file in ~/.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json');
@@ -132,6 +201,7 @@ async function cmdSend(clients, opts) {
subject: opts.subject,
body: opts.body,
isHtml: !!opts.html,
attachments: loadAttachments(opts.attach),
});
const res = await clients.gmail.users.messages.send({
@@ -222,7 +292,7 @@ async function cmdCreateEvent(clients, opts) {
console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2));
}
(async function main() {
async function main() {
try {
const args = parseArgs(process.argv.slice(2));
const cmd = args._[0];
@@ -245,4 +315,19 @@ async function cmdCreateEvent(clients, opts) {
console.error(`ERROR: ${err.message}`);
process.exit(1);
}
})();
}
if (require.main === module) {
main();
}
module.exports = {
getAttachmentContentType,
loadAttachments,
main,
makeRawEmail,
parseArgs,
resolveKeyPath,
toArray,
wrapBase64,
};

View File

@@ -0,0 +1,34 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const { makeRawEmail } = require('./gw.js');
test('help documents attachment support for send', () => {
const gwPath = path.join(__dirname, 'gw.js');
const result = spawnSync(process.execPath, [gwPath, 'help'], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /send --to <email> --subject <text> --body <text> \[--html\] \[--attach <file>\]/);
});
test('makeRawEmail builds multipart messages when attachments are present', () => {
const raw = makeRawEmail({
from: 'stefano@fiorinis.com',
to: 'stefano@fiorinis.com',
subject: 'Attachment test',
body: 'Attached PDF.',
attachments: [
{
filename: 'report.pdf',
contentType: 'application/pdf',
data: Buffer.from('%PDF-1.4\n%test\n').toString('base64'),
},
],
});
const decoded = Buffer.from(raw, 'base64').toString('utf8');
assert.match(decoded, /Content-Type: multipart\/mixed; boundary=/);
assert.match(decoded, /Content-Disposition: attachment; filename="report\.pdf"/);
assert.match(decoded, /Content-Type: application\/pdf; name="report\.pdf"/);
});

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "gw.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test"
},
"keywords": [],
"author": "",

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

View File

@@ -0,0 +1,120 @@
---
name: nordvpn-client
description: Use when managing NordVPN on macOS or Linux, including install/bootstrap, login, connect, disconnect, status checks, or verifying a VPN location before running another skill.
---
# NordVPN Client
Cross-platform NordVPN lifecycle management for macOS and Linux hosts.
## Use This Skill For
- probing whether NordVPN automation is ready
- bootstrapping missing backend dependencies
- validating auth
- connecting to a country or city
- verifying the public exit location
- disconnecting and restoring the normal network state
## Command Surface
```bash
node scripts/nordvpn-client.js status
node scripts/nordvpn-client.js install
node scripts/nordvpn-client.js login
node scripts/nordvpn-client.js verify
node scripts/nordvpn-client.js verify --country "Germany"
node scripts/nordvpn-client.js verify --country "Japan" --city "Tokyo"
node scripts/nordvpn-client.js connect --country "Germany"
node scripts/nordvpn-client.js connect --country "Japan" --city "Tokyo"
node scripts/nordvpn-client.js disconnect
node scripts/nordvpn-client.js status --debug
```
## Backend Model
- Linux:
- use the official `nordvpn` CLI
- `install` uses the official NordVPN installer
- token login is supported
- macOS:
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
- `install` bootstraps them with Homebrew
- `login` validates the token for the WireGuard backend
- the generated WireGuard config stays free of `DNS = ...`
- `connect` now requires a bounded persistence gate plus a verified exit before success is declared
- the skill snapshots and applies NordVPN DNS only to eligible physical services while connected
- NordVPN DNS is applied only after the tunnel remains up, the final liveness check still shows the requested exit, and system hostname resolution still works afterward
- `disconnect` restores the saved DNS/search-domain state even if the tunnel state is stale
- Tailscale is suspended before connect and resumed after disconnect or failed connect
- the skill writes a short-lived Tailscale suppression marker during VPN connect so host watchdogs do not immediately re-run `tailscale up`
- `NordVPN.app` may remain installed but is only the manual fallback
## Credentials
Default OpenClaw credential paths:
- token: `~/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt`
- password: `~/.openclaw/workspace/.clawdbot/credentials/nordvpn/password.txt`
Supported env vars:
- `NORDVPN_TOKEN`
- `NORDVPN_TOKEN_FILE`
- `NORDVPN_USERNAME`
- `NORDVPN_PASSWORD`
- `NORDVPN_PASSWORD_FILE`
## macOS Requirements
Automated macOS connects require all of:
- `wireguard-go`
- `wireguard-tools`
- `NORDVPN_TOKEN` or the default token file
- non-interactive `sudo` for the installed helper script:
- `~/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh`
Exact `visudo` rule for the installed OpenClaw skill:
```sudoers
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
```
Operational note:
- the persistence gate reuses the already-allowed `probe` action to confirm the live `utun*` WireGuard runtime and does not require extra sudoers actions beyond `probe`, `up`, and `down`
## Agent Guidance
- run `status` first when the machine state is unclear
- on macOS, if tooling is missing, run `install`
- if auth is unclear, run `login`
- use `connect` before location-sensitive skills such as `web-automation`
- use `verify` after connect when you need an explicit location check
- use `disconnect` after the follow-up task
- if `connect` fails its persistence or final verification gate, treat that as a safe rollback, not a partial success
## Output Rules
- normal JSON output redacts local path metadata and helper-hardening diagnostics
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
## Troubleshooting Cues
- `Invalid authorization header`:
- token file exists but the token is invalid; replace the token and rerun `login`
- `sudoReady: false`:
- the helper is not allowed in sudoers; add the `visudo` rule above
- connect succeeds but final state looks inconsistent:
- rely on the verified public IP/location first
- then inspect `status --debug`
- `verified: true` but `persistence.stable: false` should not happen anymore; if it does, the skill should roll back instead of pinning DNS
- disconnect should leave:
- normal public IP restored
- no active WireGuard state
- Tailscale resumed if the skill suspended it
For full operator setup and troubleshooting, see:
- `docs/nordvpn-client.md`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,671 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
function loadInternals() {
const scriptPath = path.join(__dirname, "nordvpn-client.js");
const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n");
const wrapped = `${source}
module.exports = {
buildMacTailscaleState:
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
markMacTailscaleRecoverySuppressed:
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
clearMacTailscaleRecoverySuppressed:
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : undefined,
buildMacDnsState:
typeof buildMacDnsState === "function" ? buildMacDnsState : undefined,
buildWireguardConfig:
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
buildLookupResult:
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
cleanupMacWireguardState:
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
cleanupMacWireguardAndDnsState:
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
collectMacWireguardDiagnostics:
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
acquireOperationLock:
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
inspectMacWireguardHelperSecurity:
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
getMacTailscalePath:
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
isBenignMacWireguardAbsentError:
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
isMacTailscaleActive:
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
checkMacWireguardPersistence:
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
normalizeSuccessfulConnectState:
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
normalizeStatusState:
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
parseMacWireguardHelperStatus:
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
shouldRejectMacDnsBaseline:
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
shouldManageMacDnsService:
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
sanitizeOutputPayload:
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
shouldFinalizeMacWireguardConnect:
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
shouldResumeMacTailscale:
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
shouldAttemptMacWireguardDisconnect:
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
detectMacWireguardActiveFromIfconfig:
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
resolveHostnameWithFallback:
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
verifySystemHostnameResolution:
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
verifyConnectionWithRetry:
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
};`;
const sandbox = {
require,
module: { exports: {} },
exports: {},
__dirname,
__filename: scriptPath,
process: { ...process, exit() {} },
console,
setTimeout,
clearTimeout,
Buffer,
};
vm.runInNewContext(wrapped, sandbox, { filename: scriptPath });
return sandbox.module.exports;
}
test("detectMacWireguardActiveFromIfconfig detects nordvpn utun client address", () => {
const { detectMacWireguardActiveFromIfconfig } = loadInternals();
assert.equal(typeof detectMacWireguardActiveFromIfconfig, "function");
const ifconfig = `
utun8: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1380
utun9: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1420
\tinet 10.5.0.2 --> 10.5.0.2 netmask 0xff000000
`;
assert.equal(detectMacWireguardActiveFromIfconfig(ifconfig), true);
assert.equal(detectMacWireguardActiveFromIfconfig("utun7: flags=8051\n\tinet 100.64.0.4"), false);
});
test("buildLookupResult supports lookup all=true mode", () => {
const { buildLookupResult } = loadInternals();
assert.equal(typeof buildLookupResult, "function");
assert.equal(
JSON.stringify(buildLookupResult("104.26.9.44", { all: true })),
JSON.stringify([{ address: "104.26.9.44", family: 4 }])
);
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
});
test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => {
const { buildWireguardConfig } = loadInternals();
assert.equal(typeof buildWireguardConfig, "function");
const config = buildWireguardConfig(
{
hostname: "tr73.nordvpn.com",
ips: [{ ip: { version: 4, ip: "45.89.52.1" } }],
technologies: [{ identifier: "wireguard_udp", metadata: [{ name: "public_key", value: "PUBKEY" }] }],
},
"PRIVATEKEY"
);
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false);
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
});
test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => {
const { shouldManageMacDnsService } = loadInternals();
assert.equal(typeof shouldManageMacDnsService, "function");
assert.equal(shouldManageMacDnsService("Wi-Fi"), true);
assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true);
assert.equal(shouldManageMacDnsService("Tailscale"), false);
assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false);
assert.equal(shouldManageMacDnsService("Acme VPN"), false);
});
test("buildMacDnsState records DNS and search domains per service", () => {
const { buildMacDnsState } = loadInternals();
assert.equal(typeof buildMacDnsState, "function");
assert.equal(
JSON.stringify(
buildMacDnsState([
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
])
),
JSON.stringify({
services: [
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
],
})
);
});
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
const { shouldRejectMacDnsBaseline } = loadInternals();
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["103.86.96.100", "103.86.99.100"],
searchDomains: [],
},
],
}),
true
);
assert.equal(
shouldRejectMacDnsBaseline({
services: [
{
name: "Wi-Fi",
dnsServers: ["1.1.1.1"],
searchDomains: [],
},
],
}),
false
);
});
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
const { getMacTailscalePath } = loadInternals();
assert.equal(typeof getMacTailscalePath, "function");
assert.equal(
getMacTailscalePath({
commandExists: () => "",
fileExists: (target) => target === "/opt/homebrew/bin/tailscale",
}),
"/opt/homebrew/bin/tailscale"
);
});
test("buildMacTailscaleState records whether tailscale was active", () => {
const { buildMacTailscaleState } = loadInternals();
assert.equal(typeof buildMacTailscaleState, "function");
assert.equal(
JSON.stringify(buildMacTailscaleState(true)),
JSON.stringify({
tailscaleWasActive: true,
})
);
});
test("tailscale recovery suppression marker can be created and cleared", () => {
const { markMacTailscaleRecoverySuppressed, clearMacTailscaleRecoverySuppressed } = loadInternals();
assert.equal(typeof markMacTailscaleRecoverySuppressed, "function");
assert.equal(typeof clearMacTailscaleRecoverySuppressed, "function");
const markerPath = path.join(process.env.HOME || "", ".nordvpn-client", "tailscale-suppressed");
clearMacTailscaleRecoverySuppressed();
markMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), true);
clearMacTailscaleRecoverySuppressed();
assert.equal(fs.existsSync(markerPath), false);
});
test("shouldResumeMacTailscale only resumes when previously active and not already running", () => {
const { shouldResumeMacTailscale } = loadInternals();
assert.equal(typeof shouldResumeMacTailscale, "function");
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true);
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false);
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false);
assert.equal(shouldResumeMacTailscale(null, false), false);
});
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
const { cleanupMacWireguardState } = loadInternals();
assert.equal(typeof cleanupMacWireguardState, "function");
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
const configPath = path.join(tmpDir, "nordvpnctl.conf");
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
fs.writeFileSync(configPath, "wireguard-config");
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
const result = cleanupMacWireguardState({
configPath,
lastConnectionPath,
});
assert.equal(result.cleaned, true);
assert.equal(fs.existsSync(configPath), false);
assert.equal(fs.existsSync(lastConnectionPath), false);
});
test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => {
const { cleanupMacWireguardAndDnsState } = loadInternals();
assert.equal(typeof cleanupMacWireguardAndDnsState, "function");
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
const configPath = path.join(tmpDir, "nordvpnctl.conf");
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
const dnsStatePath = path.join(tmpDir, "dns.json");
fs.writeFileSync(configPath, "wireguard-config");
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
fs.writeFileSync(dnsStatePath, "{\"services\":[]}");
const result = cleanupMacWireguardAndDnsState({
configPath,
lastConnectionPath,
dnsStatePath,
});
assert.equal(result.cleaned, true);
assert.equal(fs.existsSync(configPath), false);
assert.equal(fs.existsSync(lastConnectionPath), false);
assert.equal(fs.existsSync(dnsStatePath), false);
});
test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => {
const { inspectMacWireguardHelperSecurity } = loadInternals();
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
fileExists: () => true,
statSync: () => ({
uid: 501,
gid: 20,
mode: 0o100755,
}),
});
assert.equal(result.exists, true);
assert.equal(result.hardened, false);
assert.match(result.reason, /root-owned/i);
});
test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => {
const { inspectMacWireguardHelperSecurity } = loadInternals();
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
fileExists: () => true,
statSync: () => ({
uid: 0,
gid: 0,
mode: 0o100755,
}),
});
assert.equal(result.exists, true);
assert.equal(result.hardened, true);
assert.equal(result.reason, "");
});
test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => {
const { collectMacWireguardDiagnostics } = loadInternals();
assert.equal(typeof collectMacWireguardDiagnostics, "function");
const seen = [];
const result = await collectMacWireguardDiagnostics({
interfaceName: "nordvpnctl",
runExec: async (command, args) => {
seen.push(`${command} ${args.join(" ")}`);
if (command === "/opt/homebrew/bin/wg") {
return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" };
}
if (command === "ifconfig") {
return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" };
}
if (command === "route") {
return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" };
}
if (command === "pgrep") {
return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" };
}
throw new Error(`unexpected command: ${command}`);
},
});
assert.deepEqual(seen, [
"/opt/homebrew/bin/wg show nordvpnctl",
"ifconfig nordvpnctl",
"route -n get default",
"pgrep -fl wireguard-go|wg-quick|nordvpnctl",
]);
assert.equal(result.interfaceName, "nordvpnctl");
assert.equal(result.wgShow.includes("peer: abc123"), true);
assert.equal(result.ifconfig.includes("10.5.0.2"), true);
assert.equal(result.routes.includes("default 10.5.0.2"), true);
assert.equal(result.processes.includes("wireguard-go"), true);
});
test("parseMacWireguardHelperStatus reads active helper key-value output", () => {
const { parseMacWireguardHelperStatus } = loadInternals();
assert.equal(typeof parseMacWireguardHelperStatus, "function");
const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n");
assert.equal(result.active, true);
assert.equal(result.interfaceName, "nordvpnctl");
});
test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => {
const { checkMacWireguardPersistence } = loadInternals();
assert.equal(typeof checkMacWireguardPersistence, "function");
const helperStatuses = [
{ active: false, interfaceName: "nordvpnctl" },
{ active: true, interfaceName: "nordvpnctl" },
];
const verifications = [
{ ok: false, ipInfo: { ok: false, error: "timeout" } },
{ ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } },
];
const result = await checkMacWireguardPersistence(
{ country: "Germany", city: "" },
{
attempts: 2,
delayMs: 1,
getHelperStatus: async () => helperStatuses.shift(),
verifyConnection: async () => verifications.shift(),
sleep: async () => {},
}
);
assert.equal(result.stable, true);
assert.equal(result.attempts, 2);
assert.equal(result.helperStatus.active, true);
assert.equal(result.verified.ok, true);
});
test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => {
const { checkMacWireguardPersistence } = loadInternals();
assert.equal(typeof checkMacWireguardPersistence, "function");
const result = await checkMacWireguardPersistence(
{ country: "Germany", city: "" },
{
attempts: 2,
delayMs: 1,
getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }),
verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }),
sleep: async () => {},
}
);
assert.equal(result.stable, false);
assert.equal(result.attempts, 2);
assert.equal(result.helperStatus.active, false);
assert.equal(result.verified.ok, false);
});
test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => {
const { acquireOperationLock } = loadInternals();
assert.equal(typeof acquireOperationLock, "function");
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-"));
const lockPath = path.join(tmpDir, "operation.lock");
fs.writeFileSync(
lockPath,
JSON.stringify({
action: "connect",
pid: 0,
startedAt: new Date(0).toISOString(),
startedAtMs: 0,
})
);
const lock = acquireOperationLock("disconnect", { lockPath });
const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8"));
assert.equal(lockFile.action, "disconnect");
assert.equal(lockFile.pid, process.pid);
lock.release();
assert.equal(fs.existsSync(lockPath), false);
});
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
assert.equal(
shouldAttemptMacWireguardDisconnect({
active: false,
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
endpoint: null,
lastConnection: null,
}),
true
);
assert.equal(
shouldAttemptMacWireguardDisconnect({
active: false,
configPath: null,
endpoint: null,
lastConnection: { country: "Italy" },
}),
true
);
assert.equal(
shouldAttemptMacWireguardDisconnect({
active: false,
configPath: null,
endpoint: null,
lastConnection: null,
}),
false
);
});
test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => {
const { isBenignMacWireguardAbsentError } = loadInternals();
assert.equal(typeof isBenignMacWireguardAbsentError, "function");
assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true);
assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true);
assert.equal(isBenignMacWireguardAbsentError("permission denied"), false);
});
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
const { normalizeSuccessfulConnectState } = loadInternals();
assert.equal(typeof normalizeSuccessfulConnectState, "function");
const state = normalizeSuccessfulConnectState(
{
platform: "darwin",
controlMode: "wireguard",
connected: false,
wireguard: {
active: false,
endpoint: null,
},
},
{
backend: "wireguard",
server: {
hostname: "de1227.nordvpn.com",
},
},
{
ok: true,
ipInfo: {
country: "Germany",
},
}
);
assert.equal(state.connected, true);
assert.equal(state.wireguard.active, true);
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
});
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
const { shouldFinalizeMacWireguardConnect } = loadInternals();
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false);
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false);
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false);
});
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
const { normalizeStatusState } = loadInternals();
assert.equal(typeof normalizeStatusState, "function");
const state = normalizeStatusState({
platform: "darwin",
controlMode: "wireguard",
connected: false,
wireguard: {
active: false,
endpoint: "tr73.nordvpn.com:51820",
lastConnection: {
requestedTarget: { country: "Turkey", city: "" },
resolvedTarget: { country: "Turkey", city: "Istanbul" },
},
},
publicIp: {
ok: true,
country: "Turkey",
city: "Istanbul",
},
});
assert.equal(state.connected, true);
assert.equal(state.wireguard.active, true);
});
test("sanitizeOutputPayload redacts local path metadata from normal JSON output", () => {
const { sanitizeOutputPayload } = loadInternals();
assert.equal(typeof sanitizeOutputPayload, "function");
const sanitized = sanitizeOutputPayload({
cliPath: "/opt/homebrew/bin/nordvpn",
appPath: "/Applications/NordVPN.app",
wireguard: {
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
helperSecurity: {
hardened: false,
reason: "Helper must be root-owned before privileged actions are trusted.",
},
authCache: {
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
},
endpoint: "jp454.nordvpn.com:51820",
},
});
assert.equal(sanitized.cliPath, null);
assert.equal(sanitized.appPath, null);
assert.equal(sanitized.wireguard.configPath, null);
assert.equal(sanitized.wireguard.helperPath, null);
assert.equal(sanitized.wireguard.helperSecurity, null);
assert.equal(sanitized.wireguard.authCache.tokenSource, null);
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
});
test("isMacTailscaleActive treats Running backend as active", () => {
const { isMacTailscaleActive } = loadInternals();
assert.equal(typeof isMacTailscaleActive, "function");
assert.equal(isMacTailscaleActive({ BackendState: "Running" }), true);
assert.equal(isMacTailscaleActive({ BackendState: "Stopped" }), false);
});
test("verifyConnectionWithRetry retries transient reachability failures", async () => {
const { verifyConnectionWithRetry } = loadInternals();
assert.equal(typeof verifyConnectionWithRetry, "function");
let attempts = 0;
const result = await verifyConnectionWithRetry(
{ country: "Italy", city: "Milan" },
{
attempts: 3,
delayMs: 1,
getPublicIpInfo: async () => {
attempts += 1;
if (attempts === 1) {
return { ok: false, error: "read EHOSTUNREACH" };
}
return { ok: true, country: "Italy", city: "Milan" };
},
}
);
assert.equal(result.ok, true);
assert.equal(result.ipInfo.country, "Italy");
assert.equal(attempts, 2);
});
test("resolveHostnameWithFallback uses fallback resolvers when system lookup fails", async () => {
const { resolveHostnameWithFallback } = loadInternals();
assert.equal(typeof resolveHostnameWithFallback, "function");
const calls = [];
const address = await resolveHostnameWithFallback("ipapi.co", {
resolvers: ["1.1.1.1", "8.8.8.8"],
resolveWithResolver: async (hostname, resolver) => {
calls.push(`${resolver}:${hostname}`);
if (resolver === "1.1.1.1") return [];
return ["104.26.9.44"];
},
});
assert.equal(address, "104.26.9.44");
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
});
test("verifySystemHostnameResolution succeeds when any system lookup resolves", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const calls = [];
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
calls.push(hostname);
if (hostname === "www.google.com") {
throw new Error("ENOTFOUND");
}
return { address: "104.18.33.45", family: 4 };
},
});
assert.equal(result.ok, true);
assert.equal(result.hostname, "api.openai.com");
assert.equal(result.address, "104.18.33.45");
assert.deepEqual(calls, ["www.google.com", "api.openai.com"]);
});
test("verifySystemHostnameResolution fails when all hostnames fail system lookup", async () => {
const { verifySystemHostnameResolution } = loadInternals();
assert.equal(typeof verifySystemHostnameResolution, "function");
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
timeoutMs: 5,
lookup: async (hostname) => {
throw new Error(`${hostname}: timeout`);
},
});
assert.equal(result.ok, false);
assert.equal(result.hostname, "");
assert.match(result.error, /www\.google\.com/);
assert.match(result.error, /api\.openai\.com/);
});

View File

@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
ACTION="${1:-}"
case "$ACTION" in
probe|up|down|status|cleanup)
;;
*)
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2
exit 2
;;
esac
WG_QUICK="/opt/homebrew/bin/wg-quick"
WG="/opt/homebrew/bin/wg"
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
WG_INTERFACE="nordvpnctl"
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
test -x "$WG_QUICK"
ACTIVE=0
RUNTIME_INTERFACE=""
if [ -x "$WG" ]; then
RUNTIME_INTERFACE=$("$WG" show interfaces 2>/dev/null | awk 'NF { print $1; exit }')
fi
if [ -n "$RUNTIME_INTERFACE" ]; then
ACTIVE=1
elif [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then
ACTIVE=1
elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then
ACTIVE=1
elif pgrep -f "wg-quick up $WG_CONFIG" >/dev/null 2>&1; then
ACTIVE=1
elif pgrep -f "wireguard-go utun" >/dev/null 2>&1; then
ACTIVE=1
fi
echo "active=$ACTIVE"
echo "interfaceName=$WG_INTERFACE"
if [ -n "$RUNTIME_INTERFACE" ]; then
echo "wireguardInterface=$RUNTIME_INTERFACE"
fi
if [ -f "$WG_CONFIG" ]; then
echo "configPath=$WG_CONFIG"
fi
exit 0
fi
if [ "$ACTION" = "cleanup" ]; then
"$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true
exit 0
fi
exec "$WG_QUICK" "$ACTION" "$WG_CONFIG"

3
skills/property-assessor/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.venv/

View File

@@ -0,0 +1,496 @@
---
name: property-assessor
description: Assess a real property from an address or listing URL and produce a decision-grade summary. Use when a user wants fair value, comps, rental or STR viability, carry-cost review, HOA or insurance risk analysis, or offer guidance for a condo, house, townhouse, or similar residential property. Prefer when the task should discover and reconcile multiple listing sources from a street address first, then give a buy/pass/only-below-X verdict.
---
# Property Assessor
Start from the property address when possible. Treat listing URLs as supporting evidence, not the only source of truth.
## Inputs
Accept any of:
- a street address
- one or more listing URLs
- an address plus the reason for the assessment, such as investment property, vacation home, owner-occupant, long-term rental, STR, or housing for a child in college
The assessment purpose is required for a decision-grade result.
If the user does not say why they want the property assessed, stop and ask before finalizing the analysis.
Do not silently infer or reuse the assessment purpose from earlier turns just because the property address is the same.
Only reuse a prior purpose when the user explicitly says it is the same purpose as before.
If the current request includes only the property/address plus email or PDF instructions, treat the assessment purpose as missing and ask again.
If the current request explicitly says to email or send the PDF to a stated target address, treat that as delivery authorization for that target once the report is ready. Do not ask for a second "send it" confirmation unless the user expressed uncertainty or changed the destination.
If the property has a unit / apartment / suite number, include it.
Do not drop the unit when discovering listing sources. Unit-qualified condo/townhome addresses must be matched as the exact unit, while single-family addresses with no unit should still work normally.
## Core workflow
1. Normalize the address and property type.
2. Discover accessible listing or public-record sources for the same property.
3. Establish a baseline fact set from the best available source.
4. Cross-check the same property on other sites.
5. Pull same-building comps for condos or nearby comps for houses/townhomes.
6. Underwrite carrying cost with taxes, HOA, insurance, and realistic friction.
7. Flag risk drivers before giving a verdict.
8. End with a specific recommendation: `buy`, `pass`, or `only below X`.
Completion rule:
- A preliminary helper payload is not completion.
- If `assess` returns a preliminary payload, continue the remaining analysis yourself in the same run.
- Use the preliminary payload as scaffolding for the missing work: listing facts, comp analysis, valuation range, condition interpretation from photos, and final verdict.
- Do not stop and ask the user whether to continue just because the helper stopped at a preliminary checkpoint.
- Only interrupt the user when there is a genuine blocker: missing assessment purpose, missing recipient email at final render/send time, unavailable listing/public-record sources with no reasonable fallback, or missing facts that cannot be recovered.
## Approval-averse / chat-safe behavior
When operating from chat surfaces such as WhatsApp, Telegram, Signal, or other messaging channels, prefer workflows that do **not** trigger host exec approval prompts.
Use this priority order:
1. `web-automation` for Zillow/HAR listing discovery, rendered listing facts, all-photos flows, and other anti-bot-heavy property pages
2. `web_fetch` when an official CAD/assessor page or simple listing/public page is directly fetchable
3. `web_search` only as a narrow fallback to discover an alternate URL after direct `web-automation` / `web_fetch` attempts miss
4. interactive browser/gallery automation only as a last resort
Rules:
- Prefer accessible listing text, public mirrors, and scrollable all-photos pages over custom gallery traversal.
- Avoid fragile interactive gallery flows if they are likely to require approval or bounce the user into Control UI.
- If a richer photo pass would require approval, do not silently force that path first. Continue with the best approval-free workflow available and clearly lower confidence if needed.
- Only escalate to approval-heavy browser interaction when there is no reasonable alternative and the extra fidelity materially changes the assessment.
- In WhatsApp or similar messaging runs, keep the core assessment on `web-automation` plus `web_fetch`. Treat `web_search` as a fallback discovery aid, not the primary property-analysis path.
- For Zillow/HAR property discovery, photo extraction, and rendered listing review, do **not** start with Brave-backed `web_search` when `web-automation` can open the candidate source directly.
- For CAD/public-record enrichment, prefer official assessor/CAD pages via `web_fetch` first. Use `web-automation` when the official site needs rendered interaction. Do **not** start CAD lookup from generic web-search snippets when the official site is already known or derivable from the address.
- In Texas runs, do **not** use `https://www.texas.gov/propertytaxes/search/` as the CAD lookup path. Use the address-first CAD/helper path or the discovered county CAD pages directly.
- In those messaging runs, do **not** make `scripts/property-assessor assess`, `scripts/property-assessor locate-public-records`, `node zillow-discover.js`, `node har-discover.js`, `node zillow-photos.js`, `node har-photos.js`, `curl`, or `wget` the default core-analysis path.
- From messaging runs, the only subprocess-style step you should attempt by default is the final `scripts/property-assessor render-report` call after the verdict, fair-value range, and report body are complete.
- Do **not** inspect Gmail/email-send skills, mail tooling, or delivery integrations until the assessment is complete and the PDF is either already rendered or ready to render immediately.
- Property-assessor delivery emails should be sent as Luke from Luke's Google Workspace account, while still delivering to the user-specified destination.
- Use `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"` for the final PDF delivery step.
- Do **not** use generic `gog` or the Stefano helper `node ~/.openclaw/workspace/integrations/google-workspace/gw.js` for property-assessor report delivery.
- If you need to confirm Luke auth before sending, use `zsh ~/.openclaw/workspace/bin/gog-luke auth list --check --plain`.
- A silent helper is a failed helper in messaging runs. If a background helper produces no useful stdout/stderr and no result within a short bound, stop polling it, treat that path as failed, and continue on the chat-native assessment path instead of narrating that it is still chewing.
- If Zillow photo extraction fails, immediately continue with HAR photo fallback or the next available rendered listing/photo source. Do **not** stop the assessment because one Zillow photo subprocess failed.
- After a Zillow/HAR photo miss, continue the comp and CAD/public-record work in the same run. A photo-source miss is a fallback event, not a terminal state.
- Do **not** leave the user parked behind background helper polling. If a helper has not produced a result quickly, give a concise status update and continue the assessment with the next available non-helper path.
- If the user already instructed you to email/send the finished PDF to a specific target, do **not** ask for a second send confirmation after rendering. Render, send, and report the result.
- If the final PDF render fails, return the complete decision-grade report in chat and say the render/send step failed. Do not restart the whole assessment.
- Do **not** run `npm install`, `npm ci`, or other dependency-setup commands during a normal chat assessment flow when the local skill already has `node_modules` present.
- On this machine, treat `property-assessor` dependencies as already installed. Use the helper entrypoints directly unless the wrapper itself explicitly reports missing local dependencies.
- Do **not** use ad hoc shell snippets, heredocs, or inline interpreter eval for public-record or CAD lookup from chat. Avoid forms like `python3 - <<'PY'`, `python -c`, `node -e`, `node --input-type=module -e`, or raw `bash -lc '...'` probes.
- For public-record enrichment in chat, prefer `web_fetch` plus official assessor/CAD pages. If a one-off helper is truly needed, add a file-based helper under the skill tree first and use it from a non-messaging surface instead of inline code.
## Source order
Prefer this order unless the user says otherwise:
1. Zillow
2. Redfin
3. Realtor.com
4. HAR / Homes.com / brokerage mirror pages
5. county or appraisal pages
Use the `web-automation` skill for rendered pages and anti-bot-heavy sites.
Use `web_search` sparingly to discover alternate URLs, then return to `web-automation` for extraction.
## Helper runtime
These helper commands are primarily for local/manual runs, non-chat surfaces, and the final PDF render step. They are **not** the default WhatsApp core-analysis path.
`property-assessor` now includes TypeScript helper commands for:
- address-first preliminary assessment assembly
- public-record jurisdiction lookup
- fixed-template PDF rendering
Before using those helper commands:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
test -x node_modules/.bin/tsx
```
Quick command summary:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor assess --address "<street-address>"
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>"
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>" --recipient-email "<target@example.com>"
scripts/property-assessor locate-public-records --address "<street-address>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
`assess` is the address-first entrypoint for helper-driven runs. It should:
- require the assessment purpose
- treat the assessment purpose as missing unless it is present in the current request or explicitly confirmed as unchanged from earlier context
- resolve official public-record jurisdiction automatically from the address
- keep CAD discovery jurisdiction-specific from the address; do not hardcode one county CAD for every property
- try to discover Zillow and HAR listing URLs from the address when no listing URL is provided
- start Zillow and HAR discovery in parallel, while still preferring Zillow first for the photo-review path
- run the approval-safe Zillow/HAR photo extractor chain automatically
- allow slower exact-unit Zillow pages a longer source-specific discovery/photo window before giving up and falling back
- build a purpose-aware report payload
- complete the analysis without requiring recipient email(s)
- only stop and ask for recipient email(s) when the user is explicitly rendering or sending the PDF
- render the PDF only after recipient email(s) are known
- do **not** render or send a PDF from the helper's preliminary payload while verdict is still `pending` or fair value is not established
- do **not** render or send a decision-grade PDF while `photoReview.status` is anything other than `completed`
- if comps, valuation, or decision-grade condition interpretation are still incomplete, return the preliminary payload and say that the PDF/send step must wait
Agent follow-through rule:
- When the user asked for a full property assessment or asked for the PDF/email result, do not stop at the helper output.
- After `assess` returns a preliminary payload, continue with the remaining manual/model-driven steps needed to reach a decision-grade report.
- Only after the verdict and fair-value range are established should you render/send the PDF.
- A verdict and fair-value range are still not enough by themselves; the subject-unit photo review must also be completed before the PDF/send step.
- If the analysis still cannot be completed, explain the first real blocker, not just that the helper was preliminary.
- If the user sends `update?`, `and?`, or similar mid-run, answer with status and keep the original assessment going. Do not treat that message as a reset or a cue to stop at the last helper checkpoint.
- In WhatsApp or similar messaging runs, do **not** start a background `assess` helper and then wait on repeated zero-output polls. That counts as a failed path; abandon it and continue with native search/fetch/browser work.
- In WhatsApp or similar messaging runs, do **not** start mail-sending or mail-skill discovery before the report content, verdict, and delivery artifact are ready.
- Once the report is ready, treat an explicit prior instruction like "Email pdf version to me at <address>" as sufficient authorization to send.
## Public-record enrichment
Public-record / assessor data should be used when available and linked in the final result.
Default approach:
1. start from the street address
2. resolve the address to county/state/geography
3. identify the related appraisal district / assessor jurisdiction
4. use the official public-record site as a primary-source check against listing data
5. link the official jurisdiction page and any direct property page used in the final result
Approval-safe rule:
- do not perform CAD/public-record discovery with inline shell or interpreter snippets
- in chat/messaging runs, prefer `web_fetch` plus official CAD/assessor pages first
- use the built-in TypeScript helper path on local/manual surfaces or for the final PDF render step
- if rendered interaction is unavoidable, use bounded `web-automation` behavior rather than ad hoc shell text
Use the helper CLI first on local/manual surfaces:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor locate-public-records --address "<street-address>"
```
When you want the helper to assemble the preliminary assessment payload in one step from a non-messaging surface, use:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
scripts/property-assessor assess --address "<street-address>"
```
This command should automatically include the public-record jurisdiction result in the returned assessment payload.
When `--assessment-purpose` is present, it should also:
- frame the analysis around that stated purpose
- try Zillow discovery from the address
- try HAR discovery from the address
- run Zillow photo extraction first when available, then HAR as fallback
- include the extracted `imageUrls` in `photoReview` when successful
This command currently:
- resolves the address through the official Census geocoder
- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates
- retries the fallback geocoder without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider
- returns county/state/FIPS/GEOID context
- for Texas, resolves the official Texas Comptroller county directory page
- returns the county appraisal district and tax assessor/collector links when available
- when a supported official CAD detail host is found, retrieves subject-property facts from that county CAD and includes them in the assessment payload
Important rules:
- listing-site geo IDs are hints only; do **not** treat them as assessor record keys
- parcel/APN/account identifiers from Zillow/HAR/Redfin are much stronger keys than listing geo IDs
- if Zillow exposes a parcel/APN/account number on the listing, capture it and feed that identifier into CAD lookup before relying on address-only matching
- if a direct public-record property page is available, use its data in the assessment and link it explicitly
- when the helper exposes official CAD owner, legal-description, property-ID/account, value, or exemption data, treat those as primary-source facts in the model's assessment
- if the jurisdiction can be identified but the property detail page is not directly retrievable, still link the official jurisdiction page and say what could not be confirmed
- a host approval prompt triggered by an ad hoc shell snippet is workflow drift; return to `locate-public-records`, `assess`, `web_fetch`, or a file-based helper instead of approving the inline probe by default
### Texas rule
For Texas properties, public-record enrichment is required when feasible.
Process:
1. run `locate-public-records` from the subject address
2. use the returned Texas Comptroller county directory page as the official jurisdiction reference
3. use the returned CAD website for address / account / parcel lookup
- when Zillow exposes the parcel/APN/account number, prefer that over address-only search
4. when accessible, capture:
- account number
- owner name
- land value
- improvement value
- assessed total
- exemptions
- tax office links
In the final assessment, explicitly label official public-record facts as such.
Nueces-specific note:
- when searching Nueces CAD by parcel / Geographic ID, format the identifier with a dash after the first 4 digits and after the first 8 digits, for example `123456789012` -> `1234-5678-9012`
## Minimum data to capture
For the target property, capture when available:
- address
- ask price or last known list price
- property type
- beds / baths
- sqft
- lot size if relevant
- year built
- HOA fee and included services
- taxes
- days on market
- price history
- parking
- waterfront / flood clues
- subdivision / building name when applicable
- same-building or nearby active inventory
- listing photos and visible condition cues
- included appliances and obvious missing appliances
- flooring mix, especially whether carpet is present
- public-record jurisdiction and linked official source
- account / parcel / tax ID if confirmed
- official assessed values and exemptions if confirmed
## Photo and condition review
Always look at the listing photos when they are available. Do not rate a property only from structured text.
### Required photo-access workflow
When the source site exposes listing photos, prefer the **most accessible all-photos view** first. This can be:
- a scrollable all-photos page
- a photo grid
- an expanded photo list
- or, if necessary, a modal gallery/lightbox
Use `web-automation` for this. Preferred process:
1. Open the listing page.
2. Click the photo entry point such as `See all photos`, `See all 29 photos`, `Show all photos`, or the main hero image.
3. If that opens a scrollable all-photos view, grid, or photo page that clearly exposes the listing images, use that directly for photo review.
4. Only use next-arrow / slideshow traversal when the site does not provide an accessible all-photos view.
5. If you must use a modal/lightbox, verify that you are seeing distinct images, not just a gallery preview tile or repeated screenshot of the first image.
6. Review enough images to cover the key rooms and exterior, and for smaller listings aim to review all photos when practical.
7. If photo access fails or is incomplete, say so explicitly and do not claim that you reviewed all photos.
Minimum honesty rule: never say you "looked at all photos" unless the site actually exposed the full set and you successfully reviewed them.
A gallery landing page, collage preview, repeated first image, or a single screenshot of the listing page does **not** count as full photo review.
### What to inspect in the photos
At minimum, note:
- overall finish level: dated, average, lightly updated, fully updated
- kitchen condition: cabinets, counters, backsplash, appliance quality
- bathroom condition: vanity, tile, surrounds, fixtures
- flooring: tile, vinyl, laminate, hardwood, carpet
- whether carpet appears in bedrooms, stairs, or living areas
- obvious make-ready issues: paint, damaged trim, old fixtures, mismatched finishes, worn surfaces
- visible missing items: refrigerator, washer/dryer, range hood, dishwasher, etc.
- any signs of deferred maintenance or water intrusion visible in photos
- exterior/common-area condition when visible
- balconies, decks, sliders, windows, and waterfront-facing elements for condos/townhomes near water
### If photo review is incomplete
If photos are weak, incomplete, blocked, or the gallery automation fails:
- say so explicitly
- lower confidence
- avoid strong condition claims
- do not infer turnkey condition from marketing text alone
### Mandatory photo-review rule
If an accessible all-photos view, photo grid, photo page, or fallback source exists, photo review is **required** before making condition claims.
Do not silently skip photos just because pricing, comps, or carrying-cost analysis can proceed without them.
Before outputting `Photo review: not completed`, you must attempt a reasonable photo-access chain when sources are available.
Preferred order:
1. primary listing source all-photos page (for example Zillow `See all photos` / `See all X photos`)
2. HAR photo page
3. Realtor.com photo page
4. brokerage mirror or other accessible listing mirror
Use the first source that exposes the listing photos reliably. A scrollable photo page, photo grid, or expanded all-photos view counts.
Do not stop at the first failure if another accessible source is available.
### Zillow-specific rule
For Zillow listings, do **not** treat the listing shell text or hero gallery preview as a photo review attempt.
You must use **only `web-automation`** to:
1. open the Zillow listing page
2. click `See all photos` / `See all X photos`
3. access the resulting all-photos page or scrollable photo view
4. review the exposed photo set from that page
If Zillow exposes a page with a scroller that shows the listing photos, that page counts as the Zillow photo source and should be used directly.
If that scroller page exposes direct image links such as `https://photos.zillowstatic.com/...`, treat those URLs as successful photo access and use that image set for review.
This is preferred over fragile modal next/previous navigation.
If the rendered Zillow listing shell itself already exposes the full direct Zillow image set and the extracted image count matches the announced photo count, that also counts as successful photo access even if the `See all photos` click path is flaky.
For this normal Zillow all-photos workflow, stay inside **`web-automation` only**.
Use the dedicated file-based extractor first:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node zillow-photos.js "<zillow-listing-url>"
```
Do **not** escalate to coding-agent, ad hoc Python helpers, or extra dependency-heavy tooling just to open `See all photos`, inspect the scroller page, or extract Zillow image URLs.
Only escalate beyond `web-automation` if `web-automation` itself truly cannot access the all-photos/scroller page or the exposed image set.
Do not rely on generic page text, photo counts, or non-photo shells as a Zillow attempt.
Only fall back to HAR/Realtor/broker mirrors if the Zillow all-photos path was actually attempted with `web-automation` and did not expose the photos reliably.
A source only counts as an attempted photo source if you actually did one of these:
- opened the all-photos page / photo grid / photo page successfully, or
- explicitly tried to open it and observed a concrete failure
The following do **not** count as a photo-source attempt by themselves:
- seeing a `See all photos` button
- seeing a photo count
- reading listing text that mentions photos
- capturing only the listing shell, hero image, or collage preview
### HAR fallback rule
If Zillow photo extraction does not expose usable direct image URLs, try HAR next for the same property.
For HAR listings, use **only `web-automation`** to:
1. open the HAR listing page
2. click `Show all photos` / `View all photos`
3. access the resulting all-photos page or photo view
4. extract the direct image URLs from that page
Use the dedicated HAR extractor first:
```bash
cd ~/.openclaw/workspace/skills/web-automation/scripts
node har-photos.js "<har-listing-url>"
```
If HAR exposes the direct photo URLs from the all-photos page, treat that as successful photo access and use that image set for review.
Do not stop after a failed Zillow attempt if HAR is available and exposes the listing photos more reliably.
When a dedicated extractor returns `imageUrls`, inspect the images in that returned set before making condition claims.
For smaller listings, review the full extracted set when practical; for a 20-30 photo listing, that usually means all photos.
### Approval-safe command shape
When running `web-automation` from chat-driven property assessment, prefer file-based commands under `~/.openclaw/workspace/skills/web-automation/scripts`.
Good:
- `node check-install.js`
- `node zillow-photos.js "<url>"`
- `node har-photos.js "<url>"`
Avoid approval-sensitive inline interpreter eval where possible:
- `node -e "..."`
- `node --input-type=module -e "..."`
The final assessment must explicitly include these lines in the output:
- `Photo source attempts: <action-based summary>`
- `Photo review: completed via <source>`
or `Photo review: not completed`
If completed, briefly summarize the condition read from the photos.
If not completed, mark condition confidence as limited and say why.
## PDF report requirement
The deliverable is not just chat text. A fixed-template PDF report must be generated for completed assessments.
Use the property-assessor helper CLI:
```bash
cd ~/.openclaw/workspace/skills/property-assessor
npm install
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
```
The renderer uses a fixed template and must keep the same look across runs.
Template rules are documented in `references/report-template.md`.
The PDF report should include:
1. report header
2. verdict panel
3. subject-property summary table
4. Snapshot
5. What I like
6. What I do not like
7. Comp view
8. Underwriting / carry view
9. Risks and diligence items
10. Photo review
11. Public records
12. Source links
### Recipient-email gate
Before rendering or sending the PDF, the skill must know the target recipient email address(es).
This gate applies to the PDF step, not the analysis step.
Do not interrupt a normal assessment run just because recipient email is missing if the user has not yet asked to render or send the PDF.
If the prompt does **not** include target email(s):
- stop
- ask the user for the target email address(es)
- do **not** render or send the final PDF yet
If target email(s) are present:
- include them in the report payload
- render the PDF with the fixed template
- if a delivery workflow is available, use those same target email(s) for sending
The renderer enforces this gate and will fail if the payload has no recipient email list.
## Normalization / make-ready adjustment
Estimate a rough make-ready budget when condition is not turnkey. The goal is not contractor precision; the goal is apples-to-apples comparison.
Use simple buckets and state them as rough ranges:
- light make-ready: paint, fixtures, minor hardware, patching
- medium make-ready: flooring replacement in some rooms, appliance replacement, bathroom refresh
- heavy make-ready: major kitchen/bath work, widespread flooring, obvious deferred maintenance
Call out carpet separately. If carpet is present, estimate replacement or removal cost as part of the make-ready note.
## Underwriting rules
Always show a simple carrying-cost view with at least:
- principal and interest if available from the listing
- taxes per month
- HOA per month if applicable
- insurance estimate or note uncertainty
- realistic effective carry range after maintenance, vacancy, and property-specific risk
Treat these as strong caution flags:
- high HOA relative to price or expected rent
- older waterfront or coastal exposure
- unknown reserve / assessment history for condos
- many active units in the same building or micro-area
- stale days on market with weak price action
- no clear rent support
## Output format
Keep the answer concise but decision-grade:
1. Snapshot
2. What I like
3. What I do not like
4. Comp view
5. Underwriting / carry view
6. Risks and diligence items
7. Verdict with fair value range and offer guidance
Also include:
- public-record / CAD evidence and links when available
- the path to the rendered PDF after generation
If the user did not provide recipient email(s), ask for them instead of finalizing the PDF workflow.
## Reuse notes
When condos are involved, same-building comps and HOA economics usually matter more than neighborhood averages.
For detailed heuristics and the reusable memo template, read:
- `references/underwriting-rules.md`
- `references/report-template.md`

View File

@@ -0,0 +1,80 @@
{
"recipientEmails": [
"buyer@example.com"
],
"assessmentPurpose": "investment property",
"reportTitle": "Property Assessment Report",
"subtitle": "Sample property assessment payload",
"subjectProperty": {
"address": "4141 Whiteley Dr, Corpus Christi, TX 78418",
"listingPrice": 149900,
"propertyType": "Townhouse",
"beds": 2,
"baths": 2,
"squareFeet": 900,
"yearBuilt": 1978
},
"verdict": {
"decision": "only below x",
"fairValueRange": "$132,000 - $138,000",
"offerGuidance": "Only attractive below the current ask once HOA, insurance, and make-ready are priced in."
},
"snapshot": [
"Small coastal townhouse with a tight margin at the current ask.",
"Needs CAD/public-record reconciliation before a high-confidence offer."
],
"whatILike": [
"Usable 2 bed / 2 bath layout.",
"Straightforward official public-record jurisdiction in Nueces County."
],
"whatIDontLike": [
"Ask looks full for the visible finish level.",
"Coastal exposure increases long-run carry and maintenance risk."
],
"compView": [
"Need same-building or very local townhome comps before treating ask as fair value."
],
"carryView": [
"Underwrite taxes, HOA, wind/flood insurance, and maintenance together."
],
"risksAndDiligence": [
"Confirm reserve strength and special assessment history.",
"Confirm insurance obligations and any storm-related repair history."
],
"photoReview": {
"status": "completed",
"source": "Zillow",
"attempts": [
"Zillow extractor returned the full direct photo set."
],
"summary": "Interior reads dated-to-average rather than turnkey."
},
"publicRecords": {
"jurisdiction": "Nueces Appraisal District",
"accountNumber": "sample-account",
"landValue": 42000,
"improvementValue": 99000,
"assessedTotalValue": 141000,
"exemptions": "Not confirmed in sample payload",
"links": [
{
"label": "Texas Comptroller County Directory",
"url": "https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php"
},
{
"label": "Nueces CAD",
"url": "http://www.ncadistrict.com/"
}
]
},
"sourceLinks": [
{
"label": "Zillow Listing",
"url": "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
},
{
"label": "HAR Listing",
"url": "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
}
]
}

791
skills/property-assessor/package-lock.json generated Normal file
View File

@@ -0,0 +1,791 @@
{
"name": "property-assessor-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "property-assessor-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,22 @@
{
"name": "property-assessor-scripts",
"version": "1.0.0",
"description": "Property assessor helpers for public-record lookup and fixed-template PDF rendering",
"type": "module",
"scripts": {
"assess": "tsx src/cli.ts assess",
"locate-public-records": "tsx src/cli.ts locate-public-records",
"render-report": "tsx src/cli.ts render-report",
"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,56 @@
# Property Assessment PDF Template
The `property-assessor` PDF output must use the same visual template every time.
## Template sections
1. Report header
- title: `Property Assessment Report`
- subtitle / run context
- prepared-for recipient email list
- generated timestamp
2. Verdict panel
- decision badge: `BUY`, `PASS`, or `ONLY BELOW X`
- offer guidance sentence
3. Summary table
- address
- ask / last price
- property type
- beds / baths
- square feet
- year built
- verdict
- fair value range
- public-record jurisdiction
- assessed total
4. Body sections
- Snapshot
- What I Like
- What I Do Not Like
- Comp View
- Underwriting / Carry View
- Risks and Diligence Items
- Photo Review
- Public Records
- Source Links
5. Notes page
- report policy note
- reminder that listing data should be reconciled against official public records when available
## Visual rules
- fixed blue section headers
- verdict badge color depends on the decision
- same margins, typography, and section ordering for every report
- links should be shown explicitly in the PDF
- no ad hoc rearranging of sections per run
## Recipient gate
The report must not be rendered or sent unless target recipient email address(es) are known.
If the prompt does not include recipient email(s), stop and ask for them before rendering the PDF.

View File

@@ -0,0 +1,110 @@
# Property Assessor Underwriting Rules
## Goal
Turn messy property data into a fast investor or buyer decision without pretending precision where the data is weak.
## Practical heuristics
### 1. Use the most comparable comp set first
- For condos: same-building active and sold comps first.
- For houses or townhomes: same subdivision or immediate micro-area first.
- Expand outward only when the close comp set is thin.
### 2. Fixed carrying costs can kill a deal
High HOA, insurance, taxes, or unusual maintenance burden can make a property unattractive even when price-per-sqft looks cheap.
### 3. Waterfront, coastal, older, or unusual properties deserve extra skepticism
Assume elevated insurance, maintenance, and assessment risk until proven otherwise.
### 4. DOM and price cuts are negotiation signals, not automatic value
Long market time helps the buyer, but a stale listing can still be a mediocre deal if the economics are weak.
### 5. Unknowns must stay visible
If reserve studies, bylaws, STR rules, assessment history, lease restrictions, or condition details are missing, list them explicitly as unresolved diligence items.
## Minimum comp stack
For each property, aim to collect:
- target listing facts
- same-building or same-micro-area active listings
- sold listings if available
- nearby similar properties
- rent or lease signals when investment analysis matters
## Condition normalization
Listings with similar asking prices can have very different real basis once make-ready work is included.
Assess photos for:
- flooring condition and carpet presence
- appliance package completeness and age
- kitchen and bath refresh level
- paint and trim condition
- lighting, fans, doors, and visible wear
- any obvious repair or moisture concerns
Use a rough make-ready range such as:
- Light: cosmetic cleanup / paint / fixtures
- Medium: flooring plus partial appliance or bath refresh
- Heavy: major interior updates or visible deferred maintenance
When carpet is present, note it explicitly and include an estimated removal or replacement adjustment in the make-ready range.
When major appliances are missing, note the likely replacement burden rather than pretending the asking price is fully comparable to turnkey units.
## Carry framework
At minimum, estimate:
- P&I
- taxes monthly
- HOA monthly if applicable
- insurance assumption or uncertainty note
- effective carry range after maintenance / vacancy / property friction
- make-ready burden when condition is not turnkey
If the listing already provides an estimated payment, use it as the starting point, then explain what it leaves out.
## STR / rental notes
Never assume STR viability from location alone. Confirm or explicitly mark as unknown:
- HOA restrictions
- minimum stay rules
- city or building constraints
- whether the micro-location is truly tourist-driven or just adjacent to something attractive
## Verdict language
Use one of these:
- `Buy` — pricing and risk support action now
- `Pass` — weak economics or too much unresolved risk
- `Only below X` — decent candidate only if bought at a materially lower basis
## Suggested memo template
### Snapshot
- Address
- Source links checked
- Price / type / beds / baths / sqft / HOA / taxes / DOM
### Market read
- Same-building or same-area active inventory
- Nearby active comps
- Any sold signals
### Economics
- Base carry
- Effective carry range
- Rent / STR comments when relevant
### Risk flags
- HOA or fixed-cost burden
- insurance / waterfront / age
- reserves / assessments / restrictions if relevant
- liquidity / DOM
### Recommendation
- Fair value range
- Opening offer
- Ceiling offer
- Final verdict

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
TSX_BIN="${SKILL_DIR}/node_modules/.bin/tsx"
if [[ ! -e "${TSX_BIN}" ]]; then
echo "Missing local Node dependencies for property-assessor. Run 'cd ${SKILL_DIR} && npm install' first." >&2
exit 1
fi
exec node "${TSX_BIN}" "${SKILL_DIR}/src/cli.ts" "$@"

View File

@@ -0,0 +1,550 @@
import os from "node:os";
import path from "node:path";
import { extractZillowIdentifierHints } from "../../web-automation/scripts/zillow-identifiers.js";
import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js";
import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js";
import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js";
import {
isDecisionGradeReportPayload,
renderReportPdf,
type ReportPayload
} from "./report-pdf.js";
export interface AssessPropertyOptions {
address: string;
assessmentPurpose?: string;
recipientEmails?: string[] | string;
output?: string;
parcelId?: string;
listingGeoId?: string;
listingSourceUrl?: string;
}
export interface AssessPropertyResult {
ok: true;
needsAssessmentPurpose: boolean;
needsRecipientEmails: boolean;
pdfReady: boolean;
message: string;
outputPath: string | null;
reportPayload: ReportPayload | null;
publicRecords: PublicRecordsResolution | null;
}
interface AssessPropertyDeps {
resolvePublicRecordsFn?: typeof resolvePublicRecords;
renderReportPdfFn?: typeof renderReportPdf;
discoverListingSourcesFn?: typeof discoverListingSources;
extractPhotoDataFn?: typeof extractPhotoData;
extractZillowIdentifierHintsFn?: typeof extractZillowIdentifierHints;
}
interface PurposeGuidance {
label: string;
snapshot: string;
like: string;
caution: string;
comp: string;
carry: string;
diligence: string;
verdict: string;
}
function asStringArray(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
if (Array.isArray(value)) {
return value.flatMap((item) => asStringArray(item));
}
return [String(value).trim()].filter(Boolean);
}
function shouldRenderPdf(
options: AssessPropertyOptions,
recipientEmails: string[]
): boolean {
return Boolean(options.output || recipientEmails.length);
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80) || "property";
}
function pushLink(
target: Array<{ label: string; url: string }>,
label: string,
url: unknown
): void {
if (typeof url !== "string" || !url.trim()) return;
const normalized = url.trim();
if (!target.some((item) => item.url === normalized)) {
target.push({ label, url: normalized });
}
}
function buildPublicRecordLinks(
publicRecords: PublicRecordsResolution
): Array<{ label: string; url: string }> {
const links: Array<{ label: string; url: string }> = [];
pushLink(links, "Census Geocoder", publicRecords.officialLinks.censusGeocoder);
pushLink(
links,
"Texas Comptroller County Directory",
publicRecords.officialLinks.texasCountyDirectory
);
pushLink(
links,
"Texas Property Tax Portal",
publicRecords.officialLinks.texasPropertyTaxPortal
);
pushLink(links, "Appraisal District Website", publicRecords.appraisalDistrict?.Website);
pushLink(
links,
"Appraisal District Directory Page",
publicRecords.appraisalDistrict?.directoryPage
);
pushLink(
links,
"Tax Assessor / Collector Website",
publicRecords.taxAssessorCollector?.Website
);
pushLink(
links,
"Tax Assessor / Collector Directory Page",
publicRecords.taxAssessorCollector?.directoryPage
);
pushLink(links, "CAD Property Detail", publicRecords.propertyDetails?.sourceUrl);
return links;
}
function normalizePurpose(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function getPurposeGuidance(purpose: string): PurposeGuidance {
const normalized = purpose.toLowerCase();
if (/(daughter|son|college|student|school|campus)/i.test(normalized)) {
return {
label: purpose,
snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.",
like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.",
caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.",
comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.",
carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.",
diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.",
verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.`
};
}
if (/(vacation|second home|weekend|personal use|beach|getaway)/i.test(normalized)) {
return {
label: purpose,
snapshot: "Purpose fit: evaluate this as a vacation home with personal-use fit and carrying-cost tolerance in mind.",
like: "A vacation-home decision can justify paying for lifestyle fit, but only if ongoing friction is acceptable.",
caution: "Vacation home ownership can hide real recurring cost drag when insurance, HOA, storm exposure, and deferred maintenance are under-modeled.",
comp: "Comp work should focus on lifestyle alternatives, micro-location quality, and whether the premium over substitutes is defensible for a vacation home.",
carry: "Carry view should stress-test second-home costs, especially insurance, HOA, special assessments, and low-utilization months.",
diligence: "Confirm rules, reserves, storm or flood exposure, and whether the property still makes sense if usage ends up lower than expected.",
verdict: `Assessment purpose: ${purpose}. The final call should weigh personal-use fit against ongoing friction, not just headline list price.`
};
}
if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) {
return {
label: purpose,
snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`,
like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.",
caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.",
comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.",
carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.",
diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.",
verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.`
};
}
return {
label: purpose,
snapshot: `Purpose fit: ${purpose}. The final recommendation should be explicitly tested against that goal.`,
like: "The assessment should stay anchored to the stated purpose rather than defaulting to generic market commentary.",
caution: "Even a clean public-record and photo intake is not enough if the property does not fit the stated purpose.",
comp: "Comp work should compare against alternatives that solve the same purpose, not just nearby listings.",
carry: "Carry view should reflect the stated purpose and the real friction it implies.",
diligence: "Purpose-specific diligence should be listed explicitly before a final buy/pass/offer recommendation.",
verdict: `Assessment purpose: ${purpose}. The final conclusion must be explained in terms of that stated objective.`
};
}
function inferSourceFromUrl(rawUrl: string): PhotoSource | null {
try {
const url = new URL(rawUrl);
const host = url.hostname.toLowerCase();
if (host.includes("zillow.com")) return "zillow";
if (host.includes("har.com")) return "har";
return null;
} catch {
return null;
}
}
interface ResolvedListingCandidates {
attempts: string[];
listingUrls: Array<{ label: string; url: string }>;
zillowUrl: string | null;
harUrl: string | null;
}
async function resolveListingCandidates(
options: AssessPropertyOptions,
discoverListingSourcesFn: typeof discoverListingSources
): Promise<ResolvedListingCandidates> {
const attempts: string[] = [];
const listingUrls: Array<{ label: string; url: string }> = [];
const addListingUrl = (label: string, url: string | null | undefined): void => {
if (!url) return;
if (!listingUrls.some((item) => item.url === url)) {
listingUrls.push({ label, url });
}
};
let zillowUrl: string | null = null;
let harUrl: string | null = null;
if (options.listingSourceUrl) {
const explicitSource = inferSourceFromUrl(options.listingSourceUrl);
addListingUrl("Explicit Listing Source", options.listingSourceUrl);
if (explicitSource === "zillow") {
zillowUrl = options.listingSourceUrl;
attempts.push(`Using explicit Zillow listing URL: ${options.listingSourceUrl}`);
} else if (explicitSource === "har") {
harUrl = options.listingSourceUrl;
attempts.push(`Using explicit HAR listing URL: ${options.listingSourceUrl}`);
} else {
attempts.push(
`Explicit listing URL was provided but is not a supported Zillow/HAR photo source: ${options.listingSourceUrl}`
);
}
}
if (!zillowUrl && !harUrl) {
const discovered = await discoverListingSourcesFn(options.address);
attempts.push(...discovered.attempts);
zillowUrl = discovered.zillowUrl;
harUrl = discovered.harUrl;
addListingUrl("Discovered Zillow Listing", zillowUrl);
addListingUrl("Discovered HAR Listing", harUrl);
}
return {
attempts,
listingUrls,
zillowUrl,
harUrl,
};
}
async function resolvePhotoReview(
listingCandidates: ResolvedListingCandidates,
extractPhotoDataFn: typeof extractPhotoData,
additionalAttempts: string[] = []
): Promise<{
listingUrls: Array<{ label: string; url: string }>;
photoReview: Record<string, unknown>;
}> {
const attempts: string[] = [...listingCandidates.attempts, ...additionalAttempts];
const listingUrls = [...listingCandidates.listingUrls];
const candidates: Array<{ source: PhotoSource; url: string }> = [];
if (listingCandidates.zillowUrl) candidates.push({ source: "zillow", url: listingCandidates.zillowUrl });
if (listingCandidates.harUrl) candidates.push({ source: "har", url: listingCandidates.harUrl });
let extracted: PhotoExtractionResult | null = null;
for (const candidate of candidates) {
try {
const result = await extractPhotoDataFn(candidate.source, candidate.url);
extracted = result;
attempts.push(
`${candidate.source} photo extraction succeeded with ${result.photoCount} photos.`
);
if (result.notes.length) {
attempts.push(...result.notes);
}
break;
} catch (error) {
attempts.push(
`${candidate.source} photo extraction failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
if (extracted) {
return {
listingUrls,
photoReview: {
status: "completed",
source: extracted.source,
photoCount: extracted.photoCount,
expectedPhotoCount: extracted.expectedPhotoCount ?? null,
imageUrls: extracted.imageUrls,
attempts,
summary:
"Photo URLs were collected successfully. A decision-grade condition read still requires reviewing the extracted image set.",
}
};
}
if (!candidates.length) {
attempts.push(
"No supported Zillow or HAR listing URL was available for photo extraction."
);
}
return {
listingUrls,
photoReview: {
status: "not completed",
source: candidates.length ? "listing source attempted" : "no supported listing source",
attempts,
summary:
"Condition review is incomplete until Zillow or HAR photos are extracted and inspected."
}
};
}
export function buildAssessmentReportPayload(
options: AssessPropertyOptions,
publicRecords: PublicRecordsResolution,
listingUrls: Array<{ label: string; url: string }>,
photoReview: Record<string, unknown>
): ReportPayload {
const recipientEmails = asStringArray(options.recipientEmails);
const matchedAddress = publicRecords.matchedAddress || options.address;
const publicRecordLinks = buildPublicRecordLinks(publicRecords);
const sourceLinks = [...publicRecordLinks];
const purpose = normalizePurpose(options.assessmentPurpose || "");
const purposeGuidance = getPurposeGuidance(purpose);
for (const item of listingUrls) {
pushLink(sourceLinks, item.label, item.url);
}
const jurisdiction =
publicRecords.county.name && publicRecords.appraisalDistrict
? `${publicRecords.county.name} Appraisal District`
: publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction";
const assessedTotalValue = publicRecords.propertyDetails?.assessedTotalValue ?? null;
const ownerName = publicRecords.propertyDetails?.ownerName ?? undefined;
const landValue = publicRecords.propertyDetails?.landValue ?? undefined;
const improvementValue = publicRecords.propertyDetails?.improvementValue ?? undefined;
const exemptions = publicRecords.propertyDetails?.exemptions?.length
? publicRecords.propertyDetails.exemptions
: undefined;
return {
recipientEmails,
assessmentPurpose: purposeGuidance.label,
reportTitle: "Property Assessment Report",
subtitle: "Address-first intake with public-record enrichment and approval-safe photo-source orchestration.",
subjectProperty: {
address: matchedAddress,
county: publicRecords.county.name || "N/A",
state: publicRecords.state.code || publicRecords.state.name || "N/A",
geoid: publicRecords.geoid || "N/A"
},
verdict: {
decision: "pending",
fairValueRange: "Not established",
offerGuidance: purposeGuidance.verdict
},
snapshot: [
`Matched address: ${matchedAddress}`,
`Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`,
publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned.",
assessedTotalValue != null
? `Official CAD assessed value: $${assessedTotalValue.toLocaleString("en-US")}.`
: "Official CAD assessed value was not retrieved.",
purposeGuidance.snapshot
],
whatILike: [
"Address-first flow avoids treating listing-site geo IDs as assessor record keys.",
publicRecords.appraisalDistrict
? "Official appraisal-district contact and website were identified from public records."
: "Official public-record geography was identified.",
purposeGuidance.like
],
whatIDontLike: [
"This assess helper still needs listing facts, comp analysis, and a human/model review of the extracted photo set before any final valuation claim.",
purposeGuidance.caution
],
compView: [purposeGuidance.comp],
carryView: [purposeGuidance.carry],
risksAndDiligence: [
...publicRecords.lookupRecommendations,
...(publicRecords.propertyDetails?.notes || []),
purposeGuidance.diligence
],
photoReview,
publicRecords: {
jurisdiction,
accountNumber:
publicRecords.propertyDetails?.propertyId ||
options.parcelId ||
publicRecords.sourceIdentifierHints.parcelId,
ownerName,
landValue,
improvementValue,
assessedTotalValue,
exemptions,
links: publicRecordLinks
},
sourceLinks
};
}
export async function assessProperty(
options: AssessPropertyOptions,
deps: AssessPropertyDeps = {}
): Promise<AssessPropertyResult> {
const purpose = normalizePurpose(options.assessmentPurpose || "");
if (!purpose) {
return {
ok: true,
needsAssessmentPurpose: true,
needsRecipientEmails: false,
pdfReady: false,
message:
"Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.",
outputPath: null,
reportPayload: null,
publicRecords: null
};
}
const resolvePublicRecordsFn = deps.resolvePublicRecordsFn || resolvePublicRecords;
const renderReportPdfFn = deps.renderReportPdfFn || renderReportPdf;
const discoverListingSourcesFn = deps.discoverListingSourcesFn || discoverListingSources;
const extractPhotoDataFn = deps.extractPhotoDataFn || extractPhotoData;
const extractZillowIdentifierHintsFn =
deps.extractZillowIdentifierHintsFn || extractZillowIdentifierHints;
const listingCandidates = await resolveListingCandidates(
{ ...options, assessmentPurpose: purpose },
discoverListingSourcesFn
);
const identifierAttempts: string[] = [];
let effectiveParcelId = options.parcelId;
if (!effectiveParcelId && listingCandidates.zillowUrl) {
try {
const hints = await extractZillowIdentifierHintsFn(listingCandidates.zillowUrl);
effectiveParcelId = hints.parcelId || hints.apn || effectiveParcelId;
if (Array.isArray(hints.notes) && hints.notes.length) {
identifierAttempts.push(...hints.notes);
}
} catch (error) {
identifierAttempts.push(
`Zillow parcel/APN extraction failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const effectiveListingSourceUrl =
options.listingSourceUrl || listingCandidates.zillowUrl || listingCandidates.harUrl || undefined;
const publicRecords = await resolvePublicRecordsFn(options.address, {
parcelId: effectiveParcelId,
listingGeoId: options.listingGeoId,
listingSourceUrl: effectiveListingSourceUrl
});
const photoResolution = await resolvePhotoReview(
listingCandidates,
extractPhotoDataFn,
identifierAttempts
);
const reportPayload = buildAssessmentReportPayload(
{
...options,
assessmentPurpose: purpose,
parcelId: effectiveParcelId,
listingSourceUrl: effectiveListingSourceUrl
},
publicRecords,
photoResolution.listingUrls,
photoResolution.photoReview
);
const recipientEmails = asStringArray(options.recipientEmails);
const renderPdf = shouldRenderPdf(options, recipientEmails);
if (renderPdf && !recipientEmails.length) {
return {
ok: true,
needsAssessmentPurpose: false,
needsRecipientEmails: true,
pdfReady: false,
message:
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.",
outputPath: null,
reportPayload,
publicRecords
};
}
if (!renderPdf) {
return {
ok: true,
needsAssessmentPurpose: false,
needsRecipientEmails: false,
pdfReady: true,
message:
"Assessment payload is ready to render later. Review the analysis now; recipient email is only needed when you want the PDF.",
outputPath: null,
reportPayload,
publicRecords
};
}
if (!isDecisionGradeReportPayload(reportPayload)) {
return {
ok: true,
needsAssessmentPurpose: false,
needsRecipientEmails: false,
pdfReady: false,
message:
"The report payload is still preliminary. Do not render or send the PDF until comps, valuation, and a decision-grade verdict are completed.",
outputPath: null,
reportPayload,
publicRecords
};
}
const outputPath =
options.output ||
path.join(
os.tmpdir(),
`property-assessment-${slugify(publicRecords.matchedAddress || options.address)}-${Date.now()}.pdf`
);
const renderedPath = await renderReportPdfFn(reportPayload, outputPath);
return {
ok: true,
needsAssessmentPurpose: false,
needsRecipientEmails: false,
pdfReady: true,
message: `Property assessment PDF rendered: ${renderedPath}`,
outputPath: renderedPath,
reportPayload,
publicRecords
};
}

View File

@@ -0,0 +1,37 @@
export class TimeoutError extends Error {
readonly timeoutMs: number;
constructor(operationName: string, timeoutMs: number) {
super(`${operationName} timed out after ${timeoutMs}ms`);
this.name = "TimeoutError";
this.timeoutMs = timeoutMs;
}
}
export async function withTimeout<T>(
operation: () => Promise<T>,
{
operationName,
timeoutMs
}: {
operationName: string;
timeoutMs: number;
}
): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => {
reject(new TimeoutError(operationName, timeoutMs));
}, timeoutMs);
})
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
import minimist from "minimist";
import { assessProperty } from "./assessment.js";
import { resolvePublicRecords } from "./public-records.js";
import { ReportValidationError, loadReportPayload, renderReportPdf } from "./report-pdf.js";
function usage(): void {
process.stdout.write(`property-assessor\n
Commands:
assess --address "<address>" --assessment-purpose "<purpose>" [--recipient-email "<email>"] [--output "<report.pdf>"] [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
render-report --input "<payload.json>" --output "<report.pdf>"
`);
}
async function main(): Promise<void> {
const argv = minimist(process.argv.slice(2), {
string: [
"address",
"assessment-purpose",
"recipient-email",
"parcel-id",
"listing-geo-id",
"listing-source-url",
"input",
"output"
],
alias: {
h: "help"
}
});
const [command] = argv._;
if (!command || argv.help) {
usage();
process.exit(0);
}
if (command === "assess") {
if (!argv.address) {
throw new Error("Missing required option: --address");
}
const payload = await assessProperty({
address: argv.address,
assessmentPurpose: argv["assessment-purpose"],
recipientEmails: argv["recipient-email"],
output: argv.output,
parcelId: argv["parcel-id"],
listingGeoId: argv["listing-geo-id"],
listingSourceUrl: argv["listing-source-url"]
});
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
if (command === "locate-public-records") {
if (!argv.address) {
throw new Error("Missing required option: --address");
}
const payload = await resolvePublicRecords(argv.address, {
parcelId: argv["parcel-id"],
listingGeoId: argv["listing-geo-id"],
listingSourceUrl: argv["listing-source-url"]
});
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
if (command === "render-report") {
if (!argv.input || !argv.output) {
throw new Error("Missing required options: --input and --output");
}
const payload = await loadReportPayload(argv.input);
const outputPath = await renderReportPdf(payload, argv.output);
process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
return;
}
throw new Error(`Unknown command: ${command}`);
}
main().catch((error: unknown) => {
const message =
error instanceof ReportValidationError || error instanceof Error
? error.message
: String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,100 @@
import { discoverHarListing } from "../../web-automation/scripts/har-discover.js";
import { discoverZillowListing } from "../../web-automation/scripts/zillow-discover.js";
import { TimeoutError, withTimeout } from "./async-timeout.js";
export interface ListingDiscoveryResult {
attempts: string[];
zillowUrl: string | null;
harUrl: string | null;
}
interface ListingDiscoveryDeps {
timeoutMs?: number;
zillowTimeoutMs?: number;
harTimeoutMs?: number;
discoverZillowListingFn?: typeof discoverZillowListing;
discoverHarListingFn?: typeof discoverHarListing;
}
const DEFAULT_DISCOVERY_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_DISCOVERY_TIMEOUT_MS || 20_000
);
const DEFAULT_ZILLOW_DISCOVERY_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_ZILLOW_DISCOVERY_TIMEOUT_MS || 60_000
);
const DEFAULT_HAR_DISCOVERY_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_HAR_DISCOVERY_TIMEOUT_MS || DEFAULT_DISCOVERY_TIMEOUT_MS
);
interface SourceDiscoveryOutcome {
source: "zillow" | "har";
url: string | null;
attempts: string[];
}
export async function discoverListingSources(
address: string,
deps: ListingDiscoveryDeps = {}
): Promise<ListingDiscoveryResult> {
const timeoutMs = deps.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
const zillowTimeoutMs =
deps.zillowTimeoutMs ??
(deps.timeoutMs != null ? timeoutMs : DEFAULT_ZILLOW_DISCOVERY_TIMEOUT_MS);
const harTimeoutMs =
deps.harTimeoutMs ??
(deps.timeoutMs != null ? timeoutMs : DEFAULT_HAR_DISCOVERY_TIMEOUT_MS);
const discoverZillowListingFn = deps.discoverZillowListingFn || discoverZillowListing;
const discoverHarListingFn = deps.discoverHarListingFn || discoverHarListing;
const runSource = async (
source: "zillow" | "har",
timeoutForSourceMs: number,
operation: () => Promise<{ listingUrl: string | null; attempts: string[] }>
): Promise<SourceDiscoveryOutcome> => {
try {
const result = await withTimeout(operation, {
operationName: `${source === "zillow" ? "Zillow" : "HAR"} discovery`,
timeoutMs: timeoutForSourceMs
});
return {
source,
url: result.listingUrl,
attempts: result.attempts
};
} catch (error) {
if (error instanceof TimeoutError) {
return {
source,
url: null,
attempts: [
`${source === "zillow" ? "Zillow" : "HAR"} discovery timed out after ${timeoutForSourceMs}ms.`
]
};
}
return {
source,
url: null,
attempts: [
`${source === "zillow" ? "Zillow" : "HAR"} discovery failed: ${error instanceof Error ? error.message : String(error)}`
]
};
}
};
const zillowPromise = runSource("zillow", zillowTimeoutMs, () =>
discoverZillowListingFn(address, { timeoutMs: zillowTimeoutMs })
);
const harPromise = runSource("har", harTimeoutMs, () =>
discoverHarListingFn(address, { timeoutMs: harTimeoutMs })
);
const [zillowResult, harResult] = await Promise.all([zillowPromise, harPromise]);
const attempts = [...zillowResult.attempts, ...harResult.attempts];
return {
attempts,
zillowUrl: zillowResult.url,
harUrl: harResult.url
};
}

View File

@@ -0,0 +1,93 @@
import { extractHarPhotos } from "../../web-automation/scripts/har-photos.js";
import { extractZillowPhotos } from "../../web-automation/scripts/zillow-photos.js";
import { withTimeout } from "./async-timeout.js";
export type PhotoSource = "zillow" | "har";
export interface PhotoExtractionResult {
source: PhotoSource;
requestedUrl: string;
finalUrl?: string;
expectedPhotoCount?: number | null;
complete?: boolean;
photoCount: number;
imageUrls: string[];
notes: string[];
}
export interface PhotoReviewResolution {
review: Record<string, unknown>;
discoveredListingUrls: Array<{ label: string; url: string }>;
}
interface PhotoReviewDeps {
timeoutMs?: number;
zillowTimeoutMs?: number;
harTimeoutMs?: number;
extractZillowPhotosFn?: typeof extractZillowPhotos;
extractHarPhotosFn?: typeof extractHarPhotos;
}
const DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_PHOTO_TIMEOUT_MS || 25_000
);
const DEFAULT_ZILLOW_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_ZILLOW_PHOTO_TIMEOUT_MS || 60_000
);
const DEFAULT_HAR_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
process.env.PROPERTY_ASSESSOR_HAR_PHOTO_TIMEOUT_MS || DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS
);
export async function extractPhotoData(
source: PhotoSource,
url: string,
deps: PhotoReviewDeps = {}
): Promise<PhotoExtractionResult> {
const timeoutMs = deps.timeoutMs ?? DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS;
const zillowTimeoutMs =
deps.zillowTimeoutMs ??
(deps.timeoutMs != null ? timeoutMs : DEFAULT_ZILLOW_PHOTO_EXTRACTION_TIMEOUT_MS);
const harTimeoutMs =
deps.harTimeoutMs ??
(deps.timeoutMs != null ? timeoutMs : DEFAULT_HAR_PHOTO_EXTRACTION_TIMEOUT_MS);
const extractZillowPhotosFn = deps.extractZillowPhotosFn || extractZillowPhotos;
const extractHarPhotosFn = deps.extractHarPhotosFn || extractHarPhotos;
if (source === "zillow") {
const payload = await withTimeout(
() => extractZillowPhotosFn(url, { timeoutMs: zillowTimeoutMs }),
{
operationName: "Zillow photo extraction",
timeoutMs: zillowTimeoutMs
}
);
return {
source,
requestedUrl: String(payload.requestedUrl || url),
finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined,
expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null,
complete: Boolean(payload.complete),
photoCount: Number(payload.photoCount || 0),
imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [],
notes: Array.isArray(payload.notes) ? payload.notes.map(String) : []
};
}
const payload = await withTimeout(
() => extractHarPhotosFn(url, { timeoutMs: harTimeoutMs }),
{
operationName: "HAR photo extraction",
timeoutMs: harTimeoutMs
}
);
return {
source,
requestedUrl: String(payload.requestedUrl || url),
finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined,
expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null,
complete: Boolean(payload.complete),
photoCount: Number(payload.photoCount || 0),
imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [],
notes: Array.isArray(payload.notes) ? payload.notes.map(String) : []
};
}

View File

@@ -0,0 +1,803 @@
export const CENSUS_GEOCODER_URL =
"https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress";
export const CENSUS_COORDINATES_URL =
"https://geocoding.geo.census.gov/geocoder/geographies/coordinates";
export const NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search";
export const TEXAS_COUNTY_DIRECTORY_URL =
"https://comptroller.texas.gov/taxes/property-tax/county-directory/";
export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
export class PublicRecordsLookupError extends Error {}
export interface PropertyDetailsResolution {
source: string;
sourceUrl: string;
propertyId: string | null;
ownerName: string | null;
situsAddress: string | null;
legalDescription: string | null;
landValue: number | null;
improvementValue: number | null;
marketValue: number | null;
assessedTotalValue: number | null;
exemptions: string[];
notes: string[];
}
export interface PublicRecordsResolution {
requestedAddress: string;
matchedAddress: string;
latitude: number | null;
longitude: number | null;
geoid: string | null;
state: {
name: string | null;
code: string | null;
fips: string | null;
};
county: {
name: string | null;
fips: string | null;
geoid: string | null;
};
officialLinks: {
censusGeocoder: string;
texasCountyDirectory: string | null;
texasPropertyTaxPortal: string | null;
};
appraisalDistrict: Record<string, unknown> | null;
taxAssessorCollector: Record<string, unknown> | null;
lookupRecommendations: string[];
sourceIdentifierHints: Record<string, string>;
propertyDetails: PropertyDetailsResolution | null;
}
interface FetchTextInit {
body?: string;
headers?: Record<string, string>;
method?: string;
}
interface FetchLike {
(url: string, init?: FetchTextInit): Promise<string>;
}
const defaultFetchText: FetchLike = async (url, init = {}) => {
const response = await fetch(url, {
body: init.body,
headers: {
"user-agent": "property-assessor/1.0",
...(init.headers || {})
},
method: init.method || "GET"
});
if (!response.ok) {
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
}
return await response.text();
};
function collapseWhitespace(value: string | null | undefined): string {
return (value || "").replace(/\s+/g, " ").trim();
}
function normalizeCountyName(value: string): string {
return collapseWhitespace(value)
.toLowerCase()
.replace(/ county\b/, "")
.replace(/[^a-z0-9]+/g, "");
}
function stripHtml(value: string): string {
let output = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "");
output = output
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10)))
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">");
output = collapseWhitespace(output.replace(/\n/g, ", "));
output = output.replace(/\s*,\s*/g, ", ").replace(/(,\s*){2,}/g, ", ");
return output.replace(/^,\s*|\s*,\s*$/g, "");
}
function buildFallbackAddressCandidates(address: string): string[] {
const normalized = collapseWhitespace(address);
if (!normalized) return [];
const candidates = [normalized];
const [streetPartRaw, ...restParts] = normalized.split(",");
const streetPart = collapseWhitespace(streetPartRaw);
const locality = restParts.map((part) => collapseWhitespace(part)).filter(Boolean).join(", ");
const strippedStreet = collapseWhitespace(
streetPart.replace(
/\s+(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+$/i,
""
).replace(/\s+#\s*[a-z0-9-]+$/i, "")
);
if (strippedStreet && strippedStreet !== streetPart) {
candidates.push(locality ? `${strippedStreet}, ${locality}` : strippedStreet);
}
return candidates;
}
function extractAnchorHref(fragment: string): string | null {
const match = fragment.match(/<a[^>]+href="([^"]+)"/i);
if (!match) return null;
const href = match[1].trim();
if (href.startsWith("//")) return `https:${href}`;
return href;
}
function normalizeUrl(rawUrl: string): string {
const value = collapseWhitespace(rawUrl);
if (!value) return value;
if (/^https?:\/\//i.test(value)) return value;
return `https://${value.replace(/^\/+/, "")}`;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10)))
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">");
}
function parseCurrencyValue(value: string | null | undefined): number | null {
const normalized = collapseWhitespace(value);
if (!normalized) return null;
const numeric = normalized.replace(/[^0-9.-]/g, "");
if (!numeric) return null;
const parsed = Number(numeric);
return Number.isFinite(parsed) ? parsed : null;
}
function parseCurrentYearFromSearchHome(searchHomeHtml: string): number {
const configuredYear = searchHomeHtml.match(/"DefaultYear"\s*:\s*(\d{4})/i);
if (configuredYear) {
return Number(configuredYear[1]);
}
return new Date().getFullYear();
}
function buildCadSearchKeywords(address: string, year: number): string {
return `${collapseWhitespace(address)} Year:${year}`.trim();
}
function formatNuecesGeographicId(parcelId: string | null | undefined): string | null {
const normalized = collapseWhitespace(parcelId).replace(/[^0-9]/g, "");
if (!normalized) return null;
if (normalized.length <= 4) return normalized;
if (normalized.length <= 8) {
return `${normalized.slice(0, 4)}-${normalized.slice(4)}`;
}
return `${normalized.slice(0, 4)}-${normalized.slice(4, 8)}-${normalized.slice(8)}`;
}
function parseAddressForCadSearch(address: string): {
rawAddress: string;
streetNumber: string | null;
streetName: string | null;
unit: string | null;
} {
const rawAddress = collapseWhitespace(address);
const streetPart = collapseWhitespace(rawAddress.split(",")[0] || rawAddress);
const unitMatch = streetPart.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i);
const unit = unitMatch ? unitMatch[1].toUpperCase() : null;
const withoutUnit = collapseWhitespace(
streetPart
.replace(/\b(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+/gi, "")
.replace(/#\s*[a-z0-9-]+/gi, "")
);
const numberMatch = withoutUnit.match(/^(\d+[a-z]?)/i);
const streetNumber = numberMatch ? numberMatch[1] : null;
const suffixes = new Set([
"rd",
"road",
"dr",
"drive",
"st",
"street",
"ave",
"avenue",
"blvd",
"boulevard",
"ct",
"court",
"cir",
"circle",
"ln",
"lane",
"trl",
"trail",
"way",
"pkwy",
"parkway",
"pl",
"place",
"ter",
"terrace",
"loop",
"hwy",
"highway"
]);
const streetTokens = withoutUnit
.replace(/^(\d+[a-z]?)\s*/i, "")
.split(/\s+/)
.filter(Boolean);
while (streetTokens.length && suffixes.has(streetTokens[streetTokens.length - 1].toLowerCase())) {
streetTokens.pop();
}
return {
rawAddress,
streetNumber,
streetName: streetTokens.length ? streetTokens.join(" ") : null,
unit
};
}
function extractSearchToken(searchHomeHtml: string): string | null {
const match = searchHomeHtml.match(/meta name="search-token" content="([^"]+)"/i);
return match ? decodeHtmlEntities(match[1]) : null;
}
function extractPropertySearchUrl(homepageHtml: string): string | null {
const preferred = homepageHtml.match(/href="(https:\/\/[^"]*esearch[^"]*)"/i);
if (preferred) {
return preferred[1];
}
const generic = homepageHtml.match(/href="([^"]+)"[^>]*>\s*(?:SEARCH NOW|Property Search)\s*</i);
return generic ? generic[1] : null;
}
function extractDetailField(detailHtml: string, label: string): string | null {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(`<div[^>]*>\\s*${escaped}\\s*<\\/div>\\s*<div[^>]*>(.*?)<\\/div>`, "is"),
new RegExp(`<strong>\\s*${escaped}\\s*:?\\s*<\\/strong>\\s*(.*?)(?:<br\\s*\\/?>|<\\/p>|<\\/div>)`, "is"),
new RegExp(`${escaped}\\s*:?\\s*<\\/[^>]+>\\s*<[^>]+>(.*?)<\\/[^>]+>`, "is")
];
for (const pattern of patterns) {
const match = detailHtml.match(pattern);
if (match) {
return stripHtml(match[1]);
}
}
return null;
}
function extractExemptions(detailHtml: string): string[] {
const raw = extractDetailField(detailHtml, "Exemptions");
if (!raw) return [];
return raw
.split(/[,;|]/)
.map((item) => collapseWhitespace(item))
.filter(Boolean);
}
function scoreAddressMatch(needle: string, haystack: string): number {
const normalizedNeedle = collapseWhitespace(needle).toLowerCase();
const normalizedHaystack = collapseWhitespace(haystack).toLowerCase();
if (!normalizedNeedle || !normalizedHaystack) return 0;
let score = 0;
const tokens = normalizedNeedle.split(/[\s,]+/).filter(Boolean);
for (const token of tokens) {
if (normalizedHaystack.includes(token)) {
score += token.length > 3 ? 2 : 1;
}
}
const unitMatch = normalizedNeedle.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i);
if (unitMatch) {
score += normalizedHaystack.includes(unitMatch[1].toLowerCase()) ? 4 : -4;
}
return score;
}
function pickBestCadResult(
address: string,
results: Array<Record<string, unknown>>
): Record<string, unknown> | null {
const scored = results
.map((result) => {
const candidateText = [
result.address,
result.legalDescription,
result.ownerName,
result.condo,
result.geoId,
result.propertyId
]
.map((item) => collapseWhitespace(String(item || "")))
.join(" ");
return { result, score: scoreAddressMatch(address, candidateText) };
})
.sort((a, b) => b.score - a.score);
return scored[0]?.score > 0 ? scored[0].result : null;
}
async function enrichNuecesCadPropertyDetails(
address: string,
appraisalDistrictWebsite: string,
parcelId: string | null | undefined,
fetchText: FetchLike
): Promise<PropertyDetailsResolution | null> {
const parsedAddress = parseAddressForCadSearch(address);
const homepageUrl = normalizeUrl(appraisalDistrictWebsite);
const homepageHtml = await fetchText(homepageUrl);
const propertySearchUrl = extractPropertySearchUrl(homepageHtml);
if (!propertySearchUrl) return null;
const normalizedPropertySearchUrl = normalizeUrl(propertySearchUrl).replace(/\/+$/, "");
const searchHomeHtml = await fetchText(`${normalizedPropertySearchUrl}/`);
const searchToken = extractSearchToken(searchHomeHtml);
if (!searchToken) return null;
const searchYear = parseCurrentYearFromSearchHome(searchHomeHtml);
const formattedGeographicId = formatNuecesGeographicId(parcelId);
const searchKeywords =
formattedGeographicId ||
(parsedAddress.streetNumber && parsedAddress.streetName
? `StreetNumber:${parsedAddress.streetNumber} StreetName:"${parsedAddress.streetName}"`
: buildCadSearchKeywords(address, searchYear));
const fetchSearchPage = async (page: number): Promise<any> => {
const searchResultsUrl = `${normalizedPropertySearchUrl}/search/SearchResults?keywords=${encodeURIComponent(searchKeywords)}`;
if (fetchText === defaultFetchText) {
const sessionTokenResponse = await fetch(
`${normalizedPropertySearchUrl}/search/requestSessionToken`,
{
headers: {
"user-agent": "property-assessor/1.0"
}
}
);
const sessionTokenPayload = await sessionTokenResponse.json();
const searchSessionToken = sessionTokenPayload?.searchSessionToken;
const resultUrl = `${normalizedPropertySearchUrl}/search/result?keywords=${encodeURIComponent(searchKeywords)}&searchSessionToken=${encodeURIComponent(String(searchSessionToken || ""))}`;
const resultResponse = await fetch(resultUrl, {
headers: {
"user-agent": "property-assessor/1.0"
}
});
const cookieHeader = (resultResponse.headers.getSetCookie?.() || [])
.map((item) => item.split(";", 1)[0])
.join("; ");
const resultPageHtml = await resultResponse.text();
const liveSearchToken = extractSearchToken(resultPageHtml) || searchToken;
const jsonResponse = await fetch(searchResultsUrl, {
body: JSON.stringify({
page,
pageSize: 25,
isArb: false,
recaptchaToken: "",
searchToken: liveSearchToken
}),
headers: {
"content-type": "application/json",
cookie: cookieHeader,
referer: resultUrl,
"user-agent": "property-assessor/1.0"
},
method: "POST"
});
return await jsonResponse.json();
}
const searchResultsRaw = await fetchText(searchResultsUrl, {
body: JSON.stringify({
page,
pageSize: 25,
isArb: false,
recaptchaToken: "",
searchToken
}),
headers: {
"content-type": "application/json"
},
method: "POST"
});
return JSON.parse(searchResultsRaw);
};
const firstPage = await fetchSearchPage(1);
const totalPages = Math.min(Number(firstPage?.totalPages || 1), 8);
const collectedResults: Array<Record<string, unknown>> = Array.isArray(firstPage?.resultsList)
? [...firstPage.resultsList]
: [];
let bestResult = pickBestCadResult(address, collectedResults);
if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) {
bestResult = null;
}
for (let page = 2; !bestResult && page <= totalPages; page += 1) {
const nextPage = await fetchSearchPage(page);
if (Array.isArray(nextPage?.resultsList)) {
collectedResults.push(...nextPage.resultsList);
bestResult = pickBestCadResult(address, collectedResults);
if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) {
bestResult = null;
}
}
}
if (!bestResult) return null;
const detailPath = collapseWhitespace(String(bestResult.detailUrl || ""));
const canUseDetailPath = Boolean(detailPath) && !/[?&]Id=/i.test(detailPath);
const detailUrl = canUseDetailPath
? new URL(detailPath, `${normalizedPropertySearchUrl}/`).toString()
: new URL(
`/property/view/${encodeURIComponent(String(bestResult.propertyId || ""))}?year=${encodeURIComponent(String(bestResult.year || searchYear))}&ownerId=${encodeURIComponent(String(bestResult.ownerId || ""))}`,
`${normalizedPropertySearchUrl}/`
).toString();
const detailHtml = await fetchText(detailUrl);
return {
source: "nueces-esearch",
sourceUrl: detailUrl,
propertyId: collapseWhitespace(String(bestResult.propertyId || "")) || null,
ownerName:
extractDetailField(detailHtml, "Owner Name") ||
collapseWhitespace(String(bestResult.ownerName || "")) ||
null,
situsAddress:
extractDetailField(detailHtml, "Situs Address") ||
extractDetailField(detailHtml, "Address") ||
collapseWhitespace(String(bestResult.address || "")) ||
null,
legalDescription:
extractDetailField(detailHtml, "Legal Description") ||
collapseWhitespace(String(bestResult.legalDescription || "")) ||
null,
landValue: parseCurrencyValue(extractDetailField(detailHtml, "Land Value")),
improvementValue: parseCurrencyValue(extractDetailField(detailHtml, "Improvement Value")),
marketValue:
parseCurrencyValue(extractDetailField(detailHtml, "Market Value")) ||
(Number.isFinite(Number(bestResult.appraisedValue))
? Number(bestResult.appraisedValue)
: parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))),
assessedTotalValue:
parseCurrencyValue(extractDetailField(detailHtml, "Assessed Value")) ||
parseCurrencyValue(extractDetailField(detailHtml, "Appraised Value")) ||
(Number.isFinite(Number(bestResult.appraisedValue))
? Number(bestResult.appraisedValue)
: parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))),
exemptions: extractExemptions(detailHtml),
notes: [
"Official CAD property detail page exposed owner, value, and exemption data."
]
};
}
async function tryEnrichPropertyDetails(
address: string,
parcelId: string | null | undefined,
appraisalDistrictWebsite: string | null,
fetchText: FetchLike
): Promise<PropertyDetailsResolution | null> {
const website = collapseWhitespace(appraisalDistrictWebsite);
if (!website) return null;
const normalizedWebsite = normalizeUrl(website).toLowerCase();
try {
if (normalizedWebsite.includes("nuecescad.net") || normalizedWebsite.includes("ncadistrict.com")) {
return await enrichNuecesCadPropertyDetails(address, website, parcelId, fetchText);
}
} catch {
return null;
}
return null;
}
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
match: any;
censusGeocoderUrl: string;
usedFallbackGeocoder: boolean;
}> {
const query = new URLSearchParams({
address,
benchmark: "Public_AR_Current",
vintage: "Current_Current",
format: "json"
});
const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`;
const payload = JSON.parse(await fetchText(url));
const matches = payload?.result?.addressMatches || [];
if (matches.length) {
return {
match: matches[0],
censusGeocoderUrl: url,
usedFallbackGeocoder: false
};
}
let fallbackMatch: any = null;
for (const candidateAddress of buildFallbackAddressCandidates(address)) {
const fallbackQuery = new URLSearchParams({
q: candidateAddress,
format: "jsonv2",
limit: "1",
countrycodes: "us",
addressdetails: "1"
});
const fallbackUrl = `${NOMINATIM_SEARCH_URL}?${fallbackQuery.toString()}`;
const fallbackPayload = JSON.parse(await fetchText(fallbackUrl));
fallbackMatch = Array.isArray(fallbackPayload) ? fallbackPayload[0] : null;
if (fallbackMatch) {
break;
}
}
if (!fallbackMatch) {
throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`);
}
const latitude = Number(fallbackMatch.lat);
const longitude = Number(fallbackMatch.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new PublicRecordsLookupError(
`Fallback geocoder returned invalid coordinates for address: ${address}`
);
}
const coordinateQuery = new URLSearchParams({
x: String(longitude),
y: String(latitude),
benchmark: "Public_AR_Current",
vintage: "Current_Current",
format: "json"
});
const coordinateUrl = `${CENSUS_COORDINATES_URL}?${coordinateQuery.toString()}`;
const coordinatePayload = JSON.parse(await fetchText(coordinateUrl));
const geographies = coordinatePayload?.result?.geographies;
if (!geographies) {
throw new PublicRecordsLookupError(
`Census coordinate geographies lookup failed for address: ${address}`
);
}
return {
match: {
matchedAddress: collapseWhitespace(fallbackMatch.display_name || address),
coordinates: {
x: longitude,
y: latitude
},
geographies
},
censusGeocoderUrl: coordinateUrl,
usedFallbackGeocoder: true
};
}
async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise<string> {
const html = await fetchText(TEXAS_COUNTY_DIRECTORY_URL);
const countyNorm = normalizeCountyName(countyName);
const matches = html.matchAll(/<a href="([^"]+\.php)">\s*\d+\s+([^<]+)\s*<\/a>/gi);
for (const match of matches) {
const href = match[1];
const label = match[2];
if (normalizeCountyName(label) === countyNorm) {
return href.startsWith("http://") || href.startsWith("https://")
? href
: `${TEXAS_COUNTY_DIRECTORY_URL}${href.replace(/^\/+/, "")}`;
}
}
throw new PublicRecordsLookupError(
`Could not find Texas county directory page for county: ${countyName}`
);
}
function parseTexasSection(sectionHtml: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lastUpdated = sectionHtml.match(
/<p class="file-info">\s*Last Updated:\s*([^<]+)<\/p>/i
);
if (lastUpdated) {
result.lastUpdated = collapseWhitespace(lastUpdated[1]);
}
const lead = sectionHtml.match(/<h4>\s*([^:<]+):\s*([^<]+)<\/h4>/i);
if (lead) {
result[lead[1].trim()] = collapseWhitespace(lead[2]);
}
const infoBlock = sectionHtml.match(/<h4>\s*[^<]+<\/h4>\s*<p>(.*?)<\/p>/is);
if (infoBlock) {
for (const match of infoBlock[1].matchAll(
/<strong>\s*([^:<]+):\s*<\/strong>\s*(.*?)(?:<br\s*\/?>|$)/gis
)) {
const key = collapseWhitespace(match[1]);
const rawValue = match[2];
const hrefValue = extractAnchorHref(rawValue);
if (key.toLowerCase() === "website" && hrefValue) {
result[key] = hrefValue;
} else if (
key.toLowerCase() === "email" &&
hrefValue &&
hrefValue.startsWith("mailto:")
) {
result[key] = hrefValue.replace(/^mailto:/i, "");
} else {
result[key] = stripHtml(rawValue);
}
}
}
const headings: Array<[string, string]> = [
["Mailing Address", "mailingAddress"],
["Street Address", "streetAddress"],
["Collecting Unit", "collectingUnit"]
];
for (const [heading, key] of headings) {
const match = sectionHtml.match(
new RegExp(`<h4>\\s*${heading}\\s*<\\/h4>\\s*<p>(.*?)<\\/p>`, "is")
);
if (match) {
result[key] = stripHtml(match[1]);
}
}
return result;
}
async function fetchTexasCountyOffices(
countyName: string,
fetchText: FetchLike
): Promise<{
directoryPage: string;
appraisalDistrict: Record<string, unknown>;
taxAssessorCollector: Record<string, unknown> | null;
}> {
const pageUrl = await findTexasCountyHref(countyName, fetchText);
const html = await fetchText(pageUrl);
const appraisalMatch = html.match(
/<h3>\s*Appraisal District\s*<\/h3>(.*?)(?=<h3>\s*Tax Assessor\/Collector\s*<\/h3>)/is
);
const taxMatch = html.match(/<h3>\s*Tax Assessor\/Collector\s*<\/h3>(.*)$/is);
if (!appraisalMatch) {
throw new PublicRecordsLookupError(
`Could not parse Appraisal District section for county: ${countyName}`
);
}
const appraisalDistrict = parseTexasSection(appraisalMatch[1]);
appraisalDistrict.directoryPage = pageUrl;
const taxAssessorCollector = taxMatch ? parseTexasSection(taxMatch[1]) : null;
if (taxAssessorCollector) {
taxAssessorCollector.directoryPage = pageUrl;
}
return {
directoryPage: pageUrl,
appraisalDistrict,
taxAssessorCollector
};
}
export async function resolvePublicRecords(
address: string,
options: {
parcelId?: string;
listingGeoId?: string;
listingSourceUrl?: string;
fetchText?: FetchLike;
} = {}
): Promise<PublicRecordsResolution> {
const fetchText = options.fetchText || defaultFetchText;
const { match, censusGeocoderUrl, usedFallbackGeocoder } = await geocodeAddress(
address,
fetchText
);
const geographies = match.geographies || {};
const state = (geographies.States || [{}])[0];
const county = (geographies.Counties || [{}])[0];
const block = (geographies["2020 Census Blocks"] || [{}])[0];
const coordinates = match.coordinates || {};
let texasCountyDirectory: string | null = null;
let texasPropertyTaxPortal: string | null = null;
let appraisalDistrict: Record<string, unknown> | null = null;
let taxAssessorCollector: Record<string, unknown> | null = null;
let propertyDetails: PropertyDetailsResolution | null = null;
const lookupRecommendations = [
"Start from the official public-record jurisdiction instead of a listing-site geo ID.",
"Try official address search first on the appraisal district site.",
"If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs."
];
if (usedFallbackGeocoder) {
lookupRecommendations.push(
"The Census address lookup missed this address, so a fallback geocoder was used to obtain coordinates before resolving official Census geographies."
);
}
if (state.STUSAB === "TX" && county.NAME) {
const offices = await fetchTexasCountyOffices(county.NAME, fetchText);
texasCountyDirectory = offices.directoryPage;
texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL;
appraisalDistrict = offices.appraisalDistrict;
taxAssessorCollector = offices.taxAssessorCollector;
propertyDetails = await tryEnrichPropertyDetails(
address,
options.parcelId,
typeof offices.appraisalDistrict?.Website === "string"
? offices.appraisalDistrict.Website
: null,
fetchText
);
lookupRecommendations.push(
"Use the Texas Comptroller county directory page as the official jurisdiction link in the final report.",
"Attempt to retrieve assessed value, land value, improvement value, exemptions, and account number from the CAD website when a direct property page is publicly accessible."
);
if (propertyDetails) {
lookupRecommendations.push(
...propertyDetails.notes,
"Use the official CAD property-detail values in the final assessment instead of relying only on listing-site value hints."
);
}
}
const sourceIdentifierHints: Record<string, string> = {};
if (options.parcelId) sourceIdentifierHints.parcelId = options.parcelId;
if (options.listingGeoId) {
sourceIdentifierHints.listingGeoId = options.listingGeoId;
lookupRecommendations.push(
"Treat listing geo IDs as regional hints only; do not use them as assessor record keys."
);
}
if (options.listingSourceUrl) {
sourceIdentifierHints.listingSourceUrl = options.listingSourceUrl;
}
return {
requestedAddress: address,
matchedAddress: match.matchedAddress || address,
latitude: coordinates.y ?? null,
longitude: coordinates.x ?? null,
geoid: block.GEOID || null,
state: {
name: state.NAME || null,
code: state.STUSAB || null,
fips: state.STATE || null
},
county: {
name: county.NAME || null,
fips: county.COUNTY || null,
geoid: county.GEOID || null
},
officialLinks: {
censusGeocoder: censusGeocoderUrl,
texasCountyDirectory,
texasPropertyTaxPortal
},
appraisalDistrict,
taxAssessorCollector,
lookupRecommendations,
sourceIdentifierHints,
propertyDetails
};
}

View File

@@ -0,0 +1,360 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
export class ReportValidationError extends Error {}
export interface ReportPayload {
recipientEmails?: string[] | string;
assessmentPurpose?: string;
reportTitle?: string;
subtitle?: string;
generatedAt?: string;
preparedBy?: string;
reportNotes?: string;
subjectProperty?: Record<string, unknown>;
verdict?: Record<string, unknown>;
snapshot?: unknown;
whatILike?: unknown;
whatIDontLike?: unknown;
compView?: unknown;
carryView?: unknown;
risksAndDiligence?: unknown;
photoReview?: Record<string, unknown>;
publicRecords?: Record<string, unknown>;
sourceLinks?: unknown;
}
export function isDecisionGradeReportPayload(payload: ReportPayload): boolean {
const decision = String(payload.verdict?.decision || "").trim().toLowerCase();
const fairValueRange = String(payload.verdict?.fairValueRange || "").trim().toLowerCase();
const photoReviewStatus = String(payload.photoReview?.status || "").trim().toLowerCase();
if (!decision || decision === "pending") return false;
if (!fairValueRange || fairValueRange === "not established") return false;
if (photoReviewStatus !== "completed") return false;
return true;
}
function asStringArray(value: unknown): string[] {
if (value == null) return [];
if (typeof value === "string") {
const text = value.trim();
return text ? [text] : [];
}
if (typeof value === "number" || typeof value === "boolean") {
return [String(value)];
}
if (Array.isArray(value)) {
const out: string[] = [];
for (const item of value) {
out.push(...asStringArray(item));
}
return out;
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item != null && item !== "")
.map(([key, item]) => `${key}: ${item}`);
}
return [String(value)];
}
function currency(value: unknown): string {
if (value == null || value === "") return "N/A";
const num = Number(value);
if (Number.isFinite(num)) return `$${num.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
return String(value);
}
export function validateReportPayload(payload: ReportPayload): string[] {
const recipients = asStringArray(payload.recipientEmails).map((item) => item.trim()).filter(Boolean);
if (!recipients.length) {
throw new ReportValidationError(
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF."
);
}
const address = payload.subjectProperty && typeof payload.subjectProperty.address === "string"
? payload.subjectProperty.address.trim()
: "";
if (!address) {
throw new ReportValidationError("The report payload must include subjectProperty.address.");
}
if (!isDecisionGradeReportPayload(payload)) {
throw new ReportValidationError(
"The report payload is still preliminary. Stop and complete the decision-grade analysis, including subject-unit photo review, before generating or sending the property assessment PDF."
);
}
return recipients;
}
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
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("#123B5D");
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, value: unknown, fallback = "Not provided."): void {
const items = asStringArray(value);
if (!items.length) {
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(fallback);
doc.moveDown(0.6);
return;
}
for (const item of items) {
const startY = doc.y;
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10.5)
.text(item, 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);
}
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 = 150;
const valueWidth = totalWidth - keyWidth;
for (const [key, value] of rows) {
const startY = doc.y;
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
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 drawVerdictPanel(doc: PDFKit.PDFDocument, verdict: Record<string, unknown> | undefined): void {
const decision = String(verdict?.decision || "pending").trim().toLowerCase();
const badgeColor =
decision === "buy" ? "#1E6B52" : decision === "pass" ? "#8B2E2E" : "#7A5D12";
const left = doc.page.margins.left;
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const top = doc.y;
const bodyText = String(
verdict?.offerGuidance || "Offer guidance not provided."
);
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20 }) + 16;
doc.save();
doc.roundedRect(left, top, width, 26, 4).fill(badgeColor);
doc
.fillColor("white")
.font("Helvetica-Bold")
.fontSize(12)
.text(String(verdict?.decision || "N/A").toUpperCase(), left + 10, top + 7, {
width: width - 20
});
doc
.roundedRect(left, top + 26, width, bodyHeight, 4)
.fillAndStroke("#F4F6F8", "#C7D0D9");
doc
.fillColor("#1E2329")
.font("Helvetica")
.fontSize(10.5)
.text(bodyText, left + 10, top + 36, {
width: width - 20,
lineGap: 2
});
doc.restore();
doc.y = top + 26 + bodyHeight + 10;
}
function drawLinks(doc: PDFKit.PDFDocument, value: unknown): void {
const items = Array.isArray(value) ? value : [];
if (!items.length) {
drawBulletList(doc, [], "Not provided.");
return;
}
for (const item of items as Array<Record<string, unknown>>) {
const label = typeof item.label === "string" ? item.label : "Link";
const url = typeof item.url === "string" ? item.url : "";
const line = url ? `${label}: ${url}` : label;
const startY = doc.y;
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5);
if (url) {
doc.text(line, doc.page.margins.left + 14, startY, {
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
lineGap: 2,
link: url,
underline: true
});
} else {
doc.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);
}
export async function renderReportPdf(
payload: ReportPayload,
outputPath: string
): Promise<string> {
const recipients = validateReportPayload(payload);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const doc = new PDFDocument({
size: "LETTER",
margin: 50,
info: {
Title: payload.reportTitle || "Property Assessment Report",
Author: String(payload.preparedBy || "OpenClaw property-assessor")
}
});
const stream = fs.createWriteStream(outputPath);
doc.pipe(stream);
const generatedAt =
payload.generatedAt ||
new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short"
});
const subject = payload.subjectProperty || {};
const verdict = payload.verdict || {};
const publicRecords = payload.publicRecords || {};
doc.fillColor("#123B5D").font("Helvetica-Bold").fontSize(22).text(payload.reportTitle || "Property Assessment Report");
doc.moveDown(0.2);
doc.fillColor("#1E2329").font("Helvetica").fontSize(11).text(
String(
payload.subtitle ||
"Decision-grade acquisition review with listing, public-record, comp, and risk analysis."
)
);
doc.moveDown(0.4);
doc.fillColor("#5A6570").font("Helvetica").fontSize(9);
doc.text(`Prepared for: ${recipients.join(", ")}`);
doc.text(`Generated: ${generatedAt}`);
doc.moveDown(0.8);
drawVerdictPanel(doc, verdict);
drawKeyValueTable(doc, [
["Address", String(subject.address || "N/A")],
["Assessment Purpose", String(payload.assessmentPurpose || "N/A")],
["Ask / Last Price", currency(subject.listingPrice)],
["Type", String(subject.propertyType || "N/A")],
["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`],
["Sqft", String(subject.squareFeet ?? "N/A")],
["Year Built", String(subject.yearBuilt ?? "N/A")],
["Verdict", String(verdict.decision || "N/A")],
["Fair Value Range", String(verdict.fairValueRange || "N/A")],
["Public-Record Jurisdiction", String(publicRecords.jurisdiction || "N/A")],
["Assessed Total", currency(publicRecords.assessedTotalValue)]
]);
const sections: Array<[string, unknown, "list" | "links"]> = [
["Snapshot", payload.snapshot, "list"],
["What I Like", payload.whatILike, "list"],
["What I Do Not Like", payload.whatIDontLike, "list"],
["Comp View", payload.compView, "list"],
["Underwriting / Carry View", payload.carryView, "list"],
["Risks and Diligence Items", payload.risksAndDiligence, "list"],
[
"Photo Review",
[
...(asStringArray((payload.photoReview || {}).status ? [`Photo review: ${String((payload.photoReview || {}).status)}${(payload.photoReview || {}).source ? ` via ${String((payload.photoReview || {}).source)}` : ""}`] : [])),
...asStringArray((payload.photoReview || {}).attempts),
...asStringArray((payload.photoReview || {}).summary ? [`Condition read: ${String((payload.photoReview || {}).summary)}`] : [])
],
"list"
],
[
"Public Records",
[
...asStringArray({
Jurisdiction: publicRecords.jurisdiction,
"Account Number": publicRecords.accountNumber,
"Owner Name": publicRecords.ownerName,
"Land Value": publicRecords.landValue != null ? currency(publicRecords.landValue) : undefined,
"Improvement Value":
publicRecords.improvementValue != null ? currency(publicRecords.improvementValue) : undefined,
"Assessed Total":
publicRecords.assessedTotalValue != null ? currency(publicRecords.assessedTotalValue) : undefined,
Exemptions: publicRecords.exemptions
}),
...asStringArray((publicRecords.links || []).map((item: any) => `${item.label}: ${item.url}`))
],
"list"
],
["Source Links", payload.sourceLinks, "links"]
];
for (const [title, content, kind] of sections) {
if (doc.y > 660) doc.addPage();
drawSectionHeader(doc, title);
if (kind === "links") {
drawLinks(doc, content);
} else {
drawBulletList(doc, content);
}
}
doc.addPage();
drawSectionHeader(doc, "Report Notes");
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(
String(
payload.reportNotes ||
"This report uses the property-assessor fixed PDF template. Listing data should be reconciled against official public records when available, and public-record links should be included in any delivered report."
),
{
lineGap: 3
}
);
doc.end();
await new Promise<void>((resolve, reject) => {
stream.on("finish", () => resolve());
stream.on("error", reject);
});
return outputPath;
}
export async function loadReportPayload(inputPath: string): Promise<ReportPayload> {
return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload;
}

View File

@@ -0,0 +1,343 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { assessProperty } from "../src/assessment.js";
import type { PublicRecordsResolution } from "../src/public-records.js";
const samplePublicRecords: PublicRecordsResolution = {
requestedAddress: "4141 Whiteley Dr, Corpus Christi, TX 78418",
matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
latitude: 27.6138,
longitude: -97.3024,
geoid: "483550031013005",
state: {
name: "Texas",
code: "TX",
fips: "48"
},
county: {
name: "Nueces County",
fips: "355",
geoid: "48355"
},
officialLinks: {
censusGeocoder: "https://geocoding.geo.census.gov/example",
texasCountyDirectory:
"https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php",
texasPropertyTaxPortal: "https://texas.gov/PropertyTaxes"
},
appraisalDistrict: {
"Chief Appraiser": "Debra Morin, Interim",
Website: "http://www.ncadistrict.com/",
directoryPage:
"https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php"
},
taxAssessorCollector: {
"Tax Assessor-Collector": "Kevin Kieschnick",
Website: "http://www.nuecesco.com"
},
lookupRecommendations: [
"Start from the official public-record jurisdiction instead of a listing-site geo ID."
],
sourceIdentifierHints: {
parcelId: "14069438"
},
propertyDetails: {
source: "nueces-esearch",
sourceUrl: "https://esearch.nuecescad.net/property/view/14069438?year=2026",
propertyId: "14069438",
ownerName: "Fiorini Family Trust",
situsAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION",
landValue: 42000,
improvementValue: 99000,
assessedTotalValue: 141000,
exemptions: ["Homestead"],
notes: [
"Official CAD property detail page exposed owner, value, and exemption data."
]
}
};
test("assessProperty asks for assessment purpose before building a decision-grade assessment", async () => {
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418"
}
);
assert.equal(result.ok, true);
assert.equal(result.needsAssessmentPurpose, true);
assert.equal(result.needsRecipientEmails, false);
assert.equal(result.outputPath, null);
assert.match(result.message, /assessment purpose/i);
assert.equal(result.reportPayload, null);
});
test("assessProperty auto-discovers listing sources, runs Zillow photos first, and does not ask for email during analysis-only runs", async () => {
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
assessmentPurpose: "investment property",
listingGeoId: "233290",
parcelId: "14069438"
},
{
resolvePublicRecordsFn: async () => samplePublicRecords,
discoverListingSourcesFn: async () => ({
attempts: [
"Zillow discovery located a property page from the address.",
"HAR discovery located a property page from the address."
],
zillowUrl:
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
harUrl:
"https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
}),
extractPhotoDataFn: async (source, url) => ({
source,
requestedUrl: url,
finalUrl: url,
expectedPhotoCount: 29,
complete: true,
photoCount: 29,
imageUrls: ["https://photos.example/1.jpg", "https://photos.example/2.jpg"],
notes: [`${source} extractor succeeded.`]
})
}
);
assert.equal(result.ok, true);
assert.equal(result.needsAssessmentPurpose, false);
assert.equal(result.needsRecipientEmails, false);
assert.equal(result.outputPath, null);
assert.doesNotMatch(result.message, /target email/i);
assert.match(result.message, /ready to render|recipient email is only needed when you want the pdf/i);
assert.equal(result.reportPayload?.subjectProperty?.address, samplePublicRecords.matchedAddress);
assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District");
assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438");
assert.equal(result.reportPayload?.publicRecords?.ownerName, "Fiorini Family Trust");
assert.equal(result.reportPayload?.publicRecords?.landValue, 42000);
assert.equal(result.reportPayload?.publicRecords?.improvementValue, 99000);
assert.equal(result.reportPayload?.publicRecords?.assessedTotalValue, 141000);
assert.deepEqual(result.reportPayload?.publicRecords?.exemptions, ["Homestead"]);
assert.match(
String(result.reportPayload?.snapshot?.join(" ")),
/141,000|141000|assessed/i
);
assert.match(
String(result.reportPayload?.risksAndDiligence?.join(" ")),
/official cad property detail page exposed owner, value, and exemption data/i
);
assert.equal(result.reportPayload?.photoReview?.status, "completed");
assert.equal(result.reportPayload?.photoReview?.source, "zillow");
assert.equal(result.reportPayload?.photoReview?.photoCount, 29);
assert.match(
String(result.reportPayload?.verdict?.offerGuidance),
/investment property/i
);
assert.match(
String(result.reportPayload?.carryView?.[0]),
/income property/i
);
assert.deepEqual(result.reportPayload?.recipientEmails, []);
});
test("assessProperty uses parcel/APN hints extracted from Zillow before CAD lookup when parcel ID was not provided", async () => {
const seenPublicRecordOptions: Array<Record<string, unknown>> = [];
const result = await assessProperty(
{
address: "6702 Everhart Rd APT T106, Corpus Christi, TX 78413",
assessmentPurpose: "college housing for daughter attending TAMU-CC"
},
{
resolvePublicRecordsFn: async (_address, options) => {
seenPublicRecordOptions.push({ ...options });
return samplePublicRecords;
},
discoverListingSourcesFn: async () => ({
attempts: ["Zillow discovery located a property page from the address."],
zillowUrl:
"https://www.zillow.com/homedetails/6702-Everhart-Rd-APT-T106-Corpus-Christi-TX-78413/2067445642_zpid/",
harUrl: null
}),
extractZillowIdentifierHintsFn: async () => ({
parcelId: "1234567890",
notes: ["Zillow listing exposed parcel/APN number 1234567890."]
}),
extractPhotoDataFn: async (source, url) => ({
source,
requestedUrl: url,
finalUrl: url,
expectedPhotoCount: 29,
complete: true,
photoCount: 29,
imageUrls: ["https://photos.example/1.jpg"],
notes: [`${source} extractor succeeded.`]
})
}
);
assert.equal(result.ok, true);
assert.equal(seenPublicRecordOptions.length, 1);
assert.equal(seenPublicRecordOptions[0]?.parcelId, "1234567890");
});
test("assessProperty asks for recipient email only when PDF render is explicitly requested", async () => {
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
assessmentPurpose: "investment property",
output: path.join(os.tmpdir(), `property-assess-missing-email-${Date.now()}.pdf`)
},
{
resolvePublicRecordsFn: async () => samplePublicRecords,
discoverListingSourcesFn: async () => ({
attempts: ["Zillow discovery located a property page from the address."],
zillowUrl:
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
harUrl: null
}),
extractPhotoDataFn: async (source, url) => ({
source,
requestedUrl: url,
finalUrl: url,
expectedPhotoCount: 29,
complete: true,
photoCount: 29,
imageUrls: ["https://photos.example/1.jpg"],
notes: [`${source} extractor succeeded.`]
})
}
);
assert.equal(result.ok, true);
assert.equal(result.needsRecipientEmails, true);
assert.equal(result.outputPath, null);
assert.match(result.message, /target email/i);
});
test("assessProperty falls back to HAR when Zillow photo extraction fails", async () => {
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
assessmentPurpose: "vacation home"
},
{
resolvePublicRecordsFn: async () => samplePublicRecords,
discoverListingSourcesFn: async () => ({
attempts: ["Address-based discovery found Zillow and HAR candidates."],
zillowUrl:
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
harUrl:
"https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
}),
extractPhotoDataFn: async (source, url) => {
if (source === "zillow") {
throw new Error(`zillow failed for ${url}`);
}
return {
source,
requestedUrl: url,
finalUrl: url,
expectedPhotoCount: 29,
complete: true,
photoCount: 29,
imageUrls: ["https://photos.har.example/1.jpg"],
notes: ["HAR extractor succeeded after Zillow failed."]
};
}
}
);
assert.equal(result.ok, true);
assert.equal(result.reportPayload?.photoReview?.status, "completed");
assert.equal(result.reportPayload?.photoReview?.source, "har");
assert.match(
String(result.reportPayload?.photoReview?.attempts?.join(" ")),
/zillow/i
);
assert.match(
String(result.reportPayload?.verdict?.offerGuidance),
/vacation home/i
);
});
test("assessProperty does not render a PDF from a preliminary helper payload even when recipient email is present", async () => {
const outputPath = path.join(os.tmpdir(), `property-assess-command-${Date.now()}.pdf`);
let renderCalls = 0;
const result = await assessProperty(
{
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
assessmentPurpose: "rental for my daughter in college",
recipientEmails: ["buyer@example.com"],
output: outputPath
},
{
resolvePublicRecordsFn: async () => samplePublicRecords,
discoverListingSourcesFn: async () => ({
attempts: ["No listing sources discovered from the address."],
zillowUrl: null,
harUrl: null
}),
renderReportPdfFn: async () => {
renderCalls += 1;
return outputPath;
}
}
);
assert.equal(result.ok, true);
assert.equal(result.needsAssessmentPurpose, false);
assert.equal(result.needsRecipientEmails, false);
assert.equal(renderCalls, 0);
assert.equal(result.pdfReady, false);
assert.equal(result.outputPath, null);
assert.match(result.message, /preliminary|decision-grade|cannot render/i);
});
test("assessProperty prioritizes student housing guidance over investment fallback keywords", async () => {
const result = await assessProperty(
{
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
assessmentPurpose:
"college housing for daughter attending TAMU-CC; prioritize proximity, safety/livability, and resale/rental fallback"
},
{
resolvePublicRecordsFn: async () => samplePublicRecords,
discoverListingSourcesFn: async () => ({
attempts: ["Zillow discovery located a property page from the address."],
zillowUrl:
"https://www.zillow.com/homedetails/1011-Ennis-Joslin-Rd-APT-235-Corpus-Christi-TX-78412/28848927_zpid/",
harUrl: null
}),
extractPhotoDataFn: async (source, url) => ({
source,
requestedUrl: url,
finalUrl: url,
expectedPhotoCount: 20,
complete: true,
photoCount: 20,
imageUrls: ["https://photos.example/1.jpg"],
notes: [`${source} extractor succeeded.`]
})
}
);
assert.match(
String(result.reportPayload?.verdict?.offerGuidance),
/daughter|student|practicality|safety/i
);
assert.doesNotMatch(
String(result.reportPayload?.verdict?.offerGuidance),
/income property|investment property/i
);
assert.match(
String(result.reportPayload?.carryView?.[0]),
/parent-risk|upkeep burden|renting/i
);
});

View File

@@ -0,0 +1,380 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolvePublicRecords } from "../src/public-records.js";
const geocoderPayload = {
result: {
addressMatches: [
{
matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
coordinates: { x: -97.30174, y: 27.613668 },
geographies: {
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
}
}
]
}
};
const countyIndexHtml = `
<ul>
<li><a href="nueces.php">178 Nueces</a></li>
</ul>
`;
const countyPageHtml = `
<div class="medium-6 small-12 columns">
<h3>Appraisal District</h3>
<p class="file-info">Last Updated: 08/13/2025</p>
<h4>Chief Appraiser: Debra Morin, Interim</h4>
<p>
<strong>Phone:</strong> <a href="tel:361-881-9978">361-881-9978</a><br />
<strong>Email:</strong> <a href="mailto:info@nuecescad.net">info@nuecescad.net</a><br />
<strong>Website:</strong> <a href="http://www.ncadistrict.com/">www.ncadistrict.com</a>
</p>
<h4>Mailing Address</h4>
<p>201 N. Chaparral St.<br />Corpus Christi, TX 78401-2503</p>
</div>
<div class="medium-6 small-12 columns">
<h3>Tax Assessor/Collector</h3>
<p class="file-info">Last Updated: 02/18/2025</p>
<h4>Tax Assessor-Collector: Kevin Kieschnick</h4>
<p>
<strong>Phone:</strong> <a href="tel:361-888-0307">361-888-0307</a><br />
<strong>Email:</strong> <a href="mailto:nueces.tax@nuecesco.com">nueces.tax@nuecesco.com</a><br />
<strong>Website:</strong> <a href="http://www.nuecesco.com">www.nuecesco.com</a>
</p>
<h4>Street Address</h4>
<p>901 Leopard St., Room 301<br />Corpus Christi, Texas 78401-3602</p>
</div>
`;
const fakeFetchText = async (url: string): Promise<string> => {
if (url.includes("geocoding.geo.census.gov")) {
return JSON.stringify(geocoderPayload);
}
if (url.endsWith("/county-directory/")) {
return countyIndexHtml;
}
if (url.endsWith("/county-directory/nueces.php")) {
return countyPageHtml;
}
throw new Error(`Unexpected URL: ${url}`);
};
test("resolvePublicRecords uses Census and Texas county directory", async () => {
const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", {
parcelId: "14069438",
listingGeoId: "233290",
listingSourceUrl: "https://www.zillow.com/homedetails/example",
fetchText: fakeFetchText
});
assert.equal(payload.county.name, "Nueces County");
assert.equal(payload.state.code, "TX");
assert.equal(payload.appraisalDistrict?.Website, "http://www.ncadistrict.com/");
assert.equal(payload.taxAssessorCollector?.Email, "nueces.tax@nuecesco.com");
assert.equal(payload.sourceIdentifierHints.parcelId, "14069438");
assert.match(payload.lookupRecommendations.join(" "), /listing geo IDs as regional hints only/i);
});
test("resolvePublicRecords falls back to coordinate geocoding when Census address lookup misses", async () => {
const coordinatePayload = {
result: {
geographies: {
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
}
}
};
const fallbackFetchText = async (url: string): Promise<string> => {
if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) {
return JSON.stringify({ result: { addressMatches: [] } });
}
if (url.includes("nominatim.openstreetmap.org/search")) {
return JSON.stringify([
{
lat: "27.708000",
lon: "-97.360000",
display_name: "1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412"
}
]);
}
if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) {
return JSON.stringify(coordinatePayload);
}
if (url.endsWith("/county-directory/")) {
return countyIndexHtml;
}
if (url.endsWith("/county-directory/nueces.php")) {
return countyPageHtml;
}
throw new Error(`Unexpected URL: ${url}`);
};
const payload = await resolvePublicRecords(
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
{
fetchText: fallbackFetchText
}
);
assert.equal(
payload.matchedAddress,
"1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412"
);
assert.equal(payload.county.name, "Nueces County");
assert.equal(payload.state.code, "TX");
assert.equal(payload.latitude, 27.708);
assert.equal(payload.longitude, -97.36);
assert.match(
payload.lookupRecommendations.join(" "),
/fallback geocoder/i
);
});
test("resolvePublicRecords retries fallback geocoding without the unit suffix", async () => {
const seenFallbackQueries: string[] = [];
const coordinatePayload = {
result: {
geographies: {
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
}
}
};
const retryingFetchText = async (url: string): Promise<string> => {
if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) {
return JSON.stringify({ result: { addressMatches: [] } });
}
if (url.includes("nominatim.openstreetmap.org/search")) {
const query = new URL(url).searchParams.get("q") || "";
seenFallbackQueries.push(query);
if (query.includes("APT 235")) {
return "[]";
}
if (query === "1011 Ennis Joslin Rd, Corpus Christi, TX 78412") {
return JSON.stringify([
{
lat: "27.6999080",
lon: "-97.3338107",
display_name: "Ennis Joslin Road, Corpus Christi, Nueces County, Texas, 78412, United States"
}
]);
}
}
if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) {
return JSON.stringify(coordinatePayload);
}
if (url.endsWith("/county-directory/")) {
return countyIndexHtml;
}
if (url.endsWith("/county-directory/nueces.php")) {
return countyPageHtml;
}
throw new Error(`Unexpected URL: ${url}`);
};
const payload = await resolvePublicRecords(
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
{
fetchText: retryingFetchText
}
);
assert.deepEqual(seenFallbackQueries, [
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
"1011 Ennis Joslin Rd, Corpus Christi, TX 78412"
]);
assert.equal(payload.county.name, "Nueces County");
assert.equal(payload.state.code, "TX");
});
test("resolvePublicRecords enriches official CAD property facts when a supported CAD detail source is available", async () => {
const fetchedUrls: string[] = [];
const enrichedFetchText = async (url: string): Promise<string> => {
fetchedUrls.push(url);
if (url.includes("geocoding.geo.census.gov")) {
return JSON.stringify(geocoderPayload);
}
if (url.endsWith("/county-directory/")) {
return countyIndexHtml;
}
if (url.endsWith("/county-directory/nueces.php")) {
return countyPageHtml.replace(
"http://www.ncadistrict.com/",
"https://nuecescad.net/"
);
}
if (url === "https://nuecescad.net/") {
return `
<html>
<body>
<a href="https://esearch.nuecescad.net/">Property Search</a>
</body>
</html>
`;
}
if (url === "https://esearch.nuecescad.net/") {
return `
<html>
<head>
<meta name="search-token" content="token-value|2026-03-28T00:00:00Z" />
</head>
<body>
Property Search
</body>
</html>
`;
}
if (url.includes("/search/SearchResults?")) {
return JSON.stringify({
success: true,
resultsList: [
{
propertyId: "14069438",
ownerName: "Fiorini Family Trust",
ownerId: "998877",
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION",
appraisedValueDisplay: "$141,000",
detailUrl: "/property/view/14069438?year=2026"
}
]
});
}
if (url === "https://esearch.nuecescad.net/property/view/14069438?year=2026") {
return `
<html>
<body>
<div class="property-summary">
<div>Owner Name</div><div>Fiorini Family Trust</div>
<div>Account Number</div><div>14069438</div>
<div>Situs Address</div><div>4141 Whiteley Dr, Corpus Christi, TX 78418</div>
<div>Legal Description</div><div>LOT 4 BLOCK 3 EXAMPLE SUBDIVISION</div>
<div>Land Value</div><div>$42,000</div>
<div>Improvement Value</div><div>$99,000</div>
<div>Market Value</div><div>$141,000</div>
<div>Assessed Value</div><div>$141,000</div>
<div>Exemptions</div><div>Homestead</div>
</div>
</body>
</html>
`;
}
throw new Error(`Unexpected URL: ${url}`);
};
const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", {
fetchText: enrichedFetchText
});
assert.equal(payload.propertyDetails?.propertyId, "14069438");
assert.equal(payload.propertyDetails?.ownerName, "Fiorini Family Trust");
assert.equal(payload.propertyDetails?.landValue, 42000);
assert.equal(payload.propertyDetails?.improvementValue, 99000);
assert.equal(payload.propertyDetails?.assessedTotalValue, 141000);
assert.deepEqual(payload.propertyDetails?.exemptions, ["Homestead"]);
assert.match(
payload.lookupRecommendations.join(" "),
/official cad property detail/i
);
assert.ok(
fetchedUrls.some((url) => url.includes("esearch.nuecescad.net/property/view/14069438"))
);
});
test("resolvePublicRecords uses formatted Nueces Geographic ID search when a parcel ID is available", async () => {
const fetchedUrls: string[] = [];
const enrichedFetchText = async (url: string): Promise<string> => {
fetchedUrls.push(url);
if (url.includes("geocoding.geo.census.gov")) {
return JSON.stringify(geocoderPayload);
}
if (url.endsWith("/county-directory/")) {
return countyIndexHtml;
}
if (url.endsWith("/county-directory/nueces.php")) {
return countyPageHtml.replace(
"http://www.ncadistrict.com/",
"https://nuecescad.net/"
);
}
if (url === "https://nuecescad.net/") {
return `
<html>
<body>
<a href="https://esearch.nuecescad.net/">Property Search</a>
</body>
</html>
`;
}
if (url === "https://esearch.nuecescad.net/") {
return `
<html>
<head>
<meta name="search-token" content="token-value|2026-03-28T00:00:00Z" />
</head>
<body>
Property Search
</body>
</html>
`;
}
if (url.includes("/search/SearchResults?")) {
assert.match(url, /keywords=1234-5678-9012/);
return JSON.stringify({
success: true,
resultsList: [
{
propertyId: "200016970",
ownerName: "NGUYEN TRANG THUY",
ownerId: "677681",
address: "6702 Everhart Rd Apt T106, Corpus Christi, TX 78413",
legalDescription: "UNIT T106 EXAMPLE CONDO",
appraisedValueDisplay: "$128,876",
detailUrl: "/property/view/200016970?year=2026"
}
]
});
}
if (url === "https://esearch.nuecescad.net/property/view/200016970?year=2026") {
return `
<html>
<body>
<div class="property-summary">
<div>Owner Name</div><div>NGUYEN TRANG THUY</div>
<div>Account Number</div><div>200016970</div>
<div>Situs Address</div><div>6702 Everhart Rd Apt T106, Corpus Christi, TX 78413</div>
<div>Legal Description</div><div>UNIT T106 EXAMPLE CONDO</div>
<div>Land Value</div><div>$20,000</div>
<div>Improvement Value</div><div>$108,876</div>
<div>Market Value</div><div>$128,876</div>
<div>Assessed Value</div><div>$128,876</div>
</div>
</body>
</html>
`;
}
throw new Error(`Unexpected URL: ${url}`);
};
const payload = await resolvePublicRecords("6702 Everhart Rd APT T106, Corpus Christi, TX 78413", {
parcelId: "123456789012",
fetchText: enrichedFetchText
});
assert.equal(payload.propertyDetails?.propertyId, "200016970");
assert.ok(
fetchedUrls.some((url) => url.includes("keywords=1234-5678-9012"))
);
});

View File

@@ -0,0 +1,109 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { ReportValidationError, renderReportPdf } from "../src/report-pdf.js";
const samplePayload = {
recipientEmails: ["buyer@example.com"],
assessmentPurpose: "investment property",
reportTitle: "Property Assessment Report",
subjectProperty: {
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
listingPrice: 149900,
propertyType: "Townhouse",
beds: 2,
baths: 2,
squareFeet: 900,
yearBuilt: 1978
},
verdict: {
decision: "only below x",
fairValueRange: "$132,000 - $138,000",
offerGuidance:
"Only attractive below ask after HOA, insurance, and medium make-ready assumptions are priced in."
},
snapshot: ["2 bed / 2 bath coastal townhouse in Flour Bluff."],
whatILike: ["Compact layout with usable bedroom count for the size."],
whatIDontLike: ["Thin margin at the current ask."],
compView: ["Need same-building or local comp confirmation."],
carryView: ["Underwrite taxes, HOA, wind/flood exposure, and maintenance together."],
risksAndDiligence: ["Confirm reserve strength and special assessment history."],
photoReview: {
status: "completed",
source: "Zillow",
attempts: ["Zillow all-photo extractor returned the full 29-photo set."],
summary: "Interior reads dated-to-average rather than turnkey."
},
publicRecords: {
jurisdiction: "Nueces Appraisal District",
assessedTotalValue: 141000,
links: [{ label: "Nueces CAD", url: "http://www.ncadistrict.com/" }]
},
sourceLinks: [
{ label: "Zillow Listing", url: "https://www.zillow.com/homedetails/example" }
]
};
test("renderReportPdf writes a non-empty PDF", async () => {
const outputPath = path.join(os.tmpdir(), `property-assessor-${Date.now()}.pdf`);
await renderReportPdf(samplePayload, outputPath);
const stat = await fs.promises.stat(outputPath);
assert.ok(stat.size > 1000);
});
test("renderReportPdf requires recipient email", async () => {
const outputPath = path.join(os.tmpdir(), `property-assessor-missing-email-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderReportPdf(
{
...samplePayload,
recipientEmails: []
},
outputPath
),
ReportValidationError
);
});
test("renderReportPdf rejects a preliminary report with pending verdict", async () => {
const outputPath = path.join(os.tmpdir(), `property-assessor-preliminary-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderReportPdf(
{
...samplePayload,
verdict: {
decision: "pending",
fairValueRange: "Not established",
offerGuidance: "Still needs comps and decision-grade analysis."
}
},
outputPath
),
/decision-grade|preliminary|pending/i
);
});
test("renderReportPdf rejects a report when subject-unit photo review is not completed", async () => {
const outputPath = path.join(os.tmpdir(), `property-assessor-missing-photos-${Date.now()}.pdf`);
await assert.rejects(
() =>
renderReportPdf(
{
...samplePayload,
photoReview: {
status: "not completed",
source: "accessible listing-photo source not reliably exposed for unit 235",
attempts: ["Zillow and HAR photo review did not complete."],
summary: "Condition review is incomplete."
}
},
outputPath
),
/photo review|decision-grade|incomplete/i
);
});

View File

@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import { discoverListingSources } from "../src/listing-discovery.js";
import { extractPhotoData } from "../src/photo-review.js";
test("discoverListingSources times out stalled Zillow and HAR discovery calls", async () => {
const result = await discoverListingSources(
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
{
timeoutMs: 20,
discoverZillowListingFn: async () => await new Promise(() => {}),
discoverHarListingFn: async () => await new Promise(() => {})
}
);
assert.equal(result.zillowUrl, null);
assert.equal(result.harUrl, null);
assert.match(result.attempts.join(" "), /zillow discovery timed out/i);
assert.match(result.attempts.join(" "), /har discovery timed out/i);
});
test("discoverListingSources starts Zillow and HAR discovery in parallel", async () => {
let zillowStarted = false;
let harStarted = false;
const discoveryPromise = discoverListingSources("1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", {
timeoutMs: 100,
discoverZillowListingFn: async () => {
zillowStarted = true;
await new Promise((resolve) => setTimeout(resolve, 50));
return {
source: "zillow",
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
searchUrl: "https://www.zillow.com/example-search",
finalUrl: "https://www.zillow.com/example-search",
title: "Example Zillow Search",
listingUrl: null,
attempts: ["Zillow did not find a confident match."]
};
},
discoverHarListingFn: async () => {
harStarted = true;
return {
source: "har",
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
searchUrl: "https://www.har.com/example-search",
finalUrl: "https://www.har.com/example-search",
title: "Example HAR Search",
listingUrl: "https://www.har.com/homedetail/example/123",
attempts: ["HAR found a matching listing quickly."]
};
}
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(zillowStarted, true);
assert.equal(harStarted, true);
const result = await discoveryPromise;
assert.equal(result.harUrl, "https://www.har.com/homedetail/example/123");
});
test("extractPhotoData honors a longer Zillow timeout override", async () => {
const result = await extractPhotoData("zillow", "https://www.zillow.com/example", {
timeoutMs: 20,
zillowTimeoutMs: 80,
extractZillowPhotosFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 40));
return {
source: "zillow",
requestedUrl: "https://www.zillow.com/example",
finalUrl: "https://www.zillow.com/example",
expectedPhotoCount: 1,
complete: true,
photoCount: 1,
imageUrls: ["https://photos.example/1.jpg"],
notes: ["Zillow extractor succeeded after a slow page load."]
};
}
});
assert.equal(result.source, "zillow");
assert.equal(result.photoCount, 1);
});
test("extractPhotoData times out a stalled photo extraction instead of hanging forever", async () => {
await assert.rejects(
async () =>
extractPhotoData("zillow", "https://www.zillow.com/example", {
timeoutMs: 20,
extractZillowPhotosFn: async () => await new Promise(() => {})
}),
/timed out/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"]
}

86
skills/us-cpa/README.md Normal file
View File

@@ -0,0 +1,86 @@
# us-cpa package
Standalone Python CLI package for the `us-cpa` skill.
## Install
From `skills/us-cpa/`:
```bash
python3 -m ensurepip --upgrade
python3 -m pip install --upgrade pip setuptools wheel
python3 -m pip install -e '.[dev]'
```
## OpenClaw installation
Install the skill into the OpenClaw workspace copy, not only in the repo checkout.
1. Sync the skill into the workspace:
```bash
rsync -a --delete --exclude '.venv' \
~/.openclaw/workspace/projects/stef-openclaw-skills/skills/us-cpa/ \
~/.openclaw/workspace/skills/us-cpa/
```
2. Create a skill-local virtualenv in the workspace copy:
```bash
cd ~/.openclaw/workspace/skills/us-cpa
python3 -m venv .venv
. .venv/bin/activate
python3 -m ensurepip --upgrade
python3 -m pip install --upgrade pip setuptools wheel
python3 -m pip install -e '.[dev]'
```
3. Run the workspace wrapper:
```bash
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
```
The wrapper now prefers `~/.openclaw/workspace/skills/us-cpa/.venv/bin/python` when present and falls back to `python3` otherwise.
Keep the `--exclude '.venv'` flag on future syncs, otherwise the workspace virtualenv will be deleted by `rsync --delete`.
## Run
Installed entry point:
```bash
us-cpa --help
```
Repo-local wrapper without installation:
```bash
scripts/us-cpa --help
```
OpenClaw workspace wrapper:
```bash
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
```
Module execution:
```bash
python3 -m us_cpa.cli --help
```
## Tests
From `skills/us-cpa/`:
```bash
PYTHONPATH=src python3 -m unittest
```
Or with the dev extra installed:
```bash
python -m unittest
```

77
skills/us-cpa/SKILL.md Normal file
View File

@@ -0,0 +1,77 @@
---
name: us-cpa
description: Use when answering U.S. federal individual tax questions, preparing a federal Form 1040 return package, or reviewing a draft/completed federal individual return.
---
# US CPA
`us-cpa` is a Python-first federal individual tax workflow skill. The CLI is the canonical engine. Use the skill to classify the request, gather missing inputs, and invoke the CLI.
## Modes
- `question`
- one-off federal tax question
- case folder optional
- `prepare`
- new or existing return-preparation case
- case folder required
- `review`
- new or existing return-review case
- case folder required
## Agent Workflow
1. Determine whether the request is:
- question-only
- a new preparation/review case
- work on an existing case
2. If the request is `prepare` or `review` and no case folder is supplied:
- ask whether to create a new case
- ask where to store it
3. Use the bundled CLI:
```bash
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025 --style memo --format markdown
skills/us-cpa/scripts/us-cpa prepare --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa export-efile-ready --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --style memo --format markdown
skills/us-cpa/scripts/us-cpa extract-docs --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --create-case --case-label "Jane Doe" --facts-json ./facts.json
```
4. For `question` mode, do not mechanically repeat the CLI fallback text.
- If the CLI returns `analysis.excerpts` with `primaryLawRequired: false`, answer the user directly from those IRS excerpts in your own words.
- Cite the specific IRS authorities returned by the CLI.
- Only tell the user the question needs deeper legal research when the CLI returns `primaryLawRequired: true` and no relevant IRS excerpts were found.
When OpenClaw is using the installed workspace copy, the entrypoint is:
```bash
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
```
## Rules
- federal individual returns only in v1
- IRS materials first; escalate to primary law only when needed
- stop on conflicting facts and ask the user to resolve the issue before continuing
- official IRS PDFs are the target compiled-form artifacts
- deterministic field-fill is the preferred render path when the official PDF exposes usable fields
- overlay-rendered forms are the fallback and must be flagged for human review
## Output
- JSON by default
- markdown output available with `--format markdown`
- `question` supports `--style conversation|memo`
- `fetch-year` downloads the bootstrap IRS form/instruction corpus into `~/.cache/us-cpa` by default
- override the cache root with `US_CPA_CACHE_DIR` when you need an isolated run or fixture generation
- `extract-docs` creates or opens a case, registers documents, stores facts, extracts machine-usable facts from JSON/text/PDF sources where possible, and stops with a structured issue if facts conflict
- `question` now searches the downloaded IRS corpus for relevant authorities and excerpts before escalating to primary-law research
- rendered form artifacts prefer fillable-field output when possible and otherwise fall back to overlay output
- `prepare` computes the current supported federal 1040 package, preserves fact provenance in the normalized return, and writes normalized return/artifact/report files into the case directory
- `export-efile-ready` writes a draft transmission-ready payload without transmitting anything
- `review` recomputes the return from case facts, checks artifacts, flags source-fact mismatches and likely omissions, and returns findings-first output in conversation or memo style
For operator details, limitations, and the planned case structure, see `docs/us-cpa.md`.

View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "us-cpa"
version = "0.1.0"
description = "US federal individual tax workflow CLI for questions, preparation, and review."
requires-python = ">=3.9"
dependencies = [
"pypdf>=5.0.0",
"reportlab>=4.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
]
[project.scripts]
us-cpa = "us_cpa.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]

13
skills/us-cpa/scripts/us-cpa Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PYTHON_BIN="${SKILL_DIR}/.venv/bin/python"
export PYTHONPATH="${SKILL_DIR}/src${PYTHONPATH:+:${PYTHONPATH}}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
PYTHON_BIN="python3"
fi
exec "${PYTHON_BIN}" -m us_cpa.cli "$@"

View File

@@ -0,0 +1,2 @@
"""us-cpa package."""

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import hashlib
import json
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from us_cpa.document_extractors import extract_document_facts
CASE_SUBDIRECTORIES = (
"input",
"extracted",
"return",
"output",
"reports",
"issues",
"sources",
)
def _timestamp() -> str:
return datetime.now(timezone.utc).isoformat()
def _sha256_path(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
class CaseConflictError(Exception):
def __init__(self, issue: dict[str, Any]) -> None:
super().__init__(issue["message"])
self.issue = issue
@dataclass
class CaseManager:
case_dir: Path
def __post_init__(self) -> None:
self.case_dir = self.case_dir.expanduser().resolve()
@property
def manifest_path(self) -> Path:
return self.case_dir / "case-manifest.json"
@property
def facts_path(self) -> Path:
return self.case_dir / "extracted" / "facts.json"
@property
def issues_path(self) -> Path:
return self.case_dir / "issues" / "open-issues.json"
def create_case(self, *, case_label: str, tax_year: int) -> dict[str, Any]:
self.case_dir.mkdir(parents=True, exist_ok=True)
for name in CASE_SUBDIRECTORIES:
(self.case_dir / name).mkdir(exist_ok=True)
manifest = {
"caseLabel": case_label,
"taxYear": tax_year,
"createdAt": _timestamp(),
"updatedAt": _timestamp(),
"status": "open",
"documents": [],
}
self.manifest_path.write_text(json.dumps(manifest, indent=2))
if not self.facts_path.exists():
self.facts_path.write_text(json.dumps({"facts": {}}, indent=2))
if not self.issues_path.exists():
self.issues_path.write_text(json.dumps({"issues": []}, indent=2))
return manifest
def load_manifest(self) -> dict[str, Any]:
return json.loads(self.manifest_path.read_text())
def _load_facts(self) -> dict[str, Any]:
return json.loads(self.facts_path.read_text())
def _write_manifest(self, manifest: dict[str, Any]) -> None:
manifest["updatedAt"] = _timestamp()
self.manifest_path.write_text(json.dumps(manifest, indent=2))
def _write_facts(self, facts: dict[str, Any]) -> None:
self.facts_path.write_text(json.dumps(facts, indent=2))
def _write_issue(self, issue: dict[str, Any]) -> None:
current = json.loads(self.issues_path.read_text())
current["issues"].append(issue)
self.issues_path.write_text(json.dumps(current, indent=2))
def _record_fact(
self,
facts_payload: dict[str, Any],
*,
field: str,
value: Any,
source_type: str,
source_name: str,
tax_year: int,
) -> None:
existing = facts_payload["facts"].get(field)
if existing and existing["value"] != value:
issue = {
"status": "needs_resolution",
"issueType": "fact_conflict",
"field": field,
"existingValue": existing["value"],
"newValue": value,
"message": f"Conflicting values for {field}. Resolve before continuing.",
"createdAt": _timestamp(),
"taxYear": tax_year,
}
self._write_issue(issue)
raise CaseConflictError(issue)
captured_at = _timestamp()
source_entry = {
"sourceType": source_type,
"sourceName": source_name,
"capturedAt": captured_at,
}
if existing:
existing["sources"].append(source_entry)
return
facts_payload["facts"][field] = {
"value": value,
"sourceType": source_type,
"capturedAt": captured_at,
"sources": [source_entry],
}
def intake(
self,
*,
tax_year: int,
user_facts: dict[str, Any],
document_paths: list[Path],
) -> dict[str, Any]:
manifest = self.load_manifest()
if manifest["taxYear"] != tax_year:
raise ValueError(
f"Case tax year {manifest['taxYear']} does not match requested tax year {tax_year}."
)
registered_documents = []
for source_path in document_paths:
source_path = source_path.expanduser().resolve()
destination = self.case_dir / "input" / source_path.name
shutil.copy2(source_path, destination)
document_entry = {
"name": source_path.name,
"sourcePath": str(source_path),
"storedPath": str(destination),
"sha256": _sha256_path(destination),
"registeredAt": _timestamp(),
}
manifest["documents"].append(document_entry)
registered_documents.append(document_entry)
facts_payload = self._load_facts()
for document_entry in registered_documents:
extracted = extract_document_facts(Path(document_entry["storedPath"]))
document_entry["extractedFacts"] = extracted
for field, value in extracted.items():
self._record_fact(
facts_payload,
field=field,
value=value,
source_type="document_extract",
source_name=document_entry["name"],
tax_year=tax_year,
)
for field, value in user_facts.items():
self._record_fact(
facts_payload,
field=field,
value=value,
source_type="user_statement",
source_name="interactive-intake",
tax_year=tax_year,
)
self._write_manifest(manifest)
self._write_facts(facts_payload)
return {
"status": "accepted",
"caseDir": str(self.case_dir),
"taxYear": tax_year,
"registeredDocuments": registered_documents,
"factCount": len(facts_payload["facts"]),
}

View File

@@ -0,0 +1,257 @@
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from us_cpa.cases import CaseConflictError, CaseManager
from us_cpa.prepare import EfileExporter, PrepareEngine, render_case_forms
from us_cpa.questions import QuestionEngine, render_analysis, render_memo
from us_cpa.review import ReviewEngine, render_review_memo, render_review_summary
from us_cpa.sources import TaxYearCorpus, bootstrap_irs_catalog
COMMANDS = (
"question",
"prepare",
"review",
"fetch-year",
"extract-docs",
"render-forms",
"export-efile-ready",
)
def _add_common_arguments(
parser: argparse.ArgumentParser, *, include_tax_year: bool = True
) -> None:
if include_tax_year:
parser.add_argument("--tax-year", type=int, default=None)
parser.add_argument("--case-dir", default=None)
parser.add_argument("--format", choices=("json", "markdown"), default="json")
def _emit(payload: dict[str, Any], output_format: str) -> int:
if output_format == "markdown":
lines = [f"# {payload['command']}"]
for key, value in payload.items():
if key == "command":
continue
lines.append(f"- **{key}**: {value}")
print("\n".join(lines))
else:
print(json.dumps(payload, indent=2))
return 0
def _require_case_dir(args: argparse.Namespace) -> Path:
if not args.case_dir:
raise SystemExit("A case directory is required for this command.")
return Path(args.case_dir).expanduser().resolve()
def _load_json_file(path_value: str | None) -> dict[str, Any]:
if not path_value:
return {}
return json.loads(Path(path_value).expanduser().resolve().read_text())
def _ensure_question_corpus(corpus: TaxYearCorpus, tax_year: int) -> None:
paths = corpus.paths_for_year(tax_year)
required_slugs = {item.slug for item in bootstrap_irs_catalog(tax_year)}
if not paths.manifest_path.exists():
corpus.download_catalog(tax_year, bootstrap_irs_catalog(tax_year))
return
manifest = json.loads(paths.manifest_path.read_text())
existing_slugs = {item["slug"] for item in manifest.get("sources", [])}
if not required_slugs.issubset(existing_slugs):
corpus.download_catalog(tax_year, bootstrap_irs_catalog(tax_year))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="us-cpa",
description="US federal individual tax workflow CLI.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
question = subparsers.add_parser("question", help="Answer a tax question.")
_add_common_arguments(question)
question.add_argument("--question", required=True)
question.add_argument("--style", choices=("conversation", "memo"), default="conversation")
prepare = subparsers.add_parser("prepare", help="Prepare a return case.")
_add_common_arguments(prepare)
review = subparsers.add_parser("review", help="Review a return case.")
_add_common_arguments(review)
review.add_argument("--style", choices=("conversation", "memo"), default="conversation")
fetch_year = subparsers.add_parser(
"fetch-year", help="Fetch tax-year forms and instructions."
)
_add_common_arguments(fetch_year, include_tax_year=False)
fetch_year.add_argument("--tax-year", type=int, required=True)
extract_docs = subparsers.add_parser(
"extract-docs", help="Extract facts from case documents."
)
_add_common_arguments(extract_docs)
extract_docs.add_argument("--create-case", action="store_true")
extract_docs.add_argument("--case-label")
extract_docs.add_argument("--facts-json")
extract_docs.add_argument("--input-file", action="append", default=[])
render_forms = subparsers.add_parser(
"render-forms", help="Render compiled IRS forms."
)
_add_common_arguments(render_forms)
export_efile = subparsers.add_parser(
"export-efile-ready", help="Export an e-file-ready payload."
)
_add_common_arguments(export_efile)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "question":
corpus = TaxYearCorpus()
_ensure_question_corpus(corpus, args.tax_year)
engine = QuestionEngine(corpus=corpus)
case_facts: dict[str, Any] = {}
if args.case_dir:
manager = CaseManager(Path(args.case_dir))
if manager.facts_path.exists():
case_facts = {
key: value["value"]
for key, value in json.loads(manager.facts_path.read_text())["facts"].items()
}
analysis = engine.answer(
question=args.question,
tax_year=args.tax_year,
case_facts=case_facts,
)
payload = {
"command": "question",
"format": args.format,
"style": args.style,
"taxYear": args.tax_year,
"caseDir": args.case_dir,
"question": args.question,
"status": "answered",
"analysis": analysis,
}
payload["rendered"] = (
render_memo(analysis) if args.style == "memo" else render_analysis(analysis)
)
if args.format == "markdown":
print(payload["rendered"])
return 0
return _emit(payload, args.format)
if args.command == "extract-docs":
case_dir = _require_case_dir(args)
manager = CaseManager(case_dir)
if args.create_case:
if not args.case_label:
raise SystemExit("--case-label is required when --create-case is used.")
manager.create_case(case_label=args.case_label, tax_year=args.tax_year)
elif not manager.manifest_path.exists():
raise SystemExit("Case manifest not found. Use --create-case for a new case.")
try:
result = manager.intake(
tax_year=args.tax_year,
user_facts=_load_json_file(args.facts_json),
document_paths=[
Path(path_value).expanduser().resolve() for path_value in args.input_file
],
)
except CaseConflictError as exc:
print(json.dumps(exc.issue, indent=2))
return 1
payload = {
"command": args.command,
"format": args.format,
**result,
}
return _emit(payload, args.format)
if args.command == "prepare":
case_dir = _require_case_dir(args)
payload = {
"command": args.command,
"format": args.format,
**PrepareEngine().prepare_case(case_dir),
}
return _emit(payload, args.format)
if args.command == "render-forms":
case_dir = _require_case_dir(args)
manager = CaseManager(case_dir)
manifest = manager.load_manifest()
normalized = json.loads((case_dir / "return" / "normalized-return.json").read_text())
artifacts = render_case_forms(case_dir, TaxYearCorpus(), normalized)
payload = {
"command": "render-forms",
"format": args.format,
"taxYear": manifest["taxYear"],
"status": "rendered",
**artifacts,
}
return _emit(payload, args.format)
if args.command == "export-efile-ready":
case_dir = _require_case_dir(args)
payload = {
"command": "export-efile-ready",
"format": args.format,
**EfileExporter().export_case(case_dir),
}
return _emit(payload, args.format)
if args.command == "review":
case_dir = _require_case_dir(args)
review_payload = ReviewEngine().review_case(case_dir)
payload = {
"command": "review",
"format": args.format,
"style": args.style,
**review_payload,
}
payload["rendered"] = (
render_review_memo(review_payload)
if args.style == "memo"
else render_review_summary(review_payload)
)
if args.format == "markdown":
print(payload["rendered"])
return 0
return _emit(payload, args.format)
if args.command == "fetch-year":
corpus = TaxYearCorpus()
manifest = corpus.download_catalog(args.tax_year, bootstrap_irs_catalog(args.tax_year))
payload = {
"command": "fetch-year",
"format": args.format,
"taxYear": args.tax_year,
"status": "downloaded",
"sourceCount": manifest["sourceCount"],
"manifestPath": corpus.paths_for_year(args.tax_year).manifest_path.as_posix(),
}
return _emit(payload, args.format)
parser.error(f"Unsupported command: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from pypdf import PdfReader
_NUMBER = r"(-?\d+(?:,\d{3})*(?:\.\d+)?)"
def _parse_number(raw: str) -> float:
return float(raw.replace(",", ""))
def _extract_text(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in {".txt", ".md"}:
return path.read_text()
if suffix == ".pdf":
reader = PdfReader(str(path))
return "\n".join((page.extract_text() or "") for page in reader.pages)
return ""
def _facts_from_text(text: str) -> dict[str, Any]:
extracted: dict[str, Any] = {}
if match := re.search(r"Employee:\s*(.+)", text):
extracted["taxpayer.fullName"] = match.group(1).strip()
if match := re.search(r"Recipient:\s*(.+)", text):
extracted.setdefault("taxpayer.fullName", match.group(1).strip())
if match := re.search(r"Box 1 Wages, tips, other compensation\s+" + _NUMBER, text, re.I):
extracted["wages"] = _parse_number(match.group(1))
if match := re.search(r"Box 2 Federal income tax withheld\s+" + _NUMBER, text, re.I):
extracted["federalWithholding"] = _parse_number(match.group(1))
if match := re.search(r"Box 16 State wages, tips, etc\.\s+" + _NUMBER, text, re.I):
extracted["stateWages"] = _parse_number(match.group(1))
if match := re.search(r"Box 17 State income tax\s+" + _NUMBER, text, re.I):
extracted["stateWithholding"] = _parse_number(match.group(1))
if match := re.search(r"Box 3 Social security wages\s+" + _NUMBER, text, re.I):
extracted["socialSecurityWages"] = _parse_number(match.group(1))
if match := re.search(r"Box 5 Medicare wages and tips\s+" + _NUMBER, text, re.I):
extracted["medicareWages"] = _parse_number(match.group(1))
if match := re.search(r"Box 1 Interest Income\s+" + _NUMBER, text, re.I):
extracted["taxableInterest"] = _parse_number(match.group(1))
if match := re.search(r"Box 1a Total ordinary dividends\s+" + _NUMBER, text, re.I):
extracted["ordinaryDividends"] = _parse_number(match.group(1))
if match := re.search(r"Box 1 Gross distribution\s+" + _NUMBER, text, re.I):
extracted["retirementDistribution"] = _parse_number(match.group(1))
if match := re.search(r"Box 3 Other income\s+" + _NUMBER, text, re.I):
extracted["otherIncome"] = _parse_number(match.group(1))
if match := re.search(r"Net profit(?: or loss)?\s+" + _NUMBER, text, re.I):
extracted["businessIncome"] = _parse_number(match.group(1))
if match := re.search(r"Adjusted gross income\s+" + _NUMBER, text, re.I):
extracted["priorYear.adjustedGrossIncome"] = _parse_number(match.group(1))
if match := re.search(r"Taxable income\s+" + _NUMBER, text, re.I):
extracted["priorYear.taxableIncome"] = _parse_number(match.group(1))
if match := re.search(r"Refund\s+" + _NUMBER, text, re.I):
extracted["priorYear.refund"] = _parse_number(match.group(1))
return extracted
def extract_document_facts(path: Path) -> dict[str, Any]:
suffix = path.suffix.lower()
if suffix == ".json":
payload = json.loads(path.read_text())
if isinstance(payload, dict):
return payload
return {}
return _facts_from_text(_extract_text(path))

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from us_cpa.cases import CaseManager
from us_cpa.renderers import render_case_forms
from us_cpa.returns import normalize_case_facts
from us_cpa.sources import TaxYearCorpus
def _load_case_facts(case_dir: Path) -> dict[str, Any]:
facts_path = case_dir / "extracted" / "facts.json"
payload = json.loads(facts_path.read_text())
facts = {key: value["value"] for key, value in payload["facts"].items()}
facts["_factMetadata"] = {
key: {"sources": value.get("sources", [])} for key, value in payload["facts"].items()
}
return facts
class PrepareEngine:
def __init__(self, *, corpus: TaxYearCorpus | None = None) -> None:
self.corpus = corpus or TaxYearCorpus()
def prepare_case(self, case_dir: Path) -> dict[str, Any]:
manager = CaseManager(case_dir)
manifest = manager.load_manifest()
facts = _load_case_facts(manager.case_dir)
normalized = normalize_case_facts(facts, manifest["taxYear"])
normalized_path = manager.case_dir / "return" / "normalized-return.json"
normalized_path.write_text(json.dumps(normalized, indent=2))
artifacts = render_case_forms(manager.case_dir, self.corpus, normalized)
unresolved_issues = json.loads(manager.issues_path.read_text())["issues"]
summary = {
"requiredForms": normalized["requiredForms"],
"reviewRequiredArtifacts": [
artifact["formCode"] for artifact in artifacts["artifacts"] if artifact["reviewRequired"]
],
"refund": normalized["totals"]["refund"],
"balanceDue": normalized["totals"]["balanceDue"],
"unresolvedIssueCount": len(unresolved_issues),
}
result = {
"status": "prepared",
"caseDir": str(manager.case_dir),
"taxYear": manifest["taxYear"],
"normalizedReturnPath": str(normalized_path),
"artifactManifestPath": str(manager.case_dir / "output" / "artifacts.json"),
"summary": summary,
}
(manager.case_dir / "reports" / "prepare-summary.json").write_text(json.dumps(result, indent=2))
return result
class EfileExporter:
def export_case(self, case_dir: Path) -> dict[str, Any]:
case_dir = Path(case_dir).expanduser().resolve()
normalized = json.loads((case_dir / "return" / "normalized-return.json").read_text())
artifacts = json.loads((case_dir / "output" / "artifacts.json").read_text())
issues = json.loads((case_dir / "issues" / "open-issues.json").read_text())["issues"]
payload = {
"status": "draft" if issues or any(a["reviewRequired"] for a in artifacts["artifacts"]) else "ready",
"taxYear": normalized["taxYear"],
"returnSummary": {
"requiredForms": normalized["requiredForms"],
"refund": normalized["totals"]["refund"],
"balanceDue": normalized["totals"]["balanceDue"],
},
"attachments": artifacts["artifacts"],
"unresolvedIssues": issues,
}
output_path = case_dir / "output" / "efile-ready.json"
output_path.write_text(json.dumps(payload, indent=2))
return payload

View File

@@ -0,0 +1,448 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pypdf import PdfReader
from us_cpa.sources import TaxYearCorpus, build_primary_law_authorities
TOPIC_RULES = [
{
"issue": "standard_deduction",
"keywords": ("standard deduction",),
"authority_slugs": ("i1040gi",),
"answer_by_status": {
"single": "$15,750",
"married_filing_jointly": "$31,500",
"qualifying_surviving_spouse": "$31,500",
"head_of_household": "$23,625",
},
"summary_template": "{filing_status_label} filers use a {answer} standard deduction for tax year {tax_year}.",
"confidence": "high",
},
{
"issue": "schedule_c_required",
"keywords": ("schedule c", "sole proprietor", "self-employment"),
"authority_slugs": ("f1040sc", "i1040sc"),
"answer": "Schedule C is generally required when a taxpayer reports sole proprietorship business income or expenses.",
"summary": "Business income and expenses from a sole proprietorship generally belong on Schedule C.",
"confidence": "medium",
},
{
"issue": "schedule_d_required",
"keywords": ("schedule d", "capital gains"),
"authority_slugs": ("f1040sd", "i1040sd", "f8949", "i8949"),
"answer": "Schedule D is generally required when a taxpayer reports capital gains or losses, often alongside Form 8949.",
"summary": "Capital gains and losses generally flow through Schedule D, with Form 8949 supporting detail when required.",
"confidence": "medium",
},
{
"issue": "schedule_e_required",
"keywords": ("schedule e", "rental income"),
"authority_slugs": ("f1040se", "i1040se"),
"answer": "Schedule E is generally required when a taxpayer reports rental real-estate income or expenses.",
"summary": "Rental income and expenses generally belong on Schedule E.",
"confidence": "medium",
},
]
RISK_BY_CONFIDENCE = {
"high": "low",
"medium": "medium",
"low": "high",
}
QUESTION_STOPWORDS = {
"a",
"also",
"am",
"an",
"and",
"are",
"as",
"at",
"be",
"before",
"but",
"by",
"can",
"considered",
"did",
"do",
"does",
"for",
"from",
"had",
"has",
"have",
"her",
"hers",
"his",
"i",
"if",
"in",
"is",
"it",
"its",
"my",
"of",
"or",
"our",
"she",
"should",
"that",
"the",
"their",
"them",
"they",
"this",
"to",
"was",
"we",
"went",
"what",
"worked",
"would",
"year",
"you",
"your",
}
SEARCH_SOURCE_BONUS = {
"irs_publication": 30,
"irs_instructions": 20,
"irs_faq": 10,
"irs_form": 0,
}
def _normalize_question(question: str) -> str:
return question.strip().lower()
def _filing_status_label(status: str) -> str:
return status.replace("_", " ").title()
def _question_terms(normalized_question: str) -> list[str]:
terms = []
for token in re.findall(r"[a-z0-9]+", normalized_question):
if len(token) < 3 or token in QUESTION_STOPWORDS or token.isdigit():
continue
terms.append(token)
expanded = set(terms)
if any(token in expanded for token in {"dependent", "dependents", "daughter", "son", "child", "children"}):
expanded.update({"dependent", "qualifying", "child", "support", "residency"})
if any(token in expanded for token in {"college", "school", "student", "tuition"}):
expanded.update({"student", "school", "education", "temporary", "absence"})
return sorted(expanded)
def _load_searchable_pages(path: Path) -> list[str]:
payload = path.read_bytes()
if payload.startswith(b"%PDF"):
try:
reader = PdfReader(path)
pages = []
for page in reader.pages:
text = page.extract_text() or ""
if text.strip():
pages.append(text)
if pages:
return pages
except Exception:
pass
try:
decoded = payload.decode("utf-8", errors="ignore")
except Exception:
return []
return [decoded] if decoded.strip() else []
def _build_excerpt(text: str, terms: list[str], *, width: int = 420) -> str:
lowered = text.lower()
first_index = None
for term in terms:
idx = lowered.find(term)
if idx >= 0 and (first_index is None or idx < first_index):
first_index = idx
if first_index is None:
cleaned = " ".join(text.split())
return cleaned[:width]
start = max(0, first_index - 120)
end = min(len(text), first_index + width)
cleaned = " ".join(text[start:end].split())
return cleaned
def _rank_research_hits(manifest: dict[str, Any], normalized_question: str) -> list[dict[str, Any]]:
terms = _question_terms(normalized_question)
if not terms:
return []
hits: list[dict[str, Any]] = []
for source in manifest["sources"]:
path = Path(source["localPath"])
if not path.exists():
continue
pages = _load_searchable_pages(path)
for page_number, text in enumerate(pages, start=1):
lowered = text.lower()
matched_terms = [term for term in terms if term in lowered]
if not matched_terms:
continue
score = (
len(matched_terms) * 10
+ SEARCH_SOURCE_BONUS.get(source["sourceClass"], 0)
- int(source["authorityRank"])
)
hits.append(
{
"slug": source["slug"],
"title": source["title"],
"sourceClass": source["sourceClass"],
"url": source["url"],
"localPath": source["localPath"],
"authorityRank": source["authorityRank"],
"page": page_number,
"score": score,
"matchedTerms": matched_terms,
"excerpt": _build_excerpt(text, matched_terms),
}
)
hits.sort(key=lambda item: (-item["score"], item["authorityRank"], item["slug"], item["page"]))
return hits[:5]
FILING_STATUS_PATTERNS = (
(("qualifying surviving spouse",), "qualifying_surviving_spouse"),
(("qualifying widow",), "qualifying_surviving_spouse"),
(("qualifying widower",), "qualifying_surviving_spouse"),
(("surviving spouse",), "qualifying_surviving_spouse"),
(("married filing jointly",), "married_filing_jointly"),
(("mfj",), "married_filing_jointly"),
(("head of household",), "head_of_household"),
(("hoh",), "head_of_household"),
(("married filing separately",), "married_filing_separately"),
(("mfs",), "married_filing_separately"),
(("single",), "single"),
)
def _infer_filing_status(normalized_question: str, case_facts: dict[str, Any]) -> str:
if "filingStatus" in case_facts:
return case_facts["filingStatus"]
for patterns, filing_status in FILING_STATUS_PATTERNS:
if all(pattern in normalized_question for pattern in patterns):
return filing_status
return "single"
@dataclass
class QuestionEngine:
corpus: TaxYearCorpus
def _manifest(self, tax_year: int) -> dict[str, Any]:
path = self.corpus.paths_for_year(tax_year).manifest_path
if not path.exists():
raise FileNotFoundError(
f"Tax year {tax_year} corpus not found at {path}. Run fetch-year first."
)
return json.loads(path.read_text())
def _authorities_for(self, manifest: dict[str, Any], slugs: tuple[str, ...]) -> list[dict[str, Any]]:
found = []
sources = {item["slug"]: item for item in manifest["sources"]}
for slug in slugs:
if slug in sources:
source = sources[slug]
found.append(
{
"slug": source["slug"],
"title": source["title"],
"sourceClass": source["sourceClass"],
"url": source["url"],
"localPath": source["localPath"],
"authorityRank": source["authorityRank"],
}
)
return found
def answer(self, *, question: str, tax_year: int, case_facts: dict[str, Any]) -> dict[str, Any]:
manifest = self._manifest(tax_year)
normalized = _normalize_question(question)
facts_used = [{"field": key, "value": value} for key, value in sorted(case_facts.items())]
for rule in TOPIC_RULES:
if all(keyword in normalized for keyword in rule["keywords"]):
authorities = self._authorities_for(manifest, rule["authority_slugs"])
if rule["issue"] == "standard_deduction":
filing_status = _infer_filing_status(normalized, case_facts)
answer = rule["answer_by_status"].get(filing_status, rule["answer_by_status"]["single"])
summary = rule["summary_template"].format(
filing_status_label=_filing_status_label(filing_status),
answer=answer,
tax_year=tax_year,
)
else:
answer = rule["answer"]
summary = rule["summary"]
return {
"issue": rule["issue"],
"taxYear": tax_year,
"factsUsed": facts_used,
"missingFacts": [],
"authorities": authorities,
"conclusion": {"answer": answer, "summary": summary},
"confidence": rule["confidence"],
"riskLevel": RISK_BY_CONFIDENCE[rule["confidence"]],
"followUpQuestions": [],
"primaryLawRequired": False,
"excerpts": [],
}
research_hits = _rank_research_hits(manifest, normalized)
if research_hits:
authorities = []
seen = set()
for hit in research_hits:
if hit["slug"] in seen:
continue
authorities.append(
{
"slug": hit["slug"],
"title": hit["title"],
"sourceClass": hit["sourceClass"],
"url": hit["url"],
"localPath": hit["localPath"],
"authorityRank": hit["authorityRank"],
}
)
seen.add(hit["slug"])
return {
"issue": "irs_corpus_research",
"taxYear": tax_year,
"factsUsed": facts_used,
"missingFacts": [],
"authorities": authorities,
"excerpts": [
{
"slug": hit["slug"],
"title": hit["title"],
"page": hit["page"],
"matchedTerms": hit["matchedTerms"],
"excerpt": hit["excerpt"],
}
for hit in research_hits
],
"conclusion": {
"answer": "Relevant IRS authorities were found in the downloaded tax-year corpus. Answer from those authorities directly, and only escalate further if the cited passages are still insufficient.",
"summary": "Relevant IRS materials in the cached tax-year corpus address this question. Use the cited passages below to answer it directly.",
},
"confidence": "medium",
"riskLevel": "medium",
"followUpQuestions": [],
"primaryLawRequired": False,
}
return {
"issue": "requires_primary_law_escalation",
"taxYear": tax_year,
"factsUsed": facts_used,
"missingFacts": [
"Internal Revenue Code or Treasury regulation analysis is required before answering this question confidently."
],
"authorities": build_primary_law_authorities(question),
"conclusion": {
"answer": "Insufficient IRS-form and instruction support for a confident answer.",
"summary": "This question needs primary-law analysis before a reliable answer can be given.",
},
"confidence": "low",
"riskLevel": "high",
"followUpQuestions": [
"What facts drive the section-level issue?",
"Is there an existing return position or drafted treatment to review?",
],
"primaryLawRequired": True,
"excerpts": [],
}
def render_analysis(analysis: dict[str, Any]) -> str:
lines = [analysis["conclusion"]["summary"]]
lines.append(
f"Confidence: {analysis['confidence']}. Risk: {analysis['riskLevel']}."
)
if analysis["factsUsed"]:
facts = ", ".join(f"{item['field']}={item['value']}" for item in analysis["factsUsed"])
lines.append(f"Facts used: {facts}.")
if analysis["authorities"]:
titles = "; ".join(item["title"] for item in analysis["authorities"])
lines.append(f"Authorities: {titles}.")
if analysis.get("excerpts"):
excerpt_lines = []
for item in analysis["excerpts"][:3]:
excerpt_lines.append(f"{item['title']} p.{item['page']}: {item['excerpt']}")
lines.append(f"Excerpts: {' | '.join(excerpt_lines)}")
if analysis["missingFacts"]:
lines.append(f"Open items: {' '.join(analysis['missingFacts'])}")
return " ".join(lines)
def render_memo(analysis: dict[str, Any]) -> str:
lines = [
"# Tax Memo",
"",
f"## Issue\n{analysis['issue']}",
"",
"## Facts",
]
if analysis["factsUsed"]:
for item in analysis["factsUsed"]:
lines.append(f"- {item['field']}: {item['value']}")
else:
lines.append("- No case-specific facts supplied.")
lines.extend(["", "## Authorities"])
if analysis["authorities"]:
for authority in analysis["authorities"]:
lines.append(f"- {authority['title']}")
else:
lines.append("- Primary-law escalation required.")
if analysis.get("excerpts"):
lines.extend(["", "## IRS Excerpts"])
for item in analysis["excerpts"]:
lines.append(f"- {item['title']} (page {item['page']}): {item['excerpt']}")
lines.extend(
[
"",
"## Analysis",
analysis["conclusion"]["summary"],
f"Confidence: {analysis['confidence']}",
f"Risk level: {analysis['riskLevel']}",
"",
"## Conclusion",
analysis["conclusion"]["answer"],
]
)
if analysis["missingFacts"]:
lines.extend(["", "## Open Items"])
for item in analysis["missingFacts"]:
lines.append(f"- {item}")
return "\n".join(lines)

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import json
from io import BytesIO
from pathlib import Path
from typing import Any
from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas
from us_cpa.sources import TaxYearCorpus
FORM_TEMPLATES = {
"f1040": "f1040",
"f1040sb": "f1040sb",
"f1040sc": "f1040sc",
"f1040se": "f1040se",
"f1040s1": "f1040s1",
}
OVERLAY_FIELDS = {
"f1040": [
(72, 725, lambda data: f"Taxpayer: {data['taxpayer']['fullName']}"),
(72, 705, lambda data: f"Filing status: {data['filingStatus']}"),
(72, 685, lambda data: f"Wages: {data['income']['wages']:.2f}"),
(72, 665, lambda data: f"Taxable interest: {data['income']['taxableInterest']:.2f}"),
(72, 645, lambda data: f"AGI: {data['totals']['adjustedGrossIncome']:.2f}"),
(72, 625, lambda data: f"Standard deduction: {data['deductions']['standardDeduction']:.2f}"),
(72, 605, lambda data: f"Taxable income: {data['totals']['taxableIncome']:.2f}"),
(72, 585, lambda data: f"Total tax: {data['taxes']['totalTax']:.2f}"),
(72, 565, lambda data: f"Withholding: {data['payments']['federalWithholding']:.2f}"),
(72, 545, lambda data: f"Refund: {data['totals']['refund']:.2f}"),
(72, 525, lambda data: f"Balance due: {data['totals']['balanceDue']:.2f}"),
],
}
FIELD_FILL_VALUES = {
"f1040": lambda data: {
"taxpayer_full_name": data["taxpayer"]["fullName"],
"filing_status": data["filingStatus"],
"wages": f"{data['income']['wages']:.2f}",
"taxable_interest": f"{data['income']['taxableInterest']:.2f}",
}
}
def _field_fill_page(template_path: Path, output_path: Path, form_code: str, normalized: dict[str, Any]) -> bool:
reader = PdfReader(str(template_path))
fields = reader.get_fields() or {}
values = FIELD_FILL_VALUES.get(form_code, lambda _: {})(normalized)
matched = {key: value for key, value in values.items() if key in fields}
if not matched:
return False
writer = PdfWriter(clone_from=str(template_path))
writer.update_page_form_field_values(writer.pages[0], matched, auto_regenerate=False)
writer.set_need_appearances_writer()
with output_path.open("wb") as handle:
writer.write(handle)
return True
def _overlay_page(template_path: Path, output_path: Path, form_code: str, normalized: dict[str, Any]) -> None:
reader = PdfReader(str(template_path))
writer = PdfWriter(clone_from=str(template_path))
page = writer.pages[0]
width = float(page.mediabox.width)
height = float(page.mediabox.height)
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=(width, height))
for x, y, getter in OVERLAY_FIELDS.get(form_code, []):
pdf.drawString(x, y, getter(normalized))
pdf.save()
buffer.seek(0)
overlay = PdfReader(buffer)
page.merge_page(overlay.pages[0])
with output_path.open("wb") as handle:
writer.write(handle)
def render_case_forms(case_dir: Path, corpus: TaxYearCorpus, normalized: dict[str, Any]) -> dict[str, Any]:
output_dir = case_dir / "output" / "forms"
output_dir.mkdir(parents=True, exist_ok=True)
irs_dir = corpus.paths_for_year(normalized["taxYear"]).irs_dir
artifacts = []
for form_code in normalized["requiredForms"]:
template_slug = FORM_TEMPLATES.get(form_code)
if template_slug is None:
continue
template_path = irs_dir / f"{template_slug}.pdf"
output_path = output_dir / f"{form_code}.pdf"
render_method = "overlay"
review_required = True
if _field_fill_page(template_path, output_path, form_code, normalized):
render_method = "field_fill"
review_required = False
else:
_overlay_page(template_path, output_path, form_code, normalized)
artifacts.append(
{
"formCode": form_code,
"templatePath": str(template_path),
"outputPath": str(output_path),
"renderMethod": render_method,
"reviewRequired": review_required,
}
)
artifact_manifest = {
"taxYear": normalized["taxYear"],
"artifactCount": len(artifacts),
"artifacts": artifacts,
}
(case_dir / "output" / "artifacts.json").write_text(json.dumps(artifact_manifest, indent=2))
return artifact_manifest

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from typing import Any
from us_cpa.tax_years import tax_year_rules
def _as_float(value: Any) -> float:
if value in (None, ""):
return 0.0
return float(value)
def _fact_metadata(facts: dict[str, Any]) -> dict[str, Any]:
return facts.get("_factMetadata", {})
def _provenance_for(field: str, metadata: dict[str, Any]) -> dict[str, Any]:
entry = metadata.get(field, {})
return {"sources": list(entry.get("sources", []))}
def tax_on_ordinary_income(amount: float, filing_status: str, tax_year: int) -> float:
taxable = max(0.0, amount)
brackets = tax_year_rules(tax_year)["ordinaryIncomeBrackets"][filing_status]
lower = 0.0
tax = 0.0
for upper, rate in brackets:
if taxable <= lower:
break
portion = min(taxable, upper) - lower
tax += portion * rate
lower = upper
return round(tax, 2)
def resolve_required_forms(normalized: dict[str, Any]) -> list[str]:
forms = ["f1040"]
if normalized["income"]["taxableInterest"] > 1500:
forms.append("f1040sb")
if normalized["income"]["businessIncome"] != 0:
forms.extend(["f1040sc", "f1040sse", "f1040s1", "f8995"])
if normalized["income"]["capitalGainLoss"] != 0:
forms.extend(["f1040sd", "f8949"])
if normalized["income"]["rentalIncome"] != 0:
forms.extend(["f1040se", "f1040s1"])
if normalized["deductions"]["deductionType"] == "itemized":
forms.append("f1040sa")
if normalized["adjustments"]["hsaContribution"] != 0:
forms.append("f8889")
if normalized["credits"]["educationCredit"] != 0:
forms.append("f8863")
if normalized["credits"]["foreignTaxCredit"] != 0:
forms.append("f1116")
if normalized["business"]["qualifiedBusinessIncome"] != 0 and "f8995" not in forms:
forms.append("f8995")
if normalized["basis"]["traditionalIraBasis"] != 0:
forms.append("f8606")
if normalized["taxes"]["additionalMedicareTax"] != 0:
forms.append("f8959")
if normalized["taxes"]["netInvestmentIncomeTax"] != 0:
forms.append("f8960")
if normalized["taxes"]["alternativeMinimumTax"] != 0:
forms.append("f6251")
if normalized["taxes"]["additionalTaxPenalty"] != 0:
forms.append("f5329")
if normalized["credits"]["energyCredit"] != 0:
forms.append("f5695")
if normalized["depreciation"]["depreciationExpense"] != 0:
forms.append("f4562")
if normalized["assetSales"]["section1231GainLoss"] != 0:
forms.append("f4797")
return list(dict.fromkeys(forms))
def normalize_case_facts(facts: dict[str, Any], tax_year: int) -> dict[str, Any]:
rules = tax_year_rules(tax_year)
metadata = _fact_metadata(facts)
filing_status = facts.get("filingStatus", "single")
wages = _as_float(facts.get("wages"))
interest = _as_float(facts.get("taxableInterest"))
business_income = _as_float(facts.get("businessIncome"))
capital_gain_loss = _as_float(facts.get("capitalGainLoss"))
rental_income = _as_float(facts.get("rentalIncome"))
withholding = _as_float(facts.get("federalWithholding"))
itemized_deductions = _as_float(facts.get("itemizedDeductions"))
hsa_contribution = _as_float(facts.get("hsaContribution"))
education_credit = _as_float(facts.get("educationCredit"))
foreign_tax_credit = _as_float(facts.get("foreignTaxCredit"))
qualified_business_income = _as_float(facts.get("qualifiedBusinessIncome"))
traditional_ira_basis = _as_float(facts.get("traditionalIraBasis"))
additional_medicare_tax = _as_float(facts.get("additionalMedicareTax"))
net_investment_income_tax = _as_float(facts.get("netInvestmentIncomeTax"))
alternative_minimum_tax = _as_float(facts.get("alternativeMinimumTax"))
additional_tax_penalty = _as_float(facts.get("additionalTaxPenalty"))
energy_credit = _as_float(facts.get("energyCredit"))
depreciation_expense = _as_float(facts.get("depreciationExpense"))
section1231_gain_loss = _as_float(facts.get("section1231GainLoss"))
adjusted_gross_income = wages + interest + business_income + capital_gain_loss + rental_income
standard_deduction = rules["standardDeduction"][filing_status]
deduction_type = "itemized" if itemized_deductions > standard_deduction else "standard"
deduction_amount = itemized_deductions if deduction_type == "itemized" else standard_deduction
taxable_income = max(0.0, adjusted_gross_income - deduction_amount)
income_tax = tax_on_ordinary_income(taxable_income, filing_status, tax_year)
self_employment_tax = round(max(0.0, business_income) * 0.9235 * 0.153, 2)
total_tax = round(
income_tax
+ self_employment_tax
+ additional_medicare_tax
+ net_investment_income_tax
+ alternative_minimum_tax
+ additional_tax_penalty,
2,
)
total_payments = withholding
total_credits = round(education_credit + foreign_tax_credit + energy_credit, 2)
refund = round(max(0.0, total_payments + total_credits - total_tax), 2)
balance_due = round(max(0.0, total_tax - total_payments - total_credits), 2)
normalized = {
"taxYear": tax_year,
"taxpayer": {
"fullName": facts.get("taxpayer.fullName", "Unknown Taxpayer"),
},
"spouse": {
"fullName": facts.get("spouse.fullName", ""),
},
"dependents": list(facts.get("dependents", [])),
"filingStatus": filing_status,
"income": {
"wages": wages,
"taxableInterest": interest,
"businessIncome": business_income,
"capitalGainLoss": capital_gain_loss,
"rentalIncome": rental_income,
},
"adjustments": {
"hsaContribution": hsa_contribution,
},
"payments": {
"federalWithholding": withholding,
},
"deductions": {
"standardDeduction": standard_deduction,
"itemizedDeductions": itemized_deductions,
"deductionType": deduction_type,
"deductionAmount": deduction_amount,
},
"credits": {
"educationCredit": education_credit,
"foreignTaxCredit": foreign_tax_credit,
"energyCredit": energy_credit,
},
"taxes": {
"incomeTax": income_tax,
"selfEmploymentTax": self_employment_tax,
"additionalMedicareTax": additional_medicare_tax,
"netInvestmentIncomeTax": net_investment_income_tax,
"alternativeMinimumTax": alternative_minimum_tax,
"additionalTaxPenalty": additional_tax_penalty,
"totalTax": total_tax,
},
"business": {
"qualifiedBusinessIncome": qualified_business_income,
},
"basis": {
"traditionalIraBasis": traditional_ira_basis,
},
"depreciation": {
"depreciationExpense": depreciation_expense,
},
"assetSales": {
"section1231GainLoss": section1231_gain_loss,
},
"totals": {
"adjustedGrossIncome": round(adjusted_gross_income, 2),
"taxableIncome": round(taxable_income, 2),
"totalPayments": round(total_payments, 2),
"totalCredits": total_credits,
"refund": refund,
"balanceDue": balance_due,
},
"provenance": {
"income.wages": _provenance_for("wages", metadata),
"income.taxableInterest": _provenance_for("taxableInterest", metadata),
"income.businessIncome": _provenance_for("businessIncome", metadata),
"income.capitalGainLoss": _provenance_for("capitalGainLoss", metadata),
"income.rentalIncome": _provenance_for("rentalIncome", metadata),
"payments.federalWithholding": _provenance_for("federalWithholding", metadata),
},
}
normalized["requiredForms"] = resolve_required_forms(normalized)
return normalized

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from us_cpa.returns import normalize_case_facts
from us_cpa.sources import TaxYearCorpus
def _severity_rank(severity: str) -> int:
return {"high": 0, "medium": 1, "low": 2}[severity]
class ReviewEngine:
def __init__(self, *, corpus: TaxYearCorpus | None = None) -> None:
self.corpus = corpus or TaxYearCorpus()
def review_case(self, case_dir: Path) -> dict[str, Any]:
case_dir = Path(case_dir).expanduser().resolve()
manifest = json.loads((case_dir / "case-manifest.json").read_text())
stored_return = json.loads((case_dir / "return" / "normalized-return.json").read_text())
facts_payload = json.loads((case_dir / "extracted" / "facts.json").read_text())
facts = {key: value["value"] for key, value in facts_payload["facts"].items()}
facts["_factMetadata"] = {
key: {"sources": value.get("sources", [])} for key, value in facts_payload["facts"].items()
}
recomputed = normalize_case_facts(facts, manifest["taxYear"])
artifacts_payload = json.loads((case_dir / "output" / "artifacts.json").read_text())
findings: list[dict[str, Any]] = []
if stored_return["totals"]["adjustedGrossIncome"] != recomputed["totals"]["adjustedGrossIncome"]:
findings.append(
{
"severity": "high",
"title": "Adjusted gross income mismatch",
"explanation": "Stored adjusted gross income does not match the recomputed return from case facts.",
"suggestedAction": f"Update AGI to {recomputed['totals']['adjustedGrossIncome']:.2f} on Form 1040 line 11.",
"authorities": [
{"title": "Instructions for Form 1040 and Schedules 1-3", "sourceClass": "irs_instructions"}
],
}
)
for field, label in (
("wages", "wages"),
("taxableInterest", "taxable interest"),
("businessIncome", "business income"),
("capitalGainLoss", "capital gains or losses"),
("rentalIncome", "rental income"),
):
stored_value = stored_return["income"].get(field, 0.0)
recomputed_value = recomputed["income"].get(field, 0.0)
sources = recomputed.get("provenance", {}).get(f"income.{field}", {}).get("sources", [])
has_document_source = any(item.get("sourceType") == "document_extract" for item in sources)
if stored_value != recomputed_value:
findings.append(
{
"severity": "high" if has_document_source else "medium",
"title": f"Source fact mismatch for {label}",
"explanation": f"Stored return reports {stored_value:.2f} for {label}, but case facts support {recomputed_value:.2f}.",
"suggestedAction": f"Reconcile {label} to {recomputed_value:.2f} before treating the return as final.",
"authorities": [
{"title": "Case fact registry", "sourceClass": "irs_form"}
],
}
)
if stored_value == 0 and recomputed_value > 0 and has_document_source:
findings.append(
{
"severity": "high",
"title": f"Likely omitted {label}",
"explanation": f"Document-extracted facts support {recomputed_value:.2f} of {label}, but the stored return reports none.",
"suggestedAction": f"Add {label} to the return and regenerate the required forms.",
"authorities": [
{"title": "Case document extraction", "sourceClass": "irs_form"}
],
}
)
rendered_forms = {artifact["formCode"] for artifact in artifacts_payload["artifacts"]}
for required_form in recomputed["requiredForms"]:
if required_form not in rendered_forms:
findings.append(
{
"severity": "high",
"title": f"Missing rendered artifact for {required_form}",
"explanation": "The return requires this form, but no rendered artifact is present in the artifact manifest.",
"suggestedAction": f"Render and review {required_form} before treating the package as complete.",
"authorities": [{"title": "Supported form manifest", "sourceClass": "irs_form"}],
}
)
for artifact in artifacts_payload["artifacts"]:
if artifact.get("reviewRequired"):
findings.append(
{
"severity": "medium",
"title": f"Human review required for {artifact['formCode']}",
"explanation": "The form was overlay-rendered on the official IRS PDF and must be reviewed before filing.",
"suggestedAction": f"Review the rendered {artifact['formCode']} artifact visually before any filing/export handoff.",
"authorities": [{"title": "Artifact render policy", "sourceClass": "irs_form"}],
}
)
required_forms_union = set(recomputed["requiredForms"]) | set(stored_return.get("requiredForms", []))
if any(form in required_forms_union for form in ("f6251", "f8960", "f8959", "f1116")):
findings.append(
{
"severity": "medium",
"title": "High-complexity tax position requires specialist follow-up",
"explanation": "The return includes forms or computations that usually require deeper technical support and careful authority review.",
"suggestedAction": "Review the supporting authority and computations for the high-complexity forms before treating the return as filing-ready.",
"authorities": [{"title": "Required form analysis", "sourceClass": "irs_instructions"}],
}
)
findings.sort(key=lambda item: (_severity_rank(item["severity"]), item["title"]))
review = {
"status": "reviewed",
"taxYear": manifest["taxYear"],
"caseDir": str(case_dir),
"findingCount": len(findings),
"findings": findings,
}
(case_dir / "reports" / "review-report.json").write_text(json.dumps(review, indent=2))
return review
def render_review_summary(review: dict[str, Any]) -> str:
if not review["findings"]:
return "No findings detected in the reviewed return package."
lines = ["Review findings:"]
for finding in review["findings"]:
lines.append(f"- [{finding['severity'].upper()}] {finding['title']}: {finding['explanation']}")
return "\n".join(lines)
def render_review_memo(review: dict[str, Any]) -> str:
lines = ["# Review Memo", ""]
if not review["findings"]:
lines.append("No findings detected.")
return "\n".join(lines)
for index, finding in enumerate(review["findings"], start=1):
lines.extend(
[
f"## Finding {index}: {finding['title']}",
f"Severity: {finding['severity']}",
"",
"### Explanation",
finding["explanation"],
"",
"### Suggested correction",
finding["suggestedAction"],
"",
"### Authorities",
]
)
for authority in finding["authorities"]:
lines.append(f"- {authority['title']}")
lines.append("")
return "\n".join(lines).rstrip()

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
import hashlib
import json
import os
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import IntEnum
from pathlib import Path
from typing import Callable
from urllib.request import urlopen
class AuthorityRank(IntEnum):
IRS_FORM = 10
IRS_INSTRUCTIONS = 20
IRS_PUBLICATION = 30
IRS_FAQ = 40
INTERNAL_REVENUE_CODE = 100
TREASURY_REGULATION = 110
OTHER_PRIMARY_AUTHORITY = 120
AUTHORITY_RANKS: dict[str, AuthorityRank] = {
"irs_form": AuthorityRank.IRS_FORM,
"irs_instructions": AuthorityRank.IRS_INSTRUCTIONS,
"irs_publication": AuthorityRank.IRS_PUBLICATION,
"irs_faq": AuthorityRank.IRS_FAQ,
"internal_revenue_code": AuthorityRank.INTERNAL_REVENUE_CODE,
"treasury_regulation": AuthorityRank.TREASURY_REGULATION,
"other_primary_authority": AuthorityRank.OTHER_PRIMARY_AUTHORITY,
}
def authority_rank_for(source_class: str) -> AuthorityRank:
return AUTHORITY_RANKS[source_class]
@dataclass(frozen=True)
class SourceDescriptor:
slug: str
title: str
source_class: str
media_type: str
url: str
@dataclass(frozen=True)
class TaxYearPaths:
year_dir: Path
irs_dir: Path
manifest_path: Path
def default_cache_root() -> Path:
override = os.getenv("US_CPA_CACHE_DIR")
if override:
return Path(override).expanduser().resolve()
return (Path.home() / ".cache" / "us-cpa").resolve()
def build_irs_prior_pdf_url(slug: str, tax_year: int) -> str:
return f"https://www.irs.gov/pub/irs-prior/{slug}--{tax_year}.pdf"
def build_primary_law_authorities(question: str) -> list[dict[str, str | int]]:
authorities: list[dict[str, str | int]] = []
normalized = question.lower()
for match in re.finditer(r"(?:section|sec\.)\s+(\d+[a-z0-9-]*)", normalized):
section = match.group(1)
authorities.append(
{
"slug": f"irc-{section}",
"title": f"Internal Revenue Code section {section}",
"sourceClass": "internal_revenue_code",
"url": f"https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section{section}&num=0&edition=prelim",
"authorityRank": int(AuthorityRank.INTERNAL_REVENUE_CODE),
}
)
for match in re.finditer(r"(?:treas(?:ury)?\.?\s+reg(?:ulation)?\.?\s*)([\d.]+-\d+)", normalized):
section = match.group(1)
authorities.append(
{
"slug": f"reg-{section}",
"title": f"Treasury Regulation {section}",
"sourceClass": "treasury_regulation",
"url": f"https://www.ecfr.gov/current/title-26/section-{section}",
"authorityRank": int(AuthorityRank.TREASURY_REGULATION),
}
)
return authorities
def bootstrap_irs_catalog(tax_year: int) -> list[SourceDescriptor]:
entries = [
("f1040", "Form 1040", "irs_form"),
("f1040s1", "Schedule 1 (Form 1040)", "irs_form"),
("f1040s2", "Schedule 2 (Form 1040)", "irs_form"),
("f1040s3", "Schedule 3 (Form 1040)", "irs_form"),
("f1040sa", "Schedule A (Form 1040)", "irs_form"),
("f1040sb", "Schedule B (Form 1040)", "irs_form"),
("f1040sc", "Schedule C (Form 1040)", "irs_form"),
("f1040sd", "Schedule D (Form 1040)", "irs_form"),
("f1040se", "Schedule E (Form 1040)", "irs_form"),
("f1040sse", "Schedule SE (Form 1040)", "irs_form"),
("f1040s8", "Schedule 8812 (Form 1040)", "irs_form"),
("f8949", "Form 8949", "irs_form"),
("f4562", "Form 4562", "irs_form"),
("f4797", "Form 4797", "irs_form"),
("f6251", "Form 6251", "irs_form"),
("f8606", "Form 8606", "irs_form"),
("f8863", "Form 8863", "irs_form"),
("f8889", "Form 8889", "irs_form"),
("f8959", "Form 8959", "irs_form"),
("f8960", "Form 8960", "irs_form"),
("f8995", "Form 8995", "irs_form"),
("f8995a", "Form 8995-A", "irs_form"),
("f5329", "Form 5329", "irs_form"),
("f5695", "Form 5695", "irs_form"),
("f1116", "Form 1116", "irs_form"),
("i1040gi", "Instructions for Form 1040 and Schedules 1-3", "irs_instructions"),
("i1040sca", "Instructions for Schedule A", "irs_instructions"),
("i1040sc", "Instructions for Schedule C", "irs_instructions"),
("i1040sd", "Instructions for Schedule D", "irs_instructions"),
("i1040se", "Instructions for Schedule E (Form 1040)", "irs_instructions"),
("i1040sse", "Instructions for Schedule SE", "irs_instructions"),
("i1040s8", "Instructions for Schedule 8812 (Form 1040)", "irs_instructions"),
("i8949", "Instructions for Form 8949", "irs_instructions"),
("i4562", "Instructions for Form 4562", "irs_instructions"),
("i4797", "Instructions for Form 4797", "irs_instructions"),
("i6251", "Instructions for Form 6251", "irs_instructions"),
("i8606", "Instructions for Form 8606", "irs_instructions"),
("i8863", "Instructions for Form 8863", "irs_instructions"),
("i8889", "Instructions for Form 8889", "irs_instructions"),
("i8959", "Instructions for Form 8959", "irs_instructions"),
("i8960", "Instructions for Form 8960", "irs_instructions"),
("i8995", "Instructions for Form 8995", "irs_instructions"),
("i8995a", "Instructions for Form 8995-A", "irs_instructions"),
("i5329", "Instructions for Form 5329", "irs_instructions"),
("i5695", "Instructions for Form 5695", "irs_instructions"),
("i1116", "Instructions for Form 1116", "irs_instructions"),
("p501", "Publication 501, Dependents, Standard Deduction, and Filing Information", "irs_publication"),
]
return [
SourceDescriptor(
slug=slug,
title=title,
source_class=source_class,
media_type="application/pdf",
url=build_irs_prior_pdf_url(slug, tax_year),
)
for slug, title, source_class in entries
]
def _sha256_bytes(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def _http_fetch(url: str) -> bytes:
with urlopen(url) as response:
return response.read()
class TaxYearCorpus:
def __init__(self, cache_root: Path | None = None) -> None:
self.cache_root = cache_root or default_cache_root()
def paths_for_year(self, tax_year: int) -> TaxYearPaths:
year_dir = self.cache_root / "tax-years" / str(tax_year)
return TaxYearPaths(
year_dir=year_dir,
irs_dir=year_dir / "irs",
manifest_path=year_dir / "manifest.json",
)
def download_catalog(
self,
tax_year: int,
catalog: list[SourceDescriptor],
*,
fetcher: Callable[[str], bytes] = _http_fetch,
) -> dict:
paths = self.paths_for_year(tax_year)
paths.irs_dir.mkdir(parents=True, exist_ok=True)
fetched_at = datetime.now(timezone.utc).isoformat()
sources: list[dict] = []
for descriptor in catalog:
payload = fetcher(descriptor.url)
destination = paths.irs_dir / f"{descriptor.slug}.pdf"
destination.write_bytes(payload)
sources.append(
{
"slug": descriptor.slug,
"title": descriptor.title,
"sourceClass": descriptor.source_class,
"mediaType": descriptor.media_type,
"url": descriptor.url,
"localPath": str(destination),
"sha256": _sha256_bytes(payload),
"fetchedAt": fetched_at,
"authorityRank": int(authority_rank_for(descriptor.source_class)),
}
)
manifest = {
"taxYear": tax_year,
"fetchedAt": fetched_at,
"cacheRoot": str(self.cache_root),
"sourceCount": len(sources),
"sources": sources,
"indexes": self.index_manifest(sources),
"primaryLawHooks": [
{
"sourceClass": "internal_revenue_code",
"authorityRank": int(AuthorityRank.INTERNAL_REVENUE_CODE),
},
{
"sourceClass": "treasury_regulation",
"authorityRank": int(AuthorityRank.TREASURY_REGULATION),
},
],
}
paths.manifest_path.write_text(json.dumps(manifest, indent=2))
return manifest
@staticmethod
def index_manifest(sources: list[dict]) -> dict[str, dict[str, list[str]]]:
by_class: dict[str, list[str]] = {}
by_slug: dict[str, list[str]] = {}
for source in sources:
by_class.setdefault(source["sourceClass"], []).append(source["slug"])
by_slug.setdefault(source["slug"], []).append(source["localPath"])
return {"bySourceClass": by_class, "bySlug": by_slug}

View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from typing import Any
TAX_YEAR_DATA: dict[int, dict[str, Any]] = {
2024: {
"standardDeduction": {
"single": 14600.0,
"married_filing_jointly": 29200.0,
"head_of_household": 21900.0,
},
"ordinaryIncomeBrackets": {
"single": [
(11600.0, 0.10),
(47150.0, 0.12),
(100525.0, 0.22),
(191950.0, 0.24),
(243725.0, 0.32),
(609350.0, 0.35),
(float("inf"), 0.37),
],
"married_filing_jointly": [
(23200.0, 0.10),
(94300.0, 0.12),
(201050.0, 0.22),
(383900.0, 0.24),
(487450.0, 0.32),
(731200.0, 0.35),
(float("inf"), 0.37),
],
"head_of_household": [
(16550.0, 0.10),
(63100.0, 0.12),
(100500.0, 0.22),
(191950.0, 0.24),
(243700.0, 0.32),
(609350.0, 0.35),
(float("inf"), 0.37),
],
},
"sourceCitations": {
"standardDeduction": "IRS Rev. Proc. 2023-34, section 3.01; 2024 Form 1040 instructions.",
"ordinaryIncomeBrackets": "IRS Rev. Proc. 2023-34, section 3.01; 2024 Form 1040 instructions.",
},
},
2025: {
"standardDeduction": {
"single": 15750.0,
"married_filing_jointly": 31500.0,
"head_of_household": 23625.0,
},
"ordinaryIncomeBrackets": {
"single": [
(11925.0, 0.10),
(48475.0, 0.12),
(103350.0, 0.22),
(197300.0, 0.24),
(250525.0, 0.32),
(626350.0, 0.35),
(float("inf"), 0.37),
],
"married_filing_jointly": [
(23850.0, 0.10),
(96950.0, 0.12),
(206700.0, 0.22),
(394600.0, 0.24),
(501050.0, 0.32),
(751600.0, 0.35),
(float("inf"), 0.37),
],
"head_of_household": [
(17000.0, 0.10),
(64850.0, 0.12),
(103350.0, 0.22),
(197300.0, 0.24),
(250500.0, 0.32),
(626350.0, 0.35),
(float("inf"), 0.37),
],
},
"sourceCitations": {
"standardDeduction": "IRS Rev. Proc. 2024-40, section 3.01; 2025 Form 1040 instructions.",
"ordinaryIncomeBrackets": "IRS Rev. Proc. 2024-40, section 3.01; 2025 Form 1040 instructions.",
},
},
}
def supported_tax_years() -> list[int]:
return sorted(TAX_YEAR_DATA)
def tax_year_rules(tax_year: int) -> dict[str, Any]:
try:
return TAX_YEAR_DATA[tax_year]
except KeyError as exc:
years = ", ".join(str(year) for year in supported_tax_years())
raise ValueError(
f"Unsupported tax year {tax_year}. Supported tax years: {years}."
) from exc

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
Form 1099-INT
Recipient: Jane Doe
Box 1 Interest Income 1750

View File

@@ -0,0 +1,4 @@
Form W-2 Wage and Tax Statement
Employee: Jane Doe
Box 1 Wages, tips, other compensation 50000
Box 2 Federal income tax withheld 6000

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,6 @@
{
"taxpayer.fullName": "Olivia Overlay",
"filingStatus": "single",
"wages": 42000,
"federalWithholding": 5000
}

View File

@@ -0,0 +1,8 @@
{
"taxpayer.fullName": "Jane Doe",
"filingStatus": "single",
"wages": 50000,
"taxableInterest": 100,
"federalWithholding": 6000,
"expectedIssue": "agi_mismatch"
}

View File

@@ -0,0 +1,6 @@
{
"taxpayer.fullName": "Jamie Owner",
"filingStatus": "single",
"businessIncome": 12000,
"federalWithholding": 0
}

View File

@@ -0,0 +1,7 @@
{
"taxpayer.fullName": "Jane Doe",
"filingStatus": "single",
"wages": 50000,
"taxableInterest": 100,
"federalWithholding": 6000
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

Some files were not shown because too many files have changed in this diff Show More