trip-optimizer 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,2781 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ generateProgram,
4
+ getGlobalDir,
5
+ getLearnedPath,
6
+ getLlmLanguageInstruction,
7
+ getTripHistoryPath,
8
+ loadConfig,
9
+ saveConfig,
10
+ setLanguage,
11
+ t
12
+ } from "./chunk-OSZUVWNK.js";
13
+
14
+ // src/cli.ts
15
+ import { Command } from "commander";
16
+
17
+ // src/commands/init.ts
18
+ import { input, select, checkbox } from "@inquirer/prompts";
19
+ import chalk from "chalk";
20
+ import ora from "ora";
21
+ import fs3 from "fs";
22
+ import path3 from "path";
23
+ import yaml2 from "js-yaml";
24
+
25
+ // src/data/profile.ts
26
+ import fs from "fs";
27
+ import path from "path";
28
+ var DEFAULT_PROFILE = {
29
+ loyalty_program: "",
30
+ dietary: [],
31
+ stated_vibes: [],
32
+ learned_vibes: [],
33
+ anti_patterns: [],
34
+ anti_patterns_learned: [],
35
+ source_trust: {},
36
+ trips_completed: 0,
37
+ last_debrief: ""
38
+ };
39
+ function loadProfile(dir) {
40
+ const profilePath = path.join(dir ?? getGlobalDir(), "profile.json");
41
+ if (!fs.existsSync(profilePath)) return { ...DEFAULT_PROFILE };
42
+ return JSON.parse(fs.readFileSync(profilePath, "utf-8"));
43
+ }
44
+ function saveProfile(profile, dir) {
45
+ const d = dir ?? getGlobalDir();
46
+ fs.mkdirSync(d, { recursive: true });
47
+ fs.writeFileSync(path.join(d, "profile.json"), JSON.stringify(profile, null, 2));
48
+ }
49
+
50
+ // src/data/trip.ts
51
+ import fs2 from "fs";
52
+ import path2 from "path";
53
+ import { simpleGit } from "simple-git";
54
+ var GITIGNORE = `results.tsv
55
+ score_history.jsonl
56
+ node_modules/
57
+ `;
58
+ async function scaffoldTrip(tripDir, files) {
59
+ fs2.mkdirSync(tripDir, { recursive: true });
60
+ fs2.writeFileSync(path2.join(tripDir, "constraints.yaml"), files.constraints);
61
+ fs2.writeFileSync(path2.join(tripDir, "rubrics.yaml"), files.rubrics);
62
+ fs2.writeFileSync(path2.join(tripDir, "plan.md"), files.plan);
63
+ fs2.writeFileSync(path2.join(tripDir, "program.md"), files.program);
64
+ fs2.writeFileSync(path2.join(tripDir, "activities_db.json"), "{}");
65
+ fs2.writeFileSync(path2.join(tripDir, ".gitignore"), GITIGNORE);
66
+ const git = simpleGit(tripDir);
67
+ await git.init();
68
+ await git.add("-A");
69
+ await git.commit("Initial trip scaffold");
70
+ }
71
+
72
+ // src/llm/anthropic.ts
73
+ import Anthropic from "@anthropic-ai/sdk";
74
+ var AnthropicProvider = class {
75
+ client;
76
+ model;
77
+ constructor(apiKey, model = "claude-sonnet-4-20250514") {
78
+ this.client = new Anthropic({ apiKey });
79
+ this.model = model;
80
+ }
81
+ async complete(prompt, maxTokens) {
82
+ const response = await this.client.messages.create({
83
+ model: this.model,
84
+ max_tokens: maxTokens,
85
+ messages: [{ role: "user", content: prompt }]
86
+ });
87
+ const block = response.content[0];
88
+ if (block.type !== "text") throw new Error("Expected text response from LLM");
89
+ return block.text.trim();
90
+ }
91
+ };
92
+
93
+ // src/llm/vertex.ts
94
+ import AnthropicVertex from "@anthropic-ai/vertex-sdk";
95
+ var VertexProvider = class {
96
+ client;
97
+ model;
98
+ constructor(options) {
99
+ this.client = new AnthropicVertex({
100
+ projectId: options?.projectId ?? process.env.GOOGLE_CLOUD_PROJECT,
101
+ region: options?.region ?? process.env.GOOGLE_CLOUD_LOCATION ?? "us-east5"
102
+ });
103
+ this.model = options?.model ?? process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4@20250514";
104
+ }
105
+ async complete(prompt, maxTokens) {
106
+ const response = await this.client.messages.create({
107
+ model: this.model,
108
+ max_tokens: maxTokens,
109
+ messages: [{ role: "user", content: prompt }]
110
+ });
111
+ const block = response.content[0];
112
+ if (block.type !== "text") throw new Error("Expected text response from LLM");
113
+ return block.text.trim();
114
+ }
115
+ };
116
+
117
+ // src/llm/openai-compatible.ts
118
+ var THINKING_DISABLE_PATTERNS = [
119
+ /kimi-k2/i,
120
+ /deepseek-r1/i,
121
+ /deepseek-reasoner/i
122
+ ];
123
+ function supportsThinkingDisable(model) {
124
+ return THINKING_DISABLE_PATTERNS.some((p) => p.test(model));
125
+ }
126
+ var OpenAICompatibleProvider = class {
127
+ baseUrl;
128
+ apiKey;
129
+ model;
130
+ reasoning;
131
+ constructor(options) {
132
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
133
+ this.apiKey = options.apiKey;
134
+ this.model = options.model;
135
+ this.reasoning = supportsThinkingDisable(this.model);
136
+ }
137
+ async complete(prompt, maxTokens) {
138
+ const url = `${this.baseUrl}/chat/completions`;
139
+ const body = {
140
+ model: this.model,
141
+ max_tokens: maxTokens,
142
+ messages: [{ role: "user", content: prompt }]
143
+ };
144
+ if (this.reasoning) {
145
+ body.thinking = { type: "disabled" };
146
+ }
147
+ const response = await fetch(url, {
148
+ method: "POST",
149
+ headers: {
150
+ "Content-Type": "application/json",
151
+ "Authorization": `Bearer ${this.apiKey}`
152
+ },
153
+ body: JSON.stringify(body)
154
+ });
155
+ if (!response.ok) {
156
+ const body2 = await response.text();
157
+ throw new Error(`${this.model} API error ${response.status}: ${body2}`);
158
+ }
159
+ const data = await response.json();
160
+ const choice = data.choices?.[0];
161
+ if (!choice?.message) {
162
+ throw new Error(`Unexpected response from ${this.model}: no message in response`);
163
+ }
164
+ const { content, reasoning_content } = choice.message;
165
+ if (content) {
166
+ return content.trim();
167
+ }
168
+ if (reasoning_content) {
169
+ throw new Error(
170
+ `${this.model} exhausted token budget on reasoning (${effectiveMaxTokens} tokens). The model used all tokens for chain-of-thought without producing a final answer. This may resolve on retry, or the prompt may be too complex for the token budget.`
171
+ );
172
+ }
173
+ throw new Error(`${this.model} returned empty response`);
174
+ }
175
+ };
176
+
177
+ // src/llm/factory.ts
178
+ function createProvider(config) {
179
+ if (config.model_override) {
180
+ const mo = config.model_override;
181
+ return new OpenAICompatibleProvider({
182
+ baseUrl: mo.base_url,
183
+ apiKey: mo.api_key,
184
+ model: mo.model
185
+ });
186
+ }
187
+ if (process.env.CLAUDE_CODE_USE_VERTEX === "1" || process.env.GOOGLE_CLOUD_PROJECT) {
188
+ return new VertexProvider();
189
+ }
190
+ if (!config.api_key) {
191
+ throw new Error(
192
+ "No API key configured and no Vertex AI environment detected.\n Run: trip-optimizer config set api_key <key>\n Or set GOOGLE_CLOUD_PROJECT + CLAUDE_CODE_USE_VERTEX=1 for Vertex AI"
193
+ );
194
+ }
195
+ return new AnthropicProvider(config.api_key);
196
+ }
197
+
198
+ // src/generators/constraints.ts
199
+ import yaml from "js-yaml";
200
+ function generateConstraints(answers) {
201
+ const startDate = new Date(answers.start_date);
202
+ const endDate = new Date(answers.end_date);
203
+ const totalDays = Math.round((endDate.getTime() - startDate.getTime()) / (1e3 * 60 * 60 * 24));
204
+ const daysPerCity = Math.max(1, Math.floor(totalDays / answers.cities.length));
205
+ const constraints = {
206
+ trip: {
207
+ name: answers.name,
208
+ start_date: answers.start_date,
209
+ end_date: answers.end_date,
210
+ total_days: totalDays,
211
+ travelers: answers.travelers,
212
+ origin: answers.origin
213
+ },
214
+ cities: answers.cities.map((c) => ({
215
+ name: c.name,
216
+ key: c.key,
217
+ min_days: 1,
218
+ max_days: Math.min(daysPerCity + 2, totalDays)
219
+ })),
220
+ hard_requirements: ["City ordering cannot change"],
221
+ preferences: {
222
+ priority_order: answers.vibes,
223
+ anti_patterns: answers.anti_patterns.length > 0 ? answers.anti_patterns : ["tourist traps", "long queues"],
224
+ pro_patterns: [
225
+ "back-alley local spots",
226
+ "neighborhood wandering with no fixed destination",
227
+ "things you can only do in this specific place",
228
+ "seasonal specialties"
229
+ ]
230
+ },
231
+ dietary: answers.dietary,
232
+ loyalty_program: answers.loyalty_program,
233
+ budget: {
234
+ total: answers.budget_total,
235
+ currency: answers.budget_currency
236
+ }
237
+ };
238
+ return yaml.dump(constraints, { lineWidth: 120, noRefs: true });
239
+ }
240
+
241
+ // src/generators/rubrics.ts
242
+ var SEED_RUBRIC = `dimensions:
243
+ experience_quality:
244
+ weight: 0.25
245
+ sub_dimensions:
246
+ authenticity:
247
+ description: "Are activities local-oriented vs tourist-oriented?"
248
+ anchors:
249
+ 60: "Multiple tourist traps in the plan"
250
+ 80: "Mostly local-oriented, 1-2 tourist items remain"
251
+ 90: "Every activity feels like a local showed you their city"
252
+ uniqueness:
253
+ description: "'Can only do this HERE' experiences"
254
+ anchors:
255
+ 60: "Activities could be done in any major city"
256
+ 80: "Each city has 1-2 'only here' experiences"
257
+ 90: "Nearly every activity leverages something unique to this place"
258
+ logistics_efficiency:
259
+ weight: 0.20
260
+ sub_dimensions:
261
+ geographic_clustering:
262
+ description: "Activities within each day don't zigzag across the city"
263
+ anchors:
264
+ 60: "Multiple days have morning-east, lunch-north, afternoon-south"
265
+ 80: "Most days are geographically logical"
266
+ 90: "Days are designed as geographic arcs"
267
+ transit_realism:
268
+ description: "Specific times, realistic buffers"
269
+ anchors:
270
+ 60: "Vague transit like 'fly to X' with no details"
271
+ 80: "Most transit has times, some connections lack buffers"
272
+ 90: "Door-to-door timing with buffers for every connection"
273
+ food_score:
274
+ weight: 0.15
275
+ sub_dimensions:
276
+ regional_authenticity:
277
+ description: "Local specialties vs generic restaurants"
278
+ anchors:
279
+ 60: "Hotel buffets and chains for most meals"
280
+ 80: "Mostly local specialties, specific restaurants named"
281
+ 90: "Restaurants are the ones locals queue for"
282
+ time_allocation:
283
+ weight: 0.15
284
+ sub_dimensions:
285
+ pacing:
286
+ description: "Not too rushed, not too empty"
287
+ anchors:
288
+ 60: "Some days have 6 activities, others nothing"
289
+ 80: "Generally steady, a few days overpacked"
290
+ 90: "Natural rhythm \u2014 active mornings, relaxed afternoons"
291
+ budget_efficiency:
292
+ weight: 0.10
293
+ sub_dimensions:
294
+ value_ratio:
295
+ description: "High-quality experiences at reasonable cost"
296
+ anchors:
297
+ 60: "Expensive activities with low enjoyment"
298
+ 80: "Good balance, expensive items justified"
299
+ 90: "Maximizes experience per dollar"
300
+ accommodation_quality:
301
+ weight: 0.10
302
+ sub_dimensions:
303
+ location_fit:
304
+ description: "Hotels near the day's key activities"
305
+ anchors:
306
+ 60: "Hotels in random locations requiring long commutes"
307
+ 80: "Most hotels well-located"
308
+ 90: "Hotel locations chosen WITH the itinerary in mind"
309
+ transit_realism:
310
+ weight: 0.05
311
+ sub_dimensions:
312
+ specificity:
313
+ description: "Flight numbers, train types, departure times stated"
314
+ anchors:
315
+ 60: "Just 'fly to X' with no details"
316
+ 80: "Most transit has times, some still vague"
317
+ 90: "Could book every ticket from this plan"
318
+
319
+ adversarial_penalties:
320
+ logistics:
321
+ - rule: "Transit without specific departure/arrival times"
322
+ penalty: -3
323
+ - rule: "Activities scheduled during transit time"
324
+ penalty: -10
325
+ experience:
326
+ - rule: "Tourist trap still in plan"
327
+ penalty: -8
328
+ - rule: "Day with zero unstructured time"
329
+ penalty: -5
330
+ food:
331
+ - rule: "Generic 'local restaurant' without specific recommendation"
332
+ penalty: -3
333
+ - rule: "Chain restaurant when local alternative exists"
334
+ penalty: -5
335
+ realism:
336
+ - rule: "More than 4 major activities in a single day"
337
+ penalty: -5
338
+ max_penalty_per_dimension: -20`;
339
+ async function generateRubrics(provider, constraints, learnedSignals) {
340
+ const tripSummary = `Trip: ${constraints.trip.name}
341
+ Duration: ${constraints.trip.total_days} days
342
+ Travelers: ${constraints.trip.travelers}
343
+ Cities: ${constraints.cities.map((c) => c.name).join(" \u2192 ")}
344
+ Budget: ${constraints.budget.currency} ${constraints.budget.total}
345
+ Vibes: ${constraints.preferences.priority_order.join(", ")}
346
+ Anti-patterns: ${constraints.preferences.anti_patterns.join(", ")}
347
+ Dietary: ${constraints.dietary.length > 0 ? constraints.dietary.join(", ") : "none"}
348
+ Loyalty program: ${constraints.loyalty_program || "none"}`;
349
+ const learnedSection = learnedSignals ? `
350
+ ## Learned Preferences from Past Trips
351
+ ${learnedSignals}
352
+ ` : "";
353
+ const prompt = `Generate a scoring rubric for evaluating this travel plan. The rubric should have 5-7 scoring dimensions, each with 2-4 sub-dimensions. Each sub-dimension needs anchor descriptions at scores 60, 80, and 90.
354
+
355
+ Also generate adversarial penalty rules that catch specific flaws.
356
+
357
+ ## Trip Details
358
+ ${tripSummary}
359
+ ${learnedSection}
360
+
361
+ ## Seed Example (adapt dimensions and anchors to fit THIS specific trip)
362
+ \`\`\`yaml
363
+ ${SEED_RUBRIC}
364
+ \`\`\`
365
+
366
+ IMPORTANT:
367
+ - Adapt dimensions to this trip type (e.g., a solo backpacking trip might have "social_opportunities", a family trip might have "kid_friendliness")
368
+ - Dimension weights must sum to 1.0
369
+ - Anchor descriptions should reference specifics of this trip (cities, season, budget level)
370
+ - Keep the same YAML structure as the seed example
371
+ - Return ONLY valid YAML, no other text${getLlmLanguageInstruction()}`;
372
+ const response = await provider.complete(prompt, 4e3);
373
+ let yamlText = response;
374
+ if (yamlText.startsWith("```")) {
375
+ yamlText = yamlText.split("\n").slice(1).join("\n");
376
+ const lastBacktick = yamlText.lastIndexOf("```");
377
+ if (lastBacktick >= 0) {
378
+ yamlText = yamlText.substring(0, lastBacktick).trim();
379
+ }
380
+ }
381
+ return yamlText;
382
+ }
383
+
384
+ // src/generators/plan.ts
385
+ async function generatePlan(provider, constraints) {
386
+ const langInstruction = getLlmLanguageInstruction();
387
+ const prompt = `Generate a detailed day-by-day travel itinerary for this trip.
388
+
389
+ ## Trip Details
390
+ Name: ${constraints.trip.name}
391
+ Dates: ${constraints.trip.start_date} to ${constraints.trip.end_date} (${constraints.trip.total_days} days)
392
+ Travelers: ${constraints.trip.travelers}
393
+ Origin: ${constraints.trip.origin}
394
+ Cities (in order): ${constraints.cities.map((c) => `${c.name} (${c.min_days}-${c.max_days} days)`).join(" \u2192 ")}
395
+ Budget: ${constraints.budget.currency} ${constraints.budget.total} total
396
+ Preferences: ${constraints.preferences.priority_order.join(", ")}
397
+ Anti-patterns to avoid: ${constraints.preferences.anti_patterns.join(", ")}
398
+ Dietary: ${constraints.dietary.length > 0 ? constraints.dietary.join(", ") : "none"}
399
+ Hotel loyalty: ${constraints.loyalty_program || "none"}
400
+
401
+ ## Requirements
402
+ - Start with YAML frontmatter containing trip metadata
403
+ - Each day should have: morning activity, lunch, afternoon activity, dinner, evening
404
+ - Include specific restaurant recommendations (not generic "local restaurant")
405
+ - Include transit details between cities (transport mode, approximate time)
406
+ - Include hotel recommendations
407
+ - Leave some unstructured time for wandering
408
+ - Be realistic about pacing \u2014 travel days should be light on activities
409
+
410
+ ## Format
411
+ ---
412
+ trip_name: "${constraints.trip.name}"
413
+ total_days: ${constraints.trip.total_days}
414
+ start_date: ${constraints.trip.start_date}
415
+ end_date: ${constraints.trip.end_date}
416
+ ---
417
+
418
+ # Day 1: [City] \u2014 [Theme]
419
+ ## Morning
420
+ ...
421
+ ## Lunch
422
+ ...
423
+ ## Afternoon
424
+ ...
425
+ ## Dinner
426
+ ...
427
+ ## Evening
428
+ ...
429
+
430
+ **Hotel:** [Name]
431
+ **Transit:** [if applicable]
432
+
433
+ Generate the complete itinerary now.${langInstruction}`;
434
+ return await provider.complete(prompt, 8e3);
435
+ }
436
+
437
+ // src/commands/init.ts
438
+ function loadExisting(name) {
439
+ const tripDirName = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
440
+ const constraintsPath = path3.resolve(tripDirName, "constraints.yaml");
441
+ if (!fs3.existsSync(constraintsPath)) return null;
442
+ try {
443
+ return yaml2.load(fs3.readFileSync(constraintsPath, "utf-8"));
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ function vibeChoices() {
449
+ return [
450
+ { value: "wandering", name: t("vibe.wandering") },
451
+ { value: "food", name: t("vibe.food") },
452
+ { value: "culture", name: t("vibe.culture") },
453
+ { value: "nature", name: t("vibe.nature") },
454
+ { value: "adventure", name: t("vibe.adventure") },
455
+ { value: "relaxation", name: t("vibe.relaxation") },
456
+ { value: "nightlife", name: t("vibe.nightlife") },
457
+ { value: "history", name: t("vibe.history") },
458
+ { value: "shopping", name: t("vibe.shopping") },
459
+ { value: "family", name: t("vibe.family") },
460
+ { value: "romantic", name: t("vibe.romantic") }
461
+ ];
462
+ }
463
+ async function collectAnswers(name, profile) {
464
+ const startDate = await input({ message: t("trip.start_date") });
465
+ const endDate = await input({ message: t("trip.end_date") });
466
+ const travelers = await input({ message: t("trip.travelers"), default: "2" });
467
+ const origin = await input({ message: t("trip.origin"), default: "Atlanta" });
468
+ const citiesRaw = await input({
469
+ message: t("trip.cities"),
470
+ validate: (v) => v.includes(",") || v.length > 0 || t("trip.cities_validate")
471
+ });
472
+ const cities = citiesRaw.split(",").map((c) => {
473
+ const trimmed = c.trim();
474
+ const key = trimmed.toLowerCase().replace(/[^a-z0-9]/g, "_");
475
+ return { name: trimmed, key };
476
+ });
477
+ const budgetTotal = await input({ message: t("trip.budget"), default: "5000" });
478
+ const vibes = await checkbox({
479
+ message: t("trip.vibes"),
480
+ choices: vibeChoices()
481
+ });
482
+ const antiPatternsRaw = await input({
483
+ message: t("trip.anti_patterns"),
484
+ default: profile.anti_patterns_learned.length > 0 ? profile.anti_patterns_learned.join(", ") : ""
485
+ });
486
+ const antiPatterns = antiPatternsRaw ? antiPatternsRaw.split(",").map((s) => s.trim()).filter(Boolean) : [];
487
+ return {
488
+ name,
489
+ start_date: startDate,
490
+ end_date: endDate,
491
+ travelers: parseInt(travelers, 10),
492
+ origin,
493
+ cities,
494
+ budget_total: parseInt(budgetTotal, 10),
495
+ budget_currency: "USD",
496
+ vibes,
497
+ anti_patterns: antiPatterns,
498
+ dietary: profile.dietary,
499
+ loyalty_program: profile.loyalty_program
500
+ };
501
+ }
502
+ async function editAnswers(name, existing, profile) {
503
+ console.log(chalk.bold(` ${t("edit.current_settings")}
504
+ `));
505
+ console.log(` ${chalk.dim("1.")} ${t("field.dates")}: ${chalk.white(`${existing.trip.start_date} \u2192 ${existing.trip.end_date}`)}`);
506
+ console.log(` ${chalk.dim("2.")} ${t("field.travelers")}: ${chalk.white(String(existing.trip.travelers))}`);
507
+ console.log(` ${chalk.dim("3.")} ${t("field.origin")}: ${chalk.white(existing.trip.origin)}`);
508
+ console.log(` ${chalk.dim("4.")} ${t("field.cities")}: ${chalk.white(existing.cities.map((c) => c.name).join(", "))}`);
509
+ console.log(` ${chalk.dim("5.")} ${t("field.budget")}: ${chalk.white(`${existing.budget?.currency || "USD"} ${existing.budget?.total || 5e3}`)}`);
510
+ console.log(` ${chalk.dim("6.")} ${t("field.vibes")}: ${chalk.white(existing.preferences.priority_order.join(", "))}`);
511
+ console.log(` ${chalk.dim("7.")} ${t("field.anti_patterns")}: ${chalk.white(existing.preferences.anti_patterns.join(", ") || "none")}`);
512
+ console.log();
513
+ const editChoice = await select({
514
+ message: t("edit.what_to_do"),
515
+ choices: [
516
+ { value: "regenerate", name: t("edit.regenerate") },
517
+ { value: "edit", name: t("edit.edit_fields") },
518
+ { value: "restart", name: t("edit.restart") }
519
+ ]
520
+ });
521
+ if (editChoice === "restart") {
522
+ return collectAnswers(name, profile);
523
+ }
524
+ let startDate = existing.trip.start_date;
525
+ let endDate = existing.trip.end_date;
526
+ let travelers = existing.trip.travelers;
527
+ let origin = existing.trip.origin;
528
+ let cities = existing.cities.map((c) => ({ name: c.name, key: c.key }));
529
+ let budgetTotal = existing.budget?.total || 5e3;
530
+ let vibes = existing.preferences.priority_order;
531
+ let antiPatterns = existing.preferences.anti_patterns;
532
+ if (editChoice === "edit") {
533
+ const fieldsToEdit = await checkbox({
534
+ message: t("edit.which_fields"),
535
+ choices: [
536
+ { value: "dates", name: `${t("field.dates")} (${startDate} \u2192 ${endDate})` },
537
+ { value: "travelers", name: `${t("field.travelers")} (${travelers})` },
538
+ { value: "origin", name: `${t("field.origin")} (${origin})` },
539
+ { value: "cities", name: `${t("field.cities")} (${cities.map((c) => c.name).join(", ")})` },
540
+ { value: "budget", name: `${t("field.budget")} (${budgetTotal})` },
541
+ { value: "vibes", name: `${t("field.vibes")} (${vibes.join(", ")})` },
542
+ { value: "anti_patterns", name: `${t("field.anti_patterns")} (${antiPatterns.join(", ") || "none"})` }
543
+ ]
544
+ });
545
+ if (fieldsToEdit.includes("dates")) {
546
+ startDate = await input({ message: t("trip.start_date"), default: startDate });
547
+ endDate = await input({ message: t("trip.end_date"), default: endDate });
548
+ }
549
+ if (fieldsToEdit.includes("travelers")) {
550
+ const val = await input({ message: t("trip.travelers"), default: String(travelers) });
551
+ travelers = parseInt(val, 10);
552
+ }
553
+ if (fieldsToEdit.includes("origin")) {
554
+ origin = await input({ message: t("trip.origin"), default: origin });
555
+ }
556
+ if (fieldsToEdit.includes("cities")) {
557
+ const citiesRaw = await input({
558
+ message: t("trip.cities"),
559
+ default: cities.map((c) => c.name).join(", ")
560
+ });
561
+ cities = citiesRaw.split(",").map((c) => {
562
+ const trimmed = c.trim();
563
+ const key = trimmed.toLowerCase().replace(/[^a-z0-9]/g, "_");
564
+ return { name: trimmed, key };
565
+ });
566
+ }
567
+ if (fieldsToEdit.includes("budget")) {
568
+ const val = await input({ message: t("trip.budget"), default: String(budgetTotal) });
569
+ budgetTotal = parseInt(val, 10);
570
+ }
571
+ if (fieldsToEdit.includes("vibes")) {
572
+ vibes = await checkbox({
573
+ message: t("trip.vibes"),
574
+ choices: vibeChoices().map((c) => ({ ...c, checked: vibes.includes(c.value) }))
575
+ });
576
+ }
577
+ if (fieldsToEdit.includes("anti_patterns")) {
578
+ const raw = await input({
579
+ message: t("trip.anti_patterns"),
580
+ default: antiPatterns.join(", ")
581
+ });
582
+ antiPatterns = raw ? raw.split(",").map((s) => s.trim()).filter(Boolean) : [];
583
+ }
584
+ }
585
+ return {
586
+ name,
587
+ start_date: startDate,
588
+ end_date: endDate,
589
+ travelers,
590
+ origin,
591
+ cities,
592
+ budget_total: budgetTotal,
593
+ budget_currency: "USD",
594
+ vibes,
595
+ anti_patterns: antiPatterns,
596
+ dietary: profile.dietary,
597
+ loyalty_program: profile.loyalty_program
598
+ };
599
+ }
600
+ function printProviderErrorHint(cfg, msg) {
601
+ if (cfg.model_override) {
602
+ const mo = cfg.model_override;
603
+ if (msg.includes("401") || msg.includes("403") || msg.includes("Authentication") || msg.includes("Unauthorized")) {
604
+ console.log(chalk.yellow(` API key for ${mo.model} is invalid or expired.`));
605
+ console.log(chalk.yellow(` Run: trip-optimizer config set model_override.api_key <new-key>`));
606
+ console.log(chalk.yellow(` Or re-run: trip-optimizer init "${cfg.model_override.model}" to reconfigure.`));
607
+ } else if (msg.includes("404") || msg.includes("NOT_FOUND")) {
608
+ console.log(chalk.yellow(` Model "${mo.model}" not found at ${mo.base_url}`));
609
+ }
610
+ } else if (msg.includes("401") || msg.includes("403") || msg.includes("PERMISSION")) {
611
+ if (process.env.CLAUDE_CODE_USE_VERTEX === "1" || process.env.GOOGLE_CLOUD_PROJECT) {
612
+ console.log(chalk.yellow(` ${t("error.auth_check")}`));
613
+ } else {
614
+ console.log(chalk.yellow(" Anthropic API key is invalid. Run: trip-optimizer config set api_key <key>"));
615
+ }
616
+ } else if (msg.includes("404") || msg.includes("NOT_FOUND")) {
617
+ console.log(chalk.yellow(` ${t("error.model_check")}: ANTHROPIC_MODEL=${process.env.ANTHROPIC_MODEL || "(not set)"}`));
618
+ }
619
+ }
620
+ async function initCommand(name) {
621
+ const config = loadConfig();
622
+ const lang = await select({
623
+ message: t("init.language"),
624
+ choices: [
625
+ { value: "en", name: "English" },
626
+ { value: "zh", name: "\u4E2D\u6587\uFF08\u7B80\u4F53\uFF09" }
627
+ ],
628
+ default: config.language || "en"
629
+ });
630
+ setLanguage(lang);
631
+ config.language = lang;
632
+ saveConfig(config);
633
+ console.log(chalk.bold(`
634
+ ${t("init.title")}: ${name}
635
+ `));
636
+ const profile = loadProfile();
637
+ const useVertex = !!(process.env.CLAUDE_CODE_USE_VERTEX === "1" || process.env.GOOGLE_CLOUD_PROJECT);
638
+ if (!config.api_key && !useVertex) {
639
+ console.log(chalk.yellow(` ${t("init.first_time")}
640
+ `));
641
+ const apiKey = await input({
642
+ message: t("init.api_key"),
643
+ validate: (v) => v.length > 0 || t("init.api_key_required")
644
+ });
645
+ config.api_key = apiKey;
646
+ saveConfig(config);
647
+ console.log(chalk.green(` ${t("init.api_key_saved")}
648
+ `));
649
+ } else if (useVertex && !config.api_key) {
650
+ console.log(chalk.cyan(` ${t("init.vertex_detected")}
651
+ `));
652
+ }
653
+ if (config.model_override) {
654
+ const mo = config.model_override;
655
+ const maskedKey = mo.api_key.length > 8 ? mo.api_key.slice(0, 4) + "..." + mo.api_key.slice(-4) : "****";
656
+ console.log(chalk.cyan(` Custom model: ${mo.model}`));
657
+ console.log(chalk.cyan(` Base URL: ${mo.base_url}`));
658
+ console.log(chalk.cyan(` API key: ${maskedKey}
659
+ `));
660
+ const modelAction = await select({
661
+ message: t("init.model_keep_or_change"),
662
+ choices: [
663
+ { value: "keep", name: t("init.model_keep") },
664
+ { value: "edit", name: t("init.model_edit") },
665
+ { value: "remove", name: t("init.model_remove") }
666
+ ]
667
+ });
668
+ if (modelAction === "edit") {
669
+ const model = await input({
670
+ message: t("init.model_name"),
671
+ default: mo.model
672
+ });
673
+ const baseUrl = await input({
674
+ message: t("init.model_base_url"),
675
+ default: mo.base_url,
676
+ validate: (v) => v.startsWith("http") || "Must be a URL"
677
+ });
678
+ const apiKey = await input({
679
+ message: t("init.model_api_key"),
680
+ default: mo.api_key,
681
+ validate: (v) => v.length > 0 || "Required"
682
+ });
683
+ config.model_override = { provider_type: "openai-compatible", model, base_url: baseUrl, api_key: apiKey };
684
+ saveConfig(config);
685
+ console.log(chalk.green(` ${t("init.model_saved")}
686
+ `));
687
+ } else if (modelAction === "remove") {
688
+ delete config.model_override;
689
+ saveConfig(config);
690
+ console.log(chalk.green(` ${t("init.model_removed")}
691
+ `));
692
+ }
693
+ } else {
694
+ const wantOverride = await select({
695
+ message: t("init.model_override"),
696
+ choices: [
697
+ { value: "no", name: t("init.model_override_no") },
698
+ { value: "yes", name: t("init.model_override_yes") }
699
+ ]
700
+ });
701
+ if (wantOverride === "yes") {
702
+ const model = await input({
703
+ message: t("init.model_name"),
704
+ validate: (v) => v.length > 0 || "Required"
705
+ });
706
+ const baseUrl = await input({
707
+ message: t("init.model_base_url"),
708
+ validate: (v) => v.startsWith("http") || "Must be a URL"
709
+ });
710
+ const apiKey = await input({
711
+ message: t("init.model_api_key"),
712
+ validate: (v) => v.length > 0 || "Required"
713
+ });
714
+ config.model_override = { provider_type: "openai-compatible", model, base_url: baseUrl, api_key: apiKey };
715
+ saveConfig(config);
716
+ console.log(chalk.green(`
717
+ ${t("init.model_saved")}`));
718
+ console.log(chalk.yellow(` ${t("init.model_note")}
719
+ `));
720
+ }
721
+ }
722
+ const profileNeverSet = profile.loyalty_program === "" && profile.stated_vibes.length === 0 && profile.dietary.length === 0;
723
+ if (profileNeverSet && !loadExisting(name)) {
724
+ const loyalty = await select({
725
+ message: t("profile.loyalty"),
726
+ choices: [
727
+ { value: "marriott_bonvoy", name: "Marriott Bonvoy" },
728
+ { value: "hilton_honors", name: "Hilton Honors" },
729
+ { value: "ihg_rewards", name: "IHG Rewards" },
730
+ { value: "hyatt", name: "World of Hyatt" },
731
+ { value: "none", name: "None" }
732
+ ]
733
+ });
734
+ const dietaryChoices = await checkbox({
735
+ message: t("profile.dietary"),
736
+ choices: [
737
+ { value: "vegetarian", name: "Vegetarian" },
738
+ { value: "vegan", name: "Vegan" },
739
+ { value: "halal", name: "Halal" },
740
+ { value: "kosher", name: "Kosher" },
741
+ { value: "gluten_free", name: "Gluten-free" },
742
+ { value: "no_shellfish", name: "No shellfish" },
743
+ { value: "no_nuts", name: "No nuts" }
744
+ ]
745
+ });
746
+ profile.loyalty_program = loyalty === "none" ? "" : loyalty;
747
+ profile.dietary = dietaryChoices;
748
+ saveProfile(profile);
749
+ }
750
+ const existing = loadExisting(name);
751
+ let answers;
752
+ if (existing) {
753
+ answers = await editAnswers(name, existing, profile);
754
+ } else {
755
+ answers = await collectAnswers(name, profile);
756
+ }
757
+ const constraintsYaml = generateConstraints(answers);
758
+ const constraints = yaml2.load(constraintsYaml);
759
+ let learnedSignals;
760
+ const learnedPath = getLearnedPath();
761
+ if (fs3.existsSync(learnedPath)) {
762
+ learnedSignals = fs3.readFileSync(learnedPath, "utf-8");
763
+ }
764
+ let provider;
765
+ try {
766
+ provider = createProvider(config);
767
+ } catch (err) {
768
+ console.log(chalk.red(`
769
+ ${t("error.provider_fail")}: ${err instanceof Error ? err.message : String(err)}
770
+ `));
771
+ process.exit(1);
772
+ }
773
+ const spinner = ora(t("progress.generating_rubrics")).start();
774
+ let rubricsYaml;
775
+ try {
776
+ rubricsYaml = await generateRubrics(provider, constraints, learnedSignals);
777
+ spinner.succeed(t("progress.rubrics_done"));
778
+ } catch (err) {
779
+ spinner.fail(t("progress.rubrics_fail"));
780
+ const msg = err instanceof Error ? err.message : String(err);
781
+ console.log(chalk.red(`
782
+ ${msg}`));
783
+ printProviderErrorHint(config, msg);
784
+ console.log();
785
+ process.exit(1);
786
+ }
787
+ spinner.start(t("progress.generating_plan"));
788
+ let planMd;
789
+ try {
790
+ planMd = await generatePlan(provider, constraints);
791
+ spinner.succeed(t("progress.plan_done"));
792
+ } catch (err) {
793
+ spinner.fail(t("progress.plan_fail"));
794
+ console.log(chalk.red(`
795
+ ${err instanceof Error ? err.message : String(err)}
796
+ `));
797
+ process.exit(1);
798
+ }
799
+ const programMd = generateProgram(constraints, config);
800
+ const tripDirName = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
801
+ const tripDir = path3.resolve(tripDirName);
802
+ if (fs3.existsSync(tripDir)) {
803
+ fs3.rmSync(tripDir, { recursive: true, force: true });
804
+ }
805
+ spinner.start(t("progress.creating_project"));
806
+ await scaffoldTrip(tripDir, {
807
+ constraints: constraintsYaml,
808
+ rubrics: rubricsYaml,
809
+ plan: planMd,
810
+ program: programMd
811
+ });
812
+ spinner.succeed(`${t("progress.project_created")} ${chalk.bold(tripDirName)}/`);
813
+ console.log(`
814
+ ${chalk.green(t("next.title"))}
815
+ cd ${tripDirName}
816
+ ${chalk.dim(t("next.review"))}
817
+ trip-optimizer run
818
+ `);
819
+ }
820
+
821
+ // src/commands/config.ts
822
+ import chalk2 from "chalk";
823
+ function configCommand(args) {
824
+ const config = loadConfig();
825
+ if (args.length === 0) {
826
+ console.log(chalk2.bold("\n Configuration\n"));
827
+ console.log(` Provider: ${config.provider}`);
828
+ console.log(` API Key: ${config.api_key ? config.api_key.substring(0, 10) + "..." : chalk2.dim("not set")}`);
829
+ if (config.search_api) {
830
+ console.log(` Search API: ${config.search_api.provider}`);
831
+ console.log(` Search Key: ${config.search_api.api_key ? config.search_api.api_key.substring(0, 10) + "..." : chalk2.dim("not set")}`);
832
+ }
833
+ console.log();
834
+ return;
835
+ }
836
+ if (args[0] === "set" && args.length >= 3) {
837
+ const key = args[1];
838
+ const value = args.slice(2).join(" ");
839
+ if (key === "provider") config.provider = value;
840
+ else if (key === "api_key") config.api_key = value;
841
+ else if (key === "search_api.provider") {
842
+ config.search_api = config.search_api || { provider: "", api_key: "" };
843
+ config.search_api.provider = value;
844
+ } else if (key === "search_api.api_key") {
845
+ config.search_api = config.search_api || { provider: "", api_key: "" };
846
+ config.search_api.api_key = value;
847
+ } else {
848
+ console.log(chalk2.red(` Unknown config key: ${key}`));
849
+ return;
850
+ }
851
+ saveConfig(config);
852
+ console.log(chalk2.green(` Set ${key} = ${key.includes("key") ? value.substring(0, 10) + "..." : value}`));
853
+ return;
854
+ }
855
+ console.log(" Usage: trip-optimizer config [set <key> <value>]");
856
+ }
857
+
858
+ // src/commands/profile.ts
859
+ import chalk3 from "chalk";
860
+ function profileCommand() {
861
+ const profile = loadProfile();
862
+ console.log(chalk3.bold("\n Travel Profile\n"));
863
+ console.log(` Loyalty Program: ${profile.loyalty_program || chalk3.dim("none")}`);
864
+ console.log(` Dietary: ${profile.dietary.length > 0 ? profile.dietary.join(", ") : chalk3.dim("none")}`);
865
+ console.log(` Stated Vibes: ${profile.stated_vibes.length > 0 ? profile.stated_vibes.join(", ") : chalk3.dim("none")}`);
866
+ if (profile.learned_vibes.length > 0) {
867
+ console.log(` Learned Vibes: ${profile.learned_vibes.join(", ")}`);
868
+ }
869
+ if (profile.anti_patterns.length > 0) {
870
+ console.log(` Anti-patterns: ${profile.anti_patterns.join(", ")}`);
871
+ }
872
+ if (profile.anti_patterns_learned.length > 0) {
873
+ console.log(` Learned Anti-patterns: ${profile.anti_patterns_learned.join(", ")}`);
874
+ }
875
+ console.log(` Trips Completed: ${profile.trips_completed}`);
876
+ if (profile.last_debrief) {
877
+ console.log(` Last Debrief: ${profile.last_debrief}`);
878
+ }
879
+ if (Object.keys(profile.source_trust).length > 0) {
880
+ console.log(` Source Trust:`);
881
+ for (const [source, trust] of Object.entries(profile.source_trust)) {
882
+ console.log(` ${source}: ${trust}`);
883
+ }
884
+ }
885
+ console.log();
886
+ }
887
+
888
+ // src/commands/score.ts
889
+ import fs4 from "fs";
890
+ import path4 from "path";
891
+ import chalk4 from "chalk";
892
+ import yaml3 from "js-yaml";
893
+
894
+ // src/scoring/prompts.ts
895
+ function buildRubricText(dimConfig) {
896
+ const lines = [];
897
+ const subs = dimConfig.sub_dimensions || {};
898
+ for (const [subName, subConfig] of Object.entries(subs)) {
899
+ if (!subConfig) continue;
900
+ lines.push(`
901
+ ### ${subName}: ${subConfig.description || ""}`);
902
+ const anchors = subConfig.anchors || {};
903
+ const sortedAnchors = Object.entries(anchors).sort(([a], [b]) => Number(a) - Number(b));
904
+ for (const [score, desc] of sortedAnchors) {
905
+ lines.push(` ${score}: ${desc}`);
906
+ }
907
+ }
908
+ return lines.join("\n");
909
+ }
910
+ function buildDimensionPrompt(dimName, dimConfig, planContent) {
911
+ const rubricText = buildRubricText(dimConfig);
912
+ const subs = dimConfig.sub_dimensions || {};
913
+ const subDims = Object.keys(subs);
914
+ if (subDims.length === 0) {
915
+ return `Score this travel plan on the dimension: ${dimName}
916
+
917
+ Score on a 0-100 scale. Do NOT round to multiples of 5.
918
+
919
+ ## Travel Plan
920
+ ${planContent}
921
+
922
+ Respond in this exact JSON format (no other text):
923
+ {"overall": {"score": <0-100>, "note": "<1 sentence>"}}`;
924
+ }
925
+ return `Score this travel plan on the dimension: ${dimName}
926
+
927
+ Score each sub-dimension on a 0-100 scale using the rubric anchors below.
928
+ Do NOT round to multiples of 5 \u2014 use precise scores like 83, 87, 91.
929
+
930
+ ## Rubric Anchors
931
+ ${rubricText}
932
+
933
+ ## Travel Plan
934
+ ${planContent}
935
+
936
+ Respond in this exact JSON format (no other text):
937
+ {
938
+ ${subDims.map((sd) => ` "${sd}": {"score": <0-100>, "note": "<1 sentence>"}`).join(",\n")}
939
+ }`;
940
+ }
941
+ function buildCriticPrompt(planContent, activitiesDb, rubrics) {
942
+ const rulesText = [];
943
+ const penalties = rubrics.adversarial_penalties || {};
944
+ for (const [category, rules] of Object.entries(penalties)) {
945
+ if (category === "max_penalty_per_dimension") continue;
946
+ if (!Array.isArray(rules)) continue;
947
+ for (const r of rules) {
948
+ if (!r) continue;
949
+ rulesText.push(`- [${category.toUpperCase()}] ${r.rule || ""} (penalty: ${r.penalty || -3})`);
950
+ }
951
+ }
952
+ const traps = [];
953
+ for (const [city, data] of Object.entries(activitiesDb || {})) {
954
+ if (!data) continue;
955
+ for (const t2 of data.tourist_traps || []) {
956
+ traps.push(`- ${city}: ${t2.name}`);
957
+ }
958
+ }
959
+ return `You are a travel plan critic. Your ONLY job is to find flaws.
960
+ Do NOT praise anything. Apply these specific penalty rules:
961
+
962
+ ${rulesText.length > 0 ? rulesText.join("\n") : "(no specific rules defined \u2014 use general travel planning best practices)"}
963
+
964
+ ## Known Tourist Traps (from research database)
965
+ ${traps.length > 0 ? traps.join("\n") : "(none researched yet)"}
966
+
967
+ ## The Plan
968
+ ${planContent}
969
+
970
+ Find every violation of the rules above. Be strict and specific.
971
+ For each issue, cite the exact day number and the specific problem.
972
+
973
+ Return a JSON array of penalties (no other text):
974
+ [{"category": "logistics|experience|food|realism", "day": N, "issue": "specific description", "penalty": -N}]
975
+
976
+ Return an empty array [] if genuinely no issues found.`;
977
+ }
978
+ function buildHolisticPrompt(allScores) {
979
+ const scoresSummary = [];
980
+ for (const [dim, data] of Object.entries(allScores)) {
981
+ const subs = data.sub_dimensions || {};
982
+ const subDetail = Object.entries(subs).map(([sd, info]) => `${sd}: ${(info?.score ?? 0).toFixed(0)}`).join(", ");
983
+ scoresSummary.push(`- ${dim}: ${(data.score ?? 0).toFixed(1)} (weight: ${data.weight}) [${subDetail}]`);
984
+ }
985
+ return `You are reviewing scores from independent judges evaluating a travel plan.
986
+ Each judge scored one dimension without seeing the others' results.
987
+
988
+ Your job: identify where dimensions INTERACT and one judge missed something
989
+ that another judge's context reveals. Adjust +/-5 points max per dimension.
990
+
991
+ ## Current Scores
992
+ ${scoresSummary.join("\n")}
993
+
994
+ Examples of cross-dimension interactions:
995
+ - Food scored high but logistics shows 10hr transit day \u2014 station food is smart, bump food +2
996
+ - Experience scored high but accommodation is far from activities \u2014 experience -3
997
+ - Budget scored well but didn't notice cheaper hotel options available \u2014 budget -2
998
+
999
+ Return a JSON array of adjustments (no other text):
1000
+ [{"dimension": "dim_name", "adjustment": +/-N, "reason": "1 sentence"}]
1001
+
1002
+ Max +/-5 per dimension. Return [] if no adjustments needed.`;
1003
+ }
1004
+ function buildComparativePrompt(oldPlanContent, newPlanContent, mutation, rubrics) {
1005
+ const allSubDims = [];
1006
+ const dims = rubrics.dimensions || {};
1007
+ for (const [dimName, dimConfig] of Object.entries(dims)) {
1008
+ const subs = dimConfig?.sub_dimensions || {};
1009
+ for (const sdName of Object.keys(subs)) {
1010
+ allSubDims.push(`${dimName}.${sdName}`);
1011
+ }
1012
+ }
1013
+ return `You are comparing two versions of a travel plan. A single mutation was applied.
1014
+
1015
+ ## Mutation Applied
1016
+ ${mutation}
1017
+
1018
+ ## PLAN A (before mutation)
1019
+ ${oldPlanContent}
1020
+
1021
+ ## PLAN B (after mutation)
1022
+ ${newPlanContent}
1023
+
1024
+ For each sub-dimension below, score the IMPACT of this mutation:
1025
+ - Positive (+1 to +5): the mutation improved this aspect
1026
+ - Negative (-1 to -5): the mutation hurt this aspect
1027
+ - Neutral (0): no meaningful change
1028
+
1029
+ IMPORTANT: Most sub-dimensions should be NEUTRAL (0) \u2014 a single mutation
1030
+ rarely affects more than 2-3 sub-dimensions. Don't inflate changes.
1031
+
1032
+ Sub-dimensions:
1033
+ ${allSubDims.map((sd) => `- ${sd}`).join("\n")}
1034
+
1035
+ Return a JSON object with ONLY affected sub-dimensions (non-zero deltas).
1036
+ Omit neutral sub-dimensions.
1037
+
1038
+ Example (no other text):
1039
+ {"logistics_efficiency.transit_realism": 3, "experience_quality.authenticity": -1}
1040
+
1041
+ Return {} if no meaningful impact.`;
1042
+ }
1043
+
1044
+ // src/llm/json-parser.ts
1045
+ function parseJsonResponse(text) {
1046
+ if (text.startsWith("```")) {
1047
+ text = text.split("\n").slice(1).join("\n");
1048
+ const lastBacktick = text.lastIndexOf("```");
1049
+ if (lastBacktick >= 0) {
1050
+ text = text.substring(0, lastBacktick).trim();
1051
+ }
1052
+ }
1053
+ text = text.replace(/:\s*\+(\d)/g, ": $1");
1054
+ try {
1055
+ return JSON.parse(text);
1056
+ } catch {
1057
+ }
1058
+ const objStart = text.indexOf("{");
1059
+ const arrStart = text.indexOf("[");
1060
+ const extractors = [];
1061
+ const extractObject = () => {
1062
+ if (objStart < 0) return void 0;
1063
+ let depth = 0;
1064
+ for (let i = objStart; i < text.length; i++) {
1065
+ if (text[i] === "{") depth++;
1066
+ else if (text[i] === "}") {
1067
+ depth--;
1068
+ if (depth === 0) {
1069
+ try {
1070
+ return JSON.parse(text.substring(objStart, i + 1));
1071
+ } catch {
1072
+ return void 0;
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ return void 0;
1078
+ };
1079
+ const extractArray = () => {
1080
+ if (arrStart < 0) return void 0;
1081
+ let depth = 0;
1082
+ for (let i = arrStart; i < text.length; i++) {
1083
+ if (text[i] === "[") depth++;
1084
+ else if (text[i] === "]") {
1085
+ depth--;
1086
+ if (depth === 0) {
1087
+ try {
1088
+ return JSON.parse(text.substring(arrStart, i + 1));
1089
+ } catch {
1090
+ return void 0;
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ return void 0;
1096
+ };
1097
+ if (arrStart >= 0 && (objStart < 0 || arrStart < objStart)) {
1098
+ extractors.push(extractArray, extractObject);
1099
+ } else {
1100
+ extractors.push(extractObject, extractArray);
1101
+ }
1102
+ for (const extractor of extractors) {
1103
+ const result = extractor();
1104
+ if (result !== void 0) return result;
1105
+ }
1106
+ if (arrStart !== void 0 && arrStart >= 0) {
1107
+ const lastClose = text.lastIndexOf("}");
1108
+ if (lastClose > arrStart) {
1109
+ try {
1110
+ return JSON.parse(text.substring(arrStart, lastClose + 1) + "]");
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ }
1115
+ throw new Error(`Could not parse JSON from LLM response: ${text.substring(0, 200)}`);
1116
+ }
1117
+
1118
+ // src/scoring/dimension-scorer.ts
1119
+ async function scoreDimension(provider, dimName, dimConfig, planContent) {
1120
+ const subDimensions = dimConfig.sub_dimensions || {};
1121
+ const prompt = buildDimensionPrompt(dimName, { ...dimConfig, sub_dimensions: subDimensions }, planContent);
1122
+ const response = await provider.complete(prompt, 800);
1123
+ let result;
1124
+ try {
1125
+ result = parseJsonResponse(response);
1126
+ } catch {
1127
+ result = null;
1128
+ }
1129
+ const subDims = Object.keys(subDimensions);
1130
+ const subScores = {};
1131
+ if (subDims.length === 0) {
1132
+ let score = 75;
1133
+ if (result && typeof result === "object") {
1134
+ if (typeof result.overall?.score === "number") {
1135
+ score = result.overall.score;
1136
+ } else {
1137
+ for (const val of Object.values(result)) {
1138
+ if (val && typeof val.score === "number") {
1139
+ score = val.score;
1140
+ break;
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ subScores["overall"] = { score, note: "single-dimension score" };
1146
+ } else {
1147
+ for (const sd of subDims) {
1148
+ if (result && typeof result === "object" && sd in result) {
1149
+ const s = result[sd];
1150
+ subScores[sd] = {
1151
+ score: typeof s?.score === "number" ? s.score : 75,
1152
+ note: s?.note || ""
1153
+ };
1154
+ } else {
1155
+ subScores[sd] = { score: 75, note: "not scored" };
1156
+ }
1157
+ }
1158
+ }
1159
+ const scores = Object.values(subScores);
1160
+ const dimAvg = scores.length > 0 ? scores.reduce((sum, s) => sum + s.score, 0) / scores.length : 75;
1161
+ return {
1162
+ score: Math.round(dimAvg * 10) / 10,
1163
+ weight: dimConfig.weight,
1164
+ sub_dimensions: subScores
1165
+ };
1166
+ }
1167
+
1168
+ // src/scoring/critic.ts
1169
+ var PENALTY_TO_DIMENSION = {
1170
+ logistics: "logistics_efficiency",
1171
+ experience: "experience_quality",
1172
+ food: "food_score",
1173
+ realism: "transit_realism",
1174
+ accommodation: "accommodation_quality"
1175
+ };
1176
+ function mapPenaltyToDimension(category) {
1177
+ return PENALTY_TO_DIMENSION[category] || "logistics_efficiency";
1178
+ }
1179
+ async function runAdversarialCritic(provider, planContent, activitiesDb, rubrics) {
1180
+ const prompt = buildCriticPrompt(planContent, activitiesDb, rubrics);
1181
+ try {
1182
+ const response = await provider.complete(prompt, 2e3);
1183
+ const result = parseJsonResponse(response);
1184
+ if (Array.isArray(result)) return result;
1185
+ return [];
1186
+ } catch (e) {
1187
+ console.warn("WARNING: critic parse failed, returning empty penalties");
1188
+ return [];
1189
+ }
1190
+ }
1191
+ function applyPenalties(scores, penalties, maxPerDimension = -20) {
1192
+ const penaltyByDim = {};
1193
+ for (const p of penalties) {
1194
+ const dim = mapPenaltyToDimension(p.category);
1195
+ penaltyByDim[dim] = penaltyByDim[dim] || 0;
1196
+ penaltyByDim[dim] = Math.max(penaltyByDim[dim] + p.penalty, maxPerDimension);
1197
+ }
1198
+ for (const [dim, pen] of Object.entries(penaltyByDim)) {
1199
+ if (dim in scores) {
1200
+ scores[dim].penalty = pen;
1201
+ scores[dim].score_before_penalty = scores[dim].score;
1202
+ scores[dim].score = Math.max(0, scores[dim].score + pen);
1203
+ }
1204
+ }
1205
+ }
1206
+
1207
+ // src/scoring/holistic.ts
1208
+ async function runHolisticPass(provider, allScores) {
1209
+ const prompt = buildHolisticPrompt(allScores);
1210
+ try {
1211
+ const response = await provider.complete(prompt, 500);
1212
+ const result = parseJsonResponse(response);
1213
+ if (!Array.isArray(result)) return [];
1214
+ return result.filter((a) => Math.abs(a.adjustment || 0) <= 5);
1215
+ } catch (e) {
1216
+ console.warn("WARNING: holistic parse failed, returning no adjustments");
1217
+ return [];
1218
+ }
1219
+ }
1220
+ function applyHolisticAdjustments(scores, adjustments) {
1221
+ for (const adj of adjustments) {
1222
+ if (adj.dimension in scores) {
1223
+ scores[adj.dimension].holistic_adjustment = adj.adjustment;
1224
+ scores[adj.dimension].holistic_reason = adj.reason;
1225
+ scores[adj.dimension].score = Math.max(0, Math.min(100, scores[adj.dimension].score + adj.adjustment));
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ // src/scoring/scorer.ts
1231
+ var Scorer = class {
1232
+ constructor(provider, model = "unknown") {
1233
+ this.provider = provider;
1234
+ this.model = model;
1235
+ }
1236
+ async scoreAbsolute(planContent, activitiesDb, constraints, rubrics, log = console.log) {
1237
+ const dimensions = rubrics.dimensions || {};
1238
+ const allScores = {};
1239
+ for (const [dimName, dimConfig] of Object.entries(dimensions)) {
1240
+ log(` Scoring ${dimName}...`);
1241
+ const result = await scoreDimension(this.provider, dimName, dimConfig, planContent);
1242
+ allScores[dimName] = result;
1243
+ log(` ${dimName}: ${result.score.toFixed(1)}`);
1244
+ }
1245
+ log(" Running adversarial critic...");
1246
+ const penalties = await runAdversarialCritic(this.provider, planContent, activitiesDb, rubrics);
1247
+ log(` ${penalties.length} penalties found`);
1248
+ const maxPen = typeof rubrics.adversarial_penalties?.max_penalty_per_dimension === "number" ? rubrics.adversarial_penalties.max_penalty_per_dimension : -20;
1249
+ applyPenalties(allScores, penalties, maxPen);
1250
+ log(" Running holistic pass...");
1251
+ const adjustments = await runHolisticPass(this.provider, allScores);
1252
+ log(` ${adjustments.length} adjustments`);
1253
+ applyHolisticAdjustments(allScores, adjustments);
1254
+ const composite = Object.values(allScores).reduce(
1255
+ (sum, d) => sum + d.weight * d.score,
1256
+ 0
1257
+ );
1258
+ return {
1259
+ mode: "absolute",
1260
+ composite_score: Math.round(composite * 100) / 100,
1261
+ components: allScores,
1262
+ penalties,
1263
+ holistic_adjustments: adjustments,
1264
+ scored_at: (/* @__PURE__ */ new Date()).toISOString(),
1265
+ model: this.model
1266
+ };
1267
+ }
1268
+ async scoreComparative(oldPlanContent, newPlanContent, mutation, rubrics, log = console.log) {
1269
+ log(" Comparing plans...");
1270
+ const prompt = buildComparativePrompt(oldPlanContent, newPlanContent, mutation, rubrics);
1271
+ const response = await this.provider.complete(prompt, 500);
1272
+ let deltas = {};
1273
+ try {
1274
+ const parsed = parseJsonResponse(response);
1275
+ if (typeof parsed === "object" && !Array.isArray(parsed)) {
1276
+ deltas = parsed;
1277
+ }
1278
+ } catch {
1279
+ log(" WARNING: comparative parse failed, treating as neutral");
1280
+ }
1281
+ const clamped = {};
1282
+ for (const [key, delta] of Object.entries(deltas)) {
1283
+ if (typeof delta === "number") {
1284
+ clamped[key] = Math.max(-5, Math.min(5, delta));
1285
+ }
1286
+ }
1287
+ const dimensions2 = rubrics.dimensions || {};
1288
+ const dimDeltas = {};
1289
+ for (const [dimName, dimConfig] of Object.entries(dimensions2)) {
1290
+ const subs = dimConfig.sub_dimensions || {};
1291
+ const subCount = Object.keys(subs).length || 1;
1292
+ let dimTotal = 0;
1293
+ const affectedSubs = {};
1294
+ for (const sdName of Object.keys(subs)) {
1295
+ const fullKey = `${dimName}.${sdName}`;
1296
+ const d = clamped[fullKey] || 0;
1297
+ dimTotal += d;
1298
+ if (d !== 0) affectedSubs[fullKey] = d;
1299
+ }
1300
+ dimDeltas[dimName] = {
1301
+ delta: Math.round(dimTotal / subCount * 100) / 100,
1302
+ weight: dimConfig.weight,
1303
+ affected_subs: affectedSubs
1304
+ };
1305
+ }
1306
+ const compositeDelta = Object.values(dimDeltas).reduce(
1307
+ (sum, d) => sum + d.weight * d.delta,
1308
+ 0
1309
+ );
1310
+ const nonZero = Object.entries(clamped).filter(([, v]) => v !== 0);
1311
+ const verdict = compositeDelta > 0.05 ? "better" : compositeDelta < -0.05 ? "worse" : "neutral";
1312
+ log(` ${verdict} (delta: ${compositeDelta >= 0 ? "+" : ""}${compositeDelta.toFixed(2)}, ${nonZero.length} subs affected)`);
1313
+ return {
1314
+ mode: "comparative",
1315
+ verdict,
1316
+ composite_delta: Math.round(compositeDelta * 100) / 100,
1317
+ sub_dimension_deltas: clamped,
1318
+ dimension_deltas: dimDeltas,
1319
+ mutation,
1320
+ scored_at: (/* @__PURE__ */ new Date()).toISOString(),
1321
+ model: this.model
1322
+ };
1323
+ }
1324
+ };
1325
+
1326
+ // src/commands/score.ts
1327
+ async function scoreCommand() {
1328
+ const cwd = process.cwd();
1329
+ const constraintsPath = path4.join(cwd, "constraints.yaml");
1330
+ if (!fs4.existsSync(constraintsPath)) {
1331
+ console.log(chalk4.red("\n Not in a trip project directory (no constraints.yaml found).\n"));
1332
+ process.exit(1);
1333
+ }
1334
+ const config = loadConfig();
1335
+ const constraints = yaml3.load(fs4.readFileSync(constraintsPath, "utf-8"));
1336
+ const rubrics = yaml3.load(fs4.readFileSync(path4.join(cwd, "rubrics.yaml"), "utf-8"));
1337
+ const planContent = fs4.readFileSync(path4.join(cwd, "plan.md"), "utf-8");
1338
+ let activitiesDb = {};
1339
+ const dbPath = path4.join(cwd, "activities_db.json");
1340
+ if (fs4.existsSync(dbPath)) {
1341
+ activitiesDb = JSON.parse(fs4.readFileSync(dbPath, "utf-8"));
1342
+ }
1343
+ console.log(chalk4.bold(`
1344
+ Scoring ${constraints.trip?.name || "trip"} (absolute mode)...
1345
+ `));
1346
+ const provider = createProvider(config);
1347
+ const scorer = new Scorer(provider);
1348
+ const result = await scorer.scoreAbsolute(planContent, activitiesDb, constraints, rubrics);
1349
+ fs4.writeFileSync(path4.join(cwd, "score.json"), JSON.stringify(result, null, 2));
1350
+ console.log(chalk4.bold(`
1351
+ Composite Score: ${result.composite_score.toFixed(2)}/100
1352
+ `));
1353
+ console.log(chalk4.bold(" Dimension Scores:"));
1354
+ for (const [dim, data] of Object.entries(result.components)) {
1355
+ const extras = [];
1356
+ if (data.penalty) extras.push(`penalty: ${data.penalty}`);
1357
+ if (data.holistic_adjustment) extras.push(`holistic: ${data.holistic_adjustment > 0 ? "+" : ""}${data.holistic_adjustment}`);
1358
+ const extraStr = extras.length > 0 ? chalk4.dim(` (${extras.join(", ")})`) : "";
1359
+ console.log(` ${dim}: ${data.score.toFixed(1)} ${chalk4.dim(`(w=${data.weight})`)}${extraStr}`);
1360
+ for (const [sd, info] of Object.entries(data.sub_dimensions)) {
1361
+ console.log(` ${chalk4.dim(sd)}: ${info.score.toFixed(0)} \u2014 ${chalk4.dim(info.note)}`);
1362
+ }
1363
+ }
1364
+ if (result.penalties.length > 0) {
1365
+ console.log(chalk4.bold(`
1366
+ Adversarial Penalties (${result.penalties.length}):`));
1367
+ for (const p of result.penalties) {
1368
+ console.log(` Day ${p.day}: ${p.issue} ${chalk4.red(`(${p.penalty})`)}`);
1369
+ }
1370
+ }
1371
+ if (result.holistic_adjustments.length > 0) {
1372
+ console.log(chalk4.bold(`
1373
+ Holistic Adjustments (${result.holistic_adjustments.length}):`));
1374
+ for (const a of result.holistic_adjustments) {
1375
+ console.log(` ${a.dimension}: ${a.adjustment > 0 ? "+" : ""}${a.adjustment} \u2014 ${a.reason}`);
1376
+ }
1377
+ }
1378
+ console.log(chalk4.dim(`
1379
+ Results written to score.json
1380
+ `));
1381
+ }
1382
+
1383
+ // src/commands/research.ts
1384
+ import fs5 from "fs";
1385
+ import path5 from "path";
1386
+ import chalk5 from "chalk";
1387
+ import ora2 from "ora";
1388
+ import yaml4 from "js-yaml";
1389
+ import { simpleGit as simpleGit2 } from "simple-git";
1390
+
1391
+ // src/research/researcher.ts
1392
+ async function researchCity(provider, cityKey, cityName, constraints, existingCount = 0) {
1393
+ const monthNames = [
1394
+ "January",
1395
+ "February",
1396
+ "March",
1397
+ "April",
1398
+ "May",
1399
+ "June",
1400
+ "July",
1401
+ "August",
1402
+ "September",
1403
+ "October",
1404
+ "November",
1405
+ "December"
1406
+ ];
1407
+ const monthIndex = parseInt(constraints.trip.start_date.substring(5, 7), 10) - 1;
1408
+ const monthName = monthNames[monthIndex] || "this season";
1409
+ const prompt = `You are a travel researcher. Generate detailed recommendations for ${cityName}.
1410
+
1411
+ ## Trip Context
1412
+ Dates: ${constraints.trip.start_date} to ${constraints.trip.end_date}
1413
+ Travelers: ${constraints.trip.travelers}
1414
+ Preferences: ${constraints.preferences.priority_order.join(", ")}
1415
+ Anti-patterns: ${constraints.preferences.anti_patterns.join(", ")}
1416
+ ${constraints.dietary.length > 0 ? `Dietary: ${constraints.dietary.join(", ")}` : ""}
1417
+ Budget level: ${constraints.budget.currency} ${constraints.budget.total} total for ${constraints.trip.total_days} days
1418
+
1419
+ ## What to Research
1420
+ Generate authentic, local-oriented recommendations:
1421
+
1422
+ 1. **Activities** (6-8): Hidden gems, unique experiences, seasonal specialties, neighborhoods for wandering. Score each 1-10 on authenticity and uniqueness. NO tourist traps.
1423
+
1424
+ 2. **Restaurants** (5-7): Local favorites, street food spots, regional specialty restaurants. The kind of place with plastic tables that locals queue for. Score each 1-10.
1425
+
1426
+ 3. **Neighborhoods for wandering** (3-4): Walkable areas with character, local vibe, no chain stores.
1427
+
1428
+ 4. **Tourist traps** (2-4): Overrated, overcrowded places to AVOID with reasons why.
1429
+
1430
+ 5. **Seasonal highlights**: What's special about visiting ${cityName} during ${monthName}.
1431
+
1432
+ Return a JSON object matching this exact structure:
1433
+ {
1434
+ "activities": [
1435
+ {
1436
+ "name": "Activity name",
1437
+ "name_local": "Local language name or empty string",
1438
+ "type": "vibe|food|culture|nature|adventure|history",
1439
+ "score": 8,
1440
+ "authenticity": 9,
1441
+ "uniqueness": 7,
1442
+ "notes": "Why this is great",
1443
+ "crowd_level": "low|medium|high",
1444
+ "cost_per_person": 0,
1445
+ "currency": "USD",
1446
+ "duration_hours": 2,
1447
+ "location": "Neighborhood or area",
1448
+ "best_time": "morning|afternoon|evening|any",
1449
+ "seasonal": null,
1450
+ "source": "llm_knowledge"
1451
+ }
1452
+ ],
1453
+ "restaurants": [
1454
+ {
1455
+ "name": "Restaurant name",
1456
+ "name_local": "",
1457
+ "cuisine": "Regional cuisine type",
1458
+ "score": 9,
1459
+ "authenticity": 9,
1460
+ "notes": "Why locals love this place",
1461
+ "cost_per_person": 15,
1462
+ "currency": "USD",
1463
+ "location": "Area",
1464
+ "reservation_needed": false,
1465
+ "source": "llm_knowledge"
1466
+ }
1467
+ ],
1468
+ "neighborhoods_for_wandering": [
1469
+ {
1470
+ "name": "Neighborhood name",
1471
+ "vibe_score": 8,
1472
+ "walkability": "excellent|good|moderate",
1473
+ "notes": "What makes this area great for wandering"
1474
+ }
1475
+ ],
1476
+ "tourist_traps": [
1477
+ {
1478
+ "name": "Overrated place",
1479
+ "reason": "Why to skip it"
1480
+ }
1481
+ ],
1482
+ "seasonal_highlights": [
1483
+ "What's special this time of year"
1484
+ ]
1485
+ }
1486
+
1487
+ Return ONLY the JSON object, no other text.`;
1488
+ const response = await provider.complete(prompt, 4e3);
1489
+ const parsed = parseJsonResponse(response);
1490
+ return {
1491
+ activities: parsed.activities || [],
1492
+ restaurants: parsed.restaurants || [],
1493
+ neighborhoods_for_wandering: parsed.neighborhoods_for_wandering || [],
1494
+ tourist_traps: parsed.tourist_traps || [],
1495
+ seasonal_highlights: parsed.seasonal_highlights || []
1496
+ };
1497
+ }
1498
+ function mergeResearch(existing, newResearch) {
1499
+ if (!existing) return newResearch;
1500
+ const existingActivityNames = new Set(existing.activities.map((a) => a.name));
1501
+ const existingRestaurantNames = new Set(existing.restaurants.map((r) => r.name));
1502
+ const existingNeighborhoods = new Set(existing.neighborhoods_for_wandering.map((n) => n.name));
1503
+ const existingTraps = new Set(existing.tourist_traps.map((t2) => t2.name));
1504
+ return {
1505
+ activities: [
1506
+ ...existing.activities,
1507
+ ...newResearch.activities.filter((a) => !existingActivityNames.has(a.name))
1508
+ ],
1509
+ restaurants: [
1510
+ ...existing.restaurants,
1511
+ ...newResearch.restaurants.filter((r) => !existingRestaurantNames.has(r.name))
1512
+ ],
1513
+ neighborhoods_for_wandering: [
1514
+ ...existing.neighborhoods_for_wandering,
1515
+ ...newResearch.neighborhoods_for_wandering.filter((n) => !existingNeighborhoods.has(n.name))
1516
+ ],
1517
+ tourist_traps: [
1518
+ ...existing.tourist_traps,
1519
+ ...newResearch.tourist_traps.filter((t2) => !existingTraps.has(t2.name))
1520
+ ],
1521
+ seasonal_highlights: [
1522
+ .../* @__PURE__ */ new Set([...existing.seasonal_highlights, ...newResearch.seasonal_highlights])
1523
+ ]
1524
+ };
1525
+ }
1526
+
1527
+ // src/commands/research.ts
1528
+ async function researchCommand(cityArg) {
1529
+ const cwd = process.cwd();
1530
+ if (!fs5.existsSync(path5.join(cwd, "constraints.yaml"))) {
1531
+ console.log(chalk5.red("\n Not in a trip project directory.\n"));
1532
+ process.exit(1);
1533
+ }
1534
+ const config = loadConfig();
1535
+ const constraints = yaml4.load(fs5.readFileSync(path5.join(cwd, "constraints.yaml"), "utf-8"));
1536
+ const dbPath = path5.join(cwd, "activities_db.json");
1537
+ let activitiesDb = {};
1538
+ if (fs5.existsSync(dbPath)) {
1539
+ activitiesDb = JSON.parse(fs5.readFileSync(dbPath, "utf-8"));
1540
+ }
1541
+ const provider = createProvider(config);
1542
+ const git = simpleGit2(cwd);
1543
+ let cities = constraints.cities;
1544
+ if (cityArg) {
1545
+ const match = cities.find(
1546
+ (c) => c.key === cityArg.toLowerCase() || c.name.toLowerCase().includes(cityArg.toLowerCase())
1547
+ );
1548
+ if (!match) {
1549
+ console.log(chalk5.red(`
1550
+ City "${cityArg}" not found in constraints. Available: ${cities.map((c) => c.key).join(", ")}
1551
+ `));
1552
+ process.exit(1);
1553
+ }
1554
+ cities = [match];
1555
+ }
1556
+ console.log(chalk5.bold(`
1557
+ Researching ${cities.length} cit${cities.length === 1 ? "y" : "ies"}...
1558
+ `));
1559
+ for (const city of cities) {
1560
+ const existingCount = (activitiesDb[city.key]?.activities?.length || 0) + (activitiesDb[city.key]?.restaurants?.length || 0);
1561
+ const spinner = ora2(`Researching ${city.name}...`).start();
1562
+ try {
1563
+ const research = await researchCity(provider, city.key, city.name, constraints, existingCount);
1564
+ activitiesDb[city.key] = mergeResearch(activitiesDb[city.key], research);
1565
+ const newActivities = research.activities.length;
1566
+ const newRestaurants = research.restaurants.length;
1567
+ const newTraps = research.tourist_traps.length;
1568
+ spinner.succeed(`${city.name}: +${newActivities} activities, +${newRestaurants} restaurants, ${newTraps} traps identified`);
1569
+ } catch (error) {
1570
+ spinner.fail(`${city.name}: research failed \u2014 ${error.message}`);
1571
+ }
1572
+ }
1573
+ fs5.writeFileSync(dbPath, JSON.stringify(activitiesDb, null, 2));
1574
+ try {
1575
+ await git.add("activities_db.json");
1576
+ await git.commit(`research: updated activities database for ${cities.map((c) => c.name).join(", ")}`);
1577
+ console.log(chalk5.green("\n Database updated and committed.\n"));
1578
+ } catch {
1579
+ console.log(chalk5.dim("\n Database updated (no git changes to commit).\n"));
1580
+ }
1581
+ }
1582
+
1583
+ // src/commands/run.ts
1584
+ import path7 from "path";
1585
+ import fs8 from "fs";
1586
+ import chalk6 from "chalk";
1587
+
1588
+ // src/optimizer/loop.ts
1589
+ import fs7 from "fs";
1590
+ import path6 from "path";
1591
+ import yaml5 from "js-yaml";
1592
+ import { simpleGit as simpleGit3 } from "simple-git";
1593
+
1594
+ // src/optimizer/mutations.ts
1595
+ var MUTATION_ROTATION = ["SWAP", "UPGRADE", "REORDER", "SIMPLIFY", "REALLOCATE"];
1596
+ function pickMutationType(iteration, consecutiveDiscards) {
1597
+ if (consecutiveDiscards >= 5) return "RESEARCH";
1598
+ return MUTATION_ROTATION[iteration % MUTATION_ROTATION.length];
1599
+ }
1600
+ function buildMutationPrompt(type, planContent, constraints, activitiesDb, lastScoreNotes) {
1601
+ const cityList = constraints.cities.map((c) => `${c.name} (${c.min_days}-${c.max_days} days)`).join(", ");
1602
+ const dbSummary = Object.entries(activitiesDb).map(([city, data]) => `${city}: ${data.activities?.length || 0} activities, ${data.restaurants?.length || 0} restaurants`).join("\n");
1603
+ const baseContext = `## Current Plan
1604
+ ${planContent}
1605
+
1606
+ ## Constraints
1607
+ Cities: ${cityList}
1608
+ Preferences: ${constraints.preferences.priority_order.join(", ")}
1609
+ Anti-patterns: ${constraints.preferences.anti_patterns.join(", ")}
1610
+ ${constraints.dietary.length > 0 ? `Dietary: ${constraints.dietary.join(", ")}` : ""}
1611
+
1612
+ ## Activities Database Summary
1613
+ ${dbSummary || "(empty \u2014 no research done yet)"}
1614
+ ${lastScoreNotes ? `
1615
+ ## Last Score Notes
1616
+ ${lastScoreNotes}` : ""}`;
1617
+ const typePrompts = {
1618
+ SWAP: `Find the lowest-quality or most generic activity in the plan and replace it with a better alternative. Prefer activities that match the traveler's vibe preferences and are unique to the specific city. If the activities database has scored alternatives, use the highest-scored one.`,
1619
+ UPGRADE: `Find a generic or mediocre restaurant recommendation in the plan and replace it with a more authentic, local-favorite option. Prefer specific named restaurants over generic descriptions. The replacement should serve regional cuisine and be the kind of place locals actually eat at.`,
1620
+ REORDER: `Find the day with the worst geographic clustering (activities that zigzag across the city) and reorder them so they flow geographically. Morning activities should be near each other, with a natural arc through the day.`,
1621
+ SIMPLIFY: `Find the most packed day or the weakest activity in the plan and remove it, replacing it with free wandering time in a good neighborhood. Unstructured time for exploring is valuable \u2014 don't feel every hour needs an activity.`,
1622
+ REALLOCATE: `Look at the day allocation across cities. Find a city that feels rushed (too many highlights, too few days) and one that feels slow (padding activities, not enough to do). Move one day from the slow city to the rushed one. Respect min/max day bounds.`,
1623
+ RESEARCH: `Identify the city with the weakest activities or fewest database entries. Generate 5-8 new activity and restaurant recommendations for that city. Focus on hidden gems, local favorites, seasonal specialties, and neighborhoods for wandering. Add them to the activities database, then pick the best one and swap it into the plan.`
1624
+ };
1625
+ return `You are making a single "${type}" mutation to improve this travel plan.
1626
+
1627
+ ## Task
1628
+ ${typePrompts[type]}
1629
+
1630
+ ${baseContext}
1631
+
1632
+ ## Response Format
1633
+ Return a JSON object with exactly these fields:
1634
+ {
1635
+ "type": "${type}",
1636
+ "description": "Brief description of what changed (e.g., 'Day 3: replaced Temple X with Yanaka neighborhood walk')",
1637
+ "new_plan": "The COMPLETE updated plan.md content with the mutation applied"${type === "RESEARCH" ? ',\n "new_activities": "JSON string of new activities to add to the database"' : ""}
1638
+ }
1639
+
1640
+ IMPORTANT:
1641
+ - Make exactly ONE change. Do not modify anything else in the plan.
1642
+ - Return the COMPLETE plan content, not just the changed section.
1643
+ - Keep all YAML frontmatter intact.
1644
+ - The description should be specific enough to understand without reading the full plan.`;
1645
+ }
1646
+ async function generateMutation(provider, type, planContent, constraints, activitiesDb, lastScoreNotes) {
1647
+ const prompt = buildMutationPrompt(type, planContent, constraints, activitiesDb, lastScoreNotes);
1648
+ const response = await provider.complete(prompt, 1e4);
1649
+ let parsed;
1650
+ try {
1651
+ let text = response;
1652
+ if (text.startsWith("```")) {
1653
+ text = text.split("\n").slice(1).join("\n");
1654
+ const lastBacktick = text.lastIndexOf("```");
1655
+ if (lastBacktick >= 0) text = text.substring(0, lastBacktick).trim();
1656
+ }
1657
+ parsed = JSON.parse(text);
1658
+ } catch {
1659
+ const start = response.indexOf("{");
1660
+ const end = response.lastIndexOf("}");
1661
+ if (start >= 0 && end > start) {
1662
+ try {
1663
+ parsed = JSON.parse(response.substring(start, end + 1));
1664
+ } catch {
1665
+ throw new Error("Failed to parse mutation response as JSON");
1666
+ }
1667
+ } else {
1668
+ throw new Error("No JSON found in mutation response");
1669
+ }
1670
+ }
1671
+ return {
1672
+ type: parsed.type || type,
1673
+ description: parsed.description || "Unknown mutation",
1674
+ newPlanContent: parsed.new_plan || planContent
1675
+ };
1676
+ }
1677
+
1678
+ // src/optimizer/logger.ts
1679
+ import fs6 from "fs";
1680
+ var HEADER = "iteration commit score_before score_after delta status mutation_type description";
1681
+ function appendResult(resultsPath, log) {
1682
+ if (!fs6.existsSync(resultsPath)) {
1683
+ fs6.writeFileSync(resultsPath, HEADER + "\n");
1684
+ }
1685
+ const line = [
1686
+ log.iteration,
1687
+ log.commit,
1688
+ log.score_before.toFixed(2),
1689
+ log.score_after.toFixed(2),
1690
+ (log.delta >= 0 ? "+" : "") + log.delta.toFixed(2),
1691
+ log.status,
1692
+ log.mutation_type,
1693
+ log.description
1694
+ ].join(" ");
1695
+ fs6.appendFileSync(resultsPath, line + "\n");
1696
+ }
1697
+ function readResults(resultsPath) {
1698
+ if (!fs6.existsSync(resultsPath)) return [];
1699
+ const lines = fs6.readFileSync(resultsPath, "utf-8").split("\n").filter(Boolean);
1700
+ if (lines.length <= 1) return [];
1701
+ return lines.slice(1).map((line) => {
1702
+ const parts = line.split(" ");
1703
+ return {
1704
+ iteration: parseInt(parts[0], 10),
1705
+ commit: parts[1],
1706
+ score_before: parseFloat(parts[2]),
1707
+ score_after: parseFloat(parts[3]),
1708
+ delta: parseFloat(parts[4]),
1709
+ status: parts[5],
1710
+ mutation_type: parts[6],
1711
+ description: parts[7] || ""
1712
+ };
1713
+ });
1714
+ }
1715
+ function getLastBestScore(resultsPath) {
1716
+ const results = readResults(resultsPath);
1717
+ if (results.length === 0) return null;
1718
+ for (let i = results.length - 1; i >= 0; i--) {
1719
+ if (results[i].status === "keep") {
1720
+ return { score: results[i].score_after, iteration: results[i].iteration };
1721
+ }
1722
+ }
1723
+ return { score: results[0].score_after, iteration: results[0].iteration };
1724
+ }
1725
+
1726
+ // src/optimizer/loop.ts
1727
+ async function runOptimizationLoop(options) {
1728
+ const {
1729
+ provider,
1730
+ tripDir,
1731
+ recalibrationInterval = 10,
1732
+ onIteration
1733
+ } = options;
1734
+ const git = simpleGit3(tripDir);
1735
+ const resultsPath = path6.join(tripDir, "results.tsv");
1736
+ const constraintsPath = path6.join(tripDir, "constraints.yaml");
1737
+ const rubricsPath = path6.join(tripDir, "rubrics.yaml");
1738
+ const planPath = path6.join(tripDir, "plan.md");
1739
+ const dbPath = path6.join(tripDir, "activities_db.json");
1740
+ const constraints = yaml5.load(fs7.readFileSync(constraintsPath, "utf-8"));
1741
+ const rubrics = yaml5.load(fs7.readFileSync(rubricsPath, "utf-8"));
1742
+ const scorer = new Scorer(provider);
1743
+ const lastBest = getLastBestScore(resultsPath);
1744
+ let currentScore;
1745
+ let iteration;
1746
+ if (lastBest) {
1747
+ currentScore = lastBest.score;
1748
+ iteration = lastBest.iteration + 1;
1749
+ console.log(` Resuming from iteration ${iteration} (score: ${currentScore.toFixed(2)})`);
1750
+ } else {
1751
+ console.log(" Scoring baseline...");
1752
+ const planContent = fs7.readFileSync(planPath, "utf-8");
1753
+ const activitiesDb = JSON.parse(fs7.readFileSync(dbPath, "utf-8"));
1754
+ const baselineResult = await scorer.scoreAbsolute(planContent, activitiesDb, constraints, rubrics);
1755
+ currentScore = baselineResult.composite_score;
1756
+ fs7.writeFileSync(path6.join(tripDir, "score.json"), JSON.stringify(baselineResult, null, 2));
1757
+ const log = {
1758
+ iteration: 0,
1759
+ commit: (await git.revparse(["HEAD"])).trim(),
1760
+ score_before: 0,
1761
+ score_after: currentScore,
1762
+ delta: currentScore,
1763
+ status: "keep",
1764
+ mutation_type: "RESEARCH",
1765
+ description: "baseline scored"
1766
+ };
1767
+ appendResult(resultsPath, log);
1768
+ onIteration?.(log);
1769
+ console.log(` Baseline score: ${currentScore.toFixed(2)}/100`);
1770
+ iteration = 1;
1771
+ }
1772
+ let consecutiveDiscards = 0;
1773
+ console.log(" Starting optimization loop (Ctrl+C to stop)");
1774
+ console.log(` Score: ${currentScore.toFixed(2)}/100
1775
+ `);
1776
+ let running = true;
1777
+ const shutdown = () => {
1778
+ running = false;
1779
+ console.log("\n Stopping after current iteration...");
1780
+ };
1781
+ process.on("SIGINT", shutdown);
1782
+ process.on("SIGTERM", shutdown);
1783
+ while (running) {
1784
+ const planContent = fs7.readFileSync(planPath, "utf-8");
1785
+ const activitiesDb = JSON.parse(fs7.readFileSync(dbPath, "utf-8"));
1786
+ const mutationType = pickMutationType(iteration, consecutiveDiscards);
1787
+ try {
1788
+ process.stdout.write(` [${iteration}] ${mutationType} \u2014 generating mutation...`);
1789
+ const mutation = await generateMutation(
1790
+ provider,
1791
+ mutationType,
1792
+ planContent,
1793
+ constraints,
1794
+ activitiesDb
1795
+ );
1796
+ process.stdout.write("\r\x1B[K");
1797
+ fs7.writeFileSync(planPath, mutation.newPlanContent);
1798
+ await git.add("plan.md");
1799
+ await git.commit(`${mutationType}: ${mutation.description}`);
1800
+ const commitHash = (await git.revparse(["HEAD"])).trim().substring(0, 7);
1801
+ let scoreAfter;
1802
+ let verdict;
1803
+ const isRecalibration = iteration % recalibrationInterval === 0;
1804
+ process.stdout.write(` [${iteration}] ${mutationType} \u2014 scoring (${isRecalibration ? "absolute" : "comparative"})...`);
1805
+ if (isRecalibration) {
1806
+ const result = await scorer.scoreAbsolute(
1807
+ mutation.newPlanContent,
1808
+ activitiesDb,
1809
+ constraints,
1810
+ rubrics,
1811
+ (msg) => {
1812
+ }
1813
+ // silent logging during loop
1814
+ );
1815
+ scoreAfter = result.composite_score;
1816
+ verdict = scoreAfter > currentScore ? "better" : scoreAfter < currentScore ? "worse" : "neutral";
1817
+ fs7.writeFileSync(path6.join(tripDir, "score.json"), JSON.stringify(result, null, 2));
1818
+ } else {
1819
+ const result = await scorer.scoreComparative(
1820
+ planContent,
1821
+ mutation.newPlanContent,
1822
+ mutation.description,
1823
+ rubrics,
1824
+ (msg) => {
1825
+ }
1826
+ // silent
1827
+ );
1828
+ scoreAfter = currentScore + result.composite_delta;
1829
+ verdict = result.verdict;
1830
+ }
1831
+ process.stdout.write("\r\x1B[K");
1832
+ const delta = scoreAfter - currentScore;
1833
+ const status = verdict === "better" ? "keep" : "discard";
1834
+ if (status === "keep") {
1835
+ currentScore = scoreAfter;
1836
+ consecutiveDiscards = 0;
1837
+ } else {
1838
+ await git.reset(["--hard", "HEAD~1"]);
1839
+ consecutiveDiscards++;
1840
+ }
1841
+ const log = {
1842
+ iteration,
1843
+ commit: commitHash,
1844
+ score_before: currentScore - (status === "keep" ? delta : 0),
1845
+ score_after: scoreAfter,
1846
+ delta,
1847
+ status,
1848
+ mutation_type: mutationType,
1849
+ description: mutation.description
1850
+ };
1851
+ appendResult(resultsPath, log);
1852
+ onIteration?.(log);
1853
+ const statusIcon = status === "keep" ? "\x1B[32m\u2713\x1B[0m" : "\x1B[31m\u2717\x1B[0m";
1854
+ const deltaStr = delta >= 0 ? `\x1B[32m+${delta.toFixed(2)}\x1B[0m` : `\x1B[31m${delta.toFixed(2)}\x1B[0m`;
1855
+ const scoreStr = `\x1B[1m${currentScore.toFixed(2)}\x1B[0m`;
1856
+ console.log(` [${iteration}] ${statusIcon} ${mutationType.padEnd(10)} ${deltaStr} ${scoreStr} ${mutation.description.substring(0, 60)}`);
1857
+ } catch (error) {
1858
+ console.log(` [${iteration}] ERROR: ${error.message} -- reverting`);
1859
+ try {
1860
+ await git.reset(["--hard", "HEAD"]);
1861
+ } catch {
1862
+ }
1863
+ consecutiveDiscards++;
1864
+ }
1865
+ iteration++;
1866
+ }
1867
+ process.removeListener("SIGINT", shutdown);
1868
+ process.removeListener("SIGTERM", shutdown);
1869
+ console.log(`
1870
+ Stopped at iteration ${iteration - 1}. Best score: ${currentScore.toFixed(2)}/100
1871
+ `);
1872
+ }
1873
+
1874
+ // src/commands/run.ts
1875
+ async function runCommand(options) {
1876
+ const cwd = process.cwd();
1877
+ if (!fs8.existsSync(path7.join(cwd, "constraints.yaml"))) {
1878
+ console.log(chalk6.red("\n Not in a trip project directory (no constraints.yaml found).\n"));
1879
+ process.exit(1);
1880
+ }
1881
+ const config = loadConfig();
1882
+ if (options.standalone) {
1883
+ const provider = createProvider(config);
1884
+ const modelName = config.model_override?.model || process.env.ANTHROPIC_MODEL || "default";
1885
+ console.log(chalk6.bold(`
1886
+ trip-optimizer: standalone mode (${modelName})
1887
+ `));
1888
+ await runOptimizationLoop({
1889
+ provider,
1890
+ tripDir: cwd,
1891
+ onIteration: () => {
1892
+ }
1893
+ });
1894
+ return;
1895
+ }
1896
+ if (config.model_override) {
1897
+ console.log(chalk6.yellow("\n Custom model configured \u2014 agent mode still uses Claude Code."));
1898
+ console.log(chalk6.yellow(" Use --standalone to run with your custom model.\n"));
1899
+ }
1900
+ const { launchAgent } = await import("./commands/run-agent.js");
1901
+ await launchAgent(cwd, { safe: options.safe, headless: options.headless });
1902
+ }
1903
+
1904
+ // src/commands/status.ts
1905
+ import fs9 from "fs";
1906
+ import path8 from "path";
1907
+ import chalk7 from "chalk";
1908
+ import yaml6 from "js-yaml";
1909
+ function statusCommand() {
1910
+ const cwd = process.cwd();
1911
+ if (!fs9.existsSync(path8.join(cwd, "constraints.yaml"))) {
1912
+ console.log(chalk7.red("\n Not in a trip project directory.\n"));
1913
+ process.exit(1);
1914
+ }
1915
+ const constraints = yaml6.load(fs9.readFileSync(path8.join(cwd, "constraints.yaml"), "utf-8"));
1916
+ const resultsPath = path8.join(cwd, "results.tsv");
1917
+ const results = readResults(resultsPath);
1918
+ if (results.length === 0) {
1919
+ console.log(chalk7.yellow("\n No optimization results yet. Run: trip-optimizer run\n"));
1920
+ return;
1921
+ }
1922
+ const lastBest = getLastBestScore(resultsPath);
1923
+ const baseline = results[0]?.score_after || 0;
1924
+ const totalIterations = results[results.length - 1].iteration;
1925
+ const keeps = results.filter((r) => r.status === "keep").length;
1926
+ const last5 = results.slice(-5);
1927
+ let streak = 0;
1928
+ for (let i = results.length - 1; i >= 0; i--) {
1929
+ if (results[i].status === results[results.length - 1].status) streak++;
1930
+ else break;
1931
+ }
1932
+ const streakType = results[results.length - 1].status;
1933
+ console.log(chalk7.bold(`
1934
+ ${constraints.trip?.name || "Trip"} -- iteration ${totalIterations}
1935
+ `));
1936
+ console.log(` Score: ${chalk7.bold(lastBest?.score.toFixed(2) || "?")}/100 (${baseline > 0 ? `+${(lastBest.score - baseline).toFixed(1)} from baseline` : ""})`);
1937
+ console.log(` Iterations: ${totalIterations} (${keeps} kept, ${totalIterations - keeps} discarded)`);
1938
+ console.log(` Streak: ${streak} ${streakType}s in a row`);
1939
+ console.log(chalk7.bold("\n Last 5 mutations:"));
1940
+ for (const r of last5) {
1941
+ const icon = r.status === "keep" ? chalk7.green("\u2713") : chalk7.red("\u2717");
1942
+ const delta = r.delta >= 0 ? `+${r.delta.toFixed(2)}` : r.delta.toFixed(2);
1943
+ console.log(` ${icon} ${r.mutation_type.padEnd(10)} ${r.description.substring(0, 50)} ${delta}`);
1944
+ }
1945
+ const dbPath = path8.join(cwd, "activities_db.json");
1946
+ if (fs9.existsSync(dbPath)) {
1947
+ const db = JSON.parse(fs9.readFileSync(dbPath, "utf-8"));
1948
+ const cities = Object.entries(db);
1949
+ if (cities.length > 0) {
1950
+ console.log(chalk7.bold("\n Research coverage:"));
1951
+ for (const [city, data] of cities) {
1952
+ const count = (data.activities?.length || 0) + (data.restaurants?.length || 0);
1953
+ const bar = "\u2588".repeat(Math.min(count, 30));
1954
+ console.log(` ${city.padEnd(15)} ${bar} ${count}`);
1955
+ }
1956
+ }
1957
+ }
1958
+ console.log();
1959
+ }
1960
+
1961
+ // src/commands/debrief.ts
1962
+ import { input as input2, select as select2 } from "@inquirer/prompts";
1963
+ import chalk8 from "chalk";
1964
+ import ora3 from "ora";
1965
+ import fs10 from "fs";
1966
+ import path9 from "path";
1967
+
1968
+ // src/memory/debrief-processor.ts
1969
+ function processDebrief(debrief) {
1970
+ const dayRatings = debrief.day_ratings;
1971
+ const avgRating = dayRatings.length > 0 ? dayRatings.reduce((sum, d) => sum + d.rating, 0) / dayRatings.length : 0;
1972
+ const betterThanExpected = dayRatings.filter((d) => d.surprise === "better").map((d) => d.day);
1973
+ const worseThanExpected = dayRatings.filter((d) => d.surprise === "worse").map((d) => d.day);
1974
+ const rawPatterns = [];
1975
+ if (debrief.skip_next_time.trim()) {
1976
+ rawPatterns.push(
1977
+ ...debrief.skip_next_time.split(",").map((s) => s.trim()).filter(Boolean)
1978
+ );
1979
+ }
1980
+ if (debrief.new_anti_patterns.trim()) {
1981
+ rawPatterns.push(
1982
+ ...debrief.new_anti_patterns.split(",").map((s) => s.trim()).filter(Boolean)
1983
+ );
1984
+ }
1985
+ const newAntiPatterns = [...new Set(rawPatterns)];
1986
+ return {
1987
+ avgRating: Math.round(avgRating * 100) / 100,
1988
+ betterThanExpected,
1989
+ worseThanExpected,
1990
+ newAntiPatterns,
1991
+ highlights: debrief.unexpected_highlights.trim()
1992
+ };
1993
+ }
1994
+
1995
+ // src/memory/learned-generator.ts
1996
+ async function generateLearnedSignals(provider, debriefs) {
1997
+ const debriefSummaries = debriefs.map((d) => ({
1998
+ trip: d.trip_name,
1999
+ date: d.debrief_date,
2000
+ overall_rating: d.overall_rating,
2001
+ day_ratings: d.day_ratings,
2002
+ skip_next_time: d.skip_next_time,
2003
+ highlights: d.unexpected_highlights,
2004
+ anti_patterns: d.new_anti_patterns
2005
+ }));
2006
+ const prompt = `You are a travel preference analyst. Given the following trip debrief data from ${debriefs.length} trip(s), extract patterns about the traveler's preferences.
2007
+
2008
+ DEBRIEF DATA:
2009
+ ${JSON.stringify(debriefSummaries, null, 2)}
2010
+
2011
+ Analyze the data and return a JSON object with exactly these fields:
2012
+ - "preference_signals": array of strings \u2014 inferred travel preferences (e.g., "prefers walking neighborhoods over bus tours", "enjoys street food over fine dining")
2013
+ - "activity_calibration": object mapping activity type strings to rating deltas (e.g., {"museum": -0.5, "street_food": +1.2, "hiking": +0.8}) \u2014 positive means they rated these higher than expected, negative means lower
2014
+ - "anti_patterns_learned": array of strings \u2014 things the traveler consistently dislikes or wants to avoid
2015
+ - "source_reliability": object mapping source names to reliability scores 0-1 (leave empty {} if no source data)
2016
+
2017
+ Return ONLY the JSON object, no other text.`;
2018
+ const response = await provider.complete(prompt, 2e3);
2019
+ const parsed = parseJsonResponse(response);
2020
+ return {
2021
+ preference_signals: parsed.preference_signals ?? [],
2022
+ activity_calibration: parsed.activity_calibration ?? {},
2023
+ anti_patterns_learned: parsed.anti_patterns_learned ?? [],
2024
+ source_reliability: parsed.source_reliability ?? {},
2025
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2026
+ trips_analyzed: debriefs.length
2027
+ };
2028
+ }
2029
+
2030
+ // src/commands/debrief.ts
2031
+ function parseDays(planContent) {
2032
+ const dayPattern = /^## Day (\d+)/gm;
2033
+ const days = [];
2034
+ let match;
2035
+ const matches = [];
2036
+ while ((match = dayPattern.exec(planContent)) !== null) {
2037
+ matches.push({ day: parseInt(match[1], 10), index: match.index });
2038
+ }
2039
+ for (let i = 0; i < matches.length; i++) {
2040
+ const start = matches[i].index;
2041
+ const end = i + 1 < matches.length ? matches[i + 1].index : planContent.length;
2042
+ days.push({
2043
+ day: matches[i].day,
2044
+ content: planContent.substring(start, end).trim()
2045
+ });
2046
+ }
2047
+ return days;
2048
+ }
2049
+ async function debriefCommand() {
2050
+ console.log(chalk8.bold("\n Post-Trip Debrief\n"));
2051
+ const planPath = path9.resolve("plan.md");
2052
+ if (!fs10.existsSync(planPath)) {
2053
+ console.log(chalk8.red(" No plan.md found in current directory."));
2054
+ console.log(chalk8.dim(" Run this command from a trip project directory.\n"));
2055
+ return;
2056
+ }
2057
+ const planContent = fs10.readFileSync(planPath, "utf-8");
2058
+ const days = parseDays(planContent);
2059
+ if (days.length === 0) {
2060
+ console.log(chalk8.red(" No day sections found in plan.md."));
2061
+ console.log(chalk8.dim(' Expected headers like "## Day 1", "## Day 2", etc.\n'));
2062
+ return;
2063
+ }
2064
+ const tripName = path9.basename(process.cwd());
2065
+ console.log(chalk8.cyan(` Trip: ${tripName}`));
2066
+ console.log(chalk8.cyan(` Days found: ${days.length}
2067
+ `));
2068
+ const ratingChoices = [
2069
+ { value: 1, name: "1 - Terrible" },
2070
+ { value: 2, name: "2 - Below average" },
2071
+ { value: 3, name: "3 - Average" },
2072
+ { value: 4, name: "4 - Good" },
2073
+ { value: 5, name: "5 - Excellent" }
2074
+ ];
2075
+ const surpriseChoices = [
2076
+ { value: "better", name: "Better than expected" },
2077
+ { value: "expected", name: "As expected" },
2078
+ { value: "worse", name: "Worse than expected" }
2079
+ ];
2080
+ const dayRatings = [];
2081
+ for (const day of days) {
2082
+ console.log(chalk8.bold(`
2083
+ --- Day ${day.day} ---`));
2084
+ const lines = day.content.split("\n");
2085
+ const preview = lines.slice(0, 6).join("\n");
2086
+ console.log(chalk8.dim(preview));
2087
+ console.log("");
2088
+ const rating = await select2({
2089
+ message: `Day ${day.day} rating:`,
2090
+ choices: ratingChoices
2091
+ });
2092
+ const surprise = await select2({
2093
+ message: `Day ${day.day} surprise level:`,
2094
+ choices: surpriseChoices
2095
+ });
2096
+ const notes = await input2({
2097
+ message: `Day ${day.day} notes (optional):`,
2098
+ default: ""
2099
+ });
2100
+ dayRatings.push({
2101
+ day: day.day,
2102
+ rating,
2103
+ surprise,
2104
+ notes
2105
+ });
2106
+ }
2107
+ console.log(chalk8.bold("\n --- Overall Trip ---\n"));
2108
+ const overallRating = await select2({
2109
+ message: "Overall trip rating:",
2110
+ choices: ratingChoices
2111
+ });
2112
+ const skipNextTime = await input2({
2113
+ message: "What would you skip next time? (comma-separated)",
2114
+ default: ""
2115
+ });
2116
+ const highlights = await input2({
2117
+ message: "What unexpected highlights?",
2118
+ default: ""
2119
+ });
2120
+ const newAntiPatterns = await input2({
2121
+ message: "Any new anti-patterns discovered? (comma-separated)",
2122
+ default: ""
2123
+ });
2124
+ const debrief = {
2125
+ trip_name: tripName,
2126
+ trip_dir: process.cwd(),
2127
+ debrief_date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
2128
+ overall_rating: overallRating,
2129
+ day_ratings: dayRatings,
2130
+ skip_next_time: skipNextTime,
2131
+ unexpected_highlights: highlights,
2132
+ new_anti_patterns: newAntiPatterns
2133
+ };
2134
+ const globalDir = getGlobalDir();
2135
+ fs10.mkdirSync(globalDir, { recursive: true });
2136
+ const historyPath = getTripHistoryPath();
2137
+ let history = [];
2138
+ if (fs10.existsSync(historyPath)) {
2139
+ history = JSON.parse(fs10.readFileSync(historyPath, "utf-8"));
2140
+ }
2141
+ history.push(debrief);
2142
+ fs10.writeFileSync(historyPath, JSON.stringify(history, null, 2));
2143
+ console.log(chalk8.green("\n Debrief saved to trip history."));
2144
+ const processed = processDebrief(debrief);
2145
+ console.log(chalk8.dim(` Average day rating: ${processed.avgRating}`));
2146
+ if (processed.betterThanExpected.length > 0) {
2147
+ console.log(chalk8.dim(` Better than expected: Days ${processed.betterThanExpected.join(", ")}`));
2148
+ }
2149
+ if (processed.worseThanExpected.length > 0) {
2150
+ console.log(chalk8.dim(` Worse than expected: Days ${processed.worseThanExpected.join(", ")}`));
2151
+ }
2152
+ const config = loadConfig();
2153
+ const hasProvider = config.api_key || process.env.CLAUDE_CODE_USE_VERTEX === "1" || process.env.GOOGLE_CLOUD_PROJECT;
2154
+ if (hasProvider) {
2155
+ const spinner = ora3("Generating learned preferences from debrief history...").start();
2156
+ try {
2157
+ const provider = createProvider(config);
2158
+ const learned = await generateLearnedSignals(provider, history);
2159
+ const learnedPath = getLearnedPath();
2160
+ fs10.writeFileSync(learnedPath, JSON.stringify(learned, null, 2));
2161
+ spinner.succeed("Learned preferences updated");
2162
+ const profile = loadProfile();
2163
+ if (learned.preference_signals.length > 0) {
2164
+ profile.learned_vibes = learned.preference_signals;
2165
+ }
2166
+ if (learned.anti_patterns_learned.length > 0) {
2167
+ profile.anti_patterns_learned = learned.anti_patterns_learned;
2168
+ }
2169
+ if (Object.keys(learned.source_reliability).length > 0) {
2170
+ profile.source_trust = learned.source_reliability;
2171
+ }
2172
+ profile.trips_completed = history.length;
2173
+ profile.last_debrief = debrief.debrief_date;
2174
+ saveProfile(profile);
2175
+ console.log(chalk8.green(" Profile updated with learned preferences.\n"));
2176
+ } catch (err) {
2177
+ spinner.fail("Could not generate learned preferences (LLM error)");
2178
+ console.log(chalk8.dim(` ${err instanceof Error ? err.message : String(err)}
2179
+ `));
2180
+ const profile = loadProfile();
2181
+ profile.trips_completed = history.length;
2182
+ profile.last_debrief = debrief.debrief_date;
2183
+ saveProfile(profile);
2184
+ }
2185
+ } else {
2186
+ console.log(chalk8.yellow(" No API key configured \u2014 skipping learned preferences generation."));
2187
+ console.log(chalk8.dim(' Run "trip-optimizer config set api_key <key>" to enable.\n'));
2188
+ const profile = loadProfile();
2189
+ profile.trips_completed = history.length;
2190
+ profile.last_debrief = debrief.debrief_date;
2191
+ saveProfile(profile);
2192
+ }
2193
+ }
2194
+
2195
+ // src/commands/history.ts
2196
+ import chalk9 from "chalk";
2197
+ import fs11 from "fs";
2198
+ async function historyCommand() {
2199
+ const historyPath = getTripHistoryPath();
2200
+ if (!fs11.existsSync(historyPath)) {
2201
+ console.log(chalk9.yellow("\n No trip history found."));
2202
+ console.log(chalk9.dim(' Complete a trip and run "trip-optimizer debrief" to build history.\n'));
2203
+ return;
2204
+ }
2205
+ const history = JSON.parse(fs11.readFileSync(historyPath, "utf-8"));
2206
+ if (history.length === 0) {
2207
+ console.log(chalk9.yellow("\n No trip debriefs recorded yet.\n"));
2208
+ return;
2209
+ }
2210
+ console.log(chalk9.bold("\n Trip History\n"));
2211
+ const nameWidth = 30;
2212
+ const dateWidth = 12;
2213
+ const ratingWidth = 8;
2214
+ const daysWidth = 6;
2215
+ const header = " " + "Trip".padEnd(nameWidth) + "Date".padEnd(dateWidth) + "Rating".padEnd(ratingWidth) + "Days".padEnd(daysWidth);
2216
+ console.log(chalk9.dim(header));
2217
+ console.log(chalk9.dim(" " + "-".repeat(nameWidth + dateWidth + ratingWidth + daysWidth)));
2218
+ for (const trip of history) {
2219
+ const stars = "\u2605".repeat(trip.overall_rating) + "\u2606".repeat(5 - trip.overall_rating);
2220
+ const name = trip.trip_name.length > nameWidth - 2 ? trip.trip_name.substring(0, nameWidth - 5) + "..." : trip.trip_name;
2221
+ const line = " " + name.padEnd(nameWidth) + trip.debrief_date.padEnd(dateWidth) + stars.padEnd(ratingWidth) + String(trip.day_ratings.length).padEnd(daysWidth);
2222
+ console.log(line);
2223
+ }
2224
+ console.log("");
2225
+ const avgRating = history.reduce((s, t2) => s + t2.overall_rating, 0) / history.length;
2226
+ console.log(chalk9.dim(` ${history.length} trip(s) | Average rating: ${avgRating.toFixed(1)}/5
2227
+ `));
2228
+ }
2229
+
2230
+ // src/commands/dashboard.ts
2231
+ import fs12 from "fs";
2232
+ import path10 from "path";
2233
+ import chalk10 from "chalk";
2234
+ import yaml7 from "js-yaml";
2235
+ function progressBar(value, max, width = 30) {
2236
+ const filled = Math.round(value / max * width);
2237
+ const empty = width - filled;
2238
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(empty);
2239
+ return `[${bar}]`;
2240
+ }
2241
+ function trendArrow(current, previous) {
2242
+ const diff = current - previous;
2243
+ if (diff > 0.5) return chalk10.green("\u2191");
2244
+ if (diff < -0.5) return chalk10.red("\u2193");
2245
+ return chalk10.gray("\u2192");
2246
+ }
2247
+ function renderDashboard() {
2248
+ const cwd = process.cwd();
2249
+ if (!fs12.existsSync(path10.join(cwd, "constraints.yaml"))) {
2250
+ console.log(chalk10.red("\n Not in a trip project directory.\n"));
2251
+ process.exit(1);
2252
+ }
2253
+ const constraints = yaml7.load(
2254
+ fs12.readFileSync(path10.join(cwd, "constraints.yaml"), "utf-8")
2255
+ );
2256
+ const resultsPath = path10.join(cwd, "results.tsv");
2257
+ const results = readResults(resultsPath);
2258
+ console.log(chalk10.bold.cyan(`
2259
+ === ${constraints.trip?.name || "Trip Optimizer"} Dashboard ===
2260
+ `));
2261
+ if (results.length === 0) {
2262
+ console.log(chalk10.yellow(" No optimization results yet. Run: trip-optimizer run\n"));
2263
+ return;
2264
+ }
2265
+ const lastBest = getLastBestScore(resultsPath);
2266
+ const score = lastBest?.score ?? 0;
2267
+ console.log(` Score: ${progressBar(score, 100)} ${chalk10.bold(score.toFixed(2))}/100`);
2268
+ const scorePath = path10.join(cwd, "score.json");
2269
+ if (fs12.existsSync(scorePath)) {
2270
+ try {
2271
+ const scoreData = JSON.parse(fs12.readFileSync(scorePath, "utf-8"));
2272
+ if (scoreData.components) {
2273
+ console.log(chalk10.bold("\n Dimensions:"));
2274
+ const keeps2 = results.filter((r) => r.status === "keep");
2275
+ const prevScore = keeps2.length >= 2 ? keeps2[keeps2.length - 2].score_after : score;
2276
+ for (const [dim, result] of Object.entries(scoreData.components)) {
2277
+ const dimScore = result.score;
2278
+ const arrow = trendArrow(dimScore, prevScore > 0 ? dimScore : dimScore);
2279
+ const bar = progressBar(dimScore, 100, 15);
2280
+ const penalty = result.penalty ? chalk10.red(` (-${result.penalty})`) : "";
2281
+ console.log(` ${dim.padEnd(18)} ${bar} ${dimScore.toFixed(1)}${penalty} ${arrow}`);
2282
+ }
2283
+ if (scoreData.penalties && scoreData.penalties.length > 0) {
2284
+ console.log(chalk10.bold.red(`
2285
+ Penalties: ${scoreData.penalties.length} remaining`));
2286
+ for (const p of scoreData.penalties.slice(0, 5)) {
2287
+ console.log(chalk10.red(` -${p.penalty} Day ${p.day}: ${p.issue.substring(0, 60)}`));
2288
+ }
2289
+ if (scoreData.penalties.length > 5) {
2290
+ console.log(chalk10.gray(` ... and ${scoreData.penalties.length - 5} more`));
2291
+ }
2292
+ }
2293
+ }
2294
+ } catch {
2295
+ }
2296
+ }
2297
+ const last5 = results.slice(-5);
2298
+ console.log(chalk10.bold("\n Recent Mutations:"));
2299
+ for (const r of last5) {
2300
+ const icon = r.status === "keep" ? chalk10.green("\u2713") : chalk10.red("\u2717");
2301
+ const delta = r.delta >= 0 ? chalk10.green(`+${r.delta.toFixed(2)}`) : chalk10.red(r.delta.toFixed(2));
2302
+ console.log(` ${icon} #${String(r.iteration).padStart(3)} ${r.mutation_type.padEnd(10)} ${r.description.substring(0, 45).padEnd(45)} ${delta}`);
2303
+ }
2304
+ const totalIterations = results[results.length - 1].iteration;
2305
+ const keeps = results.filter((r) => r.status === "keep").length;
2306
+ const keepRate = (keeps / results.length * 100).toFixed(1);
2307
+ const baseline = results[0]?.score_after || 0;
2308
+ const totalDelta = score - baseline;
2309
+ console.log(chalk10.bold("\n Stats:"));
2310
+ console.log(` Iterations: ${totalIterations}`);
2311
+ console.log(` Keep rate: ${keepRate}% (${keeps}/${results.length})`);
2312
+ console.log(` Baseline: ${baseline.toFixed(2)}`);
2313
+ console.log(` Total delta: ${totalDelta >= 0 ? chalk10.green(`+${totalDelta.toFixed(2)}`) : chalk10.red(totalDelta.toFixed(2))}`);
2314
+ const dbPath = path10.join(cwd, "activities_db.json");
2315
+ if (fs12.existsSync(dbPath)) {
2316
+ try {
2317
+ const db = JSON.parse(fs12.readFileSync(dbPath, "utf-8"));
2318
+ const cities = Object.entries(db);
2319
+ if (cities.length > 0) {
2320
+ console.log(chalk10.bold("\n Research Coverage:"));
2321
+ for (const [city, data] of cities) {
2322
+ const activities = data.activities?.length || 0;
2323
+ const restaurants = data.restaurants?.length || 0;
2324
+ const total = activities + restaurants;
2325
+ const bar = progressBar(Math.min(total, 50), 50, 15);
2326
+ console.log(` ${city.padEnd(15)} ${bar} ${chalk10.cyan(`${activities}a`)} ${chalk10.yellow(`${restaurants}r`)}`);
2327
+ }
2328
+ }
2329
+ } catch {
2330
+ }
2331
+ }
2332
+ console.log();
2333
+ }
2334
+ function dashboardCommand(options) {
2335
+ if (options.watch) {
2336
+ const render = () => {
2337
+ process.stdout.write("\x1B[2J\x1B[0f");
2338
+ renderDashboard();
2339
+ console.log(chalk10.gray(" Auto-refreshing every 5s. Press Ctrl+C to stop."));
2340
+ };
2341
+ render();
2342
+ setInterval(render, 5e3);
2343
+ } else {
2344
+ renderDashboard();
2345
+ }
2346
+ }
2347
+
2348
+ // src/commands/chart.ts
2349
+ import path11 from "path";
2350
+ import chalk11 from "chalk";
2351
+ import asciichart from "asciichart";
2352
+ function chartCommand() {
2353
+ const cwd = process.cwd();
2354
+ const resultsPath = path11.join(cwd, "results.tsv");
2355
+ const results = readResults(resultsPath);
2356
+ if (results.length === 0) {
2357
+ console.log(chalk11.yellow("\n No optimization results yet. Run: trip-optimizer run\n"));
2358
+ return;
2359
+ }
2360
+ const scores = [];
2361
+ let currentBest = results[0].score_before;
2362
+ scores.push(currentBest);
2363
+ for (const r of results) {
2364
+ if (r.status === "keep") {
2365
+ currentBest = r.score_after;
2366
+ }
2367
+ scores.push(currentBest);
2368
+ }
2369
+ console.log(chalk11.bold.cyan("\n Score Progression"));
2370
+ console.log(chalk11.gray(` ${results.length} iterations, ${results.filter((r) => r.status === "keep").length} kept
2371
+ `));
2372
+ const width = Math.min(process.stdout.columns || 80, 120) - 15;
2373
+ const chartHeight = 15;
2374
+ let plotData = scores;
2375
+ if (scores.length > width) {
2376
+ const step = scores.length / width;
2377
+ plotData = [];
2378
+ for (let i = 0; i < width; i++) {
2379
+ plotData.push(scores[Math.floor(i * step)]);
2380
+ }
2381
+ }
2382
+ const chart = asciichart.plot(plotData, {
2383
+ height: chartHeight,
2384
+ format: (x) => x.toFixed(1).padStart(6)
2385
+ });
2386
+ console.log(chart);
2387
+ const first = scores[0];
2388
+ const last = scores[scores.length - 1];
2389
+ const max = Math.max(...scores);
2390
+ const min = Math.min(...scores);
2391
+ console.log();
2392
+ console.log(` Start: ${chalk11.gray(first.toFixed(2))} Current: ${chalk11.bold(last.toFixed(2))} Peak: ${chalk11.green(max.toFixed(2))} Low: ${chalk11.red(min.toFixed(2))}`);
2393
+ console.log();
2394
+ }
2395
+
2396
+ // src/commands/plan.ts
2397
+ import fs13 from "fs";
2398
+ import path12 from "path";
2399
+ import chalk12 from "chalk";
2400
+ import PDFDocument from "pdfkit";
2401
+ import yaml8 from "js-yaml";
2402
+ function planCommand(options = {}) {
2403
+ const cwd = process.cwd();
2404
+ const planPath = path12.join(cwd, "plan.md");
2405
+ if (!fs13.existsSync(planPath)) {
2406
+ console.log(chalk12.red("\n No plan.md found in current directory.\n"));
2407
+ process.exit(1);
2408
+ }
2409
+ const content = fs13.readFileSync(planPath, "utf-8");
2410
+ if (options.pdf) {
2411
+ const outputPath = options.output || path12.join(cwd, "plan.pdf");
2412
+ generatePdf(content, outputPath);
2413
+ console.log(chalk12.green(`
2414
+ PDF saved to ${outputPath}
2415
+ `));
2416
+ return;
2417
+ }
2418
+ const lines = content.split("\n");
2419
+ console.log();
2420
+ for (const line of lines) {
2421
+ console.log(formatLine(line));
2422
+ }
2423
+ console.log();
2424
+ }
2425
+ var MARGIN = 54;
2426
+ var PAGE_W = 612;
2427
+ var PAGE_H = 792;
2428
+ var CONTENT_W = PAGE_W - MARGIN * 2;
2429
+ var PAGE_BOTTOM = PAGE_H - MARGIN - 20;
2430
+ function parseFrontmatter(markdown) {
2431
+ if (markdown.startsWith("---")) {
2432
+ const end = markdown.indexOf("---", 3);
2433
+ if (end !== -1) {
2434
+ const fmText = markdown.slice(3, end).trim();
2435
+ const fm = yaml8.load(fmText);
2436
+ return { frontmatter: fm, body: markdown.slice(end + 3).trimStart() };
2437
+ }
2438
+ }
2439
+ return { frontmatter: {}, body: markdown };
2440
+ }
2441
+ function stripEmoji(text) {
2442
+ return text.replace(/[\u{1F600}-\u{1F9FF}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{FE00}-\u{FE0F}\u{1FA00}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu, "").replace(/\u2192/g, " -- ").replace(/[\u25C6\u2764\u2B50\u2728\u26A0\u2705\u274C\u{1F3D4}]/gu, "").trim();
2443
+ }
2444
+ function ensureSpace(doc, needed) {
2445
+ if (doc.y + needed > PAGE_BOTTOM) {
2446
+ doc.addPage();
2447
+ }
2448
+ }
2449
+ function generatePdf(markdown, outputPath) {
2450
+ const { frontmatter, body } = parseFrontmatter(markdown);
2451
+ const doc = new PDFDocument({
2452
+ size: "LETTER",
2453
+ margins: { top: MARGIN, bottom: MARGIN, left: MARGIN, right: MARGIN }
2454
+ });
2455
+ const stream = fs13.createWriteStream(outputPath);
2456
+ doc.pipe(stream);
2457
+ renderCoverPage(doc, frontmatter);
2458
+ const lines = body.split("\n");
2459
+ let i = 0;
2460
+ while (i < lines.length) {
2461
+ const line = lines[i];
2462
+ const trimmed = line.trim();
2463
+ if (!trimmed) {
2464
+ doc.moveDown(0.25);
2465
+ i++;
2466
+ continue;
2467
+ }
2468
+ if (/^---+$/.test(trimmed)) {
2469
+ doc.moveDown(0.3);
2470
+ i++;
2471
+ continue;
2472
+ }
2473
+ if (/^#{1,2}\s+Day\s+\d+/i.test(trimmed)) {
2474
+ doc.addPage();
2475
+ const headerText = stripEmoji(trimmed.replace(/^#{1,2}\s+/, ""));
2476
+ doc.font("Helvetica-Bold").fontSize(18).fillColor("#1a1a2e").text(headerText, MARGIN, MARGIN, { width: CONTENT_W });
2477
+ const y = doc.y + 2;
2478
+ doc.moveTo(MARGIN, y).lineTo(MARGIN + CONTENT_W, y).strokeColor("#0984e3").lineWidth(1.5).stroke();
2479
+ doc.lineWidth(1);
2480
+ doc.moveDown(0.4);
2481
+ i++;
2482
+ if (i < lines.length && /^\s*\*[^*]+\*\s*$/.test(lines[i].trim())) {
2483
+ const theme = lines[i].trim().replace(/^\*|\*$/g, "");
2484
+ doc.font("Helvetica-Oblique").fontSize(10).fillColor("#636e72").text(theme, MARGIN, void 0, { width: CONTENT_W });
2485
+ doc.moveDown(0.5);
2486
+ i++;
2487
+ }
2488
+ continue;
2489
+ }
2490
+ if (/^# [^#]/.test(trimmed)) {
2491
+ ensureSpace(doc, 40);
2492
+ doc.moveDown(0.5);
2493
+ const title = stripEmoji(trimmed.replace(/^# /, ""));
2494
+ doc.font("Helvetica-Bold").fontSize(16).fillColor("#1a1a2e").text(title, MARGIN, void 0, { width: CONTENT_W });
2495
+ const y = doc.y + 1;
2496
+ doc.moveTo(MARGIN, y).lineTo(MARGIN + CONTENT_W, y).strokeColor("#cccccc").lineWidth(0.5).stroke();
2497
+ doc.lineWidth(1);
2498
+ doc.moveDown(0.4);
2499
+ i++;
2500
+ continue;
2501
+ }
2502
+ if (/^#{2,3}\s+/.test(trimmed)) {
2503
+ ensureSpace(doc, 30);
2504
+ doc.moveDown(0.4);
2505
+ const heading = stripEmoji(trimmed.replace(/^#{2,3}\s+/, ""));
2506
+ doc.font("Helvetica-Bold").fontSize(12).fillColor("#2d3436").text(heading, MARGIN, void 0, { width: CONTENT_W });
2507
+ doc.moveDown(0.2);
2508
+ i++;
2509
+ continue;
2510
+ }
2511
+ if (trimmed.startsWith("|")) {
2512
+ i = renderTable(doc, lines, i);
2513
+ continue;
2514
+ }
2515
+ if (/^\*[^*]+\*$/.test(trimmed)) {
2516
+ ensureSpace(doc, 16);
2517
+ const text = stripEmoji(trimmed.replace(/^\*|\*$/g, ""));
2518
+ doc.font("Helvetica-Oblique").fontSize(10).fillColor("#636e72").text(text, MARGIN, void 0, { width: CONTENT_W });
2519
+ doc.moveDown(0.2);
2520
+ i++;
2521
+ continue;
2522
+ }
2523
+ if (/^\*\*(?:Hotel|Transit|Reality check)[:.]/.test(trimmed)) {
2524
+ ensureSpace(doc, 20);
2525
+ doc.moveDown(0.15);
2526
+ renderRichText(doc, stripEmoji(trimmed), MARGIN, CONTENT_W, 9.5, "#555555");
2527
+ doc.moveDown(0.15);
2528
+ i++;
2529
+ continue;
2530
+ }
2531
+ if (/^\s{2,}[-*]\s+/.test(line)) {
2532
+ ensureSpace(doc, 16);
2533
+ const text = stripEmoji(trimmed.replace(/^[-*]\s+/, ""));
2534
+ renderBullet(doc, text, 80, CONTENT_W - 26, "-");
2535
+ i++;
2536
+ continue;
2537
+ }
2538
+ if (/^[-*]\s+/.test(trimmed)) {
2539
+ ensureSpace(doc, 16);
2540
+ const text = stripEmoji(trimmed.replace(/^[-*]\s+/, ""));
2541
+ renderBullet(doc, text, MARGIN + 8, CONTENT_W - 8, "\u2022");
2542
+ i++;
2543
+ continue;
2544
+ }
2545
+ ensureSpace(doc, 16);
2546
+ renderRichText(doc, stripEmoji(trimmed), MARGIN, CONTENT_W, 10, "#333333");
2547
+ doc.moveDown(0.15);
2548
+ i++;
2549
+ }
2550
+ doc.end();
2551
+ }
2552
+ function renderCoverPage(doc, fm) {
2553
+ const centerX = PAGE_W / 2;
2554
+ const name = (fm.trip_name || "Travel Plan").toUpperCase();
2555
+ doc.font("Helvetica-Bold").fontSize(32).fillColor("#1a1a2e");
2556
+ doc.text(name, MARGIN, 220, { width: CONTENT_W, align: "center" });
2557
+ doc.moveDown(0.5);
2558
+ const lineY = doc.y;
2559
+ const lineW = 200;
2560
+ doc.moveTo(centerX - lineW / 2, lineY).lineTo(centerX + lineW / 2, lineY).strokeColor("#0984e3").lineWidth(2).stroke();
2561
+ doc.lineWidth(1);
2562
+ doc.moveDown(1);
2563
+ doc.font("Helvetica").fontSize(13).fillColor("#555555");
2564
+ const details = [];
2565
+ if (fm.start_date && fm.end_date) {
2566
+ const fmtDate = (d) => {
2567
+ const date = d instanceof Date ? d : new Date(d);
2568
+ return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
2569
+ };
2570
+ details.push(`${fmtDate(fm.start_date)} to ${fmtDate(fm.end_date)}`);
2571
+ }
2572
+ if (fm.total_days) {
2573
+ details.push(`${fm.total_days} days`);
2574
+ }
2575
+ if (fm.origin) {
2576
+ details.push(`From ${fm.origin}`);
2577
+ }
2578
+ if (fm.total_budget && fm.currency) {
2579
+ details.push(`Budget: ${fm.currency} ${fm.total_budget.toLocaleString()}`);
2580
+ } else if (fm.total_budget) {
2581
+ details.push(`Budget: $${fm.total_budget.toLocaleString()}`);
2582
+ }
2583
+ if (fm.travelers) {
2584
+ details.push(`${fm.travelers} traveler${fm.travelers > 1 ? "s" : ""}`);
2585
+ }
2586
+ for (const detail of details) {
2587
+ doc.text(detail, MARGIN, void 0, { width: CONTENT_W, align: "center" });
2588
+ doc.moveDown(0.3);
2589
+ }
2590
+ doc.font("Helvetica-Oblique").fontSize(9).fillColor("#999999");
2591
+ doc.text("Generated by trip-optimizer", MARGIN, PAGE_H - MARGIN - 30, {
2592
+ width: CONTENT_W,
2593
+ align: "center"
2594
+ });
2595
+ doc.addPage();
2596
+ }
2597
+ function renderTable(doc, lines, startIdx) {
2598
+ const rows = [];
2599
+ let i = startIdx;
2600
+ while (i < lines.length && lines[i].trim().startsWith("|")) {
2601
+ const row = lines[i].trim();
2602
+ if (/^\|[\s-:|]+\|$/.test(row)) {
2603
+ i++;
2604
+ continue;
2605
+ }
2606
+ const cells = row.split("|").filter((_, idx, arr) => idx > 0 && idx < arr.length - 1).map((c) => stripEmoji(c.trim().replace(/\*\*/g, "")));
2607
+ rows.push(cells);
2608
+ i++;
2609
+ }
2610
+ if (rows.length === 0) return i;
2611
+ const colCount = rows[0].length;
2612
+ const col1Width = CONTENT_W * 0.62;
2613
+ const col2Width = CONTENT_W * 0.38;
2614
+ const colWidths = colCount === 2 ? [col1Width, col2Width] : Array(colCount).fill(CONTENT_W / colCount);
2615
+ const rowHeight = 20;
2616
+ ensureSpace(doc, Math.min(rows.length + 1, 6) * rowHeight);
2617
+ const startX = MARGIN;
2618
+ let y = doc.y;
2619
+ if (rows.length > 0) {
2620
+ doc.rect(startX, y, CONTENT_W, rowHeight).fill("#e8e8e8");
2621
+ doc.font("Helvetica-Bold").fontSize(9).fillColor("#2d3436");
2622
+ let x = startX;
2623
+ for (let c = 0; c < rows[0].length && c < colWidths.length; c++) {
2624
+ doc.text(rows[0][c], x + 5, y + 5, { width: colWidths[c] - 10, lineBreak: false });
2625
+ x += colWidths[c];
2626
+ }
2627
+ y += rowHeight;
2628
+ doc.moveTo(startX, y).lineTo(startX + CONTENT_W, y).strokeColor("#0984e3").lineWidth(1).stroke();
2629
+ }
2630
+ for (let r = 1; r < rows.length; r++) {
2631
+ if (y + rowHeight > PAGE_BOTTOM) {
2632
+ doc.addPage();
2633
+ y = MARGIN;
2634
+ }
2635
+ if (r % 2 === 0) {
2636
+ doc.rect(startX, y, CONTENT_W, rowHeight).fill("#f5f5f5");
2637
+ }
2638
+ const isTotalRow = rows[r][0]?.toLowerCase().includes("total");
2639
+ doc.font(isTotalRow ? "Helvetica-Bold" : "Helvetica").fontSize(9).fillColor("#333333");
2640
+ let x = startX;
2641
+ for (let c = 0; c < rows[r].length && c < colWidths.length; c++) {
2642
+ doc.text(rows[r][c], x + 5, y + 5, { width: colWidths[c] - 10, lineBreak: false });
2643
+ x += colWidths[c];
2644
+ }
2645
+ y += rowHeight;
2646
+ }
2647
+ doc.moveTo(startX, y).lineTo(startX + CONTENT_W, y).strokeColor("#cccccc").lineWidth(0.5).stroke();
2648
+ doc.lineWidth(1);
2649
+ doc.y = y + 8;
2650
+ return i;
2651
+ }
2652
+ function renderBullet(doc, text, x, width, bulletChar) {
2653
+ const bulletX = x - 10;
2654
+ const y = doc.y;
2655
+ doc.font("Helvetica").fontSize(10).fillColor("#333333").text(bulletChar, bulletX, y, { width: 10, continued: false });
2656
+ doc.y = y;
2657
+ renderRichText(doc, text, x, width, 10, "#333333");
2658
+ doc.moveDown(0.1);
2659
+ }
2660
+ function renderRichText(doc, text, x, width, fontSize, defaultColor) {
2661
+ const segments = [];
2662
+ const pattern = /(\*\*[^*]+\*\*|\*[^*]+\*)/g;
2663
+ let lastIndex = 0;
2664
+ let match;
2665
+ while ((match = pattern.exec(text)) !== null) {
2666
+ if (match.index > lastIndex) {
2667
+ segments.push({ text: text.slice(lastIndex, match.index), bold: false, italic: false });
2668
+ }
2669
+ const m = match[0];
2670
+ if (m.startsWith("**")) {
2671
+ segments.push({ text: m.slice(2, -2), bold: true, italic: false });
2672
+ } else {
2673
+ segments.push({ text: m.slice(1, -1), bold: false, italic: true });
2674
+ }
2675
+ lastIndex = match.index + m.length;
2676
+ }
2677
+ if (lastIndex < text.length) {
2678
+ segments.push({ text: text.slice(lastIndex), bold: false, italic: false });
2679
+ }
2680
+ if (segments.length === 0) return;
2681
+ if (segments.length === 1 && !segments[0].bold && !segments[0].italic) {
2682
+ doc.font("Helvetica").fontSize(fontSize).fillColor(defaultColor).text(segments[0].text, x, doc.y, { width, lineBreak: true });
2683
+ return;
2684
+ }
2685
+ const startY = doc.y;
2686
+ for (let i = 0; i < segments.length; i++) {
2687
+ const seg = segments[i];
2688
+ const isLast = i === segments.length - 1;
2689
+ if (seg.bold) {
2690
+ doc.font("Helvetica-Bold").fontSize(fontSize).fillColor("#2d3436");
2691
+ } else if (seg.italic) {
2692
+ doc.font("Helvetica-Oblique").fontSize(fontSize).fillColor("#636e72");
2693
+ } else {
2694
+ doc.font("Helvetica").fontSize(fontSize).fillColor(defaultColor);
2695
+ }
2696
+ if (i === 0) {
2697
+ doc.text(seg.text, x, startY, { width, continued: !isLast });
2698
+ } else {
2699
+ doc.text(seg.text, { continued: !isLast });
2700
+ }
2701
+ }
2702
+ }
2703
+ function formatLine(line) {
2704
+ if (/^#{1,3}\s+Day\s+\d+/i.test(line)) {
2705
+ return chalk12.bold.magenta(line);
2706
+ }
2707
+ if (/^#{1,2}\s+/.test(line)) {
2708
+ return chalk12.bold.cyan(line);
2709
+ }
2710
+ if (/^#{3,}\s+/.test(line)) {
2711
+ return chalk12.bold(line);
2712
+ }
2713
+ let formatted = line.replace(
2714
+ /\b(\d{1,2}:\d{2}(\s*[AaPp][Mm])?)\b/g,
2715
+ (match) => chalk12.bold(match)
2716
+ );
2717
+ formatted = formatted.replace(
2718
+ /(?:restaurant|cafe|bakery|noodle|dumpling|hotpot|teahouse|bistro|eatery|diner|food stall|street food)/gi,
2719
+ (match) => chalk12.yellow(match)
2720
+ );
2721
+ formatted = formatted.replace(
2722
+ /(?:Lunch|Dinner|Breakfast|Brunch|Snack):\s*(.+?)(?:\s*[-\u2013\u2014(]|$)/gi,
2723
+ (match, name) => match.replace(name, chalk12.yellow(name))
2724
+ );
2725
+ formatted = formatted.replace(
2726
+ /\*\*([^*]+)\*\*/g,
2727
+ (_match, name) => chalk12.cyan.bold(name)
2728
+ );
2729
+ formatted = formatted.replace(
2730
+ /(?:hotel|hostel|guesthouse|airbnb|accommodation|check[- ]?in|check[- ]?out|Le\s+M[eé]ridien|Sheraton|Courtyard|Marriott)/gi,
2731
+ (match) => chalk12.blue(match)
2732
+ );
2733
+ formatted = formatted.replace(
2734
+ /[¥$€£]\s?\d[\d,.]*/g,
2735
+ (match) => chalk12.green(match)
2736
+ );
2737
+ return formatted;
2738
+ }
2739
+
2740
+ // src/cli.ts
2741
+ var _cfg = loadConfig();
2742
+ if (_cfg.language) setLanguage(_cfg.language);
2743
+ var program = new Command();
2744
+ program.name("trip-optimizer").description("Autonomously optimize travel plans using the autoresearch pattern").version("0.1.0");
2745
+ program.command("init <name>").description("Create a new trip project").action(initCommand);
2746
+ program.command("config").description("Manage API keys and provider settings").argument("[args...]", "config subcommand and arguments").action((args) => configCommand(args));
2747
+ program.command("profile").description("View travel profile and preferences").action(() => profileCommand());
2748
+ program.command("score").description("Run a one-off absolute score of the current plan").action(scoreCommand);
2749
+ program.command("research [city]").description("Research sprint for a specific city or all cities").action(researchCommand);
2750
+ program.command("run").description("Start the optimization loop (default: agent mode)").option("--standalone", "Use direct API calls instead of Claude Code agent").option("--headless", "Run agent non-interactively (fire and forget)").option("--safe", "Use normal permissions in agent mode (no yolo)").action(runCommand);
2751
+ program.command("status").description("Show current score and optimization progress").action(statusCommand);
2752
+ program.command("debrief").description("Post-trip debrief \u2014 rate experiences and build memory").action(debriefCommand);
2753
+ program.command("history").description("View past trip debriefs and learned preferences").action(historyCommand);
2754
+ program.command("dashboard").description("Live dashboard showing optimization progress").option("--watch", "Auto-refresh every 5 seconds").action((options) => dashboardCommand(options));
2755
+ program.command("chart").description("ASCII chart of score progression").action(chartCommand);
2756
+ program.command("plan").description("Pretty-print the current travel plan").option("--pdf", "Generate a PDF document").option("-o, --output <path>", "Output path for PDF").action(planCommand);
2757
+ process.on("unhandledRejection", (err) => {
2758
+ const msg = err instanceof Error ? err.message : String(err);
2759
+ console.error(`
2760
+ \x1B[31mError: ${msg}\x1B[0m`);
2761
+ if (msg.includes("401") || msg.includes("403") || msg.includes("Authentication") || msg.includes("PERMISSION")) {
2762
+ if (_cfg.model_override) {
2763
+ console.error(` \x1B[33mAPI key for ${_cfg.model_override.model} is invalid or expired.\x1B[0m`);
2764
+ } else if (process.env.CLAUDE_CODE_USE_VERTEX === "1" || process.env.GOOGLE_CLOUD_PROJECT) {
2765
+ console.error(" \x1B[33mAuthentication failed. Run: gcloud auth application-default login\x1B[0m");
2766
+ } else {
2767
+ console.error(" \x1B[33mAnthropic API key is invalid. Run: trip-optimizer config set api_key <key>\x1B[0m");
2768
+ }
2769
+ } else if (msg.includes("404") || msg.includes("NOT_FOUND")) {
2770
+ if (_cfg.model_override) {
2771
+ console.error(` \x1B[33mModel "${_cfg.model_override.model}" not found at ${_cfg.model_override.base_url}\x1B[0m`);
2772
+ } else {
2773
+ console.error(` \x1B[33mModel not found. Check ANTHROPIC_MODEL=${process.env.ANTHROPIC_MODEL || "(not set)"}\x1B[0m`);
2774
+ }
2775
+ } else if (msg.includes("ENOTFOUND") || msg.includes("ECONNREFUSED")) {
2776
+ console.error(" \x1B[33mNetwork error. Check your internet connection.\x1B[0m");
2777
+ }
2778
+ console.error();
2779
+ process.exit(1);
2780
+ });
2781
+ program.parse();