@diegopetrucci/pi-librarian 0.1.4 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +14 -3
  2. package/index.ts +414 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # librarian
2
2
 
3
- A pi GitHub research scout inspired by `pi-librarian`, with a local checkout cache enabled by default.
3
+ A pi GitHub research scout inspired by `pi-librarian`, with a local checkout cache disabled by default.
4
4
 
5
5
  When the `librarian` tool runs, it can cache/reuse repository checkouts locally. Use `/librarian-cache off` to force GitHub API/search and temporary fetched files only, or `/librarian-cache on` to re-enable cached local checkouts.
6
6
 
7
+ The internal librarian subagent uses a fast auto-selected model by default, requests `low` thinking, and prompts its scout to batch independent GitHub probes in parallel. Use `/librarian-config` to set a persistent internal model or thinking-level preference.
8
+
7
9
  ## Install
8
10
 
9
11
  ### Standalone npm package
@@ -35,8 +37,9 @@ Then reload pi:
35
37
  - Tool name: `librarian`
36
38
  - Uses a restricted subagent with `bash` and `read`
37
39
  - Uses `gh` for GitHub search/API access
38
- - Uses cached local checkouts by default
40
+ - Uses cached local checkouts only when enabled
39
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]`
40
43
  - Cached repos are removed lazily after 7 days without use
41
44
 
42
45
  ## Commands
@@ -48,7 +51,15 @@ Then reload pi:
48
51
  /librarian-cache toggle
49
52
  ```
50
53
 
51
- The command works in interactive mode, RPC mode, and print/JSON mode. It writes a global preference to `~/.pi/agent/extensions/librarian.json`, so separate non-UI invocations use the same setting. In non-UI modes, command feedback is written to stderr so stdout remains usable for normal output or JSON events.
54
+ ```text
55
+ /librarian-config status
56
+ /librarian-config model auto
57
+ /librarian-config model anthropic/claude-haiku-4-5:medium
58
+ /librarian-config thinking low
59
+ /librarian-config clear model
60
+ ```
61
+
62
+ The commands work in interactive mode, RPC mode, and print/JSON mode. They write global preferences to `~/.pi/agent/extensions/librarian.json`, so separate non-UI invocations use the same settings. In non-UI modes, command feedback is written to stderr so stdout remains usable for normal output or JSON events.
52
63
 
53
64
  ## Cache location
54
65
 
package/index.ts CHANGED
@@ -30,8 +30,22 @@ 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
34
 
34
- const DEFAULT_CACHE_MODE: CacheMode = "enabled";
35
+ const DEFAULT_CACHE_MODE: CacheMode = "disabled";
36
+ const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
37
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
38
+
39
+ const PREFERRED_FAST_MODEL_PATTERNS = [
40
+ /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
41
+ /\bgpt[-_. ]?5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
42
+ /\bgpt[-_. ]?4(?:\.1|o)?(?:[-_. ].*)?\b(?:mini|nano)\b/,
43
+ /\bgemini\b.*\b(?:flash|flash-lite|lite)\b/,
44
+ /\bclaude\b.*\bhaiku\b/,
45
+ /\b(?:mistral|codestral)\b.*\b(?:small|mini|lite)\b/,
46
+ ];
47
+
48
+ const HEAVY_MODEL_PATTERN = /\b(?:opus|pro|ultra|max)\b/;
35
49
 
36
50
  type ToolCall = {
37
51
  id: string;
@@ -51,10 +65,20 @@ type CacheDetails = {
51
65
  decisionReason: string;
52
66
  };
53
67
 
68
+ type LibrarianModelDetails = {
69
+ modelRef: string;
70
+ modelId: string;
71
+ provider: string;
72
+ thinkingLevel: ThinkingLevel;
73
+ autoSelected: boolean;
74
+ selectionReason: string;
75
+ };
76
+
54
77
  type LibrarianDetails = {
55
78
  status: LibrarianStatus;
56
79
  workspace: string;
57
80
  cache: CacheDetails;
81
+ model: LibrarianModelDetails;
58
82
  turns: number;
59
83
  toolCalls: ToolCall[];
60
84
  startedAt: number;
@@ -91,6 +115,16 @@ const LibrarianParams = Type.Object({
91
115
  default: DEFAULT_MAX_SEARCH_RESULTS,
92
116
  }),
93
117
  ),
118
+ model: Type.Optional(
119
+ Type.String({
120
+ description: "Optional model override for the internal librarian subagent. Use provider/model or auto.",
121
+ }),
122
+ ),
123
+ thinkingLevel: Type.Optional(
124
+ Type.String({
125
+ description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
126
+ }),
127
+ ),
94
128
  });
95
129
 
96
130
  function asStringArray(value: unknown, maxItems = 30): string[] {
@@ -259,6 +293,12 @@ function getCacheConfigPath(): string {
259
293
  return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
260
294
  }
261
295
 
296
+ type LibrarianPreferences = {
297
+ cacheMode: CacheMode;
298
+ model?: string;
299
+ thinkingLevel: ThinkingLevel;
300
+ };
301
+
262
302
  function parseCacheMode(value: unknown): CacheMode | undefined {
263
303
  if (typeof value === "string") {
264
304
  const normalized = value.trim().toLowerCase();
@@ -270,31 +310,74 @@ function parseCacheMode(value: unknown): CacheMode | undefined {
270
310
  return undefined;
271
311
  }
272
312
 
273
- async function readCachePreference(): Promise<CacheMode> {
313
+ function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
314
+ if (typeof value !== "string") return undefined;
315
+ const normalized = value.trim().toLowerCase();
316
+ return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
317
+ }
318
+
319
+ function normalizeModelPreference(value: unknown): string | undefined {
320
+ if (typeof value !== "string") return undefined;
321
+ const trimmed = value.trim();
322
+ const normalized = trimmed.toLowerCase();
323
+ if (!trimmed || normalized === "auto" || normalized === "current") return undefined;
324
+ return trimmed;
325
+ }
326
+
327
+ function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
328
+ const model = normalizeModelPreference(value);
329
+ if (!model) return {};
330
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh)$/i);
331
+ if (!match?.[1]) return { model };
332
+ const baseModel = match[1].trim();
333
+ if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
334
+ return { thinkingLevel: parseThinkingLevel(match[2]) };
335
+ }
336
+ return { model: baseModel, thinkingLevel: parseThinkingLevel(match[2]) };
337
+ }
338
+
339
+ async function readLibrarianPreferences(): Promise<LibrarianPreferences> {
274
340
  try {
275
341
  const raw = await fs.readFile(getCacheConfigPath(), "utf8");
276
342
  const parsed = JSON.parse(raw) as {
277
343
  cacheMode?: unknown;
278
344
  cacheEnabled?: unknown;
279
345
  cache?: { mode?: unknown; enabled?: unknown };
346
+ model?: unknown;
347
+ defaultModel?: unknown;
348
+ thinkingLevel?: unknown;
349
+ defaultThinkingLevel?: unknown;
350
+ };
351
+ return {
352
+ cacheMode:
353
+ parseCacheMode(parsed.cacheMode) ??
354
+ parseCacheMode(parsed.cache?.mode) ??
355
+ parseCacheMode(parsed.cacheEnabled) ??
356
+ parseCacheMode(parsed.cache?.enabled) ??
357
+ DEFAULT_CACHE_MODE,
358
+ model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
359
+ thinkingLevel:
360
+ parseThinkingLevel(parsed.thinkingLevel) ??
361
+ parseThinkingLevel(parsed.defaultThinkingLevel) ??
362
+ parseModelPreference(parsed.model).thinkingLevel ??
363
+ parseModelPreference(parsed.defaultModel).thinkingLevel ??
364
+ DEFAULT_THINKING_LEVEL,
280
365
  };
281
- return (
282
- parseCacheMode(parsed.cacheMode) ??
283
- parseCacheMode(parsed.cache?.mode) ??
284
- parseCacheMode(parsed.cacheEnabled) ??
285
- parseCacheMode(parsed.cache?.enabled) ??
286
- DEFAULT_CACHE_MODE
287
- );
288
366
  } catch (error) {
289
- return (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled";
367
+ return {
368
+ cacheMode: (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
369
+ thinkingLevel: DEFAULT_THINKING_LEVEL,
370
+ };
290
371
  }
291
372
  }
292
373
 
293
- async function writeCachePreference(preference: CacheMode): Promise<void> {
374
+ async function writeLibrarianPreferences(preferences: LibrarianPreferences): Promise<void> {
294
375
  const configPath = getCacheConfigPath();
295
376
  const config = {
296
- cacheMode: preference,
297
- cacheEnabled: preference === "enabled",
377
+ cacheMode: preferences.cacheMode,
378
+ cacheEnabled: preferences.cacheMode === "enabled",
379
+ ...(preferences.model ? { model: preferences.model } : {}),
380
+ thinkingLevel: preferences.thinkingLevel,
298
381
  updatedAt: new Date().toISOString(),
299
382
  };
300
383
 
@@ -302,6 +385,11 @@ async function writeCachePreference(preference: CacheMode): Promise<void> {
302
385
  await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
303
386
  }
304
387
 
388
+ async function writeCachePreference(preference: CacheMode): Promise<void> {
389
+ const preferences = await readLibrarianPreferences();
390
+ await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
391
+ }
392
+
305
393
  function resolveCacheDecision(preference: CacheMode): { enabled: boolean; reason: string } {
306
394
  if (preference === "enabled") {
307
395
  return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
@@ -314,6 +402,10 @@ function formatCachePreference(preference: CacheMode): string {
314
402
  return preference === "enabled" ? "on" : "off";
315
403
  }
316
404
 
405
+ function formatLibrarianPreferences(preferences: LibrarianPreferences): string {
406
+ return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
407
+ }
408
+
317
409
  function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
318
410
  if (ctx.hasUI) {
319
411
  ctx.ui.notify(message, type);
@@ -323,6 +415,117 @@ function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "w
323
415
  console.error(message);
324
416
  }
325
417
 
418
+ function modelRef(model: any): string {
419
+ return `${model.provider}/${model.id}`;
420
+ }
421
+
422
+ function modelCostScore(model: any): number {
423
+ const cost = model.cost ?? {};
424
+ const input = typeof cost.input === "number" ? cost.input : 0;
425
+ const output = typeof cost.output === "number" ? cost.output : 0;
426
+ return input + output;
427
+ }
428
+
429
+ function rankLibrarianModel(model: any): number {
430
+ const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
431
+ let score = modelCostScore(model) * 1_000_000;
432
+ if (model.reasoning) score += 50;
433
+ if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
434
+ if (HEAVY_MODEL_PATTERN.test(text)) score += 1_000;
435
+ if ((model.contextWindow ?? 0) < 32_000) score += 100;
436
+ return score;
437
+ }
438
+
439
+ function isPreferredFastLibrarianModel(model: any): boolean {
440
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
441
+ if (HEAVY_MODEL_PATTERN.test(text)) return false;
442
+ return PREFERRED_FAST_MODEL_PATTERNS.some((pattern) => pattern.test(text));
443
+ }
444
+
445
+ function rankPreferredFastLibrarianModel(model: any): number {
446
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
447
+ let score = rankLibrarianModel(model);
448
+ const patternIndex = PREFERRED_FAST_MODEL_PATTERNS.findIndex((pattern) => pattern.test(text));
449
+ if (patternIndex >= 0) score += patternIndex * 100;
450
+ return score;
451
+ }
452
+
453
+ async function findAvailableModel(
454
+ ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
455
+ modelPreference: string,
456
+ ): Promise<any | undefined> {
457
+ const available = await ctx.modelRegistry.getAvailable();
458
+ const trimmed = modelPreference.trim();
459
+ const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
460
+ const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
461
+
462
+ const exact = available.find(
463
+ (model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
464
+ );
465
+ if (exact) return exact;
466
+
467
+ const partial = available.find(
468
+ (model) => model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
469
+ );
470
+ if (partial) return partial;
471
+
472
+ if (!provider) {
473
+ const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
474
+ if (uniqueById.length === 1) return uniqueById[0];
475
+ }
476
+
477
+ return undefined;
478
+ }
479
+
480
+ async function selectLibrarianModel(
481
+ ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
482
+ modelPreference: string | undefined,
483
+ thinkingLevel: ThinkingLevel,
484
+ ): Promise<{ model: any; details: LibrarianModelDetails }> {
485
+ const normalized = modelPreference?.trim();
486
+ 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
+ };
500
+ }
501
+ }
502
+
503
+ const available = await ctx.modelRegistry.getAvailable();
504
+ 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
+ }
511
+
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
+ };
527
+ }
528
+
326
529
  function resolveToolPath(cwd: string, rawPath: string): string {
327
530
  const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
328
531
  return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
@@ -436,7 +639,7 @@ function buildSystemPrompt(options: {
436
639
  ? `\nLocal checkout cache is ENABLED for this call.\n- Cache root: ${options.cacheRoot}\n- Use checkout path pattern: ${options.cacheRoot}/github.com/<owner>/<repo>\n- Reuse an existing checkout when it has a .git directory. Fetch/prune before relying on it: git -C "$DIR" fetch --all --prune --tags --quiet\n- If missing, clone with gh repo clone "$REPO" "$DIR" (or git clone https://github.com/$REPO.git "$DIR").\n- If a ref/branch/SHA is requested, fetch it and check it out locally before citing files from that ref.\n- After using a checkout, update its cache marker: touch "$DIR/${CACHE_MARKER_FILE}"\n- Prefer local rg/read inside cached checkouts once a repo is cloned, and cite absolute cached paths with line ranges.\n- Clone only repositories that are relevant to the query; do not bulk-clone broad owner/org scopes unless necessary.`
437
640
  : `\nLocal checkout cache is DISABLED for this call.\n- Do not clone repositories.\n- Use gh search/API/tree/contents calls and cache only necessary proof files under ${options.workspace}/repos/<owner>/<repo>/<path>.`;
438
641
 
439
- return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse only the available bash/read tools. Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
642
+ return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse only the available bash/read tools. Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nPerformance guidance:\n- Prefer fast, parallel exploration. When probes are independent, issue multiple separate tool calls in the same assistant turn instead of waiting for each result one-by-one.\n- Aim for 4-8 independent bash/read calls in an exploration turn when useful: parallel gh searches, tree probes, contents fetches, and targeted file reads.\n- Keep dependent work sequential, and avoid concurrent writes to the same file, checkout, or cache directory.\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
440
643
  - A runtime guard blocks destructive shell commands, credential/environment inspection, and reads outside the workspace/cache.\n- Never paste whole files. Use short snippets only when they clarify the evidence.\n- If evidence is partial or access fails (404/403), state the limitation clearly.\n- Do not present anything as fact unless it appeared in tool output or in a file you read.\n\nRecommended search flow:\n1. If symbols/text are known, start with gh search code and the provided repo/owner filters.\n2. If a repo is known but paths are unclear, resolve the default branch and inspect the git tree or contents API.\n3. Fetch or clone only the files/repos required to prove the answer.\n4. Use rg/read/nl -ba locally to produce stable path and line evidence.\n\nUseful gh patterns:\n- gh repo view "$REPO" --json defaultBranchRef --jq '.defaultBranchRef.name'\n- gh search code '<terms>' --json path,repository,sha,url,textMatches --limit ${options.maxSearchResults}\n- gh api "repos/$REPO/git/trees/$REF?recursive=1" > tree.json\n- gh api "repos/$REPO/contents/$FILE?ref=$REF" --jq .content | tr -d '\\n' | base64 --decode > "repos/$REPO/$FILE"\n- rg -n '<pattern>' '<local path>'\n- nl -ba '<local file>' | sed -n '10,30p'\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences.\n## Locations\n- \`path\` or \`path:lineStart-lineEnd\` — what is here and why it matters; include GitHub URL when useful. If nothing relevant is found, write \`- (none)\`.\n## Evidence\n- \`path\` or \`path:lineStart-lineEnd\` — what this proves. Include concise snippets only if useful.\n## Searched\nOnly include when incomplete/not found or when the search path matters. List queries, filters, and probes used.\n## Next steps\nOptional: 1-3 narrow follow-up checks for remaining ambiguity.`;
441
644
  }
442
645
 
@@ -444,20 +647,56 @@ function buildUserPrompt(query: string, repos: string[], owners: string[], maxSe
444
647
  return `Task: locate and cite exact GitHub code locations that answer the query.\n\nQuery: ${query}\nRepository filters: ${repos.length ? repos.join(", ") : "(none)"}\nOwner filters: ${owners.length ? owners.join(", ") : "(none)"}\nMax search results per gh search call: ${maxSearchResults}\nLocal checkout cache: ${cache.mode === "enabled" ? `enabled at ${cache.root}` : "disabled"}\nCache decision: ${cache.decisionReason}\n\nRespond directly with concise, citation-heavy findings. Always pass --limit ${maxSearchResults} to gh search code unless a narrower command is clearly better.`;
445
648
  }
446
649
 
447
- function extractLastAssistantText(messages: unknown[]): string {
650
+ type AssistantLikeMessage = {
651
+ role?: string;
652
+ content?: unknown;
653
+ stopReason?: unknown;
654
+ errorMessage?: unknown;
655
+ };
656
+
657
+ function getLastAssistantMessage(messages: unknown[]): AssistantLikeMessage | undefined {
448
658
  for (let i = messages.length - 1; i >= 0; i--) {
449
- const message = messages[i] as { role?: string; content?: unknown };
450
- if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
451
- const parts: string[] = [];
452
- for (const part of message.content) {
453
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
454
- const text = (part as { text?: unknown }).text;
455
- if (typeof text === "string") parts.push(text);
456
- }
659
+ const message = messages[i] as AssistantLikeMessage;
660
+ if (message?.role === "assistant") return message;
661
+ }
662
+ return undefined;
663
+ }
664
+
665
+ function extractLastAssistantText(messages: unknown[]): string {
666
+ const message = getLastAssistantMessage(messages);
667
+ if (!message || !Array.isArray(message.content)) return "";
668
+ const parts: string[] = [];
669
+ for (const part of message.content) {
670
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
671
+ const text = (part as { text?: unknown }).text;
672
+ if (typeof text === "string") parts.push(text);
457
673
  }
458
- if (parts.length) return parts.join("").trim();
459
674
  }
460
- return "";
675
+ return parts.join("").trim();
676
+ }
677
+
678
+ function describeNoAnswerReason(message: AssistantLikeMessage | undefined, turns: number): string {
679
+ if (!message) return "the internal subagent produced no assistant message";
680
+ if (message.stopReason === "error") {
681
+ const errorMessage = typeof message.errorMessage === "string" && message.errorMessage.trim()
682
+ ? message.errorMessage.trim()
683
+ : "provider/model error";
684
+ return `the internal subagent stopped with an error: ${errorMessage}`;
685
+ }
686
+ if (message.stopReason === "aborted") return "the internal subagent was aborted before producing an answer";
687
+ if (turns >= MAX_TURNS) return `the internal subagent reached the ${MAX_TURNS}-turn budget without producing a final answer`;
688
+ const stopReason = typeof message.stopReason === "string" && message.stopReason.trim()
689
+ ? ` (stopReason: ${message.stopReason.trim()})`
690
+ : "";
691
+ return `the internal subagent completed without final assistant text${stopReason}`;
692
+ }
693
+
694
+ function formatInternalFailure(details: LibrarianDetails, reason: string): string {
695
+ return [
696
+ `Internal librarian run failed: ${reason}.`,
697
+ `Diagnostics: status=${details.status}; model=${details.model.modelRef}; thinking=${details.model.thinkingLevel}; modelSelection=${details.model.autoSelected ? "auto" : "configured"}; turns=${details.turns}; toolCalls=${details.toolCalls.length}.`,
698
+ "No answer was produced. This is not a reliable \"no results found\" signal; retry later or configure a different Librarian model with /librarian-config.",
699
+ ].join("\n");
461
700
  }
462
701
 
463
702
  function formatToolCall(call: ToolCall): string {
@@ -489,9 +728,119 @@ function isAbortLikeError(error: unknown): boolean {
489
728
 
490
729
  export default function librarianExtension(pi: ExtensionAPI) {
491
730
  let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
731
+ let modelPreference: string | undefined;
732
+ let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
492
733
 
493
734
  pi.on("session_start", async () => {
494
- cachePreference = await readCachePreference();
735
+ const preferences = await readLibrarianPreferences();
736
+ cachePreference = preferences.cacheMode;
737
+ modelPreference = preferences.model;
738
+ thinkingPreference = preferences.thinkingLevel;
739
+ });
740
+
741
+ const currentPreferences = (): LibrarianPreferences => ({
742
+ cacheMode: cachePreference,
743
+ ...(modelPreference ? { model: modelPreference } : {}),
744
+ thinkingLevel: thinkingPreference,
745
+ });
746
+
747
+ const savePreferences = async (preferences: LibrarianPreferences): Promise<string | undefined> => {
748
+ cachePreference = preferences.cacheMode;
749
+ modelPreference = preferences.model;
750
+ thinkingPreference = preferences.thinkingLevel;
751
+ try {
752
+ await writeLibrarianPreferences(preferences);
753
+ return undefined;
754
+ } catch (error) {
755
+ const message = error instanceof Error ? error.message : String(error);
756
+ return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
757
+ }
758
+ };
759
+
760
+ pi.registerCommand("librarian-config", {
761
+ description: "Configure Librarian subagent model and thinking defaults",
762
+ getArgumentCompletions: (prefix) => {
763
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
764
+ if (parts.length <= 1) {
765
+ const commands = ["status", "model", "thinking", "clear"];
766
+ const query = parts[0]?.toLowerCase() ?? "";
767
+ return commands.filter((command) => command.startsWith(query)).map((value) => ({ value, label: value }));
768
+ }
769
+ if (parts[0]?.toLowerCase() === "thinking") {
770
+ const query = parts[1]?.toLowerCase() ?? "";
771
+ return [...THINKING_LEVELS, "auto"].filter((level) => level.startsWith(query)).map((value) => ({ value, label: value }));
772
+ }
773
+ return null;
774
+ },
775
+ handler: async (args, ctx) => {
776
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
777
+ const [command = "status", ...rest] = tokens;
778
+ const action = command.toLowerCase();
779
+
780
+ if (action === "status" || action === "show") {
781
+ notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
782
+ return;
783
+ }
784
+
785
+ if (action === "model") {
786
+ const value = rest.join(" ").trim();
787
+ if (!value) {
788
+ notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto>", "warning");
789
+ return;
790
+ }
791
+ const normalized = value.toLowerCase();
792
+ if (normalized === "current" || normalized.startsWith("current:")) {
793
+ notifyCommand(ctx, "Librarian model=current is no longer supported. Use /librarian-config model auto or choose an explicit provider/model.", "warning");
794
+ return;
795
+ }
796
+ const parsedModel = parseModelPreference(value);
797
+ const next = normalized === "auto" || normalized === "clear" || normalized === "default"
798
+ ? { ...currentPreferences(), model: undefined }
799
+ : {
800
+ ...currentPreferences(),
801
+ model: parsedModel.model,
802
+ thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
803
+ };
804
+ const warning = await savePreferences(next);
805
+ notifyCommand(ctx, `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
806
+ return;
807
+ }
808
+
809
+ if (action === "thinking" || action === "think" || action === "thinking-level") {
810
+ const value = rest[0]?.trim().toLowerCase();
811
+ if (!value) {
812
+ notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
813
+ return;
814
+ }
815
+ const thinkingLevel = value === "auto" || value === "clear" || value === "default"
816
+ ? DEFAULT_THINKING_LEVEL
817
+ : parseThinkingLevel(value);
818
+ if (!thinkingLevel) {
819
+ notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
820
+ return;
821
+ }
822
+ const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
823
+ notifyCommand(ctx, `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
824
+ return;
825
+ }
826
+
827
+ if (action === "clear" || action === "reset") {
828
+ const target = rest[0]?.trim().toLowerCase() || "all";
829
+ let next: LibrarianPreferences;
830
+ if (target === "all") next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
831
+ else if (target === "model") next = { ...currentPreferences(), model: undefined };
832
+ else if (target === "thinking" || target === "thinking-level") next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
833
+ else {
834
+ notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
835
+ return;
836
+ }
837
+ const warning = await savePreferences(next);
838
+ notifyCommand(ctx, `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
839
+ return;
840
+ }
841
+
842
+ notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|auto> | clear [all|model|thinking]", "warning");
843
+ },
495
844
  });
496
845
 
497
846
  pi.registerCommand("librarian-cache", {
@@ -556,16 +905,24 @@ export default function librarianExtension(pi: ExtensionAPI) {
556
905
  },
557
906
  });
558
907
 
908
+ pi.on("tool_result", async (event) => {
909
+ if (event.toolName !== "librarian") return undefined;
910
+ const details = event.details as LibrarianDetails | undefined;
911
+ if (details?.status !== "error") return undefined;
912
+ return { isError: true };
913
+ });
914
+
559
915
  pi.registerTool({
560
916
  name: "librarian",
561
917
  label: "Librarian",
562
918
  description:
563
- "GitHub research scout for coding and personal-assistant tasks. Use when the answer likely lives in GitHub repos, exact repo/path locations are unknown, or you'd otherwise do exploratory gh search/tree probes plus local rg/read inspection. Librarian uses an optional 7-day local checkout cache by default; toggle it with /librarian-cache.",
919
+ "GitHub research scout for coding and personal-assistant tasks. Use when the answer likely lives in GitHub repos, exact repo/path locations are unknown, or you'd otherwise do exploratory gh search/tree probes plus local rg/read inspection. Librarian uses an optional 7-day local checkout cache that is disabled by default; toggle it with /librarian-cache. Configure its internal subagent defaults with /librarian-config.",
564
920
  promptSnippet:
565
- "Research GitHub repositories with evidence-first path and line citations; local checkout cache is enabled by default and user-toggleable with /librarian-cache.",
921
+ "Research GitHub repositories with evidence-first path and line citations; local checkout cache is disabled by default and user-toggleable with /librarian-cache. Internal subagent defaults are user-configurable with /librarian-config and default to low thinking.",
566
922
  promptGuidelines: [
567
923
  "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
568
924
  "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
925
+ "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
569
926
  ],
570
927
  parameters: LibrarianParams,
571
928
 
@@ -573,7 +930,6 @@ export default function librarianExtension(pi: ExtensionAPI) {
573
930
  const rawQuery = (params as { query?: unknown }).query;
574
931
  const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
575
932
  if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
576
- if (!ctx.model) throw new Error("Librarian needs an active pi model, but ctx.model is unavailable.");
577
933
 
578
934
  const repos = asStringArray((params as { repos?: unknown }).repos);
579
935
  const owners = asStringArray((params as { owners?: unknown }).owners);
@@ -583,6 +939,12 @@ export default function librarianExtension(pi: ExtensionAPI) {
583
939
  MAX_SEARCH_RESULTS,
584
940
  DEFAULT_MAX_SEARCH_RESULTS,
585
941
  );
942
+ const explicitModel = parseModelPreference((params as { model?: unknown }).model);
943
+ const thinkingLevel =
944
+ parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
945
+ explicitModel.thinkingLevel ??
946
+ thinkingPreference;
947
+ const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
586
948
 
587
949
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
588
950
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -617,6 +979,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
617
979
  cleanupErrors: cleanup.errors,
618
980
  decisionReason: cacheDecision.reason,
619
981
  },
982
+ model: selectedModel.details,
620
983
  turns: 0,
621
984
  toolCalls: [],
622
985
  startedAt: Date.now(),
@@ -687,8 +1050,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
687
1050
  modelRegistry: ctx.modelRegistry,
688
1051
  resourceLoader,
689
1052
  sessionManager: SessionManager.inMemory(workspace),
690
- model: ctx.model,
691
- thinkingLevel: pi.getThinkingLevel(),
1053
+ model: selectedModel.model,
1054
+ thinkingLevel,
692
1055
  tools: ["read", "bash"],
693
1056
  });
694
1057
 
@@ -736,9 +1099,20 @@ export default function librarianExtension(pi: ExtensionAPI) {
736
1099
  await Promise.race([promptPromise, timeoutPromise]);
737
1100
  }
738
1101
 
1102
+ const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
739
1103
  const answer = session ? extractLastAssistantText(session.state.messages) : "";
740
- lastContent = answer || (aborted ? "Aborted" : "(no output)");
741
- details.status = aborted ? "aborted" : "done";
1104
+ if (answer) {
1105
+ lastContent = answer;
1106
+ details.status = "done";
1107
+ } else if (aborted) {
1108
+ lastContent = "Aborted";
1109
+ details.status = "aborted";
1110
+ } else {
1111
+ details.status = "error";
1112
+ const reason = describeNoAnswerReason(lastAssistant, details.turns);
1113
+ lastContent = formatInternalFailure(details, reason);
1114
+ details.error = reason;
1115
+ }
742
1116
  details.endedAt = Date.now();
743
1117
  emit(true);
744
1118
 
@@ -807,6 +1181,10 @@ export default function librarianExtension(pi: ExtensionAPI) {
807
1181
  )}${cacheLabel}`;
808
1182
 
809
1183
  const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
1184
+ const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
1185
+ "dim",
1186
+ `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
1187
+ )}`;
810
1188
  const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
811
1189
  "dim",
812
1190
  `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
@@ -822,7 +1200,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
822
1200
  if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
823
1201
 
824
1202
  if (status === "running") {
825
- const parts = [header, workspaceLine, cacheLine];
1203
+ const parts = [header, workspaceLine, modelLine, cacheLine];
826
1204
  if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
827
1205
  parts.push("", theme.fg("muted", "Searching GitHub…"));
828
1206
  return new Text(parts.join("\n"), 0, 0);
@@ -830,7 +1208,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
830
1208
 
831
1209
  if (!expanded) {
832
1210
  const previewLines = answer.split("\n").slice(0, 18);
833
- const parts = [header, workspaceLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
1211
+ const parts = [header, workspaceLine, modelLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
834
1212
  if (answer.split("\n").length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
835
1213
  if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
836
1214
  return new Text(parts.join("\n"), 0, 0);
@@ -839,6 +1217,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
839
1217
  const container = new Container();
840
1218
  container.addChild(new Text(header, 0, 0));
841
1219
  container.addChild(new Text(workspaceLine, 0, 0));
1220
+ container.addChild(new Text(modelLine, 0, 0));
842
1221
  container.addChild(new Text(cacheLine, 0, 0));
843
1222
  if (details.cache.cleanupErrors.length) {
844
1223
  container.addChild(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",