tripkit 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -5
- package/README.md +34 -3
- package/agent/SKILL.md +168 -0
- package/convert.js +63 -0
- package/docs/MEDIA-GUIDE.md +298 -0
- package/docs/README.md +1 -0
- package/examples/new-england-fall-2026.yaml +471 -0
- package/examples/nyc-long-weekend-2026.yaml +283 -0
- package/examples/oregon-spring-2026.yaml +17 -0
- package/examples/southwest-parks-2026.yaml +463 -0
- package/media-ingest.js +389 -0
- package/package.json +14 -3
- package/renderers/html/tripkit-renderer.html +341 -33
- package/schema/tripkit.schema.yaml +25 -0
- package/scripts/check-skill-coverage.js +66 -0
- package/scripts/test-media-lifecycle.js +190 -0
- package/validate.js +97 -0
- package/agent/AGENT-SKILL.md +0 -105
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end regression test for the post-trip media flow.
|
|
4
|
+
*
|
|
5
|
+
* Exercises the full lifecycle on a freshly-authored trip:
|
|
6
|
+
* author trip -> validate -> render (pre) [no media]
|
|
7
|
+
* -> generate geotagged fixtures
|
|
8
|
+
* -> `tripkit media` (build review) [EXIF match + buckets]
|
|
9
|
+
* -> review/caption + promote an unmatched item [simulates the human/AI step]
|
|
10
|
+
* -> `tripkit media apply` [merge into stops[].media[]]
|
|
11
|
+
* -> re-apply [idempotency]
|
|
12
|
+
* -> validate (errors vs warnings) -> render (post)
|
|
13
|
+
* -> assert the rendered HTML embeds the media + captions.
|
|
14
|
+
*
|
|
15
|
+
* Self-contained: builds fixtures in an OS temp dir and cleans up. `sharp` is
|
|
16
|
+
* optional — thumbnail assertions soft-pass (with a note) when it isn't installed,
|
|
17
|
+
* so this can run in `npm test` on a sharp-free CI.
|
|
18
|
+
*
|
|
19
|
+
* Run: node scripts/test-media-lifecycle.js (also wired into `npm test`)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { execFileSync } = require('child_process');
|
|
26
|
+
|
|
27
|
+
const ROOT = path.join(__dirname, '..');
|
|
28
|
+
const CONVERT = path.join(ROOT, 'convert.js');
|
|
29
|
+
const yaml = require(path.join(ROOT, 'node_modules', 'js-yaml'));
|
|
30
|
+
const { validate } = require(path.join(ROOT, 'validate'));
|
|
31
|
+
|
|
32
|
+
let sharp = null;
|
|
33
|
+
try { sharp = require(path.join(ROOT, 'node_modules', 'sharp')); } catch (_) { /* optional */ }
|
|
34
|
+
const HAVE_SHARP = !!sharp;
|
|
35
|
+
|
|
36
|
+
let failures = 0;
|
|
37
|
+
function check(name, cond, detail) {
|
|
38
|
+
const ok = !!cond;
|
|
39
|
+
console.log(` ${ok ? '✓' : '✗'} ${name}${ok ? '' : ` — ${detail || ''}`}`);
|
|
40
|
+
if (!ok) failures++;
|
|
41
|
+
}
|
|
42
|
+
function soft(name, cond, detail) {
|
|
43
|
+
if (!HAVE_SHARP) { console.log(` ⊘ ${name} (skipped — sharp not installed)`); return; }
|
|
44
|
+
check(name, cond, detail);
|
|
45
|
+
}
|
|
46
|
+
function tk(args) {
|
|
47
|
+
return execFileSync('node', [CONVERT, ...args], { env: { ...process.env, NO_COLOR: '1' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Minimal JPEG with GPS + DateTimeOriginal EXIF (no external EXIF writer needed) ---
|
|
51
|
+
function buildExif({ lat, lng, dateStr }) {
|
|
52
|
+
const tiff = Buffer.alloc(8192);
|
|
53
|
+
let p = 0;
|
|
54
|
+
const u16 = (v) => { tiff.writeUInt16LE(v, p); p += 2; };
|
|
55
|
+
const u32 = (v) => { tiff.writeUInt32LE(v, p); p += 4; };
|
|
56
|
+
tiff.write('II', p); p += 2; tiff.writeUInt16LE(42, p); p += 2; tiff.writeUInt32LE(8, p); p += 4;
|
|
57
|
+
const dt = Buffer.from(dateStr + '\0', 'ascii');
|
|
58
|
+
const dms = (dec) => { const d = Math.floor(Math.abs(dec)); const mf = (Math.abs(dec) - d) * 60; const m = Math.floor(mf); const s = (mf - m) * 60; return [[d, 1], [m, 1], [Math.round(s * 100), 100]]; };
|
|
59
|
+
const rationals = (arr) => { const b = Buffer.alloc(arr.length * 8); arr.forEach((pr, i) => { b.writeUInt32LE(pr[0], i * 8); b.writeUInt32LE(pr[1], i * 8 + 4); }); return b; };
|
|
60
|
+
const latBuf = rationals(dms(lat)), lngBuf = rationals(dms(lng));
|
|
61
|
+
const latRef = (lat >= 0 ? 'N' : 'S'), lngRef = (lng >= 0 ? 'E' : 'W');
|
|
62
|
+
const IFD0_OFF = 8, IFD0_SIZE = 2 + 2 * 12 + 4;
|
|
63
|
+
const EXIF_OFF = IFD0_OFF + IFD0_SIZE, EXIF_SIZE = 2 + 1 * 12 + 4;
|
|
64
|
+
const GPS_OFF = EXIF_OFF + EXIF_SIZE, GPS_SIZE = 2 + 4 * 12 + 4;
|
|
65
|
+
const DATA_OFF = GPS_OFF + GPS_SIZE;
|
|
66
|
+
const dtOff = DATA_OFF, latOff = dtOff + dt.length, lngOff = latOff + latBuf.length;
|
|
67
|
+
p = IFD0_OFF; u16(2);
|
|
68
|
+
u16(0x8769); u16(4); u32(1); u32(EXIF_OFF);
|
|
69
|
+
u16(0x8825); u16(4); u32(1); u32(GPS_OFF); u32(0);
|
|
70
|
+
p = EXIF_OFF; u16(1); u16(0x9003); u16(2); u32(dt.length); u32(dtOff); u32(0);
|
|
71
|
+
p = GPS_OFF; u16(4);
|
|
72
|
+
u16(0x0001); u16(2); u32(2); { const sp = p; tiff.write(latRef, p, 'ascii'); p = sp + 4; }
|
|
73
|
+
u16(0x0002); u16(5); u32(3); u32(latOff);
|
|
74
|
+
u16(0x0003); u16(2); u32(2); { const sp = p; tiff.write(lngRef, p, 'ascii'); p = sp + 4; }
|
|
75
|
+
u16(0x0004); u16(5); u32(3); u32(lngOff); u32(0);
|
|
76
|
+
dt.copy(tiff, dtOff); latBuf.copy(tiff, latOff); lngBuf.copy(tiff, lngOff);
|
|
77
|
+
return Buffer.concat([Buffer.from('Exif\0\0', 'ascii'), tiff.subarray(0, lngOff + lngBuf.length)]);
|
|
78
|
+
}
|
|
79
|
+
// A tiny valid baseline JPEG (1x1) we can append EXIF to — avoids needing sharp to make fixtures.
|
|
80
|
+
const TINY_JPEG = Buffer.from(
|
|
81
|
+
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
|
|
82
|
+
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
|
|
83
|
+
'AAAAAAAAAAAAB//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AfwD/2Q==', 'base64');
|
|
84
|
+
function geoJpeg(file, meta) {
|
|
85
|
+
const exif = buildExif(meta);
|
|
86
|
+
const app1Len = exif.length + 2;
|
|
87
|
+
const app1 = Buffer.concat([Buffer.from([0xFF, 0xE1, (app1Len >> 8) & 0xff, app1Len & 0xff]), exif]);
|
|
88
|
+
fs.writeFileSync(file, Buffer.concat([TINY_JPEG.subarray(0, 2), app1, TINY_JPEG.subarray(2)]));
|
|
89
|
+
}
|
|
90
|
+
function plainJpeg(file) { fs.writeFileSync(file, TINY_JPEG); }
|
|
91
|
+
|
|
92
|
+
const TRIP_YAML = `
|
|
93
|
+
trip:
|
|
94
|
+
title: "Yosemite Weekend 2026"
|
|
95
|
+
dates: "May 15-16, 2026"
|
|
96
|
+
total_days: 2
|
|
97
|
+
total_stops: 4
|
|
98
|
+
origin: "Fresno, CA"
|
|
99
|
+
origin_lat: 36.7378
|
|
100
|
+
origin_lng: -119.7871
|
|
101
|
+
days:
|
|
102
|
+
- number: 1
|
|
103
|
+
title: "Valley Floor"
|
|
104
|
+
date: "Friday, May 15"
|
|
105
|
+
status: completed
|
|
106
|
+
color: "#2e7db5"
|
|
107
|
+
lodging: { name: "Yosemite Valley Lodge", lat: 37.7456, lng: -119.5967 }
|
|
108
|
+
stops:
|
|
109
|
+
- { name: "Lower Yosemite Fall", lat: 37.7561, lng: -119.5966, type: hike, navigate_url: "https://maps.google.com/?q=lyf" }
|
|
110
|
+
- { name: "Tunnel View", lat: 37.7159, lng: -119.6770, type: scenic, navigate_url: "https://maps.google.com/?q=tv" }
|
|
111
|
+
- number: 2
|
|
112
|
+
title: "Mariposa Grove"
|
|
113
|
+
date: "Saturday, May 16"
|
|
114
|
+
status: completed
|
|
115
|
+
color: "#2d7a50"
|
|
116
|
+
lodging: { name: "Home", lat: 36.7378, lng: -119.7871 }
|
|
117
|
+
stops:
|
|
118
|
+
- { name: "Mariposa Grove", lat: 37.5142, lng: -119.6010, type: hike, navigate_url: "https://maps.google.com/?q=mg" }
|
|
119
|
+
- { name: "Glacier Point", lat: 37.7275, lng: -119.5734, type: scenic, navigate_url: "https://maps.google.com/?q=gp" }
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
console.log(`media lifecycle e2e${HAVE_SHARP ? '' : ' (sharp absent — thumbnail checks skipped)'}`);
|
|
124
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'tripkit-media-'));
|
|
125
|
+
const TRIP = path.join(work, 'trip.yaml');
|
|
126
|
+
const MEDIA = path.join(work, 'media');
|
|
127
|
+
const REVIEW = path.join(work, 'trip.media-review.yaml');
|
|
128
|
+
try {
|
|
129
|
+
fs.writeFileSync(TRIP, TRIP_YAML);
|
|
130
|
+
fs.mkdirSync(MEDIA);
|
|
131
|
+
|
|
132
|
+
// PRE-TRIP
|
|
133
|
+
check('pre-trip validates clean', /is valid/.test(tk(['validate', TRIP])));
|
|
134
|
+
tk([TRIP, path.join(work, 'pre.html')]);
|
|
135
|
+
const preData = (() => { const h = fs.readFileSync(path.join(work, 'pre.html'), 'utf8'); return h.slice(h.indexOf('const TRIP_DATA ='), h.indexOf('END TRIP DATA')); })();
|
|
136
|
+
check('pre-trip TRIP_DATA has no media', !/"media"\s*:/.test(preData));
|
|
137
|
+
|
|
138
|
+
// FIXTURES (day-gated; May 15 = day 1, May 16 = day 2)
|
|
139
|
+
geoJpeg(path.join(MEDIA, 'fall_01.jpg'), { lat: 37.7560, lng: -119.5965, dateStr: '2026:05:15 10:15:00' });
|
|
140
|
+
geoJpeg(path.join(MEDIA, 'fall_02.jpg'), { lat: 37.7562, lng: -119.5968, dateStr: '2026:05:15 10:22:00' });
|
|
141
|
+
geoJpeg(path.join(MEDIA, 'tunnel_01.jpg'), { lat: 37.7160, lng: -119.6771, dateStr: '2026:05:15 16:45:00' });
|
|
142
|
+
geoJpeg(path.join(MEDIA, 'sequoia_01.jpg'), { lat: 37.5141, lng: -119.6011, dateStr: '2026:05:16 09:30:00' });
|
|
143
|
+
geoJpeg(path.join(MEDIA, 'random_sf.jpg'), { lat: 37.7749, lng: -122.4194, dateStr: '2026:05:16 12:00:00' }); // too far
|
|
144
|
+
plainJpeg(path.join(MEDIA, 'no_gps.jpg')); // no GPS
|
|
145
|
+
|
|
146
|
+
// INGEST
|
|
147
|
+
tk(['media', MEDIA, TRIP]);
|
|
148
|
+
const review = yaml.load(fs.readFileSync(REVIEW, 'utf8'));
|
|
149
|
+
check('review total = 6', review.summary.total === 6, JSON.stringify(review.summary));
|
|
150
|
+
check('review matched = 4', review.summary.matched === 4, JSON.stringify(review.summary));
|
|
151
|
+
check('review unmatched = 2', review.summary.unmatched === 2, JSON.stringify(review.summary));
|
|
152
|
+
const fall = review.stops.find(s => s.stop === 'Lower Yosemite Fall');
|
|
153
|
+
check('Lower Yosemite Fall = 2 photos, day 1 idx 0', fall && fall.media.length === 2 && fall.day === 1 && fall.stop_index === 0, JSON.stringify(fall && { n: fall.media.length, d: fall.day, i: fall.stop_index }));
|
|
154
|
+
check('Mariposa matched on day 2', !!review.stops.find(s => /Mariposa/.test(s.stop) && s.day === 2));
|
|
155
|
+
check('SF photo unmatched (too far)', review.unmatched.some(u => u.src.includes('random_sf')));
|
|
156
|
+
check('no-GPS photo unmatched', review.unmatched.some(u => u.src.includes('no_gps') && /no GPS/i.test(u.reason)));
|
|
157
|
+
soft('thumbnails generated', fs.existsSync(path.join(MEDIA, 'thumb', 'fall_01.jpg')));
|
|
158
|
+
soft('review item carries thumb path', fall && /thumb\//.test(fall.media[0].thumb || ''));
|
|
159
|
+
|
|
160
|
+
// REVIEW STEP: caption everything; promote SF photo onto Glacier Point (day 2 idx 1)
|
|
161
|
+
review.stops.forEach(s => s.media.forEach(m => { m.caption = `Photo at ${s.stop}`; }));
|
|
162
|
+
const sf = review.unmatched.find(u => u.src.includes('random_sf'));
|
|
163
|
+
sf.day = 2; sf.stop_index = 1; sf.caption = 'Manually placed';
|
|
164
|
+
fs.writeFileSync(REVIEW, yaml.dump(review));
|
|
165
|
+
|
|
166
|
+
// APPLY + idempotency
|
|
167
|
+
check('apply reports items applied', /Applied [1-9]\d* media/.test(tk(['media', 'apply', REVIEW, TRIP])));
|
|
168
|
+
const after = yaml.load(fs.readFileSync(TRIP, 'utf8'));
|
|
169
|
+
check('Lower Yosemite Fall has 2 media', (after.days[0].stops[0].media || []).length === 2);
|
|
170
|
+
check('captions merged', after.days[0].stops[0].media[0].caption === 'Photo at Lower Yosemite Fall');
|
|
171
|
+
check('promoted SF photo on Glacier Point', (after.days[1].stops[1].media || []).some(m => /random_sf/.test(m.src)));
|
|
172
|
+
check('apply is idempotent', /Applied 0 media/.test(tk(['media', 'apply', REVIEW, TRIP])));
|
|
173
|
+
|
|
174
|
+
// POST-TRIP: validate (no errors; far-GPS warning expected) + render
|
|
175
|
+
const { errors, warnings } = validate(yaml.load(fs.readFileSync(TRIP, 'utf8')));
|
|
176
|
+
check('post-trip has no validation errors', errors.length === 0, JSON.stringify(errors));
|
|
177
|
+
check('post-trip warns on far manual placement', warnings.some(w => /media GPS is \d+ mi/.test(w.message)), JSON.stringify(warnings));
|
|
178
|
+
tk([TRIP, path.join(work, 'post.html')]);
|
|
179
|
+
const post = fs.readFileSync(path.join(work, 'post.html'), 'utf8');
|
|
180
|
+
check('post render embeds media src', post.includes('fall_01.jpg'));
|
|
181
|
+
check('post render embeds caption', post.includes('Photo at Lower Yosemite Fall'));
|
|
182
|
+
|
|
183
|
+
console.log(failures === 0 ? '✓ media lifecycle e2e passed' : `✖ media lifecycle e2e: ${failures} assertion(s) failed`);
|
|
184
|
+
} finally {
|
|
185
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
process.exit(failures === 0 ? 0 : 1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main();
|
package/validate.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const STOP_TYPES = new Set(['hike', 'scenic', 'food', 'city', 'activity', 'beach', 'museum', 'shopping']);
|
|
9
|
+
const MEDIA_TYPES = new Set(['photo', 'video']);
|
|
10
|
+
// Distance above which a media item's own GPS looks suspiciously far from its stop.
|
|
11
|
+
const MEDIA_FAR_MILES = 25;
|
|
9
12
|
const DAY_STATUS = new Set(['completed', 'active', 'upcoming']);
|
|
10
13
|
const MAP_STYLES = new Set(['terrain', 'satellite', 'topo', 'street']);
|
|
11
14
|
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
@@ -13,6 +16,63 @@ const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
|
13
16
|
function isFiniteNumber(n) { return typeof n === 'number' && Number.isFinite(n); }
|
|
14
17
|
function isNonEmptyString(s) { return typeof s === 'string' && s.trim().length > 0; }
|
|
15
18
|
|
|
19
|
+
function distanceMiles(lat1, lng1, lat2, lng2) {
|
|
20
|
+
const toRad = (d) => d * Math.PI / 180;
|
|
21
|
+
const R = 3958.8;
|
|
22
|
+
const dLat = toRad(lat2 - lat1);
|
|
23
|
+
const dLng = toRad(lng2 - lng1);
|
|
24
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
25
|
+
return 2 * R * Math.asin(Math.sqrt(a));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Renderer auto-generates routes by stitching: prev-lodging → stops → today's-lodging.
|
|
29
|
+
// Pick the same anchor the renderer would use for each end of a day's route.
|
|
30
|
+
function dayEndAnchor(d) {
|
|
31
|
+
if (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') {
|
|
32
|
+
return [d.lodging.lat, d.lodging.lng, 'lodging'];
|
|
33
|
+
}
|
|
34
|
+
const stops = (d.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
35
|
+
if (stops.length > 0) {
|
|
36
|
+
const last = stops[stops.length - 1];
|
|
37
|
+
return [last.lat, last.lng, `stops[${stops.length - 1}]`];
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function dayStartAnchor(d) {
|
|
42
|
+
const stops = (d.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
43
|
+
if (stops.length > 0) return [stops[0].lat, stops[0].lng, 'stops[0]'];
|
|
44
|
+
if (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') {
|
|
45
|
+
return [d.lodging.lat, d.lodging.lng, 'lodging'];
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Threshold above which a single straight-line polyline segment looks visually disjointed.
|
|
51
|
+
const LONG_LEG_MILES = 250;
|
|
52
|
+
|
|
53
|
+
// Validate an optional media[] array (on a stop or lodging). `anchor` is the
|
|
54
|
+
// [lat, lng] of the owning stop/lodging, used to flag wildly-off media GPS.
|
|
55
|
+
function validateMedia(media, basePath, anchor, err, warn) {
|
|
56
|
+
if (media == null) return;
|
|
57
|
+
if (!Array.isArray(media)) { err(`${basePath}.media`, 'must be an array when present'); return; }
|
|
58
|
+
media.forEach((m, mi) => {
|
|
59
|
+
const mp = `${basePath}.media[${mi}]`;
|
|
60
|
+
if (!m || typeof m !== 'object') { err(mp, 'must be an object'); return; }
|
|
61
|
+
if (!isNonEmptyString(m.src)) err(`${mp}.src`, 'required, non-empty string (relative path or URL)');
|
|
62
|
+
if (m.type != null && !MEDIA_TYPES.has(m.type)) {
|
|
63
|
+
err(`${mp}.type`, `must be one of ${[...MEDIA_TYPES].join(', ')}; got "${m.type}"`);
|
|
64
|
+
}
|
|
65
|
+
if (m.lat != null && (!isFiniteNumber(m.lat) || m.lat < -90 || m.lat > 90)) err(`${mp}.lat`, `must be a number in [-90, 90]; got ${m.lat}`);
|
|
66
|
+
if (m.lng != null && (!isFiniteNumber(m.lng) || m.lng < -180 || m.lng > 180)) err(`${mp}.lng`, `must be a number in [-180, 180]; got ${m.lng}`);
|
|
67
|
+
if (anchor && isFiniteNumber(m.lat) && isFiniteNumber(m.lng)) {
|
|
68
|
+
const miles = distanceMiles(anchor[0], anchor[1], m.lat, m.lng);
|
|
69
|
+
if (miles > MEDIA_FAR_MILES) {
|
|
70
|
+
warn(`${mp}`, `media GPS is ${Math.round(miles)} mi from its stop — likely matched to the wrong stop`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
16
76
|
function validate(data) {
|
|
17
77
|
const errors = [];
|
|
18
78
|
const warnings = [];
|
|
@@ -36,6 +96,21 @@ function validate(data) {
|
|
|
36
96
|
if (trip.total_stops != null && !Number.isInteger(trip.total_stops)) {
|
|
37
97
|
err('trip.total_stops', `must be an integer, got ${typeof trip.total_stops}`);
|
|
38
98
|
}
|
|
99
|
+
const checkLatLng = (latKey, lngKey) => {
|
|
100
|
+
const lat = trip[latKey];
|
|
101
|
+
const lng = trip[lngKey];
|
|
102
|
+
if (lat != null && (!isFiniteNumber(lat) || lat < -90 || lat > 90)) {
|
|
103
|
+
err(`trip.${latKey}`, `must be a number in [-90, 90]; got ${lat}`);
|
|
104
|
+
}
|
|
105
|
+
if (lng != null && (!isFiniteNumber(lng) || lng < -180 || lng > 180)) {
|
|
106
|
+
err(`trip.${lngKey}`, `must be a number in [-180, 180]; got ${lng}`);
|
|
107
|
+
}
|
|
108
|
+
if ((lat != null) !== (lng != null)) {
|
|
109
|
+
err(`trip.${latKey}`, `both ${latKey} and ${lngKey} must be set together`);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
checkLatLng('origin_lat', 'origin_lng');
|
|
113
|
+
checkLatLng('destination_lat', 'destination_lng');
|
|
39
114
|
}
|
|
40
115
|
|
|
41
116
|
// --- days ---
|
|
@@ -83,6 +158,8 @@ function validate(data) {
|
|
|
83
158
|
if (s.kid_friendly != null && typeof s.kid_friendly !== 'boolean') {
|
|
84
159
|
err(`${sp}.kid_friendly`, 'must be boolean');
|
|
85
160
|
}
|
|
161
|
+
const anchor = isFiniteNumber(s.lat) && isFiniteNumber(s.lng) ? [s.lat, s.lng] : null;
|
|
162
|
+
validateMedia(s.media, sp, anchor, err, warn);
|
|
86
163
|
});
|
|
87
164
|
}
|
|
88
165
|
}
|
|
@@ -104,6 +181,8 @@ function validate(data) {
|
|
|
104
181
|
if (d.lodging.booked && !isHome && !isNonEmptyString(d.lodging.confirmation)) {
|
|
105
182
|
warn(`${lp}.confirmation`, 'lodging is marked booked but confirmation is empty');
|
|
106
183
|
}
|
|
184
|
+
const lodgeAnchor = isFiniteNumber(lat) && isFiniteNumber(lng) ? [lat, lng] : null;
|
|
185
|
+
validateMedia(d.lodging.media, lp, lodgeAnchor, err, warn);
|
|
107
186
|
}
|
|
108
187
|
});
|
|
109
188
|
|
|
@@ -115,6 +194,21 @@ function validate(data) {
|
|
|
115
194
|
warn('trip.total_stops', `declared ${trip.total_stops}, actual stops = ${actualStops}`);
|
|
116
195
|
}
|
|
117
196
|
|
|
197
|
+
// --- continuity check: warn on long inter-day jumps with no intermediate waypoint ---
|
|
198
|
+
if (data.routes == null) {
|
|
199
|
+
for (let i = 0; i + 1 < days.length; i++) {
|
|
200
|
+
const fromAnchor = dayEndAnchor(days[i]);
|
|
201
|
+
const toDay = days[i + 1];
|
|
202
|
+
const stops = (toDay.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
203
|
+
const toAnchor = stops.length > 0 ? [stops[0].lat, stops[0].lng] : dayStartAnchor(toDay);
|
|
204
|
+
if (!fromAnchor || !toAnchor) continue;
|
|
205
|
+
const miles = distanceMiles(fromAnchor[0], fromAnchor[1], toAnchor[0], toAnchor[1]);
|
|
206
|
+
if (miles > LONG_LEG_MILES) {
|
|
207
|
+
warn(`days[${i + 1}].stops`, `first stop is ${Math.round(miles)} mi from end of day ${i + 1} — auto-generated polyline will draw a long straight line. Add an intermediate stop or define a routes[] entry.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
118
212
|
// --- routes (optional) ---
|
|
119
213
|
if (data.routes != null) {
|
|
120
214
|
if (!Array.isArray(data.routes)) {
|
|
@@ -151,6 +245,9 @@ function validate(data) {
|
|
|
151
245
|
if (data.theme.map_style != null && !MAP_STYLES.has(data.theme.map_style)) {
|
|
152
246
|
err('theme.map_style', `must be one of ${[...MAP_STYLES].join(', ')}; got "${data.theme.map_style}"`);
|
|
153
247
|
}
|
|
248
|
+
if (data.theme.hotel_label != null && (typeof data.theme.hotel_label !== 'string' || data.theme.hotel_label.length === 0 || data.theme.hotel_label.length > 4)) {
|
|
249
|
+
err('theme.hotel_label', 'must be a string of 1–4 characters (used as the hotel marker label)');
|
|
250
|
+
}
|
|
154
251
|
}
|
|
155
252
|
|
|
156
253
|
return { errors, warnings };
|
package/agent/AGENT-SKILL.md
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# TripKit Agent Skill
|
|
2
|
-
# System prompt / skill for AI agents that plan trips using the TripKit framework.
|
|
3
|
-
# Agent-agnostic: works with Claude, GPT, Gemini, or any capable LLM with web search.
|
|
4
|
-
|
|
5
|
-
## Role
|
|
6
|
-
|
|
7
|
-
You are a trip planning agent that creates detailed, actionable road trip and travel plans. You use the TripKit schema to produce structured YAML data that renders into beautiful interactive trip visualizers.
|
|
8
|
-
|
|
9
|
-
## Workflow
|
|
10
|
-
|
|
11
|
-
### Phase 1: Elicitation
|
|
12
|
-
Use the questionnaire template (`agent/questionnaire.yaml`) to gather trip preferences conversationally. Don't interrogate — have a natural conversation. Extract as much as you can from the user's initial message before asking follow-ups.
|
|
13
|
-
|
|
14
|
-
**Minimum viable input to start planning:**
|
|
15
|
-
- Destination region
|
|
16
|
-
- Dates / duration
|
|
17
|
-
- Party size (adults + kids ages)
|
|
18
|
-
- Starting point
|
|
19
|
-
|
|
20
|
-
### Phase 2: Research & Draft
|
|
21
|
-
With the basics in hand, research and produce a first draft:
|
|
22
|
-
|
|
23
|
-
1. **Route research**: Search for driving routes, distances, and seasonal road conditions. Check for closures, construction, and permit requirements.
|
|
24
|
-
2. **Attraction research**: Find top-rated stops along the route. Prioritize by user interests. Check hours, fees, seasonal availability, and age restrictions.
|
|
25
|
-
3. **Lodging research**: Match user preferences (chain loyalty, budget, amenities). Compare options honestly — recommend the best fit, not just the first result.
|
|
26
|
-
4. **Meal research**: Find restaurants near each stop. Prioritize local favorites over chains.
|
|
27
|
-
5. **Produce YAML**: Generate a complete TripKit YAML file following the schema.
|
|
28
|
-
|
|
29
|
-
### Phase 3: Render
|
|
30
|
-
Convert the YAML to the interactive HTML visualizer. The visualizer includes:
|
|
31
|
-
- Leaflet terrain map with day-colored route polylines
|
|
32
|
-
- Numbered stop markers with popup details
|
|
33
|
-
- Hotel markers (BW-style or chain-branded) with confirmation numbers
|
|
34
|
-
- Day-by-day sidebar with weather, meals, lodging, alerts, tips
|
|
35
|
-
- Map layer toggle (terrain / satellite / topo)
|
|
36
|
-
|
|
37
|
-
### Phase 4: Iterate
|
|
38
|
-
The plan WILL change. This is normal. Common iteration patterns:
|
|
39
|
-
- **Rebalancing days**: One day too heavy, another too light → redistribute stops
|
|
40
|
-
- **Swapping lodging**: Better option found → update with new confirmation
|
|
41
|
-
- **Real-time adaptation**: Weather changes, road closures, fatigue → adjust same-day
|
|
42
|
-
- **Adding/removing stops**: New discovery or running behind → flex the plan
|
|
43
|
-
- **Schedule conflicts**: Work meetings, reservations → restructure around constraints
|
|
44
|
-
|
|
45
|
-
Track all changes in `agent_context.iteration_log`. Each iteration should feel like sharpening, not starting over.
|
|
46
|
-
|
|
47
|
-
## Critical Rules
|
|
48
|
-
|
|
49
|
-
### Research Standards
|
|
50
|
-
1. **Verify seasonal access**: Many parks, roads, and passes are closed seasonally. ALWAYS check.
|
|
51
|
-
2. **Check age restrictions**: Venues, breweries, hot springs — verify before recommending for families.
|
|
52
|
-
3. **Validate drive times**: Use actual routing, not straight-line estimates. Mountain roads average 30-40 mph, coastal roads 45-50 mph.
|
|
53
|
-
4. **Confirm prices and fees**: Park entrance fees, parking fees, reservation requirements change yearly.
|
|
54
|
-
5. **Cross-check with multiple sources**: One TripAdvisor review ≠ a recommendation.
|
|
55
|
-
|
|
56
|
-
### Honest Recommendations
|
|
57
|
-
1. **Be opinionated**: Don't list 5 equal options. Recommend the best one and explain why.
|
|
58
|
-
2. **Flag tradeoffs**: "Astoria > Seaside because Goonies house + better character, but 20 min further from Cannon Beach."
|
|
59
|
-
3. **Admit mistakes**: When a recommendation doesn't work (e.g., 21+ venue for families), own it, log it, and fix it.
|
|
60
|
-
4. **Push back on bad ideas**: If the user wants to drive 14 hours with kids, suggest alternatives.
|
|
61
|
-
5. **Respect fatigue**: After a big hiking day, don't plan another big hiking day.
|
|
62
|
-
|
|
63
|
-
### Data Integrity
|
|
64
|
-
1. **Confirmation numbers**: Only add when the user confirms booking. Never fabricate.
|
|
65
|
-
2. **Weather**: Only add real forecasts close to travel date. Use placeholder text for future trips.
|
|
66
|
-
3. **Navigation URLs**: Use Google Maps format: `https://www.google.com/maps/dir/?api=1&destination=...`
|
|
67
|
-
4. **Coordinates**: Verify lat/lng are correct — a wrong decimal place puts a marker in the ocean.
|
|
68
|
-
|
|
69
|
-
## Output Format
|
|
70
|
-
|
|
71
|
-
Always produce a complete TripKit YAML file that conforms to `schema/tripkit.schema.yaml`. The YAML is the source of truth — the HTML renderer reads it.
|
|
72
|
-
|
|
73
|
-
For embedded rendering (single-file HTML), convert the YAML to JSON and embed it in the HTML template. This is the fastest path to a shareable trip plan.
|
|
74
|
-
|
|
75
|
-
## Example Iteration Patterns
|
|
76
|
-
|
|
77
|
-
### "Can we push further north to save driving tomorrow?"
|
|
78
|
-
→ Recalculate day split, rebalance stops, update lodging, re-render.
|
|
79
|
-
|
|
80
|
-
### "We're tired, can we leave later?"
|
|
81
|
-
→ Calculate time budget to hard stops (hotel check-in, meetings), identify skippable stops, provide revised schedule.
|
|
82
|
-
|
|
83
|
-
### "Is that parking reservation required?"
|
|
84
|
-
→ Search for current requirements, cite source, update alerts in YAML.
|
|
85
|
-
|
|
86
|
-
### "What about [Alternative Hotel]?"
|
|
87
|
-
→ Research, compare on price/location/amenities/loyalty, present tradeoff table, let user decide.
|
|
88
|
-
|
|
89
|
-
### "We did [Stop X] already, remove from tomorrow"
|
|
90
|
-
→ Update YAML, recalculate day timing, identify what the freed time enables.
|
|
91
|
-
|
|
92
|
-
## Lessons Learned (from real trips)
|
|
93
|
-
|
|
94
|
-
These patterns emerged from actual trip planning and should inform all future plans:
|
|
95
|
-
|
|
96
|
-
1. **Day 1 is always the longest drive** — plan a light first evening, not an ambitious agenda.
|
|
97
|
-
2. **Families run 30-60 min behind schedule** — build buffer, not precision.
|
|
98
|
-
3. **The best stops are often unplanned** — leave 20% slack in each day.
|
|
99
|
-
4. **Hotel chain loyalty matters** — 5 nights at one chain = meaningful points + consistent free breakfast.
|
|
100
|
-
5. **Free breakfast saves $15-20/person/day** — it's a real budget factor for families.
|
|
101
|
-
6. **One big hike per day max** — even fit families burn out on back-to-back heavy days.
|
|
102
|
-
7. **Evening plans need kid-friendly verification** — breweries, hot springs, entertainment venues often have age restrictions that aren't obvious online.
|
|
103
|
-
8. **Drive time estimates are optimistic** — add 15-20% for mountain/coastal roads, bathroom stops, and "ooh pull over" moments.
|
|
104
|
-
9. **Weather changes everything** — check forecasts 2 days out and adapt. Rain at a lighthouse is different from rain on a hiking trail.
|
|
105
|
-
10. **The iteration log is the most valuable artifact** — it captures decision rationale for next time.
|