@diegopetrucci/pi-librarian 0.1.7 → 0.1.10

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.79.10
1
+ 0.80.6
package/README.md CHANGED
@@ -39,7 +39,7 @@ Then reload pi:
39
39
  - Uses `gh` for GitHub search/API access
40
40
  - Uses cached local checkouts only when enabled
41
41
  - Toggle cache behavior for future calls with `/librarian-cache on | off | toggle | status`
42
- - Configure internal subagent defaults with `/librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|auto> | clear [all|model|thinking]`
42
+ - Configure internal subagent defaults with `/librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]`
43
43
  - Cached repos are removed lazily after 7 days without use
44
44
 
45
45
  ## Commands
package/index.ts CHANGED
@@ -30,11 +30,25 @@ const CACHE_CONFIG_FILE = "librarian.json";
30
30
  type LibrarianStatus = "running" | "done" | "error" | "aborted";
31
31
 
32
32
  type CacheMode = "disabled" | "enabled";
33
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
33
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
34
+ type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
35
+ type PiModel = {
36
+ provider: string;
37
+ id: string;
38
+ reasoning?: boolean;
39
+ thinkingLevelMap?: ThinkingLevelMap;
40
+ };
41
+
42
+ type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
43
+
44
+ function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
45
+ const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
46
+ return modelRuntime ? { modelRuntime } : {};
47
+ }
34
48
 
35
49
  const DEFAULT_CACHE_MODE: CacheMode = "disabled";
36
50
  const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
37
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
51
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
38
52
 
39
53
  const PREFERRED_FAST_MODEL_PATTERNS = [
40
54
  /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
@@ -316,6 +330,29 @@ function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
316
330
  return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
317
331
  }
318
332
 
333
+ function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
334
+ if (!model.reasoning) return level === "off";
335
+ const map = model.thinkingLevelMap;
336
+ if (level === "xhigh" || level === "max") {
337
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
338
+ }
339
+ return map?.[level] !== null;
340
+ }
341
+
342
+ function resolveThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
343
+ if (isThinkingLevelSupported(model, requested)) return requested;
344
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
345
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
346
+ const level = THINKING_LEVELS[index];
347
+ if (isThinkingLevelSupported(model, level)) return level;
348
+ }
349
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
350
+ const level = THINKING_LEVELS[index];
351
+ if (isThinkingLevelSupported(model, level)) return level;
352
+ }
353
+ return "off";
354
+ }
355
+
319
356
  function normalizeModelPreference(value: unknown): string | undefined {
320
357
  if (typeof value !== "string") return undefined;
321
358
  const trimmed = value.trim();
@@ -327,7 +364,7 @@ function normalizeModelPreference(value: unknown): string | undefined {
327
364
  function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
328
365
  const model = normalizeModelPreference(value);
329
366
  if (!model) return {};
330
- const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh)$/i);
367
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
331
368
  if (!match?.[1]) return { model };
332
369
  const baseModel = match[1].trim();
333
370
  if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
@@ -477,53 +514,104 @@ async function findAvailableModel(
477
514
  return undefined;
478
515
  }
479
516
 
480
- async function selectLibrarianModel(
517
+ // A model the catalog advertises but the active provider/subscription cannot
518
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
519
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
520
+ // so we fall back to the next candidate instead of retrying it.
521
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
522
+ /\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;
523
+
524
+ function isModelAvailabilityError(message: string | undefined): boolean {
525
+ if (!message) return false;
526
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
527
+ }
528
+
529
+ type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
530
+
531
+ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
532
+ return {
533
+ model: candidate.model,
534
+ details: {
535
+ ...candidate.details,
536
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
537
+ },
538
+ };
539
+ }
540
+
541
+ // Build an ordered list of model candidates to try. The catalog can advertise
542
+ // models the active provider/subscription cannot actually serve, so callers run
543
+ // these in order and skip ones that fail with a model-availability error,
544
+ // ultimately falling back to the known-good current session model.
545
+ async function buildLibrarianCandidates(
481
546
  ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
482
547
  modelPreference: string | undefined,
483
548
  thinkingLevel: ThinkingLevel,
484
- ): Promise<{ model: any; details: LibrarianModelDetails }> {
549
+ ): Promise<LibrarianCandidate[]> {
550
+ const candidates: LibrarianCandidate[] = [];
551
+ const seen = new Set<string>();
552
+ const push = (model: any, details: LibrarianModelDetails) => {
553
+ if (!model) return;
554
+ const key = `${model.provider}/${model.id}`.toLowerCase();
555
+ if (seen.has(key)) return;
556
+ seen.add(key);
557
+ candidates.push({ model, details });
558
+ };
559
+
485
560
  const normalized = modelPreference?.trim();
561
+ let configuredMatched: any | undefined;
486
562
  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
- };
563
+ configuredMatched = await findAvailableModel(ctx, normalized);
564
+ if (configuredMatched) {
565
+ push(configuredMatched, {
566
+ modelRef: modelRef(configuredMatched),
567
+ provider: configuredMatched.provider,
568
+ modelId: configuredMatched.id,
569
+ thinkingLevel: resolveThinkingLevel(configuredMatched, thinkingLevel),
570
+ autoSelected: false,
571
+ selectionReason: "Using the configured librarian model.",
572
+ });
500
573
  }
501
574
  }
502
575
 
503
576
  const available = await ctx.modelRegistry.getAvailable();
504
577
  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) {
578
+ const ranked = preferred.length > 0
579
+ ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
580
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
581
+ const fallbackText = normalized && !configuredMatched
582
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
583
+ : "";
584
+ ranked.forEach((model, index) => {
585
+ const topReason = preferred.length > 0
586
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
587
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
588
+ push(model, {
589
+ modelRef: modelRef(model),
590
+ provider: model.provider,
591
+ modelId: model.id,
592
+ thinkingLevel: resolveThinkingLevel(model, thinkingLevel),
593
+ autoSelected: true,
594
+ selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
595
+ });
596
+ });
597
+
598
+ // Final fallback: the active session model is known to be servable.
599
+ if (ctx.model) {
600
+ push(ctx.model, {
601
+ modelRef: modelRef(ctx.model),
602
+ provider: ctx.model.provider,
603
+ modelId: ctx.model.id,
604
+ thinkingLevel: resolveThinkingLevel(ctx.model, thinkingLevel),
605
+ autoSelected: true,
606
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
607
+ });
608
+ }
609
+
610
+ if (candidates.length === 0) {
509
611
  throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
510
612
  }
511
613
 
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,
522
- thinkingLevel,
523
- autoSelected: true,
524
- selectionReason,
525
- },
526
- };
614
+ return candidates;
527
615
  }
528
616
 
529
617
  function resolveToolPath(cwd: string, rawPath: string): string {
@@ -726,6 +814,14 @@ function isAbortLikeError(error: unknown): boolean {
726
814
  return /aborted|cancelled|canceled/i.test(message);
727
815
  }
728
816
 
817
+ export const __test__ = {
818
+ buildLibrarianCandidates,
819
+ findAvailableModel,
820
+ isModelAvailabilityError,
821
+ parseModelPreference,
822
+ resolveThinkingLevel,
823
+ };
824
+
729
825
  export default function librarianExtension(pi: ExtensionAPI) {
730
826
  let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
731
827
  let modelPreference: string | undefined;
@@ -839,7 +935,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
839
935
  return;
840
936
  }
841
937
 
842
- notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|auto> | clear [all|model|thinking]", "warning");
938
+ notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]", "warning");
843
939
  },
844
940
  });
845
941
 
@@ -944,7 +1040,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
944
1040
  parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
945
1041
  explicitModel.thinkingLevel ??
946
1042
  thinkingPreference;
947
- const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1043
+ const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1044
+ const selectedModel = candidates[0];
948
1045
 
949
1046
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
950
1047
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -1045,62 +1142,124 @@ export default function librarianExtension(pi: ExtensionAPI) {
1045
1142
 
1046
1143
  await resourceLoader.reload();
1047
1144
 
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
- });
1145
+ const runAttempt = async (
1146
+ candidate: LibrarianCandidate,
1147
+ ): Promise<{ answer: string; lastAssistant: AssistantLikeMessage | undefined }> => {
1148
+ details.model = candidate.details;
1149
+ details.turns = 0;
1150
+ details.toolCalls = [];
1151
+ emit(true);
1152
+
1153
+ const created = await createAgentSession({
1154
+ cwd: workspace,
1155
+ ...getModelRuntimeOption(ctx),
1156
+ resourceLoader,
1157
+ sessionManager: SessionManager.inMemory(workspace),
1158
+ model: candidate.model,
1159
+ thinkingLevel: candidate.details.thinkingLevel,
1160
+ tools: ["read", "bash"],
1161
+ });
1057
1162
 
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);
1074
- }
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;
1163
+ session = created.session as typeof session;
1164
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1165
+ switch (event.type) {
1166
+ case "turn_end":
1167
+ details.turns += 1;
1168
+ emit();
1169
+ break;
1170
+ case "tool_execution_start":
1171
+ details.toolCalls.push({
1172
+ id: event.toolCallId,
1173
+ name: event.toolName,
1174
+ args: event.args,
1175
+ startedAt: Date.now(),
1176
+ });
1177
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1178
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1179
+ }
1180
+ emit(true);
1181
+ break;
1182
+ case "tool_execution_end": {
1183
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1184
+ if (call) {
1185
+ call.endedAt = Date.now();
1186
+ call.isError = event.isError;
1187
+ }
1188
+ emit(true);
1189
+ break;
1082
1190
  }
1083
- emit(true);
1084
- break;
1085
1191
  }
1192
+ });
1193
+
1194
+ try {
1195
+ if (!aborted) {
1196
+ const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1197
+ expandPromptTemplates: false,
1198
+ });
1199
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1200
+ runTimeout = setTimeout(() => {
1201
+ abort();
1202
+ reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1203
+ }, MAX_RUN_MS);
1204
+ });
1205
+ await Promise.race([promptPromise, timeoutPromise]);
1206
+ }
1207
+
1208
+ const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1209
+ const answer = session ? extractLastAssistantText(session.state.messages) : "";
1210
+ return { answer, lastAssistant };
1211
+ } finally {
1212
+ if (runTimeout) {
1213
+ clearTimeout(runTimeout);
1214
+ runTimeout = undefined;
1215
+ }
1216
+ unsubscribe?.();
1217
+ unsubscribe = undefined;
1218
+ session?.dispose();
1219
+ session = undefined;
1086
1220
  }
1087
- });
1221
+ };
1088
1222
 
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]);
1223
+ let answer = "";
1224
+ let lastAssistant: AssistantLikeMessage | undefined;
1225
+ let attemptErrorReason: string | undefined;
1226
+ for (let index = 0; index < candidates.length; index++) {
1227
+ if (aborted) break;
1228
+ const candidate =
1229
+ index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1230
+
1231
+ let attempt: { answer: string; lastAssistant: AssistantLikeMessage | undefined };
1232
+ try {
1233
+ attempt = await runAttempt(candidate);
1234
+ } catch (error) {
1235
+ // Let aborts/timeouts propagate to the outer handler.
1236
+ if (aborted || isAbortLikeError(error)) throw error;
1237
+ // Some providers throw (rather than carry the error on the assistant
1238
+ // message) when a model is unavailable; treat that like a
1239
+ // message-carried availability error and fall back to the next model.
1240
+ const message = error instanceof Error ? error.message : String(error);
1241
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1242
+ answer = "";
1243
+ lastAssistant = undefined;
1244
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1245
+ continue;
1246
+ }
1247
+ throw error;
1248
+ }
1249
+
1250
+ answer = attempt.answer;
1251
+ lastAssistant = attempt.lastAssistant;
1252
+ if (answer || aborted) break;
1253
+
1254
+ attemptErrorReason = describeNoAnswerReason(lastAssistant, details.turns);
1255
+ const errorMessage =
1256
+ lastAssistant && lastAssistant.stopReason === "error" && typeof lastAssistant.errorMessage === "string"
1257
+ ? lastAssistant.errorMessage
1258
+ : undefined;
1259
+ const canFallBack = index < candidates.length - 1 && isModelAvailabilityError(errorMessage);
1260
+ if (!canFallBack) break;
1100
1261
  }
1101
1262
 
1102
- const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1103
- const answer = session ? extractLastAssistantText(session.state.messages) : "";
1104
1263
  if (answer) {
1105
1264
  lastContent = answer;
1106
1265
  details.status = "done";
@@ -1109,7 +1268,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1109
1268
  details.status = "aborted";
1110
1269
  } else {
1111
1270
  details.status = "error";
1112
- const reason = describeNoAnswerReason(lastAssistant, details.turns);
1271
+ const reason = attemptErrorReason ?? describeNoAnswerReason(lastAssistant, details.turns);
1113
1272
  lastContent = formatInternalFailure(details, reason);
1114
1273
  details.error = reason;
1115
1274
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.7",
3
+ "version": "0.1.10",
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",
@@ -33,5 +33,9 @@
33
33
  "@earendil-works/pi-coding-agent": "*",
34
34
  "@earendil-works/pi-tui": "*",
35
35
  "typebox": "*"
36
+ },
37
+ "type": "module",
38
+ "engines": {
39
+ "node": ">=22.19.0"
36
40
  }
37
41
  }