@diegopetrucci/pi-librarian 0.1.6 → 0.1.8

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.
@@ -1 +1 @@
1
- 0.78.0
1
+ 0.79.10
package/index.ts CHANGED
@@ -477,53 +477,104 @@ async function findAvailableModel(
477
477
  return undefined;
478
478
  }
479
479
 
480
- async function selectLibrarianModel(
480
+ // A model the catalog advertises but the active provider/subscription cannot
481
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
482
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
483
+ // so we fall back to the next candidate instead of retrying it.
484
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
485
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
486
+
487
+ function isModelAvailabilityError(message: string | undefined): boolean {
488
+ if (!message) return false;
489
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
490
+ }
491
+
492
+ type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
493
+
494
+ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
495
+ return {
496
+ model: candidate.model,
497
+ details: {
498
+ ...candidate.details,
499
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
500
+ },
501
+ };
502
+ }
503
+
504
+ // Build an ordered list of model candidates to try. The catalog can advertise
505
+ // models the active provider/subscription cannot actually serve, so callers run
506
+ // these in order and skip ones that fail with a model-availability error,
507
+ // ultimately falling back to the known-good current session model.
508
+ async function buildLibrarianCandidates(
481
509
  ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
482
510
  modelPreference: string | undefined,
483
511
  thinkingLevel: ThinkingLevel,
484
- ): Promise<{ model: any; details: LibrarianModelDetails }> {
512
+ ): Promise<LibrarianCandidate[]> {
513
+ const candidates: LibrarianCandidate[] = [];
514
+ const seen = new Set<string>();
515
+ const push = (model: any, details: LibrarianModelDetails) => {
516
+ if (!model) return;
517
+ const key = `${model.provider}/${model.id}`.toLowerCase();
518
+ if (seen.has(key)) return;
519
+ seen.add(key);
520
+ candidates.push({ model, details });
521
+ };
522
+
485
523
  const normalized = modelPreference?.trim();
524
+ let configuredMatched: any | undefined;
486
525
  if (normalized) {
487
- const matched = await findAvailableModel(ctx, normalized);
488
- if (matched) {
489
- return {
490
- model: matched,
491
- details: {
492
- modelRef: modelRef(matched),
493
- provider: matched.provider,
494
- modelId: matched.id,
495
- thinkingLevel,
496
- autoSelected: false,
497
- selectionReason: "Using the configured librarian model.",
498
- },
499
- };
526
+ configuredMatched = await findAvailableModel(ctx, normalized);
527
+ if (configuredMatched) {
528
+ push(configuredMatched, {
529
+ modelRef: modelRef(configuredMatched),
530
+ provider: configuredMatched.provider,
531
+ modelId: configuredMatched.id,
532
+ thinkingLevel,
533
+ autoSelected: false,
534
+ selectionReason: "Using the configured librarian model.",
535
+ });
500
536
  }
501
537
  }
502
538
 
503
539
  const available = await ctx.modelRegistry.getAvailable();
504
540
  const preferred = available.filter(isPreferredFastLibrarianModel);
505
- const winner = preferred.length > 0
506
- ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))[0]
507
- : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b))[0];
508
- if (!winner) {
509
- throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
510
- }
541
+ const ranked = preferred.length > 0
542
+ ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
543
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
544
+ const fallbackText = normalized && !configuredMatched
545
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
546
+ : "";
547
+ ranked.forEach((model, index) => {
548
+ const topReason = preferred.length > 0
549
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
550
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
551
+ push(model, {
552
+ modelRef: modelRef(model),
553
+ provider: model.provider,
554
+ modelId: model.id,
555
+ thinkingLevel,
556
+ autoSelected: true,
557
+ selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
558
+ });
559
+ });
511
560
 
512
- const fallbackText = normalized ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.` : "";
513
- const selectionReason = preferred.length > 0
514
- ? `Selected a preferred fast Librarian model.${fallbackText}`
515
- : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
516
- return {
517
- model: winner,
518
- details: {
519
- modelRef: modelRef(winner),
520
- provider: winner.provider,
521
- modelId: winner.id,
561
+ // Final fallback: the active session model is known to be servable.
562
+ if (ctx.model) {
563
+ push(ctx.model, {
564
+ modelRef: modelRef(ctx.model),
565
+ provider: ctx.model.provider,
566
+ modelId: ctx.model.id,
522
567
  thinkingLevel,
523
568
  autoSelected: true,
524
- selectionReason,
525
- },
526
- };
569
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
570
+ });
571
+ }
572
+
573
+ if (candidates.length === 0) {
574
+ throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
575
+ }
576
+
577
+ return candidates;
527
578
  }
528
579
 
529
580
  function resolveToolPath(cwd: string, rawPath: string): string {
@@ -944,7 +995,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
944
995
  parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
945
996
  explicitModel.thinkingLevel ??
946
997
  thinkingPreference;
947
- const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
998
+ const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
999
+ const selectedModel = candidates[0];
948
1000
 
949
1001
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
950
1002
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -1045,62 +1097,124 @@ export default function librarianExtension(pi: ExtensionAPI) {
1045
1097
 
1046
1098
  await resourceLoader.reload();
1047
1099
 
1048
- const created = await createAgentSession({
1049
- cwd: workspace,
1050
- modelRegistry: ctx.modelRegistry,
1051
- resourceLoader,
1052
- sessionManager: SessionManager.inMemory(workspace),
1053
- model: selectedModel.model,
1054
- thinkingLevel,
1055
- tools: ["read", "bash"],
1056
- });
1100
+ const runAttempt = async (
1101
+ candidate: LibrarianCandidate,
1102
+ ): Promise<{ answer: string; lastAssistant: AssistantLikeMessage | undefined }> => {
1103
+ details.model = candidate.details;
1104
+ details.turns = 0;
1105
+ details.toolCalls = [];
1106
+ emit(true);
1107
+
1108
+ const created = await createAgentSession({
1109
+ cwd: workspace,
1110
+ modelRegistry: ctx.modelRegistry,
1111
+ resourceLoader,
1112
+ sessionManager: SessionManager.inMemory(workspace),
1113
+ model: candidate.model,
1114
+ thinkingLevel,
1115
+ tools: ["read", "bash"],
1116
+ });
1057
1117
 
1058
- session = created.session as typeof session;
1059
- unsubscribe = (created.session as any).subscribe((event: any) => {
1060
- switch (event.type) {
1061
- case "turn_end":
1062
- details.turns += 1;
1063
- emit();
1064
- break;
1065
- case "tool_execution_start":
1066
- details.toolCalls.push({
1067
- id: event.toolCallId,
1068
- name: event.toolName,
1069
- args: event.args,
1070
- startedAt: Date.now(),
1071
- });
1072
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1073
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1118
+ session = created.session as typeof session;
1119
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1120
+ switch (event.type) {
1121
+ case "turn_end":
1122
+ details.turns += 1;
1123
+ emit();
1124
+ break;
1125
+ case "tool_execution_start":
1126
+ details.toolCalls.push({
1127
+ id: event.toolCallId,
1128
+ name: event.toolName,
1129
+ args: event.args,
1130
+ startedAt: Date.now(),
1131
+ });
1132
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1133
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1134
+ }
1135
+ emit(true);
1136
+ break;
1137
+ case "tool_execution_end": {
1138
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1139
+ if (call) {
1140
+ call.endedAt = Date.now();
1141
+ call.isError = event.isError;
1142
+ }
1143
+ emit(true);
1144
+ break;
1074
1145
  }
1075
- emit(true);
1076
- break;
1077
- case "tool_execution_end": {
1078
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1079
- if (call) {
1080
- call.endedAt = Date.now();
1081
- call.isError = event.isError;
1082
- }
1083
- emit(true);
1084
- break;
1085
1146
  }
1147
+ });
1148
+
1149
+ try {
1150
+ if (!aborted) {
1151
+ const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1152
+ expandPromptTemplates: false,
1153
+ });
1154
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1155
+ runTimeout = setTimeout(() => {
1156
+ abort();
1157
+ reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1158
+ }, MAX_RUN_MS);
1159
+ });
1160
+ await Promise.race([promptPromise, timeoutPromise]);
1161
+ }
1162
+
1163
+ const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1164
+ const answer = session ? extractLastAssistantText(session.state.messages) : "";
1165
+ return { answer, lastAssistant };
1166
+ } finally {
1167
+ if (runTimeout) {
1168
+ clearTimeout(runTimeout);
1169
+ runTimeout = undefined;
1170
+ }
1171
+ unsubscribe?.();
1172
+ unsubscribe = undefined;
1173
+ session?.dispose();
1174
+ session = undefined;
1086
1175
  }
1087
- });
1176
+ };
1088
1177
 
1089
- if (!aborted) {
1090
- const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1091
- expandPromptTemplates: false,
1092
- });
1093
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1094
- runTimeout = setTimeout(() => {
1095
- abort();
1096
- reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1097
- }, MAX_RUN_MS);
1098
- });
1099
- await Promise.race([promptPromise, timeoutPromise]);
1178
+ let answer = "";
1179
+ let lastAssistant: AssistantLikeMessage | undefined;
1180
+ let attemptErrorReason: string | undefined;
1181
+ for (let index = 0; index < candidates.length; index++) {
1182
+ if (aborted) break;
1183
+ const candidate =
1184
+ index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1185
+
1186
+ let attempt: { answer: string; lastAssistant: AssistantLikeMessage | undefined };
1187
+ try {
1188
+ attempt = await runAttempt(candidate);
1189
+ } catch (error) {
1190
+ // Let aborts/timeouts propagate to the outer handler.
1191
+ if (aborted || isAbortLikeError(error)) throw error;
1192
+ // Some providers throw (rather than carry the error on the assistant
1193
+ // message) when a model is unavailable; treat that like a
1194
+ // message-carried availability error and fall back to the next model.
1195
+ const message = error instanceof Error ? error.message : String(error);
1196
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1197
+ answer = "";
1198
+ lastAssistant = undefined;
1199
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1200
+ continue;
1201
+ }
1202
+ throw error;
1203
+ }
1204
+
1205
+ answer = attempt.answer;
1206
+ lastAssistant = attempt.lastAssistant;
1207
+ if (answer || aborted) break;
1208
+
1209
+ attemptErrorReason = describeNoAnswerReason(lastAssistant, details.turns);
1210
+ const errorMessage =
1211
+ lastAssistant && lastAssistant.stopReason === "error" && typeof lastAssistant.errorMessage === "string"
1212
+ ? lastAssistant.errorMessage
1213
+ : undefined;
1214
+ const canFallBack = index < candidates.length - 1 && isModelAvailabilityError(errorMessage);
1215
+ if (!canFallBack) break;
1100
1216
  }
1101
1217
 
1102
- const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1103
- const answer = session ? extractLastAssistantText(session.state.messages) : "";
1104
1218
  if (answer) {
1105
1219
  lastContent = answer;
1106
1220
  details.status = "done";
@@ -1109,7 +1223,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1109
1223
  details.status = "aborted";
1110
1224
  } else {
1111
1225
  details.status = "error";
1112
- const reason = describeNoAnswerReason(lastAssistant, details.turns);
1226
+ const reason = attemptErrorReason ?? describeNoAnswerReason(lastAssistant, details.turns);
1113
1227
  lastContent = formatInternalFailure(details, reason);
1114
1228
  details.error = reason;
1115
1229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
5
5
  "keywords": [
6
6
  "pi-package",