pi-interview 0.6.2 → 0.8.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/index.ts CHANGED
@@ -8,8 +8,17 @@ import * as fs from "node:fs";
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { execSync, execFileSync } from "node:child_process";
10
10
  import { createRequire } from "node:module";
11
- import { startInterviewServer, getActiveSessions, type ResponseItem, type InterviewServerCallbacks } from "./server.js";
12
- import { validateQuestions, sanitizeLLMJSON, type QuestionsFile } from "./schema.js";
11
+ import {
12
+ startInterviewServer,
13
+ getActiveSessions,
14
+ type ChoiceResponseValue,
15
+ type ResponseItem,
16
+ type InterviewServerCallbacks,
17
+ type SavedOptionInsight,
18
+ type AskModelOption,
19
+ type OptionInsightResult,
20
+ } from "./server.js";
21
+ import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type QuestionsFile } from "./schema.js";
13
22
  import { loadSettings, type InterviewThemeSettings } from "./settings.js";
14
23
 
15
24
  interface GlimpseWindow {
@@ -125,6 +134,8 @@ interface SavedFromMeta {
125
134
 
126
135
  interface SavedQuestionsFile extends QuestionsFile {
127
136
  savedAnswers?: ResponseItem[];
137
+ savedOptionInsights?: SavedOptionInsight[];
138
+ optionKeysByQuestion?: Record<string, string[]>;
128
139
  savedAt?: string;
129
140
  wasSubmitted?: boolean;
130
141
  savedFrom?: SavedFromMeta;
@@ -249,6 +260,9 @@ const GENERATE_OPTIONS_SYSTEM_PROMPT =
249
260
  const REVIEW_QUESTION_SYSTEM_PROMPT =
250
261
  "You review interview questions and answer options. Preserve intent. Return only JSON with a rewritten question string and an options array.";
251
262
 
263
+ const OPTION_INSIGHT_SYSTEM_PROMPT =
264
+ "You analyze a single interview answer option. Return only JSON with this shape: {\"summary\":\"...\",\"bullets\":[\"...\"],\"suggestedText\":\"...\"}. Keep summary concise, bullets short, and omit suggestedText when no rewrite is needed.";
265
+
252
266
  function formatModelRef(model: GenerateModelCandidate): string {
253
267
  return `${model.provider}/${model.id}`;
254
268
  }
@@ -288,6 +302,37 @@ export function selectGenerateModels<T extends GenerateModelCandidate>(
288
302
  return { primary: availableModels[0] ?? null, fallback: null };
289
303
  }
290
304
 
305
+ function buildAskModelsData(
306
+ availableModels: Model<Api>[],
307
+ primaryModel: Model<Api> | null,
308
+ fallbackModel: Model<Api> | null,
309
+ ): AskModelOption[] {
310
+ const models: AskModelOption[] = [];
311
+ const seen = new Set<string>();
312
+ const addModel = (model: Model<Api> | null) => {
313
+ if (!model) return;
314
+ const value = `${model.provider}/${model.id}`;
315
+ if (seen.has(value)) return;
316
+ seen.add(value);
317
+ models.push({
318
+ value,
319
+ provider: model.provider,
320
+ label: model.id,
321
+ });
322
+ };
323
+
324
+ addModel(primaryModel);
325
+ addModel(fallbackModel);
326
+ for (const model of availableModels) {
327
+ addModel(model);
328
+ }
329
+
330
+ return models.sort((a, b) => {
331
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
332
+ return a.label.localeCompare(b.label);
333
+ });
334
+ }
335
+
291
336
  export function extractGenerateResponseText(
292
337
  modelRef: string,
293
338
  response: Pick<AssistantMessage, "content" | "stopReason" | "errorMessage">,
@@ -393,6 +438,51 @@ function normalizeGeneratedOptions(parsed: unknown): string[] {
393
438
  return options;
394
439
  }
395
440
 
441
+ function normalizeGeneratedOptionValues(parsed: unknown): OptionValue[] {
442
+ if (!Array.isArray(parsed)) {
443
+ throw new Error("Expected array of options");
444
+ }
445
+
446
+ const normalizedInput = parsed
447
+ .map((option) => {
448
+ if (typeof option === "string") {
449
+ return option.trim();
450
+ }
451
+ if (!option || typeof option !== "object") {
452
+ return option;
453
+ }
454
+ const raw = option as Record<string, unknown>;
455
+ return {
456
+ ...raw,
457
+ label: typeof raw.label === "string" ? raw.label.trim() : raw.label,
458
+ };
459
+ })
460
+ .filter((option) => {
461
+ if (typeof option === "string") {
462
+ return option.length > 0;
463
+ }
464
+ if (!option || typeof option !== "object") {
465
+ return true;
466
+ }
467
+ return typeof (option as Record<string, unknown>).label !== "string"
468
+ || (option as Record<string, unknown>).label.length > 0;
469
+ });
470
+
471
+ const validated = validateQuestions({
472
+ questions: [{
473
+ id: "generated-options",
474
+ type: "single",
475
+ question: "Generated options",
476
+ options: normalizedInput,
477
+ }],
478
+ });
479
+ const options = validated.questions[0]?.options;
480
+ if (!options || options.length === 0) {
481
+ throw new Error("No valid options generated");
482
+ }
483
+ return options;
484
+ }
485
+
396
486
  export function parseGeneratedOptions(text: string): string[] {
397
487
  let parsed: unknown;
398
488
  try {
@@ -404,6 +494,17 @@ export function parseGeneratedOptions(text: string): string[] {
404
494
  return normalizeGeneratedOptions(parsed);
405
495
  }
406
496
 
497
+ export function parseGeneratedOptionValues(text: string): OptionValue[] {
498
+ let parsed: unknown;
499
+ try {
500
+ parsed = JSON.parse(extractJSONArray(text));
501
+ } catch (err) {
502
+ const detail = err instanceof Error ? err.message : String(err);
503
+ throw new Error(`Failed to parse generated options: ${detail}`);
504
+ }
505
+ return normalizeGeneratedOptionValues(parsed);
506
+ }
507
+
407
508
  export function parseReviewedQuestion(text: string): { question: string; options: string[] } {
408
509
  let parsed: unknown;
409
510
  try {
@@ -427,6 +528,63 @@ export function parseReviewedQuestion(text: string): { question: string; options
427
528
  };
428
529
  }
429
530
 
531
+ export function parseReviewedQuestionUpdate(text: string): { question: string; options: OptionValue[] } {
532
+ let parsed: unknown;
533
+ try {
534
+ parsed = JSON.parse(extractJSONObject(text));
535
+ } catch (err) {
536
+ const detail = err instanceof Error ? err.message : String(err);
537
+ throw new Error(`Failed to parse reviewed question: ${detail}`);
538
+ }
539
+ if (typeof parsed !== "object" || parsed === null) {
540
+ throw new Error("Expected reviewed question object");
541
+ }
542
+
543
+ const review = parsed as Record<string, unknown>;
544
+ if (typeof review.question !== "string" || !review.question.trim()) {
545
+ throw new Error("Reviewed question must include a non-empty question string");
546
+ }
547
+
548
+ const options = normalizeGeneratedOptionValues(review.options);
549
+ if (options.some((option) => !isRichOption(option))) {
550
+ throw new Error("Reviewed rich options must all be objects with label");
551
+ }
552
+
553
+ return {
554
+ question: review.question.trim(),
555
+ options,
556
+ };
557
+ }
558
+
559
+ export function parseOptionInsight(text: string): OptionInsightResult {
560
+ let parsed: unknown;
561
+ try {
562
+ parsed = JSON.parse(extractJSONObject(text));
563
+ } catch (err) {
564
+ const detail = err instanceof Error ? err.message : String(err);
565
+ throw new Error(`Failed to parse option insight: ${detail}`);
566
+ }
567
+ if (typeof parsed !== "object" || parsed === null) {
568
+ throw new Error("Expected option insight object");
569
+ }
570
+
571
+ const insight = parsed as Record<string, unknown>;
572
+ if (typeof insight.summary !== "string" || !insight.summary.trim()) {
573
+ throw new Error("Option insight must include a non-empty summary string");
574
+ }
575
+ const bullets = Array.isArray(insight.bullets)
576
+ ? insight.bullets.filter((bullet): bullet is string => typeof bullet === "string" && bullet.trim().length > 0).map((bullet) => bullet.trim())
577
+ : [];
578
+
579
+ return {
580
+ summary: insight.summary.trim(),
581
+ bullets: bullets.length > 0 ? bullets : undefined,
582
+ suggestedText: typeof insight.suggestedText === "string" && insight.suggestedText.trim().length > 0
583
+ ? insight.suggestedText.trim()
584
+ : undefined,
585
+ };
586
+ }
587
+
430
588
  export function loadSavedInterview(html: string, filePath: string): SavedQuestionsFile {
431
589
  // Extract JSON from <script id="pi-interview-data">
432
590
  const match = html.match(/<script[^>]+id=["']pi-interview-data["'][^>]*>([\s\S]*?)<\/script>/i);
@@ -452,6 +610,24 @@ export function loadSavedInterview(html: string, filePath: string): SavedQuestio
452
610
  const savedAnswers = Array.isArray(raw.savedAnswers)
453
611
  ? resolveAnswerPaths(raw.savedAnswers as ResponseItem[], snapshotDir, questionTypeById)
454
612
  : undefined;
613
+ const savedOptionInsights = Array.isArray(raw.savedOptionInsights)
614
+ ? (raw.savedOptionInsights as SavedOptionInsight[]).filter((item) =>
615
+ item &&
616
+ typeof item.id === "string" &&
617
+ typeof item.questionId === "string" &&
618
+ typeof item.optionKey === "string" &&
619
+ typeof item.optionText === "string" &&
620
+ typeof item.prompt === "string" &&
621
+ typeof item.summary === "string"
622
+ )
623
+ : undefined;
624
+ const optionKeysByQuestion = raw.optionKeysByQuestion && typeof raw.optionKeysByQuestion === "object"
625
+ ? Object.fromEntries(
626
+ Object.entries(raw.optionKeysByQuestion as Record<string, unknown>)
627
+ .filter(([, value]) => Array.isArray(value) && value.every((key) => typeof key === "string"))
628
+ .map(([questionId, value]) => [questionId, [...(value as string[])]])
629
+ )
630
+ : undefined;
455
631
 
456
632
  // Validate savedFrom if present
457
633
  let savedFrom: SavedFromMeta | undefined;
@@ -470,6 +646,8 @@ export function loadSavedInterview(html: string, filePath: string): SavedQuestio
470
646
  return {
471
647
  ...validated,
472
648
  savedAnswers,
649
+ savedOptionInsights,
650
+ optionKeysByQuestion,
473
651
  savedAt: typeof raw.savedAt === "string" ? raw.savedAt : undefined,
474
652
  wasSubmitted: typeof raw.wasSubmitted === "boolean" ? raw.wasSubmitted : undefined,
475
653
  savedFrom,
@@ -507,11 +685,41 @@ function resolvePathValue(value: string | string[], baseDir: string): string | s
507
685
  return typeof value === "string" && value ? resolveImagePath(value, baseDir) : value;
508
686
  }
509
687
 
688
+ function isChoiceResponseValue(value: unknown): value is ChoiceResponseValue {
689
+ return !!value && typeof value === "object" && !Array.isArray(value) && typeof (value as ChoiceResponseValue).option === "string";
690
+ }
691
+
692
+ function formatResponseValue(value: ResponseItem["value"]): string {
693
+ if (Array.isArray(value)) {
694
+ if (value.every(isChoiceResponseValue)) {
695
+ return value.map((item) => item.note ? `${item.option} (${item.note})` : item.option).join(", ");
696
+ }
697
+ return value.join(", ");
698
+ }
699
+ if (isChoiceResponseValue(value)) {
700
+ return value.note ? `${value.option} (${value.note})` : value.option;
701
+ }
702
+ return value;
703
+ }
704
+
705
+ function hasAnswerValue(value: ResponseItem["value"]): boolean {
706
+ if (Array.isArray(value)) {
707
+ if (value.every(isChoiceResponseValue)) {
708
+ return value.some((item) => item.option.trim() !== "");
709
+ }
710
+ return value.some((item) => typeof item === "string" && item.trim() !== "");
711
+ }
712
+ if (isChoiceResponseValue(value)) {
713
+ return value.option.trim() !== "";
714
+ }
715
+ return typeof value === "string" && value.trim() !== "";
716
+ }
717
+
510
718
  function formatResponses(responses: ResponseItem[]): string {
511
719
  if (responses.length === 0) return "(none)";
512
720
  return responses
513
721
  .map((resp) => {
514
- const value = Array.isArray(resp.value) ? resp.value.join(", ") : resp.value;
722
+ const value = formatResponseValue(resp.value);
515
723
  let line = `- ${resp.id}: ${value}`;
516
724
  if (resp.attachments && resp.attachments.length > 0) {
517
725
  line += ` [attachments: ${resp.attachments.join(", ")}]`;
@@ -523,24 +731,12 @@ function formatResponses(responses: ResponseItem[]): string {
523
731
 
524
732
  function hasAnyAnswers(responses: ResponseItem[]): boolean {
525
733
  if (!responses || responses.length === 0) return false;
526
- return responses.some((resp) => {
527
- if (!resp || resp.value == null) return false;
528
- if (Array.isArray(resp.value)) {
529
- return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
530
- }
531
- return typeof resp.value === "string" && resp.value.trim() !== "";
532
- });
734
+ return responses.some((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
533
735
  }
534
736
 
535
737
  function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
536
738
  if (!responses) return [];
537
- return responses.filter((resp) => {
538
- if (!resp || resp.value == null) return false;
539
- if (Array.isArray(resp.value)) {
540
- return resp.value.some((v) => typeof v === "string" && v.trim() !== "");
541
- }
542
- return typeof resp.value === "string" && resp.value.trim() !== "";
543
- });
739
+ return responses.filter((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
544
740
  }
545
741
 
546
742
  export default function (pi: ExtensionAPI) {
@@ -608,12 +804,10 @@ export default function (pi: ExtensionAPI) {
608
804
  }
609
805
 
610
806
  let availableGenerateModels: Model<Api>[] = [];
611
- if (!configuredGenerateModel && !ctx.model) {
612
- try {
613
- availableGenerateModels = ctx.modelRegistry.getAvailable();
614
- } catch {
615
- // Leave generation disabled when model discovery is unavailable.
616
- }
807
+ try {
808
+ availableGenerateModels = ctx.modelRegistry.getAvailable();
809
+ } catch {
810
+ // Leave generation disabled when model discovery is unavailable.
617
811
  }
618
812
 
619
813
  const { primary: generateModel, fallback: fallbackGenerateModel } = selectGenerateModels(
@@ -621,6 +815,8 @@ export default function (pi: ExtensionAPI) {
621
815
  ctx.model ?? null,
622
816
  availableGenerateModels,
623
817
  );
818
+ const askModels = buildAskModelsData(availableGenerateModels, generateModel, fallbackGenerateModel);
819
+ const defaultAskModel = generateModel ? formatModelRef(generateModel) : null;
624
820
 
625
821
  // Expand ~ in snapshotDir if present
626
822
  const snapshotDir = settings.snapshotDir
@@ -697,8 +893,14 @@ export default function (pi: ExtensionAPI) {
697
893
  signal?.addEventListener("abort", handleAbort, { once: true });
698
894
 
699
895
  let onGenerate: InterviewServerCallbacks["onGenerate"];
896
+ let onOptionInsight: InterviewServerCallbacks["onOptionInsight"];
700
897
  if (generateModel) {
701
- const generateOptions = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
898
+ const generateOptions = async <T>(
899
+ model: Model<Api>,
900
+ prompt: string,
901
+ generateSignal: AbortSignal,
902
+ parse: (text: string) => T,
903
+ ) => {
702
904
  const modelRef = formatModelRef(model);
703
905
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
704
906
  if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
@@ -710,10 +912,15 @@ export default function (pi: ExtensionAPI) {
710
912
  { apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
711
913
  );
712
914
 
713
- return parseGeneratedOptions(extractGenerateResponseText(modelRef, response));
915
+ return parse(extractGenerateResponseText(modelRef, response));
714
916
  };
715
917
 
716
- const reviewQuestion = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
918
+ const reviewQuestion = async <T>(
919
+ model: Model<Api>,
920
+ prompt: string,
921
+ generateSignal: AbortSignal,
922
+ parse: (text: string) => T,
923
+ ) => {
717
924
  const modelRef = formatModelRef(model);
718
925
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
719
926
  if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
@@ -725,12 +932,30 @@ export default function (pi: ExtensionAPI) {
725
932
  { apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
726
933
  );
727
934
 
728
- return parseReviewedQuestion(extractGenerateResponseText(modelRef, response));
935
+ return parse(extractGenerateResponseText(modelRef, response));
729
936
  };
730
937
 
731
- onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
938
+ const optionInsight = async (model: Model<Api>, prompt: string, generateSignal: AbortSignal) => {
939
+ const modelRef = formatModelRef(model);
940
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
941
+ if (!auth.ok) throw new Error(`${modelRef}: ${auth.error}`);
942
+ if (!auth.apiKey) throw new Error(`No API key for ${modelRef}`);
943
+
944
+ const response = await complete(
945
+ model,
946
+ createGenerateContext(prompt, OPTION_INSIGHT_SYSTEM_PROMPT),
947
+ { apiKey: auth.apiKey, headers: auth.headers, signal: generateSignal },
948
+ );
949
+
950
+ const parsed = parseOptionInsight(extractGenerateResponseText(modelRef, response));
951
+ return { ...parsed, modelUsed: modelRef };
952
+ };
953
+
954
+ onGenerate = async (questionId, existingOptions, generateSignal, mode) => {
732
955
  const question = questionsData.questions.find((q) => q.id === questionId);
733
956
  if (!question) throw new Error(`Unknown question: ${questionId}`);
957
+ const optionValues = question.options ?? [];
958
+ const usesRichOptions = optionValues.some(isRichOption);
734
959
 
735
960
  const existingList = existingOptions.length > 0
736
961
  ? existingOptions.map((option) => `- ${option}`).join("\n")
@@ -745,47 +970,94 @@ export default function (pi: ExtensionAPI) {
745
970
  : question.recommended;
746
971
  recommended = `\nRecommended: ${value}`;
747
972
  }
748
- prompt = [
749
- "Review this interview question and its options.",
750
- "Rewrite the question so it is easier to understand while preserving the original intent.",
751
- "Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
752
- "Return ONLY JSON in this format:",
753
- '{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
754
- "",
755
- questionsData.title ? `Interview: ${questionsData.title}` : null,
756
- questionsData.description ? `Interview context: ${questionsData.description}` : null,
757
- `Question: ${question.question}`,
758
- question.context ? `Question context: ${question.context}` : null,
759
- recommended || null,
760
- "",
761
- "Current options:",
762
- existingList,
763
- ].filter((line) => line !== null).join("\n");
973
+ if (usesRichOptions) {
974
+ prompt = [
975
+ "Review this interview question and its options.",
976
+ "Rewrite the question so it is easier to understand while preserving the original intent.",
977
+ "Review the rich options as full structured objects: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
978
+ "Return ONLY JSON in this format:",
979
+ '{"question":"Clearer question text","options":[{"label":"Option A","content":{"source":"Explanation","lang":"md"}}]}',
980
+ "Each option must be an object with `label` and optional `content`.",
981
+ "",
982
+ questionsData.title ? `Interview: ${questionsData.title}` : null,
983
+ questionsData.description ? `Interview context: ${questionsData.description}` : null,
984
+ `Question: ${question.question}`,
985
+ question.context ? `Question context: ${question.context}` : null,
986
+ recommended || null,
987
+ "",
988
+ "Current options JSON:",
989
+ JSON.stringify(optionValues, null, 2),
990
+ ].filter((line) => line !== null).join("\n");
991
+ } else {
992
+ prompt = [
993
+ "Review this interview question and its options.",
994
+ "Rewrite the question so it is easier to understand while preserving the original intent.",
995
+ "Review the options the same way you already would: keep good ones as-is, fix bad ones, add missing ones, and remove bad ones.",
996
+ "Return ONLY JSON in this format:",
997
+ '{"question":"Clearer question text","options":["Option A","Option B","Option C"]}',
998
+ "",
999
+ questionsData.title ? `Interview: ${questionsData.title}` : null,
1000
+ questionsData.description ? `Interview context: ${questionsData.description}` : null,
1001
+ `Question: ${question.question}`,
1002
+ question.context ? `Question context: ${question.context}` : null,
1003
+ recommended || null,
1004
+ "",
1005
+ "Current options:",
1006
+ existingList,
1007
+ ].filter((line) => line !== null).join("\n");
1008
+ }
764
1009
  } else {
765
- prompt = [
766
- "Generate 3 new, distinct options for this question.",
767
- "Return ONLY a JSON array of short option strings. No explanation, no markdown.",
768
- "",
769
- `Question: ${question.question}`,
770
- question.context ? `Context: ${question.context}` : null,
771
- "",
772
- "Existing options (do NOT repeat):",
773
- existingList,
774
- "",
775
- 'Format: ["Option A", "Option B", "Option C"]',
776
- ].filter((line) => line !== null).join("\n");
1010
+ if (usesRichOptions) {
1011
+ prompt = [
1012
+ "Generate 3 new, distinct options for this question.",
1013
+ "Return ONLY a JSON array.",
1014
+ "Each item may be either a short option string or an object with `label` and optional `content`.",
1015
+ "Use an object when a new option needs supporting detail or example content.",
1016
+ "",
1017
+ `Question: ${question.question}`,
1018
+ question.context ? `Context: ${question.context}` : null,
1019
+ "",
1020
+ "Existing options JSON (do NOT repeat labels):",
1021
+ JSON.stringify(optionValues, null, 2),
1022
+ "",
1023
+ 'Format: ["Option A", {"label":"Option B","content":{"source":"Explanation","lang":"md"}}]',
1024
+ ].filter((line) => line !== null).join("\n");
1025
+ } else {
1026
+ prompt = [
1027
+ "Generate 3 new, distinct options for this question.",
1028
+ "Return ONLY a JSON array of short option strings. No explanation, no markdown.",
1029
+ "",
1030
+ `Question: ${question.question}`,
1031
+ question.context ? `Context: ${question.context}` : null,
1032
+ "",
1033
+ "Existing options (do NOT repeat):",
1034
+ existingList,
1035
+ "",
1036
+ 'Format: ["Option A", "Option B", "Option C"]',
1037
+ ].filter((line) => line !== null).join("\n");
1038
+ }
777
1039
  }
778
1040
 
779
1041
  if (mode === "review") {
780
- let result: { question: string; options: string[] };
1042
+ let result: { question: string; options: OptionValue[] };
781
1043
  try {
782
- result = await reviewQuestion(generateModel, prompt, generateSignal);
1044
+ result = await reviewQuestion(
1045
+ generateModel,
1046
+ prompt,
1047
+ generateSignal,
1048
+ usesRichOptions ? parseReviewedQuestionUpdate : parseReviewedQuestion,
1049
+ );
783
1050
  } catch (err) {
784
1051
  if (!fallbackGenerateModel || generateSignal.aborted) {
785
1052
  throw err;
786
1053
  }
787
1054
  try {
788
- result = await reviewQuestion(fallbackGenerateModel, prompt, generateSignal);
1055
+ result = await reviewQuestion(
1056
+ fallbackGenerateModel,
1057
+ prompt,
1058
+ generateSignal,
1059
+ usesRichOptions ? parseReviewedQuestionUpdate : parseReviewedQuestion,
1060
+ );
789
1061
  } catch (fallbackErr) {
790
1062
  const primaryMessage = err instanceof Error ? err.message : String(err);
791
1063
  const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
@@ -796,15 +1068,25 @@ export default function (pi: ExtensionAPI) {
796
1068
  return result;
797
1069
  }
798
1070
 
799
- let options: string[];
1071
+ let options: OptionValue[];
800
1072
  try {
801
- options = await generateOptions(generateModel, prompt, generateSignal);
1073
+ options = await generateOptions(
1074
+ generateModel,
1075
+ prompt,
1076
+ generateSignal,
1077
+ usesRichOptions ? parseGeneratedOptionValues : parseGeneratedOptions,
1078
+ );
802
1079
  } catch (err) {
803
1080
  if (!fallbackGenerateModel || generateSignal.aborted) {
804
1081
  throw err;
805
1082
  }
806
1083
  try {
807
- options = await generateOptions(fallbackGenerateModel, prompt, generateSignal);
1084
+ options = await generateOptions(
1085
+ fallbackGenerateModel,
1086
+ prompt,
1087
+ generateSignal,
1088
+ usesRichOptions ? parseGeneratedOptionValues : parseGeneratedOptions,
1089
+ );
808
1090
  } catch (fallbackErr) {
809
1091
  const primaryMessage = err instanceof Error ? err.message : String(err);
810
1092
  const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
@@ -814,6 +1096,66 @@ export default function (pi: ExtensionAPI) {
814
1096
 
815
1097
  return { options };
816
1098
  };
1099
+
1100
+ const getExplicitModel = (modelOverride: string): Model<Api> => {
1101
+ const slashIndex = modelOverride.indexOf("/");
1102
+ if (slashIndex <= 0 || slashIndex === modelOverride.length - 1) {
1103
+ throw new Error(`Invalid model override: ${modelOverride}. Use provider/model-id.`);
1104
+ }
1105
+ const selectedModel = ctx.modelRegistry.find(
1106
+ modelOverride.slice(0, slashIndex),
1107
+ modelOverride.slice(slashIndex + 1),
1108
+ );
1109
+ if (!selectedModel) {
1110
+ throw new Error(`Model not found: ${modelOverride}`);
1111
+ }
1112
+ return selectedModel;
1113
+ };
1114
+
1115
+ onOptionInsight = async (questionId, option, prompt, modelOverride, generateSignal) => {
1116
+ const question = questionsData.questions.find((q) => q.id === questionId);
1117
+ if (!question) throw new Error(`Unknown question: ${questionId}`);
1118
+ const optionText = getOptionLabel(option);
1119
+ const optionContent = typeof option === "string" ? null : option.content;
1120
+
1121
+ const questionPrompt = [
1122
+ "Analyze this single interview answer option.",
1123
+ "Be concrete and concise.",
1124
+ "Explain what is good or risky about the option, and suggest a rewrite only if it would materially improve clarity.",
1125
+ "Return ONLY JSON with summary, bullets, and optional suggestedText.",
1126
+ "",
1127
+ questionsData.title ? `Interview: ${questionsData.title}` : null,
1128
+ questionsData.description ? `Interview context: ${questionsData.description}` : null,
1129
+ `Question: ${question.question}`,
1130
+ question.context ? `Question context: ${question.context}` : null,
1131
+ `Option: ${optionText}`,
1132
+ optionContent?.title ? `Option content title: ${optionContent.title}` : null,
1133
+ optionContent?.file ? `Option content file: ${optionContent.file}` : null,
1134
+ optionContent?.lines ? `Option content lines: ${optionContent.lines}` : null,
1135
+ optionContent?.lang ? `Option content language: ${optionContent.lang}` : null,
1136
+ optionContent?.source ? `Option content:\n${optionContent.source}` : null,
1137
+ `User request: ${prompt}`,
1138
+ ].filter((line) => line !== null).join("\n");
1139
+
1140
+ if (modelOverride) {
1141
+ return await optionInsight(getExplicitModel(modelOverride), questionPrompt, generateSignal);
1142
+ }
1143
+
1144
+ try {
1145
+ return await optionInsight(generateModel, questionPrompt, generateSignal);
1146
+ } catch (err) {
1147
+ if (!fallbackGenerateModel || generateSignal.aborted) {
1148
+ throw err;
1149
+ }
1150
+ try {
1151
+ return await optionInsight(fallbackGenerateModel, questionPrompt, generateSignal);
1152
+ } catch (fallbackErr) {
1153
+ const primaryMessage = err instanceof Error ? err.message : String(err);
1154
+ const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
1155
+ throw new Error(`${primaryMessage}. Fallback failed: ${fallbackMessage}`);
1156
+ }
1157
+ }
1158
+ };
817
1159
  }
818
1160
 
819
1161
  startInterviewServer(
@@ -829,7 +1171,11 @@ export default function (pi: ExtensionAPI) {
829
1171
  snapshotDir,
830
1172
  autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
831
1173
  savedAnswers: questionsData.savedAnswers,
1174
+ savedOptionInsights: questionsData.savedOptionInsights,
1175
+ optionKeysByQuestion: questionsData.optionKeysByQuestion,
832
1176
  canGenerate: generateModel !== null,
1177
+ askModels,
1178
+ defaultAskModel,
833
1179
  },
834
1180
  {
835
1181
  onSubmit: (responses) => finish("completed", responses),
@@ -838,6 +1184,7 @@ export default function (pi: ExtensionAPI) {
838
1184
  ? finish("timeout", partialResponses ?? [])
839
1185
  : finish("cancelled", partialResponses ?? [], reason),
840
1186
  onGenerate,
1187
+ onOptionInsight,
841
1188
  }
842
1189
  )
843
1190
  .then(async (handle) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",