@diegopetrucci/pi-extensions 0.1.39 → 0.1.42

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/README.md CHANGED
@@ -9,10 +9,10 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
9
9
  - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
10
10
  - [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
11
11
  - [`dirty-repo-guard`](./extensions/dirty-repo-guard): Prompts before new sessions, session switches, or forks when the current git repo has uncommitted changes.
12
- - [`git-footer`](./extensions/git-footer): Standalone footer extension that replaces pi's built-in footer with a TLH-style git summary: branch, dirty counts, ahead/behind, optional PR number, session name, plus context/model status. Not auto-loaded by the collection package because it conflicts with `minimal-footer`.
12
+ - [`git-footer`](./extensions/git-footer): Standalone extension that adds TLH-style git dirty counts, ahead/behind, and optional PR number to pi's built-in footer status area.
13
13
  - [`gnosis`](./extensions/gnosis): Exposes the `gn` repo-local knowledge base CLI as an agent tool for searching and recording durable project decisions, constraints, and intent.
14
14
  - [`inline-bash`](./extensions/inline-bash): Expands `!{command}` snippets in user prompts by running them through bash before the prompt reaches the agent.
15
- - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache enabled by default under the OS user cache directory, toggleable with `/librarian-cache`, with cached repos expiring after 7 days of non-use.
15
+ - [`librarian`](./extensions/librarian): Adds a GitHub research scout with a local repo checkout cache disabled by default under the OS user cache directory, toggleable with `/librarian-cache`, configurable subagent model/thinking defaults via `/librarian-config`, and cached repos expiring after 7 days of non-use.
16
16
  - [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch/repo on the first line, context/model on the second, optional `DUMB ZONE`, plus OpenAI Codex 5-hour and 7-day usage when available.
17
17
  - [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
18
18
  - [`openai-fast`](./extensions/openai-fast): Adds `/fast` to enable OpenAI Codex Fast mode for ChatGPT-auth GPT-5.4 and GPT-5.5 by injecting the priority service tier.
@@ -167,7 +167,7 @@ function syncModelBetaHeader(ctx: ExtensionContext, state: SessionState): void {
167
167
  const requiredBase = ctx.modelRegistry.isUsingOAuth(model) ? CLAUDE_CODE_OAUTH_BETAS : [];
168
168
  const next = shouldEnable
169
169
  ? Array.from(new Set([...existing, ...requiredBase, FAST_BETA]))
170
- : existing.filter((beta) => beta !== FAST_BETA && !CLAUDE_CODE_OAUTH_BETAS.includes(beta));
170
+ : existing.filter((beta) => beta !== FAST_BETA);
171
171
 
172
172
  delete headers["Anthropic-Beta"];
173
173
  if (next.length > 0) headers["anthropic-beta"] = next.join(",");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-claude-fast",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi extension that enables Anthropic Claude Fast mode for supported Claude Opus models by injecting speed=fast.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,14 +1,15 @@
1
1
  # git-footer
2
2
 
3
- A TLH-style git status footer for pi.
3
+ A TLH-style git status add-on for pi's built-in footer.
4
4
 
5
- This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package because only one custom footer should win.
5
+ This package is standalone-only and is not auto-loaded by the `@diegopetrucci/pi-extensions` collection package.
6
6
 
7
- It replaces pi's built-in footer with a compact two-line layout whose first line mirrors how [`the-last-harness`](https://github.com/diegopetrucci/the-last-harness) summarizes repository state:
7
+ It keeps pi's default footer intact and adds a compact git status segment through pi's extension status API:
8
8
 
9
9
  ```text
10
- <repo> <branch> <git-status> • PR #<number> • <session-name>
11
- ctx <percent>% • <model> <thinking> • <extension-statuses>
10
+ ~/repo (main) • session-name
11
+ ↑12k ↓3k 44.1%/200k model
12
+ +2 ~1 ?3 ↑1 • PR #123
12
13
  ```
13
14
 
14
15
  Git status indicators:
@@ -20,7 +21,7 @@ Git status indicators:
20
21
  - `↑N`: commits ahead of upstream
21
22
  - `↓N`: commits behind upstream
22
23
 
23
- The extension polls git status in the background, caches the latest snapshot, and keeps footer rendering synchronous. It also performs a best-effort `gh pr view` lookup for the current branch; if `gh` is unavailable or the branch has no PR, the PR segment is omitted.
24
+ The extension polls git status in the background and caches the latest snapshot. It also performs a best-effort `gh pr view` lookup for the current branch; if `gh` is unavailable or the branch has no PR, the PR segment is omitted.
24
25
 
25
26
  ## Install
26
27
 
@@ -36,6 +37,7 @@ Then reload pi:
36
37
 
37
38
  ## Notes
38
39
 
39
- - Replaces pi's built-in footer entirely.
40
- - Intended as a separate footer extension; do not enable it at the same time as another custom-footer extension such as `minimal-footer` unless you want the last-loaded footer to win.
41
- - `render()` never spawns subprocesses. Git and GitHub CLI lookups run on a short background interval with timeouts, and the footer reads only cached snapshots.
40
+ - Does not replace pi's built-in footer.
41
+ - Uses `ctx.ui.setStatus()`, so pi renders the git summary with other extension statuses.
42
+ - The current pi extension API does not support literally appending text inside the built-in footer's first `cwd (branch)` line without replacing the footer.
43
+ - Git and GitHub CLI lookups run on a short background interval with timeouts.
@@ -1,7 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { basename } from "node:path";
3
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { truncateToWidth } from "@earendil-works/pi-tui";
5
3
 
6
4
  type GitStatusSnapshot = {
7
5
  branch?: string;
@@ -48,12 +46,12 @@ type GitFooterCacheOptions = {
48
46
  gitTimeoutMs?: number;
49
47
  ghTimeoutMs?: number;
50
48
  onChange?: () => void;
51
- onBranchChangeSource?: (callback: () => void) => () => void;
52
49
  };
53
50
 
54
51
  const BRANCH_HEAD_PREFIX = "# branch.head ";
55
52
  const BRANCH_AB_PREFIX = "# branch.ab ";
56
- const FOOTER_SEPARATOR = "";
53
+ const STATUS_KEY = "git-footer";
54
+ const STATUS_SEPARATOR = " • ";
57
55
  const DEFAULT_REFRESH_INTERVAL_MS = 8_000;
58
56
  const DEFAULT_GIT_TIMEOUT_MS = 1_500;
59
57
  const DEFAULT_GH_TIMEOUT_MS = 3_000;
@@ -157,21 +155,15 @@ function formatPullRequestFooterSegment(pullRequest: PullRequestSnapshot | undef
157
155
  return undefined;
158
156
  }
159
157
 
160
- function formatGitFooterSegments(
158
+ function formatGitFooterStatus(
161
159
  status: GitStatusSnapshot | undefined,
162
160
  pullRequest: PullRequestSnapshot | undefined,
163
- ): string[] {
164
- const segments: string[] = [];
165
- const branch = typeof status?.branch === "string" ? status.branch.trim() : "";
166
- if (branch) segments.push(branch);
167
-
168
- const statusSegment = formatGitStatusFooterSegment(status);
169
- if (statusSegment) segments.push(statusSegment);
170
-
171
- const pullRequestSegment = formatPullRequestFooterSegment(pullRequest);
172
- if (pullRequestSegment) segments.push(pullRequestSegment);
173
-
174
- return segments;
161
+ ): string | undefined {
162
+ const parts = [
163
+ formatGitStatusFooterSegment(status),
164
+ formatPullRequestFooterSegment(pullRequest),
165
+ ].filter((part): part is string => !!part);
166
+ return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined;
175
167
  }
176
168
 
177
169
  function parsePullRequestJson(stdout: string): PullRequestSnapshot | undefined {
@@ -307,7 +299,6 @@ class GitFooterCache {
307
299
  private readonly inflightControllers = new Set<AbortController>();
308
300
  private disposed = false;
309
301
  private refreshInFlight: Promise<void> | undefined;
310
- private branchChangeUnsubscribe: (() => void) | undefined;
311
302
  private statusSnapshot: GitStatusSnapshot | undefined;
312
303
  private pullRequestSnapshot: PullRequestSnapshot | undefined;
313
304
  private lastSeenBranch: string | undefined;
@@ -325,12 +316,6 @@ class GitFooterCache {
325
316
  void this.refresh();
326
317
  }, this.refreshIntervalMs);
327
318
  void this.refresh();
328
-
329
- if (options.onBranchChangeSource) {
330
- this.branchChangeUnsubscribe = options.onBranchChangeSource(() => {
331
- void this.refresh();
332
- });
333
- }
334
319
  }
335
320
 
336
321
  getStatusSnapshot(): GitStatusSnapshot | undefined {
@@ -453,81 +438,43 @@ class GitFooterCache {
453
438
  this.clock.clearInterval(this.intervalHandle);
454
439
  this.intervalHandle = undefined;
455
440
  }
456
- if (this.branchChangeUnsubscribe) {
457
- try {
458
- this.branchChangeUnsubscribe();
459
- } catch {
460
- // Ignore misbehaving notifier.
461
- }
462
- this.branchChangeUnsubscribe = undefined;
463
- }
464
441
  for (const controller of this.inflightControllers) controller.abort();
465
442
  this.inflightControllers.clear();
466
443
  }
467
444
  }
468
445
 
469
- function composeFooterFirstLine(input: {
470
- cwd: string;
471
- sessionName?: string | null;
472
- status?: GitStatusSnapshot;
473
- pullRequest?: PullRequestSnapshot;
474
- }): string {
475
- const segments = [input.cwd];
476
- if (input.status !== undefined) {
477
- segments.push(...formatGitFooterSegments(input.status, input.pullRequest));
478
- }
479
- if (input.sessionName) segments.push(input.sessionName);
480
- return segments.join(FOOTER_SEPARATOR);
481
- }
446
+ export default function (pi: ExtensionAPI) {
447
+ let cache: GitFooterCache | undefined;
482
448
 
483
- function sanitizeFooterSegment(value: string): string {
484
- return value.replace(/[\r\n\t]+/g, " ").trim();
485
- }
449
+ function disposeCache(): void {
450
+ cache?.dispose();
451
+ cache = undefined;
452
+ }
486
453
 
487
- export default function (pi: ExtensionAPI) {
488
454
  pi.on("session_start", (_event, ctx) => {
489
- let cache: GitFooterCache | undefined;
490
-
491
- ctx.ui.setFooter((tui, theme, footerData) => {
492
- cache = new GitFooterCache({
493
- cwd: () => ctx.cwd,
494
- onChange: () => tui.requestRender(),
495
- onBranchChangeSource: (callback) => footerData.onBranchChange(callback),
496
- });
455
+ disposeCache();
456
+
457
+ const updateStatus = () => {
458
+ const text = formatGitFooterStatus(
459
+ cache?.getStatusSnapshot(),
460
+ cache?.getPullRequestSnapshot(),
461
+ );
462
+ ctx.ui.setStatus(STATUS_KEY, text ? ctx.ui.theme.fg("dim", text) : undefined);
463
+ };
497
464
 
498
- return {
499
- dispose() {
500
- cache?.dispose();
501
- cache = undefined;
502
- },
503
- invalidate() {},
504
- render(width: number): string[] {
505
- const status = cache?.getStatusSnapshot();
506
- const pullRequest = cache?.getPullRequestSnapshot();
507
- const firstLine = composeFooterFirstLine({
508
- cwd: basename(ctx.cwd),
509
- status,
510
- pullRequest,
511
- sessionName: pi.getSessionName(),
512
- });
513
-
514
- const usage = ctx.getContextUsage();
515
- const context = usage?.percent == null ? "ctx ?" : `ctx ${usage.percent.toFixed(1)}%`;
516
- const thinking = pi.getThinkingLevel();
517
- const model = ctx.model?.id ?? "no-model";
518
- const modelText = thinking === "off" ? model : `${model} ${thinking}`;
519
- const statuses = [...footerData.getExtensionStatuses().values()]
520
- .map(sanitizeFooterSegment)
521
- .filter(Boolean);
522
- const secondLine = [theme.fg("dim", context), theme.fg("dim", modelText), ...statuses]
523
- .join(theme.fg("dim", FOOTER_SEPARATOR));
524
-
525
- return [
526
- truncateToWidth(theme.fg("dim", firstLine), width),
527
- truncateToWidth(secondLine, width),
528
- ];
529
- },
530
- };
465
+ cache = new GitFooterCache({
466
+ cwd: () => ctx.cwd,
467
+ onChange: updateStatus,
531
468
  });
469
+ updateStatus();
470
+ });
471
+
472
+ pi.on("turn_end", () => {
473
+ void cache?.refresh();
474
+ });
475
+
476
+ pi.on("session_shutdown", (_event, ctx) => {
477
+ disposeCache();
478
+ ctx.ui.setStatus(STATUS_KEY, undefined);
532
479
  });
533
480
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-git-footer",
3
- "version": "0.1.0",
4
- "description": "A TLH-style git status footer for pi.",
3
+ "version": "0.1.1",
4
+ "description": "A TLH-style git status add-on for pi's built-in footer.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -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 lightweight auto-selected model by default and requests `medium` thinking. 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|current> | 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,16 @@ 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 current
58
+ /librarian-config model anthropic/claude-haiku-4-5:medium
59
+ /librarian-config thinking medium
60
+ /librarian-config clear model
61
+ ```
62
+
63
+ 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
64
 
53
65
  ## Cache location
54
66
 
@@ -30,8 +30,11 @@ 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 = "medium";
37
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
35
38
 
36
39
  type ToolCall = {
37
40
  id: string;
@@ -51,10 +54,20 @@ type CacheDetails = {
51
54
  decisionReason: string;
52
55
  };
53
56
 
57
+ type LibrarianModelDetails = {
58
+ modelRef: string;
59
+ modelId: string;
60
+ provider: string;
61
+ thinkingLevel: ThinkingLevel;
62
+ autoSelected: boolean;
63
+ selectionReason: string;
64
+ };
65
+
54
66
  type LibrarianDetails = {
55
67
  status: LibrarianStatus;
56
68
  workspace: string;
57
69
  cache: CacheDetails;
70
+ model: LibrarianModelDetails;
58
71
  turns: number;
59
72
  toolCalls: ToolCall[];
60
73
  startedAt: number;
@@ -91,6 +104,16 @@ const LibrarianParams = Type.Object({
91
104
  default: DEFAULT_MAX_SEARCH_RESULTS,
92
105
  }),
93
106
  ),
107
+ model: Type.Optional(
108
+ Type.String({
109
+ description: "Optional model override for the internal librarian subagent. Use provider/model, auto, or current.",
110
+ }),
111
+ ),
112
+ thinkingLevel: Type.Optional(
113
+ Type.String({
114
+ description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
115
+ }),
116
+ ),
94
117
  });
95
118
 
96
119
  function asStringArray(value: unknown, maxItems = 30): string[] {
@@ -259,6 +282,12 @@ function getCacheConfigPath(): string {
259
282
  return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
260
283
  }
261
284
 
285
+ type LibrarianPreferences = {
286
+ cacheMode: CacheMode;
287
+ model?: string;
288
+ thinkingLevel: ThinkingLevel;
289
+ };
290
+
262
291
  function parseCacheMode(value: unknown): CacheMode | undefined {
263
292
  if (typeof value === "string") {
264
293
  const normalized = value.trim().toLowerCase();
@@ -270,31 +299,69 @@ function parseCacheMode(value: unknown): CacheMode | undefined {
270
299
  return undefined;
271
300
  }
272
301
 
273
- async function readCachePreference(): Promise<CacheMode> {
302
+ function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
303
+ if (typeof value !== "string") return undefined;
304
+ const normalized = value.trim().toLowerCase();
305
+ return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
306
+ }
307
+
308
+ function normalizeModelPreference(value: unknown): string | undefined {
309
+ if (typeof value !== "string") return undefined;
310
+ const trimmed = value.trim();
311
+ if (!trimmed || trimmed.toLowerCase() === "auto") return undefined;
312
+ return trimmed;
313
+ }
314
+
315
+ function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
316
+ const model = normalizeModelPreference(value);
317
+ if (!model) return {};
318
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh)$/i);
319
+ if (!match?.[1]) return { model };
320
+ return { model: match[1], thinkingLevel: parseThinkingLevel(match[2]) };
321
+ }
322
+
323
+ async function readLibrarianPreferences(): Promise<LibrarianPreferences> {
274
324
  try {
275
325
  const raw = await fs.readFile(getCacheConfigPath(), "utf8");
276
326
  const parsed = JSON.parse(raw) as {
277
327
  cacheMode?: unknown;
278
328
  cacheEnabled?: unknown;
279
329
  cache?: { mode?: unknown; enabled?: unknown };
330
+ model?: unknown;
331
+ defaultModel?: unknown;
332
+ thinkingLevel?: unknown;
333
+ defaultThinkingLevel?: unknown;
334
+ };
335
+ return {
336
+ cacheMode:
337
+ parseCacheMode(parsed.cacheMode) ??
338
+ parseCacheMode(parsed.cache?.mode) ??
339
+ parseCacheMode(parsed.cacheEnabled) ??
340
+ parseCacheMode(parsed.cache?.enabled) ??
341
+ DEFAULT_CACHE_MODE,
342
+ model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
343
+ thinkingLevel:
344
+ parseThinkingLevel(parsed.thinkingLevel) ??
345
+ parseThinkingLevel(parsed.defaultThinkingLevel) ??
346
+ parseModelPreference(parsed.model).thinkingLevel ??
347
+ parseModelPreference(parsed.defaultModel).thinkingLevel ??
348
+ DEFAULT_THINKING_LEVEL,
280
349
  };
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
350
  } catch (error) {
289
- return (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled";
351
+ return {
352
+ cacheMode: (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
353
+ thinkingLevel: DEFAULT_THINKING_LEVEL,
354
+ };
290
355
  }
291
356
  }
292
357
 
293
- async function writeCachePreference(preference: CacheMode): Promise<void> {
358
+ async function writeLibrarianPreferences(preferences: LibrarianPreferences): Promise<void> {
294
359
  const configPath = getCacheConfigPath();
295
360
  const config = {
296
- cacheMode: preference,
297
- cacheEnabled: preference === "enabled",
361
+ cacheMode: preferences.cacheMode,
362
+ cacheEnabled: preferences.cacheMode === "enabled",
363
+ ...(preferences.model ? { model: preferences.model } : {}),
364
+ thinkingLevel: preferences.thinkingLevel,
298
365
  updatedAt: new Date().toISOString(),
299
366
  };
300
367
 
@@ -302,6 +369,11 @@ async function writeCachePreference(preference: CacheMode): Promise<void> {
302
369
  await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
303
370
  }
304
371
 
372
+ async function writeCachePreference(preference: CacheMode): Promise<void> {
373
+ const preferences = await readLibrarianPreferences();
374
+ await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
375
+ }
376
+
305
377
  function resolveCacheDecision(preference: CacheMode): { enabled: boolean; reason: string } {
306
378
  if (preference === "enabled") {
307
379
  return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
@@ -314,6 +386,10 @@ function formatCachePreference(preference: CacheMode): string {
314
386
  return preference === "enabled" ? "on" : "off";
315
387
  }
316
388
 
389
+ function formatLibrarianPreferences(preferences: LibrarianPreferences): string {
390
+ return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
391
+ }
392
+
317
393
  function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
318
394
  if (ctx.hasUI) {
319
395
  ctx.ui.notify(message, type);
@@ -323,6 +399,115 @@ function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "w
323
399
  console.error(message);
324
400
  }
325
401
 
402
+ function modelRef(model: any): string {
403
+ return `${model.provider}/${model.id}`;
404
+ }
405
+
406
+ function modelCostScore(model: any): number {
407
+ const cost = model.cost ?? {};
408
+ const input = typeof cost.input === "number" ? cost.input : 0;
409
+ const output = typeof cost.output === "number" ? cost.output : 0;
410
+ return input + output;
411
+ }
412
+
413
+ function rankLibrarianModel(model: any): number {
414
+ const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
415
+ let score = modelCostScore(model) * 1_000_000;
416
+ if (model.reasoning) score += 50;
417
+ if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
418
+ if (/\b(?:opus|pro|ultra|max)\b/.test(text)) score += 1_000;
419
+ if ((model.contextWindow ?? 0) < 32_000) score += 100;
420
+ return score;
421
+ }
422
+
423
+ async function findAvailableModel(
424
+ ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
425
+ modelPreference: string,
426
+ ): Promise<any | undefined> {
427
+ const available = await ctx.modelRegistry.getAvailable();
428
+ const trimmed = modelPreference.trim();
429
+ const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
430
+ const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
431
+
432
+ const exact = available.find(
433
+ (model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
434
+ );
435
+ if (exact) return exact;
436
+
437
+ const partial = available.find(
438
+ (model) => model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
439
+ );
440
+ if (partial) return partial;
441
+
442
+ if (!provider) {
443
+ const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
444
+ if (uniqueById.length === 1) return uniqueById[0];
445
+ }
446
+
447
+ return undefined;
448
+ }
449
+
450
+ async function selectLibrarianModel(
451
+ ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
452
+ modelPreference: string | undefined,
453
+ thinkingLevel: ThinkingLevel,
454
+ ): Promise<{ model: any; details: LibrarianModelDetails }> {
455
+ const normalized = modelPreference?.trim();
456
+ if (normalized?.toLowerCase() === "current") {
457
+ if (!ctx.model) throw new Error("Librarian model=current needs an active pi model, but ctx.model is unavailable.");
458
+ return {
459
+ model: ctx.model,
460
+ details: {
461
+ modelRef: modelRef(ctx.model),
462
+ provider: ctx.model.provider,
463
+ modelId: ctx.model.id,
464
+ thinkingLevel,
465
+ autoSelected: false,
466
+ selectionReason: "Using the caller's current model because librarian model=current is configured.",
467
+ },
468
+ };
469
+ }
470
+
471
+ if (normalized) {
472
+ const matched = await findAvailableModel(ctx, normalized);
473
+ if (matched) {
474
+ return {
475
+ model: matched,
476
+ details: {
477
+ modelRef: modelRef(matched),
478
+ provider: matched.provider,
479
+ modelId: matched.id,
480
+ thinkingLevel,
481
+ autoSelected: false,
482
+ selectionReason: "Using the configured librarian model.",
483
+ },
484
+ };
485
+ }
486
+ }
487
+
488
+ const available = await ctx.modelRegistry.getAvailable();
489
+ const currentProvider = ctx.model?.provider;
490
+ const sameProvider = currentProvider ? available.filter((model) => model.provider === currentProvider) : [];
491
+ const candidates = sameProvider.length > 0 ? sameProvider : available;
492
+ const winner = [...candidates].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b))[0] ?? ctx.model;
493
+ if (!winner) {
494
+ throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
495
+ }
496
+
497
+ const fallbackText = normalized ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.` : "";
498
+ return {
499
+ model: winner,
500
+ details: {
501
+ modelRef: modelRef(winner),
502
+ provider: winner.provider,
503
+ modelId: winner.id,
504
+ thinkingLevel,
505
+ autoSelected: true,
506
+ selectionReason: `Selected the cheapest available model${sameProvider.length > 0 ? " on the current provider" : ""}.${fallbackText}`,
507
+ },
508
+ };
509
+ }
510
+
326
511
  function resolveToolPath(cwd: string, rawPath: string): string {
327
512
  const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
328
513
  return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
@@ -489,9 +674,115 @@ function isAbortLikeError(error: unknown): boolean {
489
674
 
490
675
  export default function librarianExtension(pi: ExtensionAPI) {
491
676
  let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
677
+ let modelPreference: string | undefined;
678
+ let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
492
679
 
493
680
  pi.on("session_start", async () => {
494
- cachePreference = await readCachePreference();
681
+ const preferences = await readLibrarianPreferences();
682
+ cachePreference = preferences.cacheMode;
683
+ modelPreference = preferences.model;
684
+ thinkingPreference = preferences.thinkingLevel;
685
+ });
686
+
687
+ const currentPreferences = (): LibrarianPreferences => ({
688
+ cacheMode: cachePreference,
689
+ ...(modelPreference ? { model: modelPreference } : {}),
690
+ thinkingLevel: thinkingPreference,
691
+ });
692
+
693
+ const savePreferences = async (preferences: LibrarianPreferences): Promise<string | undefined> => {
694
+ cachePreference = preferences.cacheMode;
695
+ modelPreference = preferences.model;
696
+ thinkingPreference = preferences.thinkingLevel;
697
+ try {
698
+ await writeLibrarianPreferences(preferences);
699
+ return undefined;
700
+ } catch (error) {
701
+ const message = error instanceof Error ? error.message : String(error);
702
+ return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
703
+ }
704
+ };
705
+
706
+ pi.registerCommand("librarian-config", {
707
+ description: "Configure Librarian subagent model and thinking defaults",
708
+ getArgumentCompletions: (prefix) => {
709
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
710
+ if (parts.length <= 1) {
711
+ const commands = ["status", "model", "thinking", "clear"];
712
+ const query = parts[0]?.toLowerCase() ?? "";
713
+ return commands.filter((command) => command.startsWith(query)).map((value) => ({ value, label: value }));
714
+ }
715
+ if (parts[0]?.toLowerCase() === "thinking") {
716
+ const query = parts[1]?.toLowerCase() ?? "";
717
+ return [...THINKING_LEVELS, "auto"].filter((level) => level.startsWith(query)).map((value) => ({ value, label: value }));
718
+ }
719
+ return null;
720
+ },
721
+ handler: async (args, ctx) => {
722
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
723
+ const [command = "status", ...rest] = tokens;
724
+ const action = command.toLowerCase();
725
+
726
+ if (action === "status" || action === "show") {
727
+ notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
728
+ return;
729
+ }
730
+
731
+ if (action === "model") {
732
+ const value = rest.join(" ").trim();
733
+ if (!value) {
734
+ notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto|current>", "warning");
735
+ return;
736
+ }
737
+ const normalized = value.toLowerCase();
738
+ const parsedModel = parseModelPreference(value);
739
+ const next = normalized === "auto" || normalized === "clear" || normalized === "default"
740
+ ? { ...currentPreferences(), model: undefined }
741
+ : {
742
+ ...currentPreferences(),
743
+ model: normalized === "current" ? "current" : parsedModel.model,
744
+ thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
745
+ };
746
+ const warning = await savePreferences(next);
747
+ notifyCommand(ctx, `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
748
+ return;
749
+ }
750
+
751
+ if (action === "thinking" || action === "think" || action === "thinking-level") {
752
+ const value = rest[0]?.trim().toLowerCase();
753
+ if (!value) {
754
+ notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
755
+ return;
756
+ }
757
+ const thinkingLevel = value === "auto" || value === "clear" || value === "default"
758
+ ? DEFAULT_THINKING_LEVEL
759
+ : parseThinkingLevel(value);
760
+ if (!thinkingLevel) {
761
+ notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
762
+ return;
763
+ }
764
+ const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
765
+ notifyCommand(ctx, `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
766
+ return;
767
+ }
768
+
769
+ if (action === "clear" || action === "reset") {
770
+ const target = rest[0]?.trim().toLowerCase() || "all";
771
+ let next: LibrarianPreferences;
772
+ if (target === "all") next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
773
+ else if (target === "model") next = { ...currentPreferences(), model: undefined };
774
+ else if (target === "thinking" || target === "thinking-level") next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
775
+ else {
776
+ notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
777
+ return;
778
+ }
779
+ const warning = await savePreferences(next);
780
+ notifyCommand(ctx, `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
781
+ return;
782
+ }
783
+
784
+ notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto|current> | thinking <off|minimal|low|medium|high|xhigh|auto> | clear [all|model|thinking]", "warning");
785
+ },
495
786
  });
496
787
 
497
788
  pi.registerCommand("librarian-cache", {
@@ -560,12 +851,13 @@ export default function librarianExtension(pi: ExtensionAPI) {
560
851
  name: "librarian",
561
852
  label: "Librarian",
562
853
  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.",
854
+ "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
855
  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.",
856
+ "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 medium thinking.",
566
857
  promptGuidelines: [
567
858
  "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
568
859
  "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
860
+ "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
569
861
  ],
570
862
  parameters: LibrarianParams,
571
863
 
@@ -573,7 +865,6 @@ export default function librarianExtension(pi: ExtensionAPI) {
573
865
  const rawQuery = (params as { query?: unknown }).query;
574
866
  const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
575
867
  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
868
 
578
869
  const repos = asStringArray((params as { repos?: unknown }).repos);
579
870
  const owners = asStringArray((params as { owners?: unknown }).owners);
@@ -583,6 +874,12 @@ export default function librarianExtension(pi: ExtensionAPI) {
583
874
  MAX_SEARCH_RESULTS,
584
875
  DEFAULT_MAX_SEARCH_RESULTS,
585
876
  );
877
+ const explicitModel = parseModelPreference((params as { model?: unknown }).model);
878
+ const thinkingLevel =
879
+ parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
880
+ explicitModel.thinkingLevel ??
881
+ thinkingPreference;
882
+ const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
586
883
 
587
884
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
588
885
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -617,6 +914,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
617
914
  cleanupErrors: cleanup.errors,
618
915
  decisionReason: cacheDecision.reason,
619
916
  },
917
+ model: selectedModel.details,
620
918
  turns: 0,
621
919
  toolCalls: [],
622
920
  startedAt: Date.now(),
@@ -687,8 +985,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
687
985
  modelRegistry: ctx.modelRegistry,
688
986
  resourceLoader,
689
987
  sessionManager: SessionManager.inMemory(workspace),
690
- model: ctx.model,
691
- thinkingLevel: pi.getThinkingLevel(),
988
+ model: selectedModel.model,
989
+ thinkingLevel,
692
990
  tools: ["read", "bash"],
693
991
  });
694
992
 
@@ -807,6 +1105,10 @@ export default function librarianExtension(pi: ExtensionAPI) {
807
1105
  )}${cacheLabel}`;
808
1106
 
809
1107
  const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
1108
+ const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
1109
+ "dim",
1110
+ `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
1111
+ )}`;
810
1112
  const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
811
1113
  "dim",
812
1114
  `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
@@ -822,7 +1124,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
822
1124
  if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
823
1125
 
824
1126
  if (status === "running") {
825
- const parts = [header, workspaceLine, cacheLine];
1127
+ const parts = [header, workspaceLine, modelLine, cacheLine];
826
1128
  if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
827
1129
  parts.push("", theme.fg("muted", "Searching GitHub…"));
828
1130
  return new Text(parts.join("\n"), 0, 0);
@@ -830,7 +1132,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
830
1132
 
831
1133
  if (!expanded) {
832
1134
  const previewLines = answer.split("\n").slice(0, 18);
833
- const parts = [header, workspaceLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
1135
+ const parts = [header, workspaceLine, modelLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
834
1136
  if (answer.split("\n").length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
835
1137
  if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
836
1138
  return new Text(parts.join("\n"), 0, 0);
@@ -839,6 +1141,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
839
1141
  const container = new Container();
840
1142
  container.addChild(new Text(header, 0, 0));
841
1143
  container.addChild(new Text(workspaceLine, 0, 0));
1144
+ container.addChild(new Text(modelLine, 0, 0));
842
1145
  container.addChild(new Text(cacheLine, 0, 0));
843
1146
  if (details.cache.cleanupErrors.length) {
844
1147
  container.addChild(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.39",
3
+ "version": "0.1.42",
4
4
  "description": "A collection of pi extensions for context management, workflow audits, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, and model/provider helpers.",
5
5
  "keywords": [
6
6
  "pi-package",