pi-interview 0.8.0 → 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") {
@@ -738,6 +763,12 @@
738
763
  return askModels[0]?.provider || "";
739
764
  }
740
765
 
766
+ const ASK_DEPTH_OPTIONS = [
767
+ { key: "quick", label: "Quick" },
768
+ { key: "standard", label: "Standard" },
769
+ { key: "deep", label: "Deep" },
770
+ ];
771
+
741
772
  function createDefaultActiveInsight(questionId, optionKey) {
742
773
  const selectedModel = askModels.some((model) => model.value === defaultAskModel)
743
774
  ? defaultAskModel
@@ -754,6 +785,7 @@
754
785
  advancedOpen: false,
755
786
  selectedProvider: parsed.provider || getFirstProvider(),
756
787
  selectedModel,
788
+ selectedDepth: "standard",
757
789
  abortController: null,
758
790
  };
759
791
  }
@@ -1021,6 +1053,7 @@
1021
1053
  optionKey,
1022
1054
  prompt,
1023
1055
  model: modelOverride && modelOverride !== defaultAskModel ? modelOverride : null,
1056
+ depth: active.selectedDepth || "standard",
1024
1057
  }),
1025
1058
  signal: active.abortController.signal,
1026
1059
  });
@@ -1180,22 +1213,27 @@
1180
1213
  const metaRow = document.createElement("div");
1181
1214
  metaRow.className = "option-insight-meta-row";
1182
1215
 
1183
- const model = document.createElement("div");
1184
- model.className = "option-insight-model";
1185
- model.textContent = getInsightModelLabel(active);
1186
- metaRow.appendChild(model);
1187
-
1188
- const advancedToggle = document.createElement("button");
1189
- advancedToggle.type = "button";
1190
- advancedToggle.className = "option-insight-advanced-toggle";
1191
- advancedToggle.textContent = active.advancedOpen ? "Advanced model settings ▾" : "Advanced model settings ▸";
1192
- advancedToggle.addEventListener("click", (event) => {
1216
+ const modelBadge = document.createElement("button");
1217
+ modelBadge.type = "button";
1218
+ modelBadge.className = "option-insight-model-badge";
1219
+ const badgeLabel = document.createElement("span");
1220
+ badgeLabel.className = "badge-label";
1221
+ badgeLabel.textContent = "Model";
1222
+ const badgeValue = document.createElement("span");
1223
+ badgeValue.textContent = getInsightModelLabel(active);
1224
+ const badgeCaret = document.createElement("span");
1225
+ badgeCaret.className = "badge-caret";
1226
+ badgeCaret.textContent = active.advancedOpen ? "▾" : "▸";
1227
+ modelBadge.appendChild(badgeLabel);
1228
+ modelBadge.appendChild(badgeValue);
1229
+ modelBadge.appendChild(badgeCaret);
1230
+ modelBadge.addEventListener("click", (event) => {
1193
1231
  event.preventDefault();
1194
1232
  event.stopPropagation();
1195
1233
  active.advancedOpen = !active.advancedOpen;
1196
1234
  replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1197
1235
  });
1198
- metaRow.appendChild(advancedToggle);
1236
+ metaRow.appendChild(modelBadge);
1199
1237
 
1200
1238
  panel.appendChild(metaRow);
1201
1239
 
@@ -1203,38 +1241,63 @@
1203
1241
  const advanced = document.createElement("div");
1204
1242
  advanced.className = "option-insight-advanced";
1205
1243
 
1206
- const providerSelect = document.createElement("select");
1207
- providerSelect.className = "option-insight-select";
1208
1244
  const providers = [...new Set(askModels.map((model) => model.provider))];
1245
+ const providerRow = document.createElement("div");
1246
+ providerRow.className = "option-insight-provider-row";
1209
1247
  providers.forEach((provider) => {
1210
- const option = document.createElement("option");
1211
- option.value = provider;
1212
- option.textContent = providerLabel(provider);
1213
- providerSelect.appendChild(option);
1214
- });
1215
- providerSelect.value = active.selectedProvider || providers[0] || "";
1216
- providerSelect.addEventListener("change", () => {
1217
- active.selectedProvider = providerSelect.value;
1218
- const providerModels = getModelsForProvider(active.selectedProvider);
1219
- active.selectedModel = providerModels[0]?.value || null;
1220
- replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1248
+ const btn = document.createElement("button");
1249
+ btn.type = "button";
1250
+ const isSelected = (active.selectedProvider || providers[0] || "") === provider;
1251
+ btn.className = "option-insight-provider-btn" + (isSelected ? " is-selected" : "");
1252
+ btn.textContent = providerLabel(provider);
1253
+ btn.addEventListener("click", (event) => {
1254
+ event.preventDefault();
1255
+ event.stopPropagation();
1256
+ active.selectedProvider = provider;
1257
+ const providerModels = getModelsForProvider(provider);
1258
+ active.selectedModel = providerModels[0]?.value || null;
1259
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1260
+ });
1261
+ providerRow.appendChild(btn);
1221
1262
  });
1222
- advanced.appendChild(providerSelect);
1263
+ advanced.appendChild(providerRow);
1223
1264
 
1224
- const modelSelect = document.createElement("select");
1225
- modelSelect.className = "option-insight-select";
1226
1265
  const providerModels = getModelsForProvider(active.selectedProvider);
1266
+ const modelRow = document.createElement("div");
1267
+ modelRow.className = "option-insight-model-row";
1227
1268
  providerModels.forEach((modelOption) => {
1228
- const option = document.createElement("option");
1229
- option.value = modelOption.value;
1230
- option.textContent = modelOption.label;
1231
- modelSelect.appendChild(option);
1269
+ const btn = document.createElement("button");
1270
+ btn.type = "button";
1271
+ const isSelected = active.selectedModel === modelOption.value;
1272
+ btn.className = "option-insight-model-btn" + (isSelected ? " is-selected" : "");
1273
+ btn.textContent = modelOption.label;
1274
+ btn.addEventListener("click", (event) => {
1275
+ event.preventDefault();
1276
+ event.stopPropagation();
1277
+ active.selectedModel = modelOption.value;
1278
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1279
+ });
1280
+ modelRow.appendChild(btn);
1232
1281
  });
1233
- modelSelect.value = active.selectedModel || providerModels[0]?.value || "";
1234
- modelSelect.addEventListener("change", () => {
1235
- active.selectedModel = modelSelect.value;
1282
+ advanced.appendChild(modelRow);
1283
+
1284
+ const depthRow = document.createElement("div");
1285
+ depthRow.className = "option-insight-depth-row";
1286
+ ASK_DEPTH_OPTIONS.forEach((depth) => {
1287
+ const btn = document.createElement("button");
1288
+ btn.type = "button";
1289
+ const isSelected = active.selectedDepth === depth.key;
1290
+ btn.className = "option-insight-depth-btn" + (isSelected ? " is-selected" : "");
1291
+ btn.textContent = depth.label;
1292
+ btn.addEventListener("click", (event) => {
1293
+ event.preventDefault();
1294
+ event.stopPropagation();
1295
+ active.selectedDepth = depth.key;
1296
+ replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
1297
+ });
1298
+ depthRow.appendChild(btn);
1236
1299
  });
1237
- advanced.appendChild(modelSelect);
1300
+ advanced.appendChild(depthRow);
1238
1301
 
1239
1302
  panel.appendChild(advanced);
1240
1303
  }
@@ -1255,6 +1318,20 @@
1255
1318
 
1256
1319
  panel.appendChild(actions);
1257
1320
 
1321
+ if (active.loading && !active.result) {
1322
+ const loading = document.createElement("div");
1323
+ loading.className = "option-insight-loading";
1324
+ const spinner = document.createElement("span");
1325
+ spinner.className = "option-insight-spinner";
1326
+ spinner.textContent = "Thinking";
1327
+ loading.appendChild(spinner);
1328
+ const dots = document.createElement("span");
1329
+ dots.className = "option-insight-dots";
1330
+ dots.textContent = "...";
1331
+ loading.appendChild(dots);
1332
+ panel.appendChild(loading);
1333
+ }
1334
+
1258
1335
  if (active.error) {
1259
1336
  const error = document.createElement("div");
1260
1337
  error.className = "option-insight-error";
@@ -1429,12 +1506,7 @@
1429
1506
  text.className = "option-item-label";
1430
1507
  text.textContent = optionLabel;
1431
1508
 
1432
- const recommended = question.recommended;
1433
- const recommendedList = Array.isArray(recommended)
1434
- ? recommended
1435
- : recommended
1436
- ? [recommended]
1437
- : [];
1509
+ const recommendedList = resolveRecommendedLabels(question.recommended, question.options || []);
1438
1510
  const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
1439
1511
 
1440
1512
  if (recommendedList.includes(optionLabel)) {
@@ -3515,9 +3587,7 @@
3515
3587
  }
3516
3588
  }
3517
3589
 
3518
- // Pre-populate form from saved interview answers
3519
3590
  function populateFromSavedAnswers(savedAnswers) {
3520
- // Convert ResponseItem[] to Record for existing populateForm()
3521
3591
  const valueMap = {};
3522
3592
  savedAnswers.forEach((ans) => {
3523
3593
  const question = questions.find((q) => q.id === ans.id);
@@ -3527,7 +3597,6 @@
3527
3597
  });
3528
3598
  populateForm(valueMap);
3529
3599
 
3530
- // Restore attachments to attachPathState
3531
3600
  savedAnswers.forEach((ans) => {
3532
3601
  if (ans.attachments && ans.attachments.length > 0) {
3533
3602
  attachPathState.set(ans.id, [...ans.attachments]);
@@ -3543,7 +3612,6 @@
3543
3612
  }
3544
3613
  });
3545
3614
 
3546
- // Restore image paths for image-type questions
3547
3615
  savedAnswers.forEach((ans) => {
3548
3616
  const question = questions.find((q) => q.id === ans.id);
3549
3617
  if (question?.type === "image" && ans.value) {
@@ -3556,7 +3624,6 @@
3556
3624
  }
3557
3625
  });
3558
3626
 
3559
- // Update done states for multi-select
3560
3627
  questions.forEach((q) => {
3561
3628
  if (q.type === "multi") {
3562
3629
  updateDoneState(q.id);
@@ -3575,13 +3642,13 @@
3575
3642
  const reader = new FileReader();
3576
3643
  reader.onload = () => {
3577
3644
  if (typeof reader.result !== "string") {
3578
- reject(new Error("Failed to read file"));
3645
+ reject(new Error(`Failed to read file: unexpected FileReader result type ${typeof reader.result}`));
3579
3646
  return;
3580
3647
  }
3581
3648
  const parts = reader.result.split(",");
3582
3649
  resolve(parts[1] || "");
3583
3650
  };
3584
- reader.onerror = () => reject(new Error("Failed to read file"));
3651
+ reader.onerror = () => reject(new Error(reader.error?.message || "Failed to read file"));
3585
3652
  reader.readAsDataURL(file);
3586
3653
  });
3587
3654
  }
@@ -3622,7 +3689,6 @@
3622
3689
  return { responses, images };
3623
3690
  }
3624
3691
 
3625
- // Save interview snapshot
3626
3692
  async function saveInterview(options = {}) {
3627
3693
  const { submitted = false } = options;
3628
3694
 
@@ -3707,8 +3773,6 @@
3707
3773
  return;
3708
3774
  }
3709
3775
 
3710
- // Auto-save on successful submit (fire-and-forget)
3711
- // Note: data is window.__INTERVIEW_DATA__, result is server response
3712
3776
  if (data.autoSaveOnSubmit !== false) {
3713
3777
  saveInterview({ submitted: true });
3714
3778
  }
package/form/styles.css CHANGED
@@ -2018,7 +2018,6 @@ button {
2018
2018
 
2019
2019
  .option-insight-meta-row {
2020
2020
  display: flex;
2021
- justify-content: space-between;
2022
2021
  align-items: center;
2023
2022
  gap: 10px;
2024
2023
  }
@@ -2029,6 +2028,39 @@ button {
2029
2028
  color: var(--fg-muted);
2030
2029
  }
2031
2030
 
2031
+ .option-insight-model-badge {
2032
+ font-family: var(--font-body);
2033
+ font-size: 11px;
2034
+ font-weight: 600;
2035
+ padding: 5px 12px;
2036
+ border-radius: 999px;
2037
+ border: 1px solid var(--border-muted);
2038
+ background: var(--bg-elevated);
2039
+ color: var(--fg-muted);
2040
+ cursor: pointer;
2041
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
2042
+ display: inline-flex;
2043
+ align-items: center;
2044
+ gap: 6px;
2045
+ }
2046
+
2047
+ .option-insight-model-badge:hover {
2048
+ color: var(--fg);
2049
+ border-color: var(--card-accent, var(--accent));
2050
+ background: color-mix(in srgb, var(--card-accent, var(--accent)) 8%, var(--bg-elevated));
2051
+ }
2052
+
2053
+ .option-insight-model-badge .badge-caret {
2054
+ font-size: 14px;
2055
+ opacity: 0.85;
2056
+ line-height: 1;
2057
+ }
2058
+
2059
+ .option-insight-model-badge .badge-label {
2060
+ font-weight: 500;
2061
+ opacity: 0.55;
2062
+ }
2063
+
2032
2064
  .option-insight-chips {
2033
2065
  display: flex;
2034
2066
  flex-wrap: wrap;
@@ -2066,8 +2098,7 @@ button {
2066
2098
  background: color-mix(in srgb, var(--card-accent, var(--accent)) 10%, transparent);
2067
2099
  }
2068
2100
 
2069
- .option-insight-input,
2070
- .option-insight-select {
2101
+ .option-insight-input {
2071
2102
  width: 100%;
2072
2103
  border: 1px solid var(--border-muted);
2073
2104
  border-radius: 12px;
@@ -2076,23 +2107,129 @@ button {
2076
2107
  color: var(--fg);
2077
2108
  font-family: var(--font-body);
2078
2109
  font-size: 13px;
2079
- }
2080
-
2081
- .option-insight-input {
2082
2110
  min-height: 72px;
2083
2111
  resize: vertical;
2084
2112
  }
2085
2113
 
2086
- .option-insight-advanced-toggle {
2087
- padding: 0;
2088
- border: none;
2114
+ .option-insight-advanced {
2115
+ display: flex;
2116
+ flex-direction: column;
2117
+ gap: 10px;
2118
+ }
2119
+
2120
+ .option-insight-provider-row {
2121
+ display: flex;
2122
+ flex-wrap: wrap;
2123
+ gap: 6px;
2124
+ }
2125
+
2126
+ .option-insight-provider-btn {
2127
+ font-family: var(--font-body);
2128
+ font-size: 11px;
2129
+ font-weight: 600;
2130
+ padding: 4px 10px;
2131
+ border-radius: 999px;
2132
+ border: 1px solid var(--border-muted);
2133
+ background: transparent;
2089
2134
  color: var(--fg-muted);
2135
+ cursor: pointer;
2136
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
2090
2137
  }
2091
2138
 
2092
- .option-insight-advanced {
2093
- display: grid;
2094
- grid-template-columns: 1fr 1fr;
2095
- gap: 8px;
2139
+ .option-insight-provider-btn:hover {
2140
+ color: var(--fg);
2141
+ border-color: var(--accent);
2142
+ }
2143
+
2144
+ .option-insight-provider-btn.is-selected {
2145
+ box-shadow: inset 0 -2px 0 0 var(--card-accent, var(--accent));
2146
+ border-color: var(--card-accent, var(--accent));
2147
+ color: var(--card-accent, var(--accent));
2148
+ }
2149
+
2150
+ .option-insight-model-row {
2151
+ display: flex;
2152
+ flex-wrap: wrap;
2153
+ gap: 6px;
2154
+ }
2155
+
2156
+ .option-insight-model-btn {
2157
+ font-family: var(--font-body);
2158
+ font-size: 11px;
2159
+ font-weight: 600;
2160
+ padding: 4px 10px;
2161
+ border-radius: 999px;
2162
+ border: 1px solid var(--border-muted);
2163
+ background: transparent;
2164
+ color: var(--fg-muted);
2165
+ cursor: pointer;
2166
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
2167
+ }
2168
+
2169
+ .option-insight-model-btn:hover {
2170
+ color: var(--fg);
2171
+ border-color: var(--accent);
2172
+ }
2173
+
2174
+ .option-insight-model-btn.is-selected {
2175
+ border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 55%, transparent);
2176
+ color: var(--card-accent, var(--accent));
2177
+ background: color-mix(in srgb, var(--card-accent, var(--accent)) 10%, transparent);
2178
+ }
2179
+
2180
+ .option-insight-depth-row {
2181
+ display: flex;
2182
+ flex-wrap: wrap;
2183
+ gap: 6px;
2184
+ padding-top: 6px;
2185
+ border-top: 1px solid var(--border-muted);
2186
+ }
2187
+
2188
+ .option-insight-depth-btn {
2189
+ font-family: var(--font-body);
2190
+ font-size: 11px;
2191
+ font-weight: 600;
2192
+ padding: 4px 10px;
2193
+ border-radius: 999px;
2194
+ border: 1px solid var(--border-muted);
2195
+ background: transparent;
2196
+ color: var(--fg-muted);
2197
+ cursor: pointer;
2198
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
2199
+ }
2200
+
2201
+ .option-insight-depth-btn:hover {
2202
+ color: var(--fg);
2203
+ border-color: var(--accent);
2204
+ }
2205
+
2206
+ .option-insight-depth-btn.is-selected {
2207
+ box-shadow: inset 0 -2px 0 0 var(--card-accent, var(--accent));
2208
+ border-color: var(--card-accent, var(--accent));
2209
+ color: var(--card-accent, var(--accent));
2210
+ }
2211
+
2212
+ .option-insight-loading {
2213
+ display: flex;
2214
+ align-items: center;
2215
+ gap: 4px;
2216
+ padding: 12px 0;
2217
+ font-size: 13px;
2218
+ color: var(--fg-muted);
2219
+ }
2220
+
2221
+ .option-insight-spinner {
2222
+ font-weight: 600;
2223
+ color: var(--card-accent, var(--accent));
2224
+ }
2225
+
2226
+ .option-insight-dots {
2227
+ animation: option-insight-pulse 1.2s ease-in-out infinite;
2228
+ }
2229
+
2230
+ @keyframes option-insight-pulse {
2231
+ 0%, 100% { opacity: 0.3; }
2232
+ 50% { opacity: 1; }
2096
2233
  }
2097
2234
 
2098
2235
  .option-insight-actions,
@@ -2210,10 +2347,6 @@ button {
2210
2347
  align-items: flex-start;
2211
2348
  flex-direction: column;
2212
2349
  }
2213
-
2214
- .option-insight-advanced {
2215
- grid-template-columns: 1fr;
2216
- }
2217
2350
  }
2218
2351
 
2219
2352
  /* Side-by-side layout */
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 {
@@ -302,8 +302,9 @@ export function selectGenerateModels<T extends GenerateModelCandidate>(
302
302
  return { primary: availableModels[0] ?? null, fallback: null };
303
303
  }
304
304
 
305
- function buildAskModelsData(
305
+ export function buildAskModelsData(
306
306
  availableModels: Model<Api>[],
307
+ currentModel: Model<Api> | null,
307
308
  primaryModel: Model<Api> | null,
308
309
  fallbackModel: Model<Api> | null,
309
310
  ): AskModelOption[] {
@@ -321,16 +322,15 @@ function buildAskModelsData(
321
322
  });
322
323
  };
323
324
 
325
+ addModel(currentModel);
324
326
  addModel(primaryModel);
325
327
  addModel(fallbackModel);
326
- for (const model of availableModels) {
327
- addModel(model);
328
+ for (const modelRef of PREFERRED_GENERATE_MODELS) {
329
+ const preferredModel = findModelByRef(availableModels, modelRef);
330
+ addModel(preferredModel);
328
331
  }
329
332
 
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
- });
333
+ return models;
334
334
  }
335
335
 
336
336
  export function extractGenerateResponseText(
@@ -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) {
@@ -815,7 +887,7 @@ export default function (pi: ExtensionAPI) {
815
887
  ctx.model ?? null,
816
888
  availableGenerateModels,
817
889
  );
818
- const askModels = buildAskModelsData(availableGenerateModels, generateModel, fallbackGenerateModel);
890
+ const askModels = buildAskModelsData(availableGenerateModels, ctx.model ?? null, generateModel, fallbackGenerateModel);
819
891
  const defaultAskModel = generateModel ? formatModelRef(generateModel) : null;
820
892
 
821
893
  // Expand ~ in snapshotDir if present
@@ -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
  }
@@ -1112,15 +1184,21 @@ export default function (pi: ExtensionAPI) {
1112
1184
  return selectedModel;
1113
1185
  };
1114
1186
 
1115
- onOptionInsight = async (questionId, option, prompt, modelOverride, generateSignal) => {
1187
+ onOptionInsight = async (questionId, option, prompt, modelOverride, depth, generateSignal) => {
1116
1188
  const question = questionsData.questions.find((q) => q.id === questionId);
1117
1189
  if (!question) throw new Error(`Unknown question: ${questionId}`);
1118
1190
  const optionText = getOptionLabel(option);
1119
1191
  const optionContent = typeof option === "string" ? null : option.content;
1120
1192
 
1193
+ const depthInstructions = {
1194
+ quick: "Keep the analysis very brief: a one-sentence summary and at most one bullet point.",
1195
+ standard: "Be concrete and concise. A short summary and a few bullet points.",
1196
+ deep: "Provide a thorough analysis: detailed summary, multiple bullet points covering tradeoffs, risks, and edge cases.",
1197
+ };
1198
+
1121
1199
  const questionPrompt = [
1122
1200
  "Analyze this single interview answer option.",
1123
- "Be concrete and concise.",
1201
+ depthInstructions[depth as keyof typeof depthInstructions] || depthInstructions.standard,
1124
1202
  "Explain what is good or risky about the option, and suggest a rewrite only if it would materially improve clarity.",
1125
1203
  "Return ONLY JSON with summary, bullets, and optional suggestedText.",
1126
1204
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-interview",
3
- "version": "0.8.0",
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
@@ -250,6 +250,7 @@ export interface InterviewServerCallbacks {
250
250
  option: OptionValue,
251
251
  prompt: string,
252
252
  modelOverride: string | null,
253
+ depth: string,
253
254
  signal: AbortSignal,
254
255
  ) => Promise<OptionInsightResult>;
255
256
  }
@@ -431,12 +432,41 @@ function ensureQuestionId(
431
432
  return { ok: true, question };
432
433
  }
433
434
 
434
- 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 [];
435
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 {
436
464
  if (!question.recommended) return;
465
+ const resolvedRecommended = resolveRecommendedLabels(question.recommended, options);
437
466
 
438
467
  if (question.type === "single") {
439
- if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
468
+ if (resolvedRecommended.length > 0) {
469
+ question.recommended = resolvedRecommended[0];
440
470
  return;
441
471
  }
442
472
  delete question.recommended;
@@ -450,15 +480,12 @@ function syncRecommendations(question: Question, options: OptionValue[]): void {
450
480
  return;
451
481
  }
452
482
 
453
- const nextRecommended = (Array.isArray(question.recommended)
454
- ? question.recommended
455
- : [question.recommended]).filter((option) => optionLabels.includes(option));
456
- if (nextRecommended.length === 0) {
483
+ if (resolvedRecommended.length === 0) {
457
484
  delete question.recommended;
458
485
  delete question.conviction;
459
486
  return;
460
487
  }
461
- question.recommended = nextRecommended;
488
+ question.recommended = resolvedRecommended;
462
489
  }
463
490
 
464
491
  function makeOptionKey(): string {
@@ -923,9 +950,7 @@ function recommendedIndicatorHtml(q: Question): string {
923
950
  }
924
951
 
925
952
  function savedAnswerItemHtml(text: string, q: Question): string {
926
- const recs = Array.isArray(q.recommended)
927
- ? q.recommended
928
- : q.recommended ? [q.recommended] : [];
953
+ const recs = q.options ? resolveRecommendedLabels(q.recommended, q.options) : [];
929
954
  const indicator = recs.includes(text) ? " " + recommendedIndicatorHtml(q) : "";
930
955
  return escapeHtml(text) + indicator;
931
956
  }
@@ -1932,6 +1957,7 @@ export async function startInterviewServer(
1932
1957
  optionKey?: string;
1933
1958
  prompt?: string;
1934
1959
  model?: string | null;
1960
+ depth?: string;
1935
1961
  };
1936
1962
 
1937
1963
  if (typeof payload.questionId !== "string") {
@@ -1973,6 +1999,7 @@ export async function startInterviewServer(
1973
1999
  option,
1974
2000
  payload.prompt.trim(),
1975
2001
  typeof payload.model === "string" ? payload.model : null,
2002
+ typeof payload.depth === "string" ? payload.depth : "standard",
1976
2003
  controller.signal,
1977
2004
  );
1978
2005
  sendJson(res, 200, { ok: true, optionText, ...result });