tripkit 1.0.1 → 1.2.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 +22 -0
- package/README.md +14 -1
- package/agent/AGENT-SKILL.md +114 -68
- package/convert.js +84 -13
- package/examples/new-england-fall-2026.yaml +471 -0
- package/examples/nyc-long-weekend-2026.yaml +283 -0
- package/examples/oregon-spring-2026.yaml +2 -0
- package/examples/southwest-parks-2026.yaml +463 -0
- package/package.json +7 -2
- package/renderers/html/tripkit-renderer.html +90 -16
- package/schema/tripkit.schema.yaml +7 -0
- package/scripts/check-skill-coverage.js +66 -0
- package/validate.js +226 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Coverage check: every renderer-meaningful schema field must be mentioned
|
|
4
|
+
* by name in the agent skill. Prevents drift when fields are added.
|
|
5
|
+
*
|
|
6
|
+
* Run: node scripts/check-skill-coverage.js
|
|
7
|
+
* (also wired into `npm test`)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.join(__dirname, '..');
|
|
14
|
+
const skill = fs.readFileSync(path.join(ROOT, 'agent', 'AGENT-SKILL.md'), 'utf8');
|
|
15
|
+
|
|
16
|
+
// Hand-curated list of fields the agent must know about. When you add a renderer-
|
|
17
|
+
// meaningful field, add it here and to AGENT-SKILL.md in the same change.
|
|
18
|
+
const REQUIRED_FIELDS = [
|
|
19
|
+
// trip
|
|
20
|
+
'trip.title', 'trip.dates', 'trip.total_days', 'trip.total_stops',
|
|
21
|
+
'trip.origin', 'trip.origin_lat', 'trip.origin_lng',
|
|
22
|
+
'trip.destination_lat', 'trip.destination_lng',
|
|
23
|
+
// days
|
|
24
|
+
'days[].number', 'days[].title', 'days[].date', 'days[].status', 'days[].color',
|
|
25
|
+
'days[].summary', 'days[].weather', 'days[].meals', 'days[].lodging',
|
|
26
|
+
'days[].alerts', 'days[].tips', 'days[].stops',
|
|
27
|
+
// stops
|
|
28
|
+
'stops[].name', 'stops[].lat', 'stops[].lng', 'stops[].type', 'stops[].label',
|
|
29
|
+
'stops[].description', 'stops[].kid_friendly', 'stops[].reservation_required',
|
|
30
|
+
'stops[].navigate_url',
|
|
31
|
+
// lodging
|
|
32
|
+
'lodging.name', 'lodging.lat', 'lodging.lng', 'lodging.confirmation', 'lodging.booked',
|
|
33
|
+
// routes
|
|
34
|
+
'routes[]',
|
|
35
|
+
// theme
|
|
36
|
+
'theme.font_family', 'theme.accent_color', 'theme.map_style', 'theme.dark_mode',
|
|
37
|
+
'theme.hotel_label',
|
|
38
|
+
// agent_context
|
|
39
|
+
'agent_context.preferences', 'agent_context.constraints', 'agent_context.iteration_log',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Each entry above resolves to one or more "tokens" the skill must contain.
|
|
43
|
+
// Strip the path prefix so the lookup is forgiving across formats like
|
|
44
|
+
// `trip.origin_lat`, ``trip.origin_lat``, or just `origin_lat`.
|
|
45
|
+
function lookupTokens(field) {
|
|
46
|
+
const leaf = field.replace(/^.*\./, '').replace(/\[\]$/, '');
|
|
47
|
+
return [field, leaf];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (const field of REQUIRED_FIELDS) {
|
|
52
|
+
const tokens = lookupTokens(field);
|
|
53
|
+
const found = tokens.some(t => skill.includes(t));
|
|
54
|
+
if (!found) missing.push(field);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (missing.length === 0) {
|
|
58
|
+
console.log(`✓ AGENT-SKILL.md covers all ${REQUIRED_FIELDS.length} required fields`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.error(`✖ AGENT-SKILL.md is missing references to ${missing.length} required field(s):`);
|
|
63
|
+
for (const f of missing) console.error(` - ${f}`);
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Either add a mention to agent/AGENT-SKILL.md, or remove it from REQUIRED_FIELDS in this script if the field is no longer renderer-meaningful.');
|
|
66
|
+
process.exit(1);
|
package/validate.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TripKit YAML validator.
|
|
3
|
+
*
|
|
4
|
+
* Exports `validate(data)` returning { errors, warnings } — each is a list of
|
|
5
|
+
* { path, message } objects. Errors block render; warnings are advisory.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STOP_TYPES = new Set(['hike', 'scenic', 'food', 'city', 'activity', 'beach', 'museum', 'shopping']);
|
|
9
|
+
const DAY_STATUS = new Set(['completed', 'active', 'upcoming']);
|
|
10
|
+
const MAP_STYLES = new Set(['terrain', 'satellite', 'topo', 'street']);
|
|
11
|
+
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
12
|
+
|
|
13
|
+
function isFiniteNumber(n) { return typeof n === 'number' && Number.isFinite(n); }
|
|
14
|
+
function isNonEmptyString(s) { return typeof s === 'string' && s.trim().length > 0; }
|
|
15
|
+
|
|
16
|
+
function distanceMiles(lat1, lng1, lat2, lng2) {
|
|
17
|
+
const toRad = (d) => d * Math.PI / 180;
|
|
18
|
+
const R = 3958.8;
|
|
19
|
+
const dLat = toRad(lat2 - lat1);
|
|
20
|
+
const dLng = toRad(lng2 - lng1);
|
|
21
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
|
22
|
+
return 2 * R * Math.asin(Math.sqrt(a));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Renderer auto-generates routes by stitching: prev-lodging → stops → today's-lodging.
|
|
26
|
+
// Pick the same anchor the renderer would use for each end of a day's route.
|
|
27
|
+
function dayEndAnchor(d) {
|
|
28
|
+
if (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') {
|
|
29
|
+
return [d.lodging.lat, d.lodging.lng, 'lodging'];
|
|
30
|
+
}
|
|
31
|
+
const stops = (d.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
32
|
+
if (stops.length > 0) {
|
|
33
|
+
const last = stops[stops.length - 1];
|
|
34
|
+
return [last.lat, last.lng, `stops[${stops.length - 1}]`];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function dayStartAnchor(d) {
|
|
39
|
+
const stops = (d.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
40
|
+
if (stops.length > 0) return [stops[0].lat, stops[0].lng, 'stops[0]'];
|
|
41
|
+
if (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') {
|
|
42
|
+
return [d.lodging.lat, d.lodging.lng, 'lodging'];
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Threshold above which a single straight-line polyline segment looks visually disjointed.
|
|
48
|
+
const LONG_LEG_MILES = 250;
|
|
49
|
+
|
|
50
|
+
function validate(data) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const err = (path, message) => errors.push({ path, message });
|
|
54
|
+
const warn = (path, message) => warnings.push({ path, message });
|
|
55
|
+
|
|
56
|
+
if (!data || typeof data !== 'object') {
|
|
57
|
+
err('(root)', 'YAML did not parse to an object');
|
|
58
|
+
return { errors, warnings };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- trip ---
|
|
62
|
+
const trip = data.trip;
|
|
63
|
+
if (!trip || typeof trip !== 'object') {
|
|
64
|
+
err('trip', 'missing trip metadata block');
|
|
65
|
+
} else {
|
|
66
|
+
if (!isNonEmptyString(trip.title)) err('trip.title', 'required, non-empty string');
|
|
67
|
+
if (trip.total_days != null && !Number.isInteger(trip.total_days)) {
|
|
68
|
+
err('trip.total_days', `must be an integer, got ${typeof trip.total_days}`);
|
|
69
|
+
}
|
|
70
|
+
if (trip.total_stops != null && !Number.isInteger(trip.total_stops)) {
|
|
71
|
+
err('trip.total_stops', `must be an integer, got ${typeof trip.total_stops}`);
|
|
72
|
+
}
|
|
73
|
+
const checkLatLng = (latKey, lngKey) => {
|
|
74
|
+
const lat = trip[latKey];
|
|
75
|
+
const lng = trip[lngKey];
|
|
76
|
+
if (lat != null && (!isFiniteNumber(lat) || lat < -90 || lat > 90)) {
|
|
77
|
+
err(`trip.${latKey}`, `must be a number in [-90, 90]; got ${lat}`);
|
|
78
|
+
}
|
|
79
|
+
if (lng != null && (!isFiniteNumber(lng) || lng < -180 || lng > 180)) {
|
|
80
|
+
err(`trip.${lngKey}`, `must be a number in [-180, 180]; got ${lng}`);
|
|
81
|
+
}
|
|
82
|
+
if ((lat != null) !== (lng != null)) {
|
|
83
|
+
err(`trip.${latKey}`, `both ${latKey} and ${lngKey} must be set together`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
checkLatLng('origin_lat', 'origin_lng');
|
|
87
|
+
checkLatLng('destination_lat', 'destination_lng');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- days ---
|
|
91
|
+
const days = data.days;
|
|
92
|
+
if (!Array.isArray(days) || days.length === 0) {
|
|
93
|
+
err('days', 'must be a non-empty array');
|
|
94
|
+
return { errors, warnings };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let actualStops = 0;
|
|
98
|
+
days.forEach((d, di) => {
|
|
99
|
+
const dp = `days[${di}]`;
|
|
100
|
+
if (!d || typeof d !== 'object') {
|
|
101
|
+
err(dp, 'must be an object');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isInteger(d.number)) err(`${dp}.number`, 'required integer');
|
|
105
|
+
else if (d.number !== di + 1) warn(`${dp}.number`, `expected ${di + 1} (sequential), got ${d.number}`);
|
|
106
|
+
if (!isNonEmptyString(d.title)) err(`${dp}.title`, 'required, non-empty string');
|
|
107
|
+
if (!isNonEmptyString(d.date)) err(`${dp}.date`, 'required, non-empty string');
|
|
108
|
+
if (d.status != null && !DAY_STATUS.has(d.status)) {
|
|
109
|
+
err(`${dp}.status`, `must be one of ${[...DAY_STATUS].join(', ')}; got "${d.status}"`);
|
|
110
|
+
}
|
|
111
|
+
if (d.color != null && !HEX.test(d.color)) {
|
|
112
|
+
err(`${dp}.color`, `must be a hex color (#abc or #aabbcc); got "${d.color}"`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// stops
|
|
116
|
+
if (d.stops != null) {
|
|
117
|
+
if (!Array.isArray(d.stops)) {
|
|
118
|
+
err(`${dp}.stops`, 'must be an array');
|
|
119
|
+
} else {
|
|
120
|
+
d.stops.forEach((s, si) => {
|
|
121
|
+
const sp = `${dp}.stops[${si}]`;
|
|
122
|
+
actualStops++;
|
|
123
|
+
if (!s || typeof s !== 'object') { err(sp, 'must be an object'); return; }
|
|
124
|
+
if (!isNonEmptyString(s.name)) err(`${sp}.name`, 'required, non-empty string');
|
|
125
|
+
if (!isFiniteNumber(s.lat)) err(`${sp}.lat`, 'required finite number');
|
|
126
|
+
else if (s.lat < -90 || s.lat > 90) err(`${sp}.lat`, `out of range [-90, 90]: ${s.lat}`);
|
|
127
|
+
if (!isFiniteNumber(s.lng)) err(`${sp}.lng`, 'required finite number');
|
|
128
|
+
else if (s.lng < -180 || s.lng > 180) err(`${sp}.lng`, `out of range [-180, 180]: ${s.lng}`);
|
|
129
|
+
if (s.type != null && !STOP_TYPES.has(s.type)) {
|
|
130
|
+
err(`${sp}.type`, `must be one of ${[...STOP_TYPES].join(', ')}; got "${s.type}"`);
|
|
131
|
+
}
|
|
132
|
+
if (s.kid_friendly != null && typeof s.kid_friendly !== 'boolean') {
|
|
133
|
+
err(`${sp}.kid_friendly`, 'must be boolean');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// lodging
|
|
140
|
+
if (d.lodging && typeof d.lodging === 'object') {
|
|
141
|
+
const lp = `${dp}.lodging`;
|
|
142
|
+
const { lat, lng } = d.lodging;
|
|
143
|
+
if (lat != null && (!isFiniteNumber(lat) || lat < -90 || lat > 90)) {
|
|
144
|
+
err(`${lp}.lat`, `must be a number in [-90, 90]; got ${lat}`);
|
|
145
|
+
}
|
|
146
|
+
if (lng != null && (!isFiniteNumber(lng) || lng < -180 || lng > 180)) {
|
|
147
|
+
err(`${lp}.lng`, `must be a number in [-180, 180]; got ${lng}`);
|
|
148
|
+
}
|
|
149
|
+
if (d.lodging.booked != null && typeof d.lodging.booked !== 'boolean') {
|
|
150
|
+
err(`${lp}.booked`, 'must be boolean');
|
|
151
|
+
}
|
|
152
|
+
const isHome = d.lodging.name === 'Home';
|
|
153
|
+
if (d.lodging.booked && !isHome && !isNonEmptyString(d.lodging.confirmation)) {
|
|
154
|
+
warn(`${lp}.confirmation`, 'lodging is marked booked but confirmation is empty');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- cross-check totals ---
|
|
160
|
+
if (trip && Number.isInteger(trip.total_days) && trip.total_days !== days.length) {
|
|
161
|
+
warn('trip.total_days', `declared ${trip.total_days}, actual days = ${days.length}`);
|
|
162
|
+
}
|
|
163
|
+
if (trip && Number.isInteger(trip.total_stops) && trip.total_stops !== actualStops) {
|
|
164
|
+
warn('trip.total_stops', `declared ${trip.total_stops}, actual stops = ${actualStops}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- continuity check: warn on long inter-day jumps with no intermediate waypoint ---
|
|
168
|
+
if (data.routes == null) {
|
|
169
|
+
for (let i = 0; i + 1 < days.length; i++) {
|
|
170
|
+
const fromAnchor = dayEndAnchor(days[i]);
|
|
171
|
+
const toDay = days[i + 1];
|
|
172
|
+
const stops = (toDay.stops || []).filter(s => isFiniteNumber(s.lat) && isFiniteNumber(s.lng));
|
|
173
|
+
const toAnchor = stops.length > 0 ? [stops[0].lat, stops[0].lng] : dayStartAnchor(toDay);
|
|
174
|
+
if (!fromAnchor || !toAnchor) continue;
|
|
175
|
+
const miles = distanceMiles(fromAnchor[0], fromAnchor[1], toAnchor[0], toAnchor[1]);
|
|
176
|
+
if (miles > LONG_LEG_MILES) {
|
|
177
|
+
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.`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- routes (optional) ---
|
|
183
|
+
if (data.routes != null) {
|
|
184
|
+
if (!Array.isArray(data.routes)) {
|
|
185
|
+
err('routes', 'must be an array when present');
|
|
186
|
+
} else {
|
|
187
|
+
data.routes.forEach((r, ri) => {
|
|
188
|
+
const rp = `routes[${ri}]`;
|
|
189
|
+
if (!Number.isInteger(r.day)) err(`${rp}.day`, 'required integer');
|
|
190
|
+
else if (r.day < 1 || r.day > days.length) {
|
|
191
|
+
err(`${rp}.day`, `references day ${r.day}, but only ${days.length} days exist`);
|
|
192
|
+
}
|
|
193
|
+
if (r.color != null && !HEX.test(r.color)) err(`${rp}.color`, `must be hex; got "${r.color}"`);
|
|
194
|
+
if (!Array.isArray(r.points) || r.points.length < 2) {
|
|
195
|
+
err(`${rp}.points`, 'must be an array of at least 2 [lat, lng] pairs');
|
|
196
|
+
} else {
|
|
197
|
+
r.points.forEach((pt, pi) => {
|
|
198
|
+
if (!Array.isArray(pt) || pt.length !== 2 || !isFiniteNumber(pt[0]) || !isFiniteNumber(pt[1])) {
|
|
199
|
+
err(`${rp}.points[${pi}]`, 'must be [lat, lng] number pair');
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- theme (optional) ---
|
|
208
|
+
if (data.theme && typeof data.theme === 'object') {
|
|
209
|
+
if (data.theme.accent_color != null && !HEX.test(data.theme.accent_color)) {
|
|
210
|
+
err('theme.accent_color', `must be hex; got "${data.theme.accent_color}"`);
|
|
211
|
+
}
|
|
212
|
+
if (data.theme.dark_mode != null && typeof data.theme.dark_mode !== 'boolean') {
|
|
213
|
+
err('theme.dark_mode', 'must be boolean');
|
|
214
|
+
}
|
|
215
|
+
if (data.theme.map_style != null && !MAP_STYLES.has(data.theme.map_style)) {
|
|
216
|
+
err('theme.map_style', `must be one of ${[...MAP_STYLES].join(', ')}; got "${data.theme.map_style}"`);
|
|
217
|
+
}
|
|
218
|
+
if (data.theme.hotel_label != null && (typeof data.theme.hotel_label !== 'string' || data.theme.hotel_label.length === 0 || data.theme.hotel_label.length > 4)) {
|
|
219
|
+
err('theme.hotel_label', 'must be a string of 1–4 characters (used as the hotel marker label)');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { errors, warnings };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { validate };
|