pi-interview 0.8.1 → 0.8.2

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/form/script.js CHANGED
@@ -529,6 +529,33 @@
529
529
  return typeof option === "string" ? option : option.label;
530
530
  }
531
531
 
532
+ function normalizeRecommendationMatchText(value) {
533
+ return value.normalize("NFC").trim();
534
+ }
535
+
536
+ function resolveRecommendedLabels(recommended, options) {
537
+ if (!recommended || !Array.isArray(options)) return [];
538
+
539
+ const labelsByNormalized = new Map();
540
+ options.forEach((option) => {
541
+ const label = getOptionLabel(option);
542
+ const normalized = normalizeRecommendationMatchText(label);
543
+ if (!normalized || labelsByNormalized.has(normalized)) return;
544
+ labelsByNormalized.set(normalized, label);
545
+ });
546
+
547
+ const resolved = [];
548
+ const candidates = Array.isArray(recommended) ? recommended : [recommended];
549
+ candidates.forEach((candidate) => {
550
+ if (typeof candidate !== "string") return;
551
+ const match = labelsByNormalized.get(normalizeRecommendationMatchText(candidate));
552
+ if (match && !resolved.includes(match)) {
553
+ resolved.push(match);
554
+ }
555
+ });
556
+ return resolved;
557
+ }
558
+
532
559
  function questionCanClarifyOption(question) {
533
560
  return (question.type === "single" || question.type === "multi")
534
561
  && Array.isArray(question.options)
@@ -629,11 +656,12 @@
629
656
  }
630
657
 
631
658
  function syncRecommendations(question, options) {
632
- const optionLabels = options.map((option) => getOptionLabel(option));
633
659
  if (!question.recommended) return;
660
+ const resolvedRecommended = resolveRecommendedLabels(question.recommended, options);
634
661
 
635
662
  if (question.type === "single") {
636
- if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
663
+ if (resolvedRecommended.length > 0) {
664
+ question.recommended = resolvedRecommended[0];
637
665
  return;
638
666
  }
639
667
  delete question.recommended;
@@ -647,15 +675,12 @@
647
675
  return;
648
676
  }
649
677
 
650
- const nextRecommended = (Array.isArray(question.recommended)
651
- ? question.recommended
652
- : [question.recommended]).filter((option) => optionLabels.includes(option));
653
- if (nextRecommended.length === 0) {
678
+ if (resolvedRecommended.length === 0) {
654
679
  delete question.recommended;
655
680
  delete question.conviction;
656
681
  return;
657
682
  }
658
- question.recommended = nextRecommended;
683
+ question.recommended = resolvedRecommended;
659
684
  }
660
685
 
661
686
  function makeClientId(prefix = "id") {
@@ -1481,12 +1506,7 @@
1481
1506
  text.className = "option-item-label";
1482
1507
  text.textContent = optionLabel;
1483
1508
 
1484
- const recommended = question.recommended;
1485
- const recommendedList = Array.isArray(recommended)
1486
- ? recommended
1487
- : recommended
1488
- ? [recommended]
1489
- : [];
1509
+ const recommendedList = resolveRecommendedLabels(question.recommended, question.options || []);
1490
1510
  const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
1491
1511
 
1492
1512
  if (recommendedList.includes(optionLabel)) {
@@ -3567,9 +3587,7 @@
3567
3587
  }
3568
3588
  }
3569
3589
 
3570
- // Pre-populate form from saved interview answers
3571
3590
  function populateFromSavedAnswers(savedAnswers) {
3572
- // Convert ResponseItem[] to Record for existing populateForm()
3573
3591
  const valueMap = {};
3574
3592
  savedAnswers.forEach((ans) => {
3575
3593
  const question = questions.find((q) => q.id === ans.id);
@@ -3579,7 +3597,6 @@
3579
3597
  });
3580
3598
  populateForm(valueMap);
3581
3599
 
3582
- // Restore attachments to attachPathState
3583
3600
  savedAnswers.forEach((ans) => {
3584
3601
  if (ans.attachments && ans.attachments.length > 0) {
3585
3602
  attachPathState.set(ans.id, [...ans.attachments]);
@@ -3595,7 +3612,6 @@
3595
3612
  }
3596
3613
  });
3597
3614
 
3598
- // Restore image paths for image-type questions
3599
3615
  savedAnswers.forEach((ans) => {
3600
3616
  const question = questions.find((q) => q.id === ans.id);
3601
3617
  if (question?.type === "image" && ans.value) {
@@ -3608,7 +3624,6 @@
3608
3624
  }
3609
3625
  });
3610
3626
 
3611
- // Update done states for multi-select
3612
3627
  questions.forEach((q) => {
3613
3628
  if (q.type === "multi") {
3614
3629
  updateDoneState(q.id);
@@ -3627,13 +3642,13 @@
3627
3642
  const reader = new FileReader();
3628
3643
  reader.onload = () => {
3629
3644
  if (typeof reader.result !== "string") {
3630
- reject(new Error("Failed to read file"));
3645
+ reject(new Error(`Failed to read file: unexpected FileReader result type ${typeof reader.result}`));
3631
3646
  return;
3632
3647
  }
3633
3648
  const parts = reader.result.split(",");
3634
3649
  resolve(parts[1] || "");
3635
3650
  };
3636
- reader.onerror = () => reject(new Error("Failed to read file"));
3651
+ reader.onerror = () => reject(new Error(reader.error?.message || "Failed to read file"));
3637
3652
  reader.readAsDataURL(file);
3638
3653
  });
3639
3654
  }
@@ -3674,7 +3689,6 @@
3674
3689
  return { responses, images };
3675
3690
  }
3676
3691
 
3677
- // Save interview snapshot
3678
3692
  async function saveInterview(options = {}) {
3679
3693
  const { submitted = false } = options;
3680
3694
 
@@ -3759,8 +3773,6 @@
3759
3773
  return;
3760
3774
  }
3761
3775
 
3762
- // Auto-save on successful submit (fire-and-forget)
3763
- // Note: data is window.__INTERVIEW_DATA__, result is server response
3764
3776
  if (data.autoSaveOnSubmit !== false) {
3765
3777
  saveInterview({ submitted: true });
3766
3778
  }
package/index.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  type AskModelOption,
19
19
  type OptionInsightResult,
20
20
  } from "./server.js";
21
- import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type QuestionsFile } from "./schema.js";
21
+ import { getOptionLabel, isRichOption, validateQuestions, sanitizeLLMJSON, type OptionValue, type Question, type QuestionsFile } from "./schema.js";
22
22
  import { loadSettings, type InterviewThemeSettings } from "./settings.js";
23
23
 
24
24
  interface GlimpseWindow {
@@ -715,28 +715,100 @@ function hasAnswerValue(value: ResponseItem["value"]): boolean {
715
715
  return typeof value === "string" && value.trim() !== "";
716
716
  }
717
717
 
718
- function formatResponses(responses: ResponseItem[]): string {
719
- if (responses.length === 0) return "(none)";
720
- return responses
721
- .map((resp) => {
722
- const value = formatResponseValue(resp.value);
723
- let line = `- ${resp.id}: ${value}`;
724
- if (resp.attachments && resp.attachments.length > 0) {
725
- line += ` [attachments: ${resp.attachments.join(", ")}]`;
718
+ function hasResponseContent(response: ResponseItem): boolean {
719
+ return hasAnswerValue(response.value) || !!response.attachments?.length;
720
+ }
721
+
722
+ function summarizeResponseValue(question: Question, response: ResponseItem): string {
723
+ if (question.type === "image") {
724
+ if (Array.isArray(response.value)) {
725
+ return response.value.length === 1 ? "1 image attached" : `${response.value.length} images attached`;
726
+ }
727
+ if (typeof response.value === "string" && response.value.trim() !== "") {
728
+ return "1 image attached";
729
+ }
730
+ }
731
+
732
+ if (hasAnswerValue(response.value)) {
733
+ return String(formatResponseValue(response.value));
734
+ }
735
+
736
+ if (response.attachments?.length) {
737
+ return response.attachments.length === 1 ? "1 attachment included" : `${response.attachments.length} attachments included`;
738
+ }
739
+
740
+ return "";
741
+ }
742
+
743
+ interface AgentResponseItem {
744
+ id: string;
745
+ question: string;
746
+ type: Question["type"];
747
+ value: ResponseItem["value"];
748
+ attachments?: string[];
749
+ }
750
+
751
+ export function buildAnsweredAgentResponseItems(
752
+ responses: ResponseItem[],
753
+ questions: Question[],
754
+ ): AgentResponseItem[] {
755
+ const responseById = new Map<string, ResponseItem>();
756
+ for (const response of responses) {
757
+ if (!response || typeof response.id !== "string") continue;
758
+ responseById.set(response.id, response);
759
+ }
760
+
761
+ return questions
762
+ .map((question) => {
763
+ const response = responseById.get(question.id);
764
+ if (!response || !hasResponseContent(response)) return null;
765
+ return {
766
+ id: question.id,
767
+ question: question.question,
768
+ type: question.type,
769
+ value: response.value,
770
+ attachments: response.attachments?.length ? [...response.attachments] : undefined,
771
+ } satisfies AgentResponseItem;
772
+ })
773
+ .filter((item): item is AgentResponseItem => item !== null);
774
+ }
775
+
776
+ export function formatAnsweredResponsesForAgent(
777
+ responses: ResponseItem[],
778
+ questions: Question[],
779
+ ): string {
780
+ const answeredItems = buildAnsweredAgentResponseItems(responses, questions);
781
+ if (answeredItems.length === 0) return "(none)";
782
+ const questionById = new Map(questions.map((question) => [question.id, question]));
783
+ const responseById = new Map(responses.map((response) => [response.id, response]));
784
+
785
+ const summary = answeredItems
786
+ .map((item) => {
787
+ const question = questionById.get(item.id);
788
+ const response = responseById.get(item.id);
789
+ if (!question || !response) {
790
+ return `- ${item.question}`;
791
+ }
792
+ let line = `- ${item.question}: ${summarizeResponseValue(question, response)}`;
793
+ if (item.attachments?.length) {
794
+ line += ` [attachments: ${item.attachments.join(", ")}]`;
726
795
  }
727
796
  return line;
728
797
  })
729
798
  .join("\n");
799
+
800
+ const json = JSON.stringify(answeredItems, null, 2);
801
+ return `${summary}\n\nStructured response data:\n\n\`\`\`json\n${json}\n\`\`\``;
730
802
  }
731
803
 
732
804
  function hasAnyAnswers(responses: ResponseItem[]): boolean {
733
805
  if (!responses || responses.length === 0) return false;
734
- return responses.some((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
806
+ return responses.some((resp) => !!resp && hasResponseContent(resp));
735
807
  }
736
808
 
737
809
  function filterAnsweredResponses(responses: ResponseItem[]): ResponseItem[] {
738
810
  if (!responses) return [];
739
- return responses.filter((resp) => !!resp && resp.value != null && hasAnswerValue(resp.value));
811
+ return responses.filter((resp) => !!resp && hasResponseContent(resp));
740
812
  }
741
813
 
742
814
  export default function (pi: ExtensionAPI) {
@@ -855,21 +927,21 @@ export default function (pi: ExtensionAPI) {
855
927
 
856
928
  let text = "";
857
929
  if (status === "completed") {
858
- text = `User completed the interview form.\n\nResponses:\n${formatResponses(responses)}`;
930
+ text = `User completed the interview form.\n\nAnswered responses:\n${formatAnsweredResponsesForAgent(responses, questionsData.questions)}`;
859
931
  } else if (status === "cancelled") {
860
932
  if (cancelReason === "stale") {
861
933
  text =
862
934
  "Interview session ended due to lost heartbeat.\n\nQuestions saved to: ~/.pi/interview-recovery/";
863
935
  } else if (hasAnyAnswers(responses)) {
864
936
  const answered = filterAnsweredResponses(responses);
865
- text = `User cancelled the interview with partial responses:\n${formatResponses(answered)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
937
+ text = `User cancelled the interview with partial responses.\n\nAnswered responses:\n${formatAnsweredResponsesForAgent(answered, questionsData.questions)}\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
866
938
  } else {
867
939
  text = "User skipped the interview without providing answers. Proceed with your best judgment - use recommended options where specified, make reasonable choices elsewhere. Don't ask for clarification unless absolutely necessary.";
868
940
  }
869
941
  } else if (status === "timeout") {
870
942
  if (hasAnyAnswers(responses)) {
871
943
  const answered = filterAnsweredResponses(responses);
872
- text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nPartial responses before timeout:\n${formatResponses(answered)}\n\nQuestions saved to: ~/.pi/interview-recovery/\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
944
+ text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nAnswered responses before timeout:\n${formatAnsweredResponsesForAgent(answered, questionsData.questions)}\n\nQuestions saved to: ~/.pi/interview-recovery/\n\nProceed with these inputs and use your best judgment for unanswered questions.`;
873
945
  } else {
874
946
  text = `Interview form timed out after ${timeoutSeconds} seconds.\n\nQuestions saved to: ~/.pi/interview-recovery/`;
875
947
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Interactive interview form extension for pi coding agent",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -432,12 +432,41 @@ function ensureQuestionId(
432
432
  return { ok: true, question };
433
433
  }
434
434
 
435
- function syncRecommendations(question: Question, options: OptionValue[]): void {
435
+ function normalizeRecommendationMatchText(value: string): string {
436
+ return value.normalize("NFC").trim();
437
+ }
438
+
439
+ function resolveRecommendedLabels(
440
+ recommended: Question["recommended"],
441
+ options: OptionValue[]
442
+ ): string[] {
443
+ if (!recommended) return [];
436
444
  const optionLabels = options.map((option) => getOptionLabel(option));
445
+ const labelsByNormalized = new Map<string, string>();
446
+ for (const label of optionLabels) {
447
+ const normalized = normalizeRecommendationMatchText(label);
448
+ if (!normalized || labelsByNormalized.has(normalized)) continue;
449
+ labelsByNormalized.set(normalized, label);
450
+ }
451
+
452
+ const resolved: string[] = [];
453
+ for (const candidate of Array.isArray(recommended) ? recommended : [recommended]) {
454
+ if (typeof candidate !== "string") continue;
455
+ const match = labelsByNormalized.get(normalizeRecommendationMatchText(candidate));
456
+ if (match && !resolved.includes(match)) {
457
+ resolved.push(match);
458
+ }
459
+ }
460
+ return resolved;
461
+ }
462
+
463
+ function syncRecommendations(question: Question, options: OptionValue[]): void {
437
464
  if (!question.recommended) return;
465
+ const resolvedRecommended = resolveRecommendedLabels(question.recommended, options);
438
466
 
439
467
  if (question.type === "single") {
440
- if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
468
+ if (resolvedRecommended.length > 0) {
469
+ question.recommended = resolvedRecommended[0];
441
470
  return;
442
471
  }
443
472
  delete question.recommended;
@@ -451,15 +480,12 @@ function syncRecommendations(question: Question, options: OptionValue[]): void {
451
480
  return;
452
481
  }
453
482
 
454
- const nextRecommended = (Array.isArray(question.recommended)
455
- ? question.recommended
456
- : [question.recommended]).filter((option) => optionLabels.includes(option));
457
- if (nextRecommended.length === 0) {
483
+ if (resolvedRecommended.length === 0) {
458
484
  delete question.recommended;
459
485
  delete question.conviction;
460
486
  return;
461
487
  }
462
- question.recommended = nextRecommended;
488
+ question.recommended = resolvedRecommended;
463
489
  }
464
490
 
465
491
  function makeOptionKey(): string {
@@ -924,9 +950,7 @@ function recommendedIndicatorHtml(q: Question): string {
924
950
  }
925
951
 
926
952
  function savedAnswerItemHtml(text: string, q: Question): string {
927
- const recs = Array.isArray(q.recommended)
928
- ? q.recommended
929
- : q.recommended ? [q.recommended] : [];
953
+ const recs = q.options ? resolveRecommendedLabels(q.recommended, q.options) : [];
930
954
  const indicator = recs.includes(text) ? " " + recommendedIndicatorHtml(q) : "";
931
955
  return escapeHtml(text) + indicator;
932
956
  }