@diegopetrucci/pi-extensions 0.1.73 → 0.1.76

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 (93) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +9 -4
  3. package/extensions/agent-workflow-audit/index.ts +1398 -1009
  4. package/extensions/agent-workflow-audit/package.json +1 -1
  5. package/extensions/annotate-git-diff/README.md +3 -1
  6. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  7. package/extensions/annotate-git-diff/git.ts +853 -767
  8. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  9. package/extensions/annotate-git-diff/index.ts +712 -671
  10. package/extensions/annotate-git-diff/package.json +1 -1
  11. package/extensions/annotate-git-diff/prompt.ts +45 -45
  12. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  13. package/extensions/annotate-git-diff/review-server.ts +337 -311
  14. package/extensions/annotate-git-diff/types.ts +132 -132
  15. package/extensions/annotate-git-diff/ui.ts +27 -27
  16. package/extensions/annotate-git-diff/watch.ts +74 -74
  17. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  18. package/extensions/annotate-git-diff/web/index.html +5 -6
  19. package/extensions/annotate-last-message/README.md +1 -1
  20. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  21. package/extensions/annotate-last-message/index.ts +210 -206
  22. package/extensions/annotate-last-message/package.json +1 -1
  23. package/extensions/annotate-last-message/prompt.ts +87 -66
  24. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  25. package/extensions/annotate-last-message/session.ts +94 -89
  26. package/extensions/annotate-last-message/theme.ts +199 -193
  27. package/extensions/annotate-last-message/types.ts +23 -23
  28. package/extensions/annotate-last-message/ui.ts +27 -25
  29. package/extensions/annotate-last-message/web/app.js +4 -7
  30. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  31. package/extensions/brrr/README.md +1 -1
  32. package/extensions/brrr/index.ts +213 -191
  33. package/extensions/brrr/package.json +1 -1
  34. package/extensions/claude-fast/README.md +5 -79
  35. package/extensions/claude-fast/index.ts +31 -289
  36. package/extensions/claude-fast/package.json +2 -3
  37. package/extensions/code-reviewer/README.md +1 -1
  38. package/extensions/code-reviewer/index.ts +1598 -1350
  39. package/extensions/code-reviewer/package.json +1 -1
  40. package/extensions/confirm-destructive/README.md +1 -1
  41. package/extensions/confirm-destructive/index.ts +55 -51
  42. package/extensions/confirm-destructive/package.json +1 -1
  43. package/extensions/context-cap/index.ts +312 -304
  44. package/extensions/context-cap/package.json +1 -1
  45. package/extensions/context-inspector/index.ts +1211 -1064
  46. package/extensions/context-inspector/package.json +1 -1
  47. package/extensions/contrarian/index.ts +1854 -1735
  48. package/extensions/contrarian/package.json +1 -1
  49. package/extensions/dirty-repo-guard/index.ts +41 -41
  50. package/extensions/dynamic-context-pruning/README.md +6 -38
  51. package/extensions/dynamic-context-pruning/index.ts +2124 -1980
  52. package/extensions/dynamic-context-pruning/package.json +1 -1
  53. package/extensions/fast/README.md +71 -21
  54. package/extensions/fast/index.ts +513 -313
  55. package/extensions/fast/package.json +1 -1
  56. package/extensions/git-footer/README.md +1 -1
  57. package/extensions/git-footer/index.ts +458 -411
  58. package/extensions/git-footer/package.json +2 -3
  59. package/extensions/gnosis/index.ts +180 -130
  60. package/extensions/gnosis/package.json +1 -1
  61. package/extensions/inline-bash/index.ts +88 -88
  62. package/extensions/inline-bash/package.json +1 -1
  63. package/extensions/librarian/index.ts +1669 -1419
  64. package/extensions/librarian/package.json +1 -1
  65. package/extensions/minimal-footer/index.ts +994 -1012
  66. package/extensions/minimal-footer/openai-usage.ts +195 -190
  67. package/extensions/notify/index.ts +222 -230
  68. package/extensions/notify/package.json +1 -1
  69. package/extensions/openai-fast/README.md +5 -73
  70. package/extensions/openai-fast/index.ts +32 -272
  71. package/extensions/openai-fast/package.json +2 -3
  72. package/extensions/oracle/index.ts +1731 -1625
  73. package/extensions/oracle/package.json +1 -1
  74. package/extensions/permission-gate/index.ts +658 -608
  75. package/extensions/permission-gate/package.json +1 -1
  76. package/extensions/permission-gate/powershell-safety.ts +840 -793
  77. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  78. package/extensions/project-mcp-json/README.md +199 -0
  79. package/extensions/project-mcp-json/config.ts +564 -0
  80. package/extensions/project-mcp-json/index.ts +119 -0
  81. package/extensions/project-mcp-json/package.json +38 -0
  82. package/extensions/quiet-tools/index.ts +282 -264
  83. package/extensions/review/README.md +2 -2
  84. package/extensions/review/index.ts +1745 -1872
  85. package/extensions/review/package.json +1 -1
  86. package/extensions/todo/index.ts +284 -265
  87. package/extensions/triage-comments/README.md +2 -2
  88. package/extensions/triage-comments/index.ts +2294 -1882
  89. package/extensions/triage-comments/package.json +1 -1
  90. package/package.json +6 -3
  91. package/assets/social-preview.png +0 -0
  92. package/extensions/claude-fast/claude-fast.example.json +0 -4
  93. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -5,15 +5,19 @@ import * as fs from "node:fs/promises";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
 
8
- import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
8
+ import type {
9
+ ExtensionAPI,
10
+ ExtensionContext,
11
+ ExtensionFactory,
12
+ } from "@earendil-works/pi-coding-agent";
9
13
  import type { Usage } from "@earendil-works/pi-ai";
10
14
  import {
11
- DefaultResourceLoader,
12
- SessionManager,
13
- SettingsManager,
14
- createAgentSession,
15
- getAgentDir,
16
- getMarkdownTheme,
15
+ DefaultResourceLoader,
16
+ SessionManager,
17
+ SettingsManager,
18
+ createAgentSession,
19
+ getAgentDir,
20
+ getMarkdownTheme,
17
21
  } from "@earendil-works/pi-coding-agent";
18
22
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
19
23
  import { Type } from "typebox";
@@ -41,42 +45,46 @@ type CacheMode = "disabled" | "enabled";
41
45
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
42
46
  type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
43
47
  type PiModel = {
44
- provider: string;
45
- id: string;
46
- reasoning?: boolean;
47
- thinkingLevelMap?: ThinkingLevelMap;
48
+ provider: string;
49
+ id: string;
50
+ reasoning?: boolean;
51
+ thinkingLevelMap?: ThinkingLevelMap;
48
52
  };
49
53
 
50
54
  type ModelSelectionContext = {
51
- model?: any;
52
- modelRegistry: { getAvailable(): any[] | Promise<any[]> };
53
- scopedModels?: ReadonlyArray<{ model: any; thinkingLevel?: ThinkingLevel }>;
55
+ model?: any;
56
+ modelRegistry: { getAvailable(): any[] | Promise<any[]> };
57
+ scopedModels?: ReadonlyArray<{ model: any; thinkingLevel?: ThinkingLevel }>;
54
58
  };
55
59
 
56
60
  function modelKey(model: any): string {
57
- return `${model.provider}/${model.id}`.toLowerCase();
61
+ return `${model.provider}/${model.id}`.toLowerCase();
58
62
  }
59
63
 
60
64
  function hasModelScope(ctx: ModelSelectionContext): boolean {
61
- return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
65
+ return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
62
66
  }
63
67
 
64
68
  function isModelInScope(ctx: ModelSelectionContext, model: any): boolean {
65
- if (!hasModelScope(ctx)) return true;
66
- const key = modelKey(model);
67
- return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
69
+ if (!hasModelScope(ctx)) return true;
70
+ const key = modelKey(model);
71
+ return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
68
72
  }
69
73
 
70
74
  async function getAutoSelectableModels(ctx: ModelSelectionContext): Promise<any[]> {
71
- const available = await ctx.modelRegistry.getAvailable();
72
- return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
75
+ const available = await ctx.modelRegistry.getAvailable();
76
+ return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
73
77
  }
74
78
 
75
79
  type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
76
80
 
77
- function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
78
- const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
79
- return modelRuntime ? { modelRuntime } : {};
81
+ function getModelRuntimeOption(ctx: {
82
+ modelRegistry?: unknown;
83
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
84
+ const modelRuntime = (
85
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
86
+ )?.runtime;
87
+ return modelRuntime ? { modelRuntime } : {};
80
88
  }
81
89
 
82
90
  const DEFAULT_CACHE_MODE: CacheMode = "disabled";
@@ -84,649 +92,702 @@ const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
84
92
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
85
93
 
86
94
  const PREFERRED_FAST_MODEL_PATTERNS = [
87
- /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
88
- /\bgpt[-_. ]?5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
89
- /\bgpt[-_. ]?4(?:\.1|o)?(?:[-_. ].*)?\b(?:mini|nano)\b/,
90
- /\bgemini\b.*\b(?:flash|flash-lite|lite)\b/,
91
- /\bclaude\b.*\bhaiku\b/,
92
- /\b(?:mistral|codestral)\b.*\b(?:small|mini|lite)\b/,
95
+ /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
96
+ /\bgpt[-_. ]?5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
97
+ /\bgpt[-_. ]?4(?:\.1|o)?(?:[-_. ].*)?\b(?:mini|nano)\b/,
98
+ /\bgemini\b.*\b(?:flash|flash-lite|lite)\b/,
99
+ /\bclaude\b.*\bhaiku\b/,
100
+ /\b(?:mistral|codestral)\b.*\b(?:small|mini|lite)\b/,
93
101
  ];
94
102
 
95
103
  const HEAVY_MODEL_PATTERN = /\b(?:opus|pro|ultra|max)\b/;
96
104
 
97
105
  type ToolCall = {
98
- id: string;
99
- name: string;
100
- args: unknown;
101
- startedAt: number;
102
- endedAt?: number;
103
- isError?: boolean;
106
+ id: string;
107
+ name: string;
108
+ args: unknown;
109
+ startedAt: number;
110
+ endedAt?: number;
111
+ isError?: boolean;
104
112
  };
105
113
 
106
114
  type CacheDetails = {
107
- mode: CacheMode;
108
- root: string;
109
- ttlDays: number;
110
- cleanupDeleted: number;
111
- cleanupErrors: string[];
112
- decisionReason: string;
115
+ mode: CacheMode;
116
+ root: string;
117
+ ttlDays: number;
118
+ cleanupDeleted: number;
119
+ cleanupErrors: string[];
120
+ decisionReason: string;
113
121
  };
114
122
 
115
123
  type LibrarianModelDetails = {
116
- modelRef: string;
117
- modelId: string;
118
- provider: string;
119
- thinkingLevel: ThinkingLevel;
120
- autoSelected: boolean;
121
- selectionReason: string;
124
+ modelRef: string;
125
+ modelId: string;
126
+ provider: string;
127
+ thinkingLevel: ThinkingLevel;
128
+ autoSelected: boolean;
129
+ selectionReason: string;
122
130
  };
123
131
 
124
132
  type LibrarianDetails = {
125
- status: LibrarianStatus;
126
- workspace: string;
127
- cache: CacheDetails;
128
- model: LibrarianModelDetails;
129
- turns: number;
130
- toolCalls: ToolCall[];
131
- startedAt: number;
132
- endedAt?: number;
133
- error?: string;
133
+ status: LibrarianStatus;
134
+ workspace: string;
135
+ cache: CacheDetails;
136
+ model: LibrarianModelDetails;
137
+ turns: number;
138
+ toolCalls: ToolCall[];
139
+ startedAt: number;
140
+ endedAt?: number;
141
+ error?: string;
134
142
  };
135
143
 
136
144
  const LibrarianParams = Type.Object({
137
- query: Type.String({
138
- description: [
139
- "Describe exactly what to find in GitHub code.",
140
- "Include known context in the query when you have it (symbols/behavior, repo or owner hints, refs/branches, paths, and desired output).",
141
- "Do not guess unknown details; if scope is uncertain, say that explicitly and let Librarian discover it.",
142
- "Librarian returns concise path-first findings with line-ranged evidence from GitHub files.",
143
- ].join("\n"),
144
- }),
145
- repos: Type.Optional(
146
- Type.Array(Type.String({ description: "Optional owner/repo filters (e.g. octocat/hello-world)" }), {
147
- description: "Optional explicit repository scope.",
148
- maxItems: 30,
149
- }),
150
- ),
151
- owners: Type.Optional(
152
- Type.Array(Type.String({ description: "Optional owner/org filters" }), {
153
- description: "Optional owner/org scope.",
154
- maxItems: 30,
155
- }),
156
- ),
157
- maxSearchResults: Type.Optional(
158
- Type.Number({
159
- description: `Maximum GitHub search hits per query (1-${MAX_SEARCH_RESULTS}, default ${DEFAULT_MAX_SEARCH_RESULTS})`,
160
- minimum: 1,
161
- maximum: MAX_SEARCH_RESULTS,
162
- default: DEFAULT_MAX_SEARCH_RESULTS,
163
- }),
164
- ),
165
- model: Type.Optional(
166
- Type.String({
167
- description: "Optional model override for the internal librarian subagent. Use provider/model or auto.",
168
- }),
169
- ),
170
- thinkingLevel: Type.Optional(
171
- Type.String({
172
- description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
173
- }),
174
- ),
145
+ query: Type.String({
146
+ description: [
147
+ "Describe exactly what to find in GitHub code.",
148
+ "Include known context in the query when you have it (symbols/behavior, repo or owner hints, refs/branches, paths, and desired output).",
149
+ "Do not guess unknown details; if scope is uncertain, say that explicitly and let Librarian discover it.",
150
+ "Librarian returns concise path-first findings with line-ranged evidence from GitHub files.",
151
+ ].join("\n"),
152
+ }),
153
+ repos: Type.Optional(
154
+ Type.Array(
155
+ Type.String({ description: "Optional owner/repo filters (e.g. octocat/hello-world)" }),
156
+ {
157
+ description: "Optional explicit repository scope.",
158
+ maxItems: 30,
159
+ },
160
+ ),
161
+ ),
162
+ owners: Type.Optional(
163
+ Type.Array(Type.String({ description: "Optional owner/org filters" }), {
164
+ description: "Optional owner/org scope.",
165
+ maxItems: 30,
166
+ }),
167
+ ),
168
+ maxSearchResults: Type.Optional(
169
+ Type.Number({
170
+ description: `Maximum GitHub search hits per query (1-${MAX_SEARCH_RESULTS}, default ${DEFAULT_MAX_SEARCH_RESULTS})`,
171
+ minimum: 1,
172
+ maximum: MAX_SEARCH_RESULTS,
173
+ default: DEFAULT_MAX_SEARCH_RESULTS,
174
+ }),
175
+ ),
176
+ model: Type.Optional(
177
+ Type.String({
178
+ description:
179
+ "Optional model override for the internal librarian subagent. Use provider/model or auto.",
180
+ }),
181
+ ),
182
+ thinkingLevel: Type.Optional(
183
+ Type.String({
184
+ description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
185
+ }),
186
+ ),
175
187
  });
176
188
 
177
189
  function asStringArray(value: unknown, maxItems = 30): string[] {
178
- if (!Array.isArray(value)) return [];
179
- const out: string[] = [];
180
- for (const item of value) {
181
- if (typeof item !== "string") continue;
182
- const trimmed = item.trim();
183
- if (!trimmed) continue;
184
- out.push(trimmed);
185
- if (out.length >= maxItems) break;
186
- }
187
- return out;
190
+ if (!Array.isArray(value)) return [];
191
+ const out: string[] = [];
192
+ for (const item of value) {
193
+ if (typeof item !== "string") continue;
194
+ const trimmed = item.trim();
195
+ if (!trimmed) continue;
196
+ out.push(trimmed);
197
+ if (out.length >= maxItems) break;
198
+ }
199
+ return out;
188
200
  }
189
201
 
190
202
  function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
191
- const parsed = typeof value === "number" ? value : Number(value);
192
- if (!Number.isFinite(parsed)) return fallback;
193
- return Math.min(max, Math.max(min, Math.floor(parsed)));
203
+ const parsed = typeof value === "number" ? value : Number(value);
204
+ if (!Number.isFinite(parsed)) return fallback;
205
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
194
206
  }
195
207
 
196
208
  function shorten(text: string, max: number): string {
197
- const oneLine = text.replace(/\s+/g, " ").trim();
198
- if (oneLine.length <= max) return oneLine;
199
- return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
209
+ const oneLine = text.replace(/\s+/g, " ").trim();
210
+ if (oneLine.length <= max) return oneLine;
211
+ return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
200
212
  }
201
213
 
202
214
  function getUserCacheReposRoot(): string {
203
- if (process.env.PI_LIBRARIAN_CACHE_ROOT?.trim()) {
204
- return path.resolve(expandHome(process.env.PI_LIBRARIAN_CACHE_ROOT.trim()));
205
- }
215
+ if (process.env.PI_LIBRARIAN_CACHE_ROOT?.trim()) {
216
+ return path.resolve(expandHome(process.env.PI_LIBRARIAN_CACHE_ROOT.trim()));
217
+ }
206
218
 
207
- if (process.platform === "darwin") {
208
- return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
209
- }
219
+ if (process.platform === "darwin") {
220
+ return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
221
+ }
210
222
 
211
- if (process.platform === "win32") {
212
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
213
- return path.join(localAppData, "pi-librarian", "repos");
214
- }
223
+ if (process.platform === "win32") {
224
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
225
+ return path.join(localAppData, "pi-librarian", "repos");
226
+ }
215
227
 
216
- const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
217
- return path.join(xdgCacheHome, "pi-librarian", "repos");
228
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
229
+ return path.join(xdgCacheHome, "pi-librarian", "repos");
218
230
  }
219
231
 
220
232
  function expandHome(value: string): string {
221
- if (value === "~") return os.homedir();
222
- if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
223
- if (path.sep === "/" && value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
224
- return value;
233
+ if (value === "~") return os.homedir();
234
+ if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
235
+ if (path.sep === "/" && value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
236
+ return value;
225
237
  }
226
238
 
227
239
  async function pathExists(filePath: string): Promise<boolean> {
228
- try {
229
- await fs.access(filePath);
230
- return true;
231
- } catch {
232
- return false;
233
- }
240
+ try {
241
+ await fs.access(filePath);
242
+ return true;
243
+ } catch {
244
+ return false;
245
+ }
234
246
  }
235
247
 
236
248
  async function safeReadDir(dir: string): Promise<Dirent[]> {
237
- try {
238
- return await fs.readdir(dir, { withFileTypes: true });
239
- } catch (error) {
240
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
241
- throw error;
242
- }
249
+ try {
250
+ return await fs.readdir(dir, { withFileTypes: true });
251
+ } catch (error) {
252
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
253
+ throw error;
254
+ }
243
255
  }
244
256
 
245
257
  function isInside(parent: string, child: string): boolean {
246
- const parentResolved = path.resolve(parent);
247
- const childResolved = path.resolve(child);
248
- return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
258
+ const parentResolved = path.resolve(parent);
259
+ const childResolved = path.resolve(child);
260
+ return (
261
+ childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
262
+ );
249
263
  }
250
264
 
251
265
  function parseLastUsedAt(value: unknown): number | undefined {
252
- if (typeof value === "number" && Number.isFinite(value)) return value;
253
- if (typeof value === "string") {
254
- const numeric = Number(value);
255
- if (Number.isFinite(numeric)) return numeric;
256
- const parsed = Date.parse(value);
257
- if (Number.isFinite(parsed)) return parsed;
258
- }
259
- return undefined;
266
+ if (typeof value === "number" && Number.isFinite(value)) return value;
267
+ if (typeof value === "string") {
268
+ const numeric = Number(value);
269
+ if (Number.isFinite(numeric)) return numeric;
270
+ const parsed = Date.parse(value);
271
+ if (Number.isFinite(parsed)) return parsed;
272
+ }
273
+ return undefined;
260
274
  }
261
275
 
262
276
  async function getRepoLastUsedAt(repoDir: string): Promise<number | undefined> {
263
- const metadataPath = path.join(repoDir, CACHE_METADATA_FILE);
264
- try {
265
- const raw = await fs.readFile(metadataPath, "utf8");
266
- const parsed = JSON.parse(raw) as { lastUsedAt?: unknown; updatedAt?: unknown; createdAt?: unknown };
267
- const fromMetadata =
268
- parseLastUsedAt(parsed.lastUsedAt) ?? parseLastUsedAt(parsed.updatedAt) ?? parseLastUsedAt(parsed.createdAt);
269
- if (fromMetadata !== undefined) return fromMetadata;
270
- } catch {
271
- // Fall through to marker mtime for older managed caches.
272
- }
273
-
274
- const markerPath = path.join(repoDir, CACHE_MARKER_FILE);
275
- try {
276
- return (await fs.stat(markerPath)).mtimeMs;
277
- } catch {
278
- return undefined;
279
- }
277
+ const metadataPath = path.join(repoDir, CACHE_METADATA_FILE);
278
+ try {
279
+ const raw = await fs.readFile(metadataPath, "utf8");
280
+ const parsed = JSON.parse(raw) as {
281
+ lastUsedAt?: unknown;
282
+ updatedAt?: unknown;
283
+ createdAt?: unknown;
284
+ };
285
+ const fromMetadata =
286
+ parseLastUsedAt(parsed.lastUsedAt) ??
287
+ parseLastUsedAt(parsed.updatedAt) ??
288
+ parseLastUsedAt(parsed.createdAt);
289
+ if (fromMetadata !== undefined) return fromMetadata;
290
+ } catch {
291
+ // Fall through to marker mtime for older managed caches.
292
+ }
293
+
294
+ const markerPath = path.join(repoDir, CACHE_MARKER_FILE);
295
+ try {
296
+ return (await fs.stat(markerPath)).mtimeMs;
297
+ } catch {
298
+ return undefined;
299
+ }
280
300
  }
281
301
 
282
302
  async function isManagedRepoCache(repoDir: string): Promise<boolean> {
283
- return (await pathExists(path.join(repoDir, ".git"))) &&
284
- ((await pathExists(path.join(repoDir, CACHE_METADATA_FILE))) ||
285
- (await pathExists(path.join(repoDir, CACHE_MARKER_FILE))));
303
+ return (
304
+ (await pathExists(path.join(repoDir, ".git"))) &&
305
+ ((await pathExists(path.join(repoDir, CACHE_METADATA_FILE))) ||
306
+ (await pathExists(path.join(repoDir, CACHE_MARKER_FILE))))
307
+ );
286
308
  }
287
309
 
288
310
  type CleanupLockOwner = {
289
- pid: number;
290
- hostname: string;
291
- token: string;
292
- acquiredAt: string;
311
+ pid: number;
312
+ hostname: string;
313
+ token: string;
314
+ acquiredAt: string;
293
315
  };
294
316
 
295
317
  type CleanupLock = {
296
- handle: FileHandle;
297
- path: string;
298
- owner: CleanupLockOwner;
299
- identity: Pick<Stats, "dev" | "ino">;
318
+ handle: FileHandle;
319
+ path: string;
320
+ owner: CleanupLockOwner;
321
+ identity: Pick<Stats, "dev" | "ino">;
300
322
  };
301
323
 
302
- function sameFileIdentity(left: Pick<Stats, "dev" | "ino">, right: Pick<Stats, "dev" | "ino">): boolean {
303
- return left.dev === right.dev && left.ino === right.ino;
324
+ function sameFileIdentity(
325
+ left: Pick<Stats, "dev" | "ino">,
326
+ right: Pick<Stats, "dev" | "ino">,
327
+ ): boolean {
328
+ return left.dev === right.dev && left.ino === right.ino;
304
329
  }
305
330
 
306
331
  function parseCleanupLockOwner(value: string): CleanupLockOwner | undefined {
307
- try {
308
- const owner = JSON.parse(value) as Partial<CleanupLockOwner>;
309
- if (!Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0) return undefined;
310
- if (typeof owner.hostname !== "string" || !owner.hostname) return undefined;
311
- if (typeof owner.token !== "string" || !owner.token) return undefined;
312
- if (typeof owner.acquiredAt !== "string" || !owner.acquiredAt) return undefined;
313
- if (!Number.isFinite(Date.parse(owner.acquiredAt))) return undefined;
314
- return owner as CleanupLockOwner;
315
- } catch {
316
- return undefined;
317
- }
332
+ try {
333
+ const owner = JSON.parse(value) as Partial<CleanupLockOwner>;
334
+ if (!Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0) return undefined;
335
+ if (typeof owner.hostname !== "string" || !owner.hostname) return undefined;
336
+ if (typeof owner.token !== "string" || !owner.token) return undefined;
337
+ if (typeof owner.acquiredAt !== "string" || !owner.acquiredAt) return undefined;
338
+ if (!Number.isFinite(Date.parse(owner.acquiredAt))) return undefined;
339
+ return owner as CleanupLockOwner;
340
+ } catch {
341
+ return undefined;
342
+ }
318
343
  }
319
344
 
320
345
  function isCleanupLockLeaseExpired(owner: CleanupLockOwner | undefined, now = Date.now()): boolean {
321
- if (!owner) return true;
322
- const acquiredAt = Date.parse(owner.acquiredAt);
323
- if (!Number.isFinite(acquiredAt)) return true;
324
- return Math.max(0, now - acquiredAt) > CLEANUP_LOCK_MAX_LEASE_MS;
346
+ if (!owner) return true;
347
+ const acquiredAt = Date.parse(owner.acquiredAt);
348
+ if (!Number.isFinite(acquiredAt)) return true;
349
+ return Math.max(0, now - acquiredAt) > CLEANUP_LOCK_MAX_LEASE_MS;
325
350
  }
326
351
 
327
352
  function isRecordedOwnerAlive(owner: CleanupLockOwner | undefined): boolean {
328
- if (!owner || owner.hostname !== os.hostname()) return false;
329
- try {
330
- process.kill(owner.pid, 0);
331
- return true;
332
- } catch (error) {
333
- return (error as NodeJS.ErrnoException).code === "EPERM";
334
- }
353
+ if (!owner || owner.hostname !== os.hostname()) return false;
354
+ try {
355
+ process.kill(owner.pid, 0);
356
+ return true;
357
+ } catch (error) {
358
+ return (error as NodeJS.ErrnoException).code === "EPERM";
359
+ }
335
360
  }
336
361
 
337
362
  async function restoreMovedLock(movedPath: string, lockPath: string): Promise<void> {
338
- try {
339
- // link() restores only when lockPath is still absent; unlike rename(), it cannot overwrite a newer owner.
340
- await fs.link(movedPath, lockPath);
341
- await fs.rm(movedPath, { force: true });
342
- } catch (error) {
343
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
344
- // Preserve both owners rather than deleting the moved lock when another owner won the canonical path.
345
- }
346
- }
347
-
348
- async function removeLockPathIfOwned(lockPath: string, identity: Pick<Stats, "dev" | "ino">): Promise<void> {
349
- const currentStat = await fs.stat(lockPath).catch(() => undefined);
350
- if (!currentStat || !sameFileIdentity(identity, currentStat)) return;
351
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
352
- }
353
-
354
- async function getCleanupLockPathIdentity(lockPath: string): Promise<Pick<Stats, "dev" | "ino"> | undefined> {
355
- const canonicalPath = await fs.realpath(lockPath).catch((error: NodeJS.ErrnoException) => {
356
- if (error.code === "ENOENT") return undefined;
357
- throw error;
358
- });
359
- if (!canonicalPath) return undefined;
360
- const currentStat = await fs.stat(canonicalPath).catch((error: NodeJS.ErrnoException) => {
361
- if (error.code === "ENOENT") return undefined;
362
- throw error;
363
- });
364
- return currentStat ? { dev: currentStat.dev, ino: currentStat.ino } : undefined;
365
- }
366
-
367
- async function cleanupLockOwnershipError(lock: CleanupLock, stage: string): Promise<string | undefined> {
368
- const currentIdentity = await getCleanupLockPathIdentity(lock.path);
369
- if (currentIdentity && sameFileIdentity(lock.identity, currentIdentity)) return undefined;
370
- return `cleanup lock ownership lost before ${stage}; stopping cleanup.`;
363
+ try {
364
+ // link() restores only when lockPath is still absent; unlike rename(), it cannot overwrite a newer owner.
365
+ await fs.link(movedPath, lockPath);
366
+ await fs.rm(movedPath, { force: true });
367
+ } catch (error) {
368
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
369
+ // Preserve both owners rather than deleting the moved lock when another owner won the canonical path.
370
+ }
371
+ }
372
+
373
+ async function removeLockPathIfOwned(
374
+ lockPath: string,
375
+ identity: Pick<Stats, "dev" | "ino">,
376
+ ): Promise<void> {
377
+ const currentStat = await fs.stat(lockPath).catch(() => undefined);
378
+ if (!currentStat || !sameFileIdentity(identity, currentStat)) return;
379
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
380
+ }
381
+
382
+ async function getCleanupLockPathIdentity(
383
+ lockPath: string,
384
+ ): Promise<Pick<Stats, "dev" | "ino"> | undefined> {
385
+ const canonicalPath = await fs.realpath(lockPath).catch((error: NodeJS.ErrnoException) => {
386
+ if (error.code === "ENOENT") return undefined;
387
+ throw error;
388
+ });
389
+ if (!canonicalPath) return undefined;
390
+ const currentStat = await fs.stat(canonicalPath).catch((error: NodeJS.ErrnoException) => {
391
+ if (error.code === "ENOENT") return undefined;
392
+ throw error;
393
+ });
394
+ return currentStat ? { dev: currentStat.dev, ino: currentStat.ino } : undefined;
395
+ }
396
+
397
+ async function cleanupLockOwnershipError(
398
+ lock: CleanupLock,
399
+ stage: string,
400
+ ): Promise<string | undefined> {
401
+ const currentIdentity = await getCleanupLockPathIdentity(lock.path);
402
+ if (currentIdentity && sameFileIdentity(lock.identity, currentIdentity)) return undefined;
403
+ return `cleanup lock ownership lost before ${stage}; stopping cleanup.`;
371
404
  }
372
405
 
373
406
  async function tryAcquireCleanupLock(root: string): Promise<CleanupLock | undefined> {
374
- const lockPath = path.join(root, CLEANUP_LOCK_FILE);
375
- for (let attempt = 0; attempt < 3; attempt += 1) {
376
- const owner: CleanupLockOwner = {
377
- pid: process.pid,
378
- hostname: os.hostname(),
379
- token: randomUUID(),
380
- acquiredAt: new Date().toISOString(),
381
- };
382
- try {
383
- const handle = await fs.open(lockPath, "wx");
384
- const identity = await handle.stat();
385
- try {
386
- await handle.writeFile(JSON.stringify(owner), "utf8");
387
- await handle.sync();
388
- return { handle, path: lockPath, owner, identity };
389
- } catch (error) {
390
- await handle.close().catch(() => undefined);
391
- await removeLockPathIfOwned(lockPath, identity);
392
- throw error;
393
- }
394
- } catch (error) {
395
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
396
- }
397
-
398
- let existing: FileHandle | undefined;
399
- try {
400
- existing = await fs.open(lockPath, "r");
401
- } catch (error) {
402
- if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
403
- throw error;
404
- }
405
- try {
406
- const stat = await existing.stat();
407
- const recordedOwner = parseCleanupLockOwner(await existing.readFile("utf8"));
408
- const now = Date.now();
409
- const ageMs = Math.max(0, now - stat.mtimeMs);
410
- if (!recordedOwner) {
411
- if (ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
412
- } else {
413
- const leaseExpired = isCleanupLockLeaseExpired(recordedOwner, now);
414
- if (isRecordedOwnerAlive(recordedOwner) && !leaseExpired) return undefined;
415
- if (!leaseExpired && ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
416
- }
417
-
418
- const stalePath = path.join(root, `${CLEANUP_LOCK_FILE}.stale-${process.pid}-${randomUUID()}`);
419
- try {
420
- await fs.rename(lockPath, stalePath);
421
- } catch (renameError) {
422
- if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
423
- throw renameError;
424
- }
425
- const movedStat = await fs.stat(stalePath).catch(() => undefined);
426
- if (!movedStat || !sameFileIdentity(stat, movedStat)) {
427
- await restoreMovedLock(stalePath, lockPath);
428
- return undefined;
429
- }
430
- await fs.rm(stalePath, { force: true });
431
- } finally {
432
- await existing.close().catch(() => undefined);
433
- }
434
- }
435
- return undefined;
407
+ const lockPath = path.join(root, CLEANUP_LOCK_FILE);
408
+ for (let attempt = 0; attempt < 3; attempt += 1) {
409
+ const owner: CleanupLockOwner = {
410
+ pid: process.pid,
411
+ hostname: os.hostname(),
412
+ token: randomUUID(),
413
+ acquiredAt: new Date().toISOString(),
414
+ };
415
+ try {
416
+ const handle = await fs.open(lockPath, "wx");
417
+ const identity = await handle.stat();
418
+ try {
419
+ await handle.writeFile(JSON.stringify(owner), "utf8");
420
+ await handle.sync();
421
+ return { handle, path: lockPath, owner, identity };
422
+ } catch (error) {
423
+ await handle.close().catch(() => undefined);
424
+ await removeLockPathIfOwned(lockPath, identity);
425
+ throw error;
426
+ }
427
+ } catch (error) {
428
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
429
+ }
430
+
431
+ let existing: FileHandle | undefined;
432
+ try {
433
+ existing = await fs.open(lockPath, "r");
434
+ } catch (error) {
435
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
436
+ throw error;
437
+ }
438
+ try {
439
+ const stat = await existing.stat();
440
+ const recordedOwner = parseCleanupLockOwner(await existing.readFile("utf8"));
441
+ const now = Date.now();
442
+ const ageMs = Math.max(0, now - stat.mtimeMs);
443
+ if (!recordedOwner) {
444
+ if (ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
445
+ } else {
446
+ const leaseExpired = isCleanupLockLeaseExpired(recordedOwner, now);
447
+ if (isRecordedOwnerAlive(recordedOwner) && !leaseExpired) return undefined;
448
+ if (!leaseExpired && ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
449
+ }
450
+
451
+ const stalePath = path.join(
452
+ root,
453
+ `${CLEANUP_LOCK_FILE}.stale-${process.pid}-${randomUUID()}`,
454
+ );
455
+ try {
456
+ await fs.rename(lockPath, stalePath);
457
+ } catch (renameError) {
458
+ if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
459
+ throw renameError;
460
+ }
461
+ const movedStat = await fs.stat(stalePath).catch(() => undefined);
462
+ if (!movedStat || !sameFileIdentity(stat, movedStat)) {
463
+ await restoreMovedLock(stalePath, lockPath);
464
+ return undefined;
465
+ }
466
+ await fs.rm(stalePath, { force: true });
467
+ } finally {
468
+ await existing.close().catch(() => undefined);
469
+ }
470
+ }
471
+ return undefined;
436
472
  }
437
473
 
438
474
  async function heartbeatCleanupLock(lock: CleanupLock): Promise<void> {
439
- const now = new Date();
440
- await lock.handle.utimes(now, now);
475
+ const now = new Date();
476
+ await lock.handle.utimes(now, now);
441
477
  }
442
478
 
443
479
  async function releaseCleanupLock(lock: CleanupLock): Promise<void> {
444
- await lock.handle.close().catch(() => undefined);
445
- const currentStat = await fs.stat(lock.path).catch(() => undefined);
446
- if (!currentStat || !sameFileIdentity(lock.identity, currentStat)) return;
447
- const releasePath = `${lock.path}.release-${process.pid}-${lock.owner.token}`;
448
- try {
449
- await fs.rename(lock.path, releasePath);
450
- } catch (error) {
451
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
452
- throw error;
453
- }
454
- const movedStat = await fs.stat(releasePath).catch(() => undefined);
455
- if (!movedStat || !sameFileIdentity(lock.identity, movedStat)) {
456
- await restoreMovedLock(releasePath, lock.path);
457
- return;
458
- }
459
- await fs.rm(releasePath, { force: true });
460
- }
461
-
462
- async function cleanupExpiredRepos(root: string, now: number, lock: CleanupLock): Promise<{ deleted: number; errors: string[] }> {
463
- const errors: string[] = [];
464
- let deleted = 0;
465
- const ownershipBeforeScan = await cleanupLockOwnershipError(lock, "cache scan");
466
- if (ownershipBeforeScan) return { deleted, errors: [ownershipBeforeScan] };
467
-
468
- const hosts = await safeReadDir(root);
469
- for (const host of hosts) {
470
- if (!host.isDirectory()) continue;
471
- const hostDir = path.join(root, host.name);
472
- const owners = await safeReadDir(hostDir);
473
- for (const owner of owners) {
474
- if (!owner.isDirectory()) continue;
475
- const ownerDir = path.join(hostDir, owner.name);
476
- const repos = await safeReadDir(ownerDir);
477
- for (const repo of repos) {
478
- if (!repo.isDirectory()) continue;
479
- const repoDir = path.join(ownerDir, repo.name);
480
- if (!isInside(root, repoDir)) continue;
481
-
482
- try {
483
- if (!(await isManagedRepoCache(repoDir))) continue;
484
- const lastUsedAt = await getRepoLastUsedAt(repoDir);
485
- if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
486
- const ownershipBeforeDelete = await cleanupLockOwnershipError(lock, `deleting ${repoDir}`);
487
- if (ownershipBeforeDelete) return { deleted, errors: [...errors, ownershipBeforeDelete] };
488
- await fs.rm(repoDir, { recursive: true, force: true });
489
- deleted += 1;
490
- } catch (error) {
491
- const message = error instanceof Error ? error.message : String(error);
492
- errors.push(`${repoDir}: ${message}`);
493
- }
494
- }
495
- }
496
- }
497
-
498
- return { deleted, errors };
499
- }
500
-
501
- async function cleanupExpiredCache(cacheRoot: string): Promise<{ deleted: number; errors: string[] }> {
502
- const now = Date.now();
503
- const root = path.resolve(cacheRoot);
504
- let lock: CleanupLock | undefined;
505
- let heartbeat: NodeJS.Timeout | undefined;
506
-
507
- try {
508
- lock = await tryAcquireCleanupLock(root);
509
- if (!lock) return { deleted: 0, errors: [] };
510
- heartbeat = setInterval(() => {
511
- if (lock) void heartbeatCleanupLock(lock).catch(() => undefined);
512
- }, CLEANUP_LOCK_HEARTBEAT_MS);
513
- heartbeat.unref?.();
514
- return await cleanupExpiredRepos(root, now, lock);
515
- } finally {
516
- if (heartbeat) clearInterval(heartbeat);
517
- if (lock) await releaseCleanupLock(lock).catch(() => undefined);
518
- }
480
+ await lock.handle.close().catch(() => undefined);
481
+ const currentStat = await fs.stat(lock.path).catch(() => undefined);
482
+ if (!currentStat || !sameFileIdentity(lock.identity, currentStat)) return;
483
+ const releasePath = `${lock.path}.release-${process.pid}-${lock.owner.token}`;
484
+ try {
485
+ await fs.rename(lock.path, releasePath);
486
+ } catch (error) {
487
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
488
+ throw error;
489
+ }
490
+ const movedStat = await fs.stat(releasePath).catch(() => undefined);
491
+ if (!movedStat || !sameFileIdentity(lock.identity, movedStat)) {
492
+ await restoreMovedLock(releasePath, lock.path);
493
+ return;
494
+ }
495
+ await fs.rm(releasePath, { force: true });
496
+ }
497
+
498
+ async function cleanupExpiredRepos(
499
+ root: string,
500
+ now: number,
501
+ lock: CleanupLock,
502
+ ): Promise<{ deleted: number; errors: string[] }> {
503
+ const errors: string[] = [];
504
+ let deleted = 0;
505
+ const ownershipBeforeScan = await cleanupLockOwnershipError(lock, "cache scan");
506
+ if (ownershipBeforeScan) return { deleted, errors: [ownershipBeforeScan] };
507
+
508
+ const hosts = await safeReadDir(root);
509
+ for (const host of hosts) {
510
+ if (!host.isDirectory()) continue;
511
+ const hostDir = path.join(root, host.name);
512
+ const owners = await safeReadDir(hostDir);
513
+ for (const owner of owners) {
514
+ if (!owner.isDirectory()) continue;
515
+ const ownerDir = path.join(hostDir, owner.name);
516
+ const repos = await safeReadDir(ownerDir);
517
+ for (const repo of repos) {
518
+ if (!repo.isDirectory()) continue;
519
+ const repoDir = path.join(ownerDir, repo.name);
520
+ if (!isInside(root, repoDir)) continue;
521
+
522
+ try {
523
+ if (!(await isManagedRepoCache(repoDir))) continue;
524
+ const lastUsedAt = await getRepoLastUsedAt(repoDir);
525
+ if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
526
+ const ownershipBeforeDelete = await cleanupLockOwnershipError(
527
+ lock,
528
+ `deleting ${repoDir}`,
529
+ );
530
+ if (ownershipBeforeDelete) return { deleted, errors: [...errors, ownershipBeforeDelete] };
531
+ await fs.rm(repoDir, { recursive: true, force: true });
532
+ deleted += 1;
533
+ } catch (error) {
534
+ const message = error instanceof Error ? error.message : String(error);
535
+ errors.push(`${repoDir}: ${message}`);
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ return { deleted, errors };
542
+ }
543
+
544
+ async function cleanupExpiredCache(
545
+ cacheRoot: string,
546
+ ): Promise<{ deleted: number; errors: string[] }> {
547
+ const now = Date.now();
548
+ const root = path.resolve(cacheRoot);
549
+ let lock: CleanupLock | undefined;
550
+ let heartbeat: NodeJS.Timeout | undefined;
551
+
552
+ try {
553
+ lock = await tryAcquireCleanupLock(root);
554
+ if (!lock) return { deleted: 0, errors: [] };
555
+ heartbeat = setInterval(() => {
556
+ if (lock) void heartbeatCleanupLock(lock).catch(() => undefined);
557
+ }, CLEANUP_LOCK_HEARTBEAT_MS);
558
+ heartbeat.unref?.();
559
+ return await cleanupExpiredRepos(root, now, lock);
560
+ } finally {
561
+ if (heartbeat) clearInterval(heartbeat);
562
+ if (lock) await releaseCleanupLock(lock).catch(() => undefined);
563
+ }
519
564
  }
520
565
 
521
566
  function getCacheConfigPath(): string {
522
- return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
567
+ return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
523
568
  }
524
569
 
525
570
  type LibrarianPreferences = {
526
- cacheMode: CacheMode;
527
- model?: string;
528
- thinkingLevel: ThinkingLevel;
571
+ cacheMode: CacheMode;
572
+ model?: string;
573
+ thinkingLevel: ThinkingLevel;
529
574
  };
530
575
 
531
576
  function parseCacheMode(value: unknown): CacheMode | undefined {
532
- if (typeof value === "string") {
533
- const normalized = value.trim().toLowerCase();
534
- if (normalized === "enabled" || normalized === "on" || normalized === "true") return "enabled";
535
- if (normalized === "disabled" || normalized === "off" || normalized === "false") return "disabled";
536
- }
537
- if (value === true) return "enabled";
538
- if (value === false) return "disabled";
539
- return undefined;
577
+ if (typeof value === "string") {
578
+ const normalized = value.trim().toLowerCase();
579
+ if (normalized === "enabled" || normalized === "on" || normalized === "true") return "enabled";
580
+ if (normalized === "disabled" || normalized === "off" || normalized === "false")
581
+ return "disabled";
582
+ }
583
+ if (value === true) return "enabled";
584
+ if (value === false) return "disabled";
585
+ return undefined;
540
586
  }
541
587
 
542
588
  function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
543
- if (typeof value !== "string") return undefined;
544
- const normalized = value.trim().toLowerCase();
545
- return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
589
+ if (typeof value !== "string") return undefined;
590
+ const normalized = value.trim().toLowerCase();
591
+ return (THINKING_LEVELS as readonly string[]).includes(normalized)
592
+ ? (normalized as ThinkingLevel)
593
+ : undefined;
546
594
  }
547
595
 
548
596
  function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
549
- if (!model.reasoning) return level === "off";
550
- const map = model.thinkingLevelMap;
551
- if (level === "xhigh" || level === "max") {
552
- return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
553
- }
554
- return map?.[level] !== null;
597
+ if (!model.reasoning) return level === "off";
598
+ const map = model.thinkingLevelMap;
599
+ if (level === "xhigh" || level === "max") {
600
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
601
+ }
602
+ return map?.[level] !== null;
555
603
  }
556
604
 
557
605
  function resolveThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
558
- if (isThinkingLevelSupported(model, requested)) return requested;
559
- const requestedIndex = THINKING_LEVELS.indexOf(requested);
560
- for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
561
- const level = THINKING_LEVELS[index];
562
- if (isThinkingLevelSupported(model, level)) return level;
563
- }
564
- for (let index = requestedIndex - 1; index >= 0; index -= 1) {
565
- const level = THINKING_LEVELS[index];
566
- if (isThinkingLevelSupported(model, level)) return level;
567
- }
568
- return "off";
606
+ if (isThinkingLevelSupported(model, requested)) return requested;
607
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
608
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
609
+ const level = THINKING_LEVELS[index];
610
+ if (isThinkingLevelSupported(model, level)) return level;
611
+ }
612
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
613
+ const level = THINKING_LEVELS[index];
614
+ if (isThinkingLevelSupported(model, level)) return level;
615
+ }
616
+ return "off";
569
617
  }
570
618
 
571
619
  function normalizeModelPreference(value: unknown): string | undefined {
572
- if (typeof value !== "string") return undefined;
573
- const trimmed = value.trim();
574
- const normalized = trimmed.toLowerCase();
575
- if (!trimmed || normalized === "auto" || normalized === "current") return undefined;
576
- return trimmed;
620
+ if (typeof value !== "string") return undefined;
621
+ const trimmed = value.trim();
622
+ const normalized = trimmed.toLowerCase();
623
+ if (!trimmed || normalized === "auto" || normalized === "current") return undefined;
624
+ return trimmed;
577
625
  }
578
626
 
579
627
  function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
580
- const model = normalizeModelPreference(value);
581
- if (!model) return {};
582
- const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
583
- if (!match?.[1]) return { model };
584
- const baseModel = match[1].trim();
585
- if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
586
- return { thinkingLevel: parseThinkingLevel(match[2]) };
587
- }
588
- return { model: baseModel, thinkingLevel: parseThinkingLevel(match[2]) };
628
+ const model = normalizeModelPreference(value);
629
+ if (!model) return {};
630
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
631
+ if (!match?.[1]) return { model };
632
+ const baseModel = match[1].trim();
633
+ if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
634
+ return { thinkingLevel: parseThinkingLevel(match[2]) };
635
+ }
636
+ return { model: baseModel, thinkingLevel: parseThinkingLevel(match[2]) };
589
637
  }
590
638
 
591
639
  async function readLibrarianPreferences(): Promise<LibrarianPreferences> {
592
- try {
593
- const raw = await fs.readFile(getCacheConfigPath(), "utf8");
594
- const parsed = JSON.parse(raw) as {
595
- cacheMode?: unknown;
596
- cacheEnabled?: unknown;
597
- cache?: { mode?: unknown; enabled?: unknown };
598
- model?: unknown;
599
- defaultModel?: unknown;
600
- thinkingLevel?: unknown;
601
- defaultThinkingLevel?: unknown;
602
- };
603
- return {
604
- cacheMode:
605
- parseCacheMode(parsed.cacheMode) ??
606
- parseCacheMode(parsed.cache?.mode) ??
607
- parseCacheMode(parsed.cacheEnabled) ??
608
- parseCacheMode(parsed.cache?.enabled) ??
609
- DEFAULT_CACHE_MODE,
610
- model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
611
- thinkingLevel:
612
- parseThinkingLevel(parsed.thinkingLevel) ??
613
- parseThinkingLevel(parsed.defaultThinkingLevel) ??
614
- parseModelPreference(parsed.model).thinkingLevel ??
615
- parseModelPreference(parsed.defaultModel).thinkingLevel ??
616
- DEFAULT_THINKING_LEVEL,
617
- };
618
- } catch (error) {
619
- return {
620
- cacheMode: (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
621
- thinkingLevel: DEFAULT_THINKING_LEVEL,
622
- };
623
- }
640
+ try {
641
+ const raw = await fs.readFile(getCacheConfigPath(), "utf8");
642
+ const parsed = JSON.parse(raw) as {
643
+ cacheMode?: unknown;
644
+ cacheEnabled?: unknown;
645
+ cache?: { mode?: unknown; enabled?: unknown };
646
+ model?: unknown;
647
+ defaultModel?: unknown;
648
+ thinkingLevel?: unknown;
649
+ defaultThinkingLevel?: unknown;
650
+ };
651
+ return {
652
+ cacheMode:
653
+ parseCacheMode(parsed.cacheMode) ??
654
+ parseCacheMode(parsed.cache?.mode) ??
655
+ parseCacheMode(parsed.cacheEnabled) ??
656
+ parseCacheMode(parsed.cache?.enabled) ??
657
+ DEFAULT_CACHE_MODE,
658
+ model:
659
+ parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
660
+ thinkingLevel:
661
+ parseThinkingLevel(parsed.thinkingLevel) ??
662
+ parseThinkingLevel(parsed.defaultThinkingLevel) ??
663
+ parseModelPreference(parsed.model).thinkingLevel ??
664
+ parseModelPreference(parsed.defaultModel).thinkingLevel ??
665
+ DEFAULT_THINKING_LEVEL,
666
+ };
667
+ } catch (error) {
668
+ return {
669
+ cacheMode:
670
+ (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
671
+ thinkingLevel: DEFAULT_THINKING_LEVEL,
672
+ };
673
+ }
624
674
  }
625
675
 
626
676
  async function writeLibrarianPreferences(preferences: LibrarianPreferences): Promise<void> {
627
- const configPath = getCacheConfigPath();
628
- const config = {
629
- cacheMode: preferences.cacheMode,
630
- cacheEnabled: preferences.cacheMode === "enabled",
631
- ...(preferences.model ? { model: preferences.model } : {}),
632
- thinkingLevel: preferences.thinkingLevel,
633
- updatedAt: new Date().toISOString(),
634
- };
677
+ const configPath = getCacheConfigPath();
678
+ const config = {
679
+ cacheMode: preferences.cacheMode,
680
+ cacheEnabled: preferences.cacheMode === "enabled",
681
+ ...(preferences.model ? { model: preferences.model } : {}),
682
+ thinkingLevel: preferences.thinkingLevel,
683
+ updatedAt: new Date().toISOString(),
684
+ };
635
685
 
636
- await fs.mkdir(path.dirname(configPath), { recursive: true });
637
- await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
686
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
687
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
638
688
  }
639
689
 
640
690
  async function writeCachePreference(preference: CacheMode): Promise<void> {
641
- const preferences = await readLibrarianPreferences();
642
- await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
691
+ const preferences = await readLibrarianPreferences();
692
+ await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
643
693
  }
644
694
 
645
695
  function resolveCacheDecision(preference: CacheMode): { enabled: boolean; reason: string } {
646
- if (preference === "enabled") {
647
- return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
648
- }
696
+ if (preference === "enabled") {
697
+ return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
698
+ }
649
699
 
650
- return { enabled: false, reason: "cache preference disabled; using GitHub API/temp files only" };
700
+ return { enabled: false, reason: "cache preference disabled; using GitHub API/temp files only" };
651
701
  }
652
702
 
653
703
  function formatCachePreference(preference: CacheMode): string {
654
- return preference === "enabled" ? "on" : "off";
704
+ return preference === "enabled" ? "on" : "off";
655
705
  }
656
706
 
657
707
  function formatLibrarianPreferences(preferences: LibrarianPreferences): string {
658
- return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
708
+ return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
659
709
  }
660
710
 
661
- function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
662
- if (ctx.hasUI) {
663
- ctx.ui.notify(message, type);
664
- return;
665
- }
711
+ function notifyCommand(
712
+ ctx: ExtensionContext,
713
+ message: string,
714
+ type: "info" | "warning" | "error" = "info",
715
+ ): void {
716
+ if (ctx.hasUI) {
717
+ ctx.ui.notify(message, type);
718
+ return;
719
+ }
666
720
 
667
- console.error(message);
721
+ console.error(message);
668
722
  }
669
723
 
670
724
  function modelRef(model: any): string {
671
- return `${model.provider}/${model.id}`;
725
+ return `${model.provider}/${model.id}`;
672
726
  }
673
727
 
674
728
  function modelCostScore(model: any): number {
675
- const cost = model.cost ?? {};
676
- const input = typeof cost.input === "number" ? cost.input : 0;
677
- const output = typeof cost.output === "number" ? cost.output : 0;
678
- return input + output;
729
+ const cost = model.cost ?? {};
730
+ const input = typeof cost.input === "number" ? cost.input : 0;
731
+ const output = typeof cost.output === "number" ? cost.output : 0;
732
+ return input + output;
679
733
  }
680
734
 
681
735
  function rankLibrarianModel(model: any): number {
682
- const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
683
- let score = modelCostScore(model) * 1_000_000;
684
- if (model.reasoning) score += 50;
685
- if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
686
- if (HEAVY_MODEL_PATTERN.test(text)) score += 1_000;
687
- if ((model.contextWindow ?? 0) < 32_000) score += 100;
688
- return score;
736
+ const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
737
+ let score = modelCostScore(model) * 1_000_000;
738
+ if (model.reasoning) score += 50;
739
+ if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
740
+ if (HEAVY_MODEL_PATTERN.test(text)) score += 1_000;
741
+ if ((model.contextWindow ?? 0) < 32_000) score += 100;
742
+ return score;
689
743
  }
690
744
 
691
745
  function isPreferredFastLibrarianModel(model: any): boolean {
692
- const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
693
- if (HEAVY_MODEL_PATTERN.test(text)) return false;
694
- return PREFERRED_FAST_MODEL_PATTERNS.some((pattern) => pattern.test(text));
746
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
747
+ if (HEAVY_MODEL_PATTERN.test(text)) return false;
748
+ return PREFERRED_FAST_MODEL_PATTERNS.some((pattern) => pattern.test(text));
695
749
  }
696
750
 
697
751
  function rankPreferredFastLibrarianModel(model: any): number {
698
- const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
699
- let score = rankLibrarianModel(model);
700
- const patternIndex = PREFERRED_FAST_MODEL_PATTERNS.findIndex((pattern) => pattern.test(text));
701
- if (patternIndex >= 0) score += patternIndex * 100;
702
- return score;
752
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
753
+ let score = rankLibrarianModel(model);
754
+ const patternIndex = PREFERRED_FAST_MODEL_PATTERNS.findIndex((pattern) => pattern.test(text));
755
+ if (patternIndex >= 0) score += patternIndex * 100;
756
+ return score;
703
757
  }
704
758
 
705
759
  async function findAvailableModel(
706
- ctx: ModelSelectionContext,
707
- modelPreference: string,
760
+ ctx: ModelSelectionContext,
761
+ modelPreference: string,
708
762
  ): Promise<any | undefined> {
709
- const available = await ctx.modelRegistry.getAvailable();
710
- const trimmed = modelPreference.trim();
711
- const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
712
- const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
713
-
714
- const exact = available.find(
715
- (model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
716
- );
717
- if (exact) return exact;
718
-
719
- const partial = available.find(
720
- (model) => model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
721
- );
722
- if (partial) return partial;
723
-
724
- if (!provider) {
725
- const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
726
- if (uniqueById.length === 1) return uniqueById[0];
727
- }
728
-
729
- return undefined;
763
+ const available = await ctx.modelRegistry.getAvailable();
764
+ const trimmed = modelPreference.trim();
765
+ const provider = trimmed.includes("/")
766
+ ? trimmed.split("/")[0].toLowerCase()
767
+ : ctx.model?.provider?.toLowerCase();
768
+ const id = trimmed.includes("/")
769
+ ? trimmed.split("/").slice(1).join("/").toLowerCase()
770
+ : trimmed.toLowerCase();
771
+
772
+ const exact = available.find(
773
+ (model) =>
774
+ model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
775
+ );
776
+ if (exact) return exact;
777
+
778
+ const partial = available.find(
779
+ (model) =>
780
+ model.id.toLowerCase().includes(id) &&
781
+ (!provider || model.provider.toLowerCase() === provider),
782
+ );
783
+ if (partial) return partial;
784
+
785
+ if (!provider) {
786
+ const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
787
+ if (uniqueById.length === 1) return uniqueById[0];
788
+ }
789
+
790
+ return undefined;
730
791
  }
731
792
 
732
793
  // A model the catalog advertises but the active provider/subscription cannot
@@ -734,23 +795,26 @@ async function findAvailableModel(
734
795
  // access-gated tiers). These are NOT transient: the same model keeps failing,
735
796
  // so we fall back to the next candidate instead of retrying it.
736
797
  const MODEL_AVAILABILITY_ERROR_PATTERN =
737
- /\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;
798
+ /\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;
738
799
 
739
800
  function isModelAvailabilityError(message: string | undefined): boolean {
740
- if (!message) return false;
741
- return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
801
+ if (!message) return false;
802
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
742
803
  }
743
804
 
744
805
  type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
745
806
 
746
- function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
747
- return {
748
- model: candidate.model,
749
- details: {
750
- ...candidate.details,
751
- selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
752
- },
753
- };
807
+ function withLibrarianFallbackReason(
808
+ candidate: LibrarianCandidate,
809
+ previous: LibrarianCandidate,
810
+ ): LibrarianCandidate {
811
+ return {
812
+ model: candidate.model,
813
+ details: {
814
+ ...candidate.details,
815
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
816
+ },
817
+ };
754
818
  }
755
819
 
756
820
  // Build an ordered list of model candidates to try. The catalog can advertise
@@ -758,953 +822,1139 @@ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: Li
758
822
  // these in order and skip ones that fail with a model-availability error,
759
823
  // ultimately falling back to the known-good current session model.
760
824
  async function buildLibrarianCandidates(
761
- ctx: ModelSelectionContext,
762
- modelPreference: string | undefined,
763
- thinkingLevel: ThinkingLevel,
825
+ ctx: ModelSelectionContext,
826
+ modelPreference: string | undefined,
827
+ thinkingLevel: ThinkingLevel,
764
828
  ): Promise<LibrarianCandidate[]> {
765
- const candidates: LibrarianCandidate[] = [];
766
- const seen = new Set<string>();
767
- const push = (model: any, details: LibrarianModelDetails) => {
768
- if (!model) return;
769
- const key = `${model.provider}/${model.id}`.toLowerCase();
770
- if (seen.has(key)) return;
771
- seen.add(key);
772
- candidates.push({ model, details });
773
- };
774
-
775
- const normalized = modelPreference?.trim();
776
- let configuredMatched: any | undefined;
777
- if (normalized) {
778
- configuredMatched = await findAvailableModel(ctx, normalized);
779
- if (configuredMatched) {
780
- push(configuredMatched, {
781
- modelRef: modelRef(configuredMatched),
782
- provider: configuredMatched.provider,
783
- modelId: configuredMatched.id,
784
- thinkingLevel: resolveThinkingLevel(configuredMatched, thinkingLevel),
785
- autoSelected: false,
786
- selectionReason: "Using the configured librarian model.",
787
- });
788
- }
789
- }
790
-
791
- const available = await getAutoSelectableModels(ctx);
792
- const preferred = available.filter(isPreferredFastLibrarianModel);
793
- const ranked = preferred.length > 0
794
- ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
795
- : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
796
- const fallbackText = normalized && !configuredMatched
797
- ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
798
- : "";
799
- ranked.forEach((model, index) => {
800
- const topReason = preferred.length > 0
801
- ? `Selected a preferred fast Librarian model.${fallbackText}`
802
- : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
803
- push(model, {
804
- modelRef: modelRef(model),
805
- provider: model.provider,
806
- modelId: model.id,
807
- thinkingLevel: resolveThinkingLevel(model, thinkingLevel),
808
- autoSelected: true,
809
- selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
810
- });
811
- });
812
-
813
- // Final fallback: the active session model is known to be servable.
814
- if (ctx.model && isModelInScope(ctx, ctx.model)) {
815
- push(ctx.model, {
816
- modelRef: modelRef(ctx.model),
817
- provider: ctx.model.provider,
818
- modelId: ctx.model.id,
819
- thinkingLevel: resolveThinkingLevel(ctx.model, thinkingLevel),
820
- autoSelected: true,
821
- selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
822
- });
823
- }
824
-
825
- if (candidates.length === 0) {
826
- throw new Error(
827
- hasModelScope(ctx)
828
- ? "No authenticated models are available for Librarian in the current session model scope. Adjust the scope, log in, or configure an API key."
829
- : "No authenticated models are available for Librarian. Log in or configure an API key first.",
830
- );
831
- }
832
-
833
- return candidates;
829
+ const candidates: LibrarianCandidate[] = [];
830
+ const seen = new Set<string>();
831
+ const push = (model: any, details: LibrarianModelDetails) => {
832
+ if (!model) return;
833
+ const key = `${model.provider}/${model.id}`.toLowerCase();
834
+ if (seen.has(key)) return;
835
+ seen.add(key);
836
+ candidates.push({ model, details });
837
+ };
838
+
839
+ const normalized = modelPreference?.trim();
840
+ let configuredMatched: any | undefined;
841
+ if (normalized) {
842
+ configuredMatched = await findAvailableModel(ctx, normalized);
843
+ if (configuredMatched) {
844
+ push(configuredMatched, {
845
+ modelRef: modelRef(configuredMatched),
846
+ provider: configuredMatched.provider,
847
+ modelId: configuredMatched.id,
848
+ thinkingLevel: resolveThinkingLevel(configuredMatched, thinkingLevel),
849
+ autoSelected: false,
850
+ selectionReason: "Using the configured librarian model.",
851
+ });
852
+ }
853
+ }
854
+
855
+ const available = await getAutoSelectableModels(ctx);
856
+ const preferred = available.filter(isPreferredFastLibrarianModel);
857
+ const ranked =
858
+ preferred.length > 0
859
+ ? [...preferred].sort(
860
+ (a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b),
861
+ )
862
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
863
+ const fallbackText =
864
+ normalized && !configuredMatched
865
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
866
+ : "";
867
+ ranked.forEach((model, index) => {
868
+ const topReason =
869
+ preferred.length > 0
870
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
871
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
872
+ push(model, {
873
+ modelRef: modelRef(model),
874
+ provider: model.provider,
875
+ modelId: model.id,
876
+ thinkingLevel: resolveThinkingLevel(model, thinkingLevel),
877
+ autoSelected: true,
878
+ selectionReason:
879
+ index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
880
+ });
881
+ });
882
+
883
+ // Final fallback: the active session model is known to be servable.
884
+ if (ctx.model && isModelInScope(ctx, ctx.model)) {
885
+ push(ctx.model, {
886
+ modelRef: modelRef(ctx.model),
887
+ provider: ctx.model.provider,
888
+ modelId: ctx.model.id,
889
+ thinkingLevel: resolveThinkingLevel(ctx.model, thinkingLevel),
890
+ autoSelected: true,
891
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
892
+ });
893
+ }
894
+
895
+ if (candidates.length === 0) {
896
+ throw new Error(
897
+ hasModelScope(ctx)
898
+ ? "No authenticated models are available for Librarian in the current session model scope. Adjust the scope, log in, or configure an API key."
899
+ : "No authenticated models are available for Librarian. Log in or configure an API key first.",
900
+ );
901
+ }
902
+
903
+ return candidates;
834
904
  }
835
905
 
836
906
  function resolveToolPath(cwd: string, rawPath: string): string {
837
- const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
838
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
839
- }
840
-
841
- function getBlockedBashReason(command: string, options: { workspace: string; cacheRoot: string; cacheEnabled: boolean }): string | undefined {
842
- if (!options.cacheEnabled && command.includes(options.cacheRoot)) {
843
- return "Local repo checkout cache is disabled for this Librarian call.";
844
- }
845
-
846
- let scan = command.split(options.workspace).join("<WORKSPACE>");
847
- if (options.cacheEnabled) scan = scan.split(options.cacheRoot).join("<CACHE>");
848
- if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
849
-
850
- const destructiveLocal = /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
851
- if (destructiveLocal.test(scan)) return "Librarian bash blocks destructive/local side-effect commands.";
852
-
853
- const extraNetworkOrShell = /(^|[\n;&|()])\s*(?:curl|wget|nc|netcat|ssh|scp|sftp|rsync|bash|sh|zsh|fish|python|python3|perl|ruby|node|deno)(?=$|[\s;&|()])/;
854
- if (extraNetworkOrShell.test(scan)) return "Librarian bash is limited to gh/git and simple local inspection commands.";
855
-
856
- if (/(^|[\s"'=])(?:~\/|\$HOME\b|\/etc\/|\/var\/|\/private\/|\/root\/|\/Users\/|\/home\/|\/opt\/)/.test(scan)) {
857
- return "Librarian bash may only access its workspace and approved cache root.";
858
- }
859
-
860
- if (/\bgh\s+auth\s+token\b/.test(scan) || /(^|[\n;&|()])\s*(?:env|printenv|set|export|declare|echo|printf)(?=$|[\s;&|()])/.test(scan)) {
861
- return "Librarian bash blocks credential/environment inspection.";
862
- }
863
-
864
- if (/\bgh\s+api\b(?=.*(?:(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b|(?:-f|--field|-F|--raw-field|--input)\b))/i.test(scan)) {
865
- return "Librarian bash allows read-only GitHub API calls only.";
866
- }
867
-
868
- if (/\bgh\s+(?:repo\s+(?:delete|edit|archive|fork|create)|pr\s+(?:merge|close|edit)|issue\s+(?:close|edit|delete)|release\s+(?:create|delete|upload)|workflow\s+run|gist\s+(?:create|delete|edit))\b/.test(scan)) {
869
- return "Librarian bash blocks mutating gh commands.";
870
- }
871
-
872
- if (/\bgit\b[^\n;&|]*\b(?:push|commit|merge|rebase|clean|reset|tag)\b/.test(scan)) {
873
- return "Librarian bash blocks mutating git commands except local checkout/fetch for cache use.";
874
- }
875
-
876
- return undefined;
907
+ const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
908
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
909
+ }
910
+
911
+ function getBlockedBashReason(
912
+ command: string,
913
+ options: { workspace: string; cacheRoot: string; cacheEnabled: boolean },
914
+ ): string | undefined {
915
+ if (!options.cacheEnabled && command.includes(options.cacheRoot)) {
916
+ return "Local repo checkout cache is disabled for this Librarian call.";
917
+ }
918
+
919
+ let scan = command.split(options.workspace).join("<WORKSPACE>");
920
+ if (options.cacheEnabled) scan = scan.split(options.cacheRoot).join("<CACHE>");
921
+ if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
922
+
923
+ const destructiveLocal =
924
+ /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
925
+ if (destructiveLocal.test(scan))
926
+ return "Librarian bash blocks destructive/local side-effect commands.";
927
+
928
+ const extraNetworkOrShell =
929
+ /(^|[\n;&|()])\s*(?:curl|wget|nc|netcat|ssh|scp|sftp|rsync|bash|sh|zsh|fish|python|python3|perl|ruby|node|deno)(?=$|[\s;&|()])/;
930
+ if (extraNetworkOrShell.test(scan))
931
+ return "Librarian bash is limited to gh/git and simple local inspection commands.";
932
+
933
+ if (
934
+ /(^|[\s"'=])(?:~\/|\$HOME\b|\/etc\/|\/var\/|\/private\/|\/root\/|\/Users\/|\/home\/|\/opt\/)/.test(
935
+ scan,
936
+ )
937
+ ) {
938
+ return "Librarian bash may only access its workspace and approved cache root.";
939
+ }
940
+
941
+ if (
942
+ /\bgh\s+auth\s+token\b/.test(scan) ||
943
+ /(^|[\n;&|()])\s*(?:env|printenv|set|export|declare|echo|printf)(?=$|[\s;&|()])/.test(scan)
944
+ ) {
945
+ return "Librarian bash blocks credential/environment inspection.";
946
+ }
947
+
948
+ if (
949
+ /\bgh\s+api\b(?=.*(?:(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b|(?:-f|--field|-F|--raw-field|--input)\b))/i.test(
950
+ scan,
951
+ )
952
+ ) {
953
+ return "Librarian bash allows read-only GitHub API calls only.";
954
+ }
955
+
956
+ if (
957
+ /\bgh\s+(?:repo\s+(?:delete|edit|archive|fork|create)|pr\s+(?:merge|close|edit)|issue\s+(?:close|edit|delete)|release\s+(?:create|delete|upload)|workflow\s+run|gist\s+(?:create|delete|edit))\b/.test(
958
+ scan,
959
+ )
960
+ ) {
961
+ return "Librarian bash blocks mutating gh commands.";
962
+ }
963
+
964
+ if (/\bgit\b[^\n;&|]*\b(?:push|commit|merge|rebase|clean|reset|tag)\b/.test(scan)) {
965
+ return "Librarian bash blocks mutating git commands except local checkout/fetch for cache use.";
966
+ }
967
+
968
+ return undefined;
877
969
  }
878
970
 
879
971
  function createLibrarianRuntimeGuardExtension(options: {
880
- maxTurns: number;
881
- workspace: string;
882
- cacheRoot: string;
883
- cacheEnabled: boolean;
972
+ maxTurns: number;
973
+ workspace: string;
974
+ cacheRoot: string;
975
+ cacheEnabled: boolean;
884
976
  }): ExtensionFactory {
885
- return (pi) => {
886
- let currentTurn = 0;
887
-
888
- pi.on("turn_start", async (event) => {
889
- currentTurn = event.turnIndex;
890
- });
891
-
892
- pi.on("tool_call", async (event) => {
893
- if (currentTurn >= options.maxTurns - 1) {
894
- return {
895
- block: true,
896
- reason: `Tool use is disabled on final Librarian turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
897
- };
898
- }
899
-
900
- if (event.toolName === "read") {
901
- const input = event.input as { path?: unknown };
902
- if (typeof input.path !== "string") return undefined;
903
- const resolved = resolveToolPath(options.workspace, input.path);
904
- const realPath = await fs.realpath(resolved).catch(() => resolved);
905
- const allowed =
906
- isInside(options.workspace, realPath) ||
907
- (options.cacheEnabled && isInside(options.cacheRoot, realPath));
908
- if (!allowed) return { block: true, reason: `Librarian read is limited to its workspace/cache: ${realPath}` };
909
- }
910
-
911
- if (event.toolName === "bash") {
912
- const input = event.input as { command?: unknown; timeout?: unknown };
913
- if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
914
-
915
- const command = typeof input.command === "string" ? input.command : "";
916
- if (!options.cacheEnabled && /\b(?:git\s+clone|gh\s+repo\s+clone)\b/.test(command)) {
917
- return { block: true, reason: "Local repo checkout cache is disabled for this Librarian call." };
918
- }
919
-
920
- const blockedReason = getBlockedBashReason(command, options);
921
- if (blockedReason) return { block: true, reason: blockedReason };
922
- }
923
-
924
- return undefined;
925
- });
926
-
927
- pi.on("tool_result", async (event) => ({
928
- content: [
929
- ...(event.content ?? []),
930
- {
931
- type: "text",
932
- text: `\n\n[librarian turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
933
- },
934
- ],
935
- }));
936
- };
977
+ return (pi) => {
978
+ let currentTurn = 0;
979
+
980
+ pi.on("turn_start", async (event) => {
981
+ currentTurn = event.turnIndex;
982
+ });
983
+
984
+ pi.on("tool_call", async (event) => {
985
+ if (currentTurn >= options.maxTurns - 1) {
986
+ return {
987
+ block: true,
988
+ reason: `Tool use is disabled on final Librarian turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
989
+ };
990
+ }
991
+
992
+ if (event.toolName === "read") {
993
+ const input = event.input as { path?: unknown };
994
+ if (typeof input.path !== "string") return undefined;
995
+ const resolved = resolveToolPath(options.workspace, input.path);
996
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
997
+ const allowed =
998
+ isInside(options.workspace, realPath) ||
999
+ (options.cacheEnabled && isInside(options.cacheRoot, realPath));
1000
+ if (!allowed)
1001
+ return {
1002
+ block: true,
1003
+ reason: `Librarian read is limited to its workspace/cache: ${realPath}`,
1004
+ };
1005
+ }
1006
+
1007
+ if (event.toolName === "bash") {
1008
+ const input = event.input as { command?: unknown; timeout?: unknown };
1009
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1010
+
1011
+ const command = typeof input.command === "string" ? input.command : "";
1012
+ if (!options.cacheEnabled && /\b(?:git\s+clone|gh\s+repo\s+clone)\b/.test(command)) {
1013
+ return {
1014
+ block: true,
1015
+ reason: "Local repo checkout cache is disabled for this Librarian call.",
1016
+ };
1017
+ }
1018
+
1019
+ const blockedReason = getBlockedBashReason(command, options);
1020
+ if (blockedReason) return { block: true, reason: blockedReason };
1021
+ }
1022
+
1023
+ return undefined;
1024
+ });
1025
+
1026
+ pi.on("tool_result", async (event) => ({
1027
+ content: [
1028
+ ...(event.content ?? []),
1029
+ {
1030
+ type: "text",
1031
+ text: `\n\n[librarian turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1032
+ },
1033
+ ],
1034
+ }));
1035
+ };
937
1036
  }
938
1037
 
939
1038
  function buildSystemPrompt(options: {
940
- workspace: string;
941
- maxSearchResults: number;
942
- cacheEnabled: boolean;
943
- cacheRoot: string;
1039
+ workspace: string;
1040
+ maxSearchResults: number;
1041
+ cacheEnabled: boolean;
1042
+ cacheRoot: string;
944
1043
  }): string {
945
- const cacheSection = options.cacheEnabled
946
- ? `\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.`
947
- : `\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>.`;
1044
+ const cacheSection = options.cacheEnabled
1045
+ ? `\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.`
1046
+ : `\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>.`;
948
1047
 
949
- 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.
1048
+ 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.
950
1049
  - 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.`;
951
1050
  }
952
1051
 
953
- function buildUserPrompt(query: string, repos: string[], owners: string[], maxSearchResults: number, cache: CacheDetails): string {
954
- 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.`;
1052
+ function buildUserPrompt(
1053
+ query: string,
1054
+ repos: string[],
1055
+ owners: string[],
1056
+ maxSearchResults: number,
1057
+ cache: CacheDetails,
1058
+ ): string {
1059
+ 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.`;
955
1060
  }
956
1061
 
957
1062
  function createEmptyUsage(): Usage {
958
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
1063
+ return {
1064
+ input: 0,
1065
+ output: 0,
1066
+ cacheRead: 0,
1067
+ cacheWrite: 0,
1068
+ totalTokens: 0,
1069
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1070
+ };
959
1071
  }
960
1072
 
961
1073
  function addUsage(total: Usage, usage: Usage): void {
962
- total.input += usage.input || 0;
963
- total.output += usage.output || 0;
964
- total.cacheRead += usage.cacheRead || 0;
965
- total.cacheWrite += usage.cacheWrite || 0;
966
- if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
967
- if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
968
- total.totalTokens += usage.totalTokens || 0;
969
- total.cost.input += usage.cost?.input || 0;
970
- total.cost.output += usage.cost?.output || 0;
971
- total.cost.cacheRead += usage.cost?.cacheRead || 0;
972
- total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
973
- total.cost.total += usage.cost?.total || 0;
974
- }
975
-
976
- type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
1074
+ total.input += usage.input || 0;
1075
+ total.output += usage.output || 0;
1076
+ total.cacheRead += usage.cacheRead || 0;
1077
+ total.cacheWrite += usage.cacheWrite || 0;
1078
+ if (usage.cacheWrite1h !== undefined)
1079
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
1080
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
1081
+ total.totalTokens += usage.totalTokens || 0;
1082
+ total.cost.input += usage.cost?.input || 0;
1083
+ total.cost.output += usage.cost?.output || 0;
1084
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
1085
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
1086
+ total.cost.total += usage.cost?.total || 0;
1087
+ }
1088
+
1089
+ type AssistantLikeMessage = {
1090
+ role?: string;
1091
+ content?: unknown;
1092
+ stopReason?: unknown;
1093
+ errorMessage?: unknown;
1094
+ usage?: unknown;
1095
+ };
977
1096
 
978
1097
  function addAssistantMessageUsage(total: Usage, message: unknown): void {
979
- const assistant = message as AssistantLikeMessage;
980
- if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
981
- addUsage(total, assistant.usage as Usage);
1098
+ const assistant = message as AssistantLikeMessage;
1099
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
1100
+ return;
1101
+ addUsage(total, assistant.usage as Usage);
982
1102
  }
983
1103
 
984
1104
  function aggregateAssistantUsage(messages: unknown[]): Usage {
985
- const total = createEmptyUsage();
986
- for (const message of messages) addAssistantMessageUsage(total, message);
987
- return total;
1105
+ const total = createEmptyUsage();
1106
+ for (const message of messages) addAssistantMessageUsage(total, message);
1107
+ return total;
988
1108
  }
989
1109
 
990
1110
  function addSessionEventUsage(total: Usage, event: unknown): void {
991
- const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
992
- if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
993
- if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
1111
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
1112
+ if (sessionEvent?.type === "message_end")
1113
+ return void addAssistantMessageUsage(total, sessionEvent.message);
1114
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
1115
+ addUsage(total, sessionEvent.result.usage);
994
1116
  }
995
1117
 
996
1118
  function getLastAssistantMessage(messages: unknown[]): AssistantLikeMessage | undefined {
997
- for (let i = messages.length - 1; i >= 0; i--) {
998
- const message = messages[i] as AssistantLikeMessage;
999
- if (message?.role === "assistant") return message;
1000
- }
1001
- return undefined;
1119
+ for (let i = messages.length - 1; i >= 0; i--) {
1120
+ const message = messages[i] as AssistantLikeMessage;
1121
+ if (message?.role === "assistant") return message;
1122
+ }
1123
+ return undefined;
1002
1124
  }
1003
1125
 
1004
1126
  function inspectFinalAssistant(messages: unknown[]):
1005
- | { ok: true; answer: string; message: AssistantLikeMessage; stopReason?: string }
1006
- | { ok: false; reason: string; message?: AssistantLikeMessage; stopReason?: string; errorMessage?: string } {
1007
- const message = getLastAssistantMessage(messages);
1008
- if (!message) return { ok: false, reason: "the internal subagent produced no assistant message" };
1009
- const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
1010
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
1011
- const answer = Array.isArray(message.content)
1012
- ? message.content
1013
- .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
1014
- ? (part as { text: string }).text
1015
- : ""))
1016
- .join("")
1017
- .trim()
1018
- : "";
1019
- if (stopReason === "error") {
1020
- return {
1021
- ok: false,
1022
- reason: `the internal subagent stopped with an error: ${errorMessage || "provider/model error"}`,
1023
- message,
1024
- stopReason,
1025
- errorMessage: errorMessage || undefined,
1026
- };
1027
- }
1028
- if (stopReason === "aborted") {
1029
- return { ok: false, reason: "the internal subagent was aborted before producing an answer", message, stopReason };
1030
- }
1031
- if (answer) return { ok: true, answer, message, stopReason: stopReason || undefined };
1032
- return { ok: false, reason: describeNoAnswerReason(message, 0), message, stopReason: stopReason || undefined };
1033
- }
1034
-
1035
- function classifyRunFailure(error: unknown, callerAborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
1036
- const message = callerAborted ? "Aborted" : error instanceof Error ? error.message : String(error);
1037
- return { status: callerAborted ? "aborted" : "error", message, error: callerAborted ? undefined : message };
1127
+ | { ok: true; answer: string; message: AssistantLikeMessage; stopReason?: string }
1128
+ | {
1129
+ ok: false;
1130
+ reason: string;
1131
+ message?: AssistantLikeMessage;
1132
+ stopReason?: string;
1133
+ errorMessage?: string;
1134
+ } {
1135
+ const message = getLastAssistantMessage(messages);
1136
+ if (!message) return { ok: false, reason: "the internal subagent produced no assistant message" };
1137
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
1138
+ const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
1139
+ const answer = Array.isArray(message.content)
1140
+ ? message.content
1141
+ .map((part) =>
1142
+ part &&
1143
+ typeof part === "object" &&
1144
+ (part as { type?: string }).type === "text" &&
1145
+ typeof (part as { text?: unknown }).text === "string"
1146
+ ? (part as { text: string }).text
1147
+ : "",
1148
+ )
1149
+ .join("")
1150
+ .trim()
1151
+ : "";
1152
+ if (stopReason === "error") {
1153
+ return {
1154
+ ok: false,
1155
+ reason: `the internal subagent stopped with an error: ${errorMessage || "provider/model error"}`,
1156
+ message,
1157
+ stopReason,
1158
+ errorMessage: errorMessage || undefined,
1159
+ };
1160
+ }
1161
+ if (stopReason === "aborted") {
1162
+ return {
1163
+ ok: false,
1164
+ reason: "the internal subagent was aborted before producing an answer",
1165
+ message,
1166
+ stopReason,
1167
+ };
1168
+ }
1169
+ if (answer) return { ok: true, answer, message, stopReason: stopReason || undefined };
1170
+ return {
1171
+ ok: false,
1172
+ reason: describeNoAnswerReason(message, 0),
1173
+ message,
1174
+ stopReason: stopReason || undefined,
1175
+ };
1176
+ }
1177
+
1178
+ function classifyRunFailure(
1179
+ error: unknown,
1180
+ callerAborted: boolean,
1181
+ ): { status: "aborted" | "error"; message: string; error?: string } {
1182
+ const message = callerAborted
1183
+ ? "Aborted"
1184
+ : error instanceof Error
1185
+ ? error.message
1186
+ : String(error);
1187
+ return {
1188
+ status: callerAborted ? "aborted" : "error",
1189
+ message,
1190
+ error: callerAborted ? undefined : message,
1191
+ };
1038
1192
  }
1039
1193
 
1040
1194
  function resolveLibrarianTerminalOutcome(
1041
- outcome: ReturnType<typeof inspectFinalAssistant> | undefined,
1042
- callerAborted: boolean,
1043
- turns: number,
1044
- fallbackReason?: string,
1045
- ): { status: "done"; content: string } | { status: "aborted"; content: "Aborted" } | { status: "error"; reason: string } {
1046
- if (callerAborted) return { status: "aborted", content: "Aborted" };
1047
- if (outcome?.ok) return { status: "done", content: outcome.answer };
1048
- const reason = fallbackReason ?? (outcome?.stopReason ? outcome.reason : describeNoAnswerReason(outcome?.message, turns));
1049
- return { status: "error", reason };
1195
+ outcome: ReturnType<typeof inspectFinalAssistant> | undefined,
1196
+ callerAborted: boolean,
1197
+ turns: number,
1198
+ fallbackReason?: string,
1199
+ ):
1200
+ | { status: "done"; content: string }
1201
+ | { status: "aborted"; content: "Aborted" }
1202
+ | { status: "error"; reason: string } {
1203
+ if (callerAborted) return { status: "aborted", content: "Aborted" };
1204
+ if (outcome?.ok) return { status: "done", content: outcome.answer };
1205
+ const reason =
1206
+ fallbackReason ??
1207
+ (outcome?.stopReason ? outcome.reason : describeNoAnswerReason(outcome?.message, turns));
1208
+ return { status: "error", reason };
1050
1209
  }
1051
1210
 
1052
1211
  function createLibrarianAbortController(abortChild: () => void, onCallerAbort: () => void) {
1053
- let callerAborted = false;
1054
- return {
1055
- get callerAborted() {
1056
- return callerAborted;
1057
- },
1058
- abortForCleanup() {
1059
- abortChild();
1060
- },
1061
- abortFromCaller() {
1062
- callerAborted = true;
1063
- onCallerAbort();
1064
- abortChild();
1065
- },
1066
- };
1212
+ let callerAborted = false;
1213
+ return {
1214
+ get callerAborted() {
1215
+ return callerAborted;
1216
+ },
1217
+ abortForCleanup() {
1218
+ abortChild();
1219
+ },
1220
+ abortFromCaller() {
1221
+ callerAborted = true;
1222
+ onCallerAbort();
1223
+ abortChild();
1224
+ },
1225
+ };
1067
1226
  }
1068
1227
 
1069
1228
  function describeNoAnswerReason(message: AssistantLikeMessage | undefined, turns: number): string {
1070
- if (!message) return "the internal subagent produced no assistant message";
1071
- if (message.stopReason === "error") {
1072
- const errorMessage = typeof message.errorMessage === "string" && message.errorMessage.trim()
1073
- ? message.errorMessage.trim()
1074
- : "provider/model error";
1075
- return `the internal subagent stopped with an error: ${errorMessage}`;
1076
- }
1077
- if (message.stopReason === "aborted") return "the internal subagent was aborted before producing an answer";
1078
- if (turns >= MAX_TURNS) return `the internal subagent reached the ${MAX_TURNS}-turn budget without producing a final answer`;
1079
- const stopReason = typeof message.stopReason === "string" && message.stopReason.trim()
1080
- ? ` (stopReason: ${message.stopReason.trim()})`
1081
- : "";
1082
- return `the internal subagent completed without final assistant text${stopReason}`;
1229
+ if (!message) return "the internal subagent produced no assistant message";
1230
+ if (message.stopReason === "error") {
1231
+ const errorMessage =
1232
+ typeof message.errorMessage === "string" && message.errorMessage.trim()
1233
+ ? message.errorMessage.trim()
1234
+ : "provider/model error";
1235
+ return `the internal subagent stopped with an error: ${errorMessage}`;
1236
+ }
1237
+ if (message.stopReason === "aborted")
1238
+ return "the internal subagent was aborted before producing an answer";
1239
+ if (turns >= MAX_TURNS)
1240
+ return `the internal subagent reached the ${MAX_TURNS}-turn budget without producing a final answer`;
1241
+ const stopReason =
1242
+ typeof message.stopReason === "string" && message.stopReason.trim()
1243
+ ? ` (stopReason: ${message.stopReason.trim()})`
1244
+ : "";
1245
+ return `the internal subagent completed without final assistant text${stopReason}`;
1083
1246
  }
1084
1247
 
1085
1248
  function formatInternalFailure(details: LibrarianDetails, reason: string): string {
1086
- return [
1087
- `Internal librarian run failed: ${reason}.`,
1088
- `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}.`,
1089
- "No answer was produced. This is not a reliable \"no results found\" signal; retry later or configure a different Librarian model with /librarian-config.",
1090
- ].join("\n");
1249
+ return [
1250
+ `Internal librarian run failed: ${reason}.`,
1251
+ `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}.`,
1252
+ 'No answer was produced. This is not a reliable "no results found" signal; retry later or configure a different Librarian model with /librarian-config.',
1253
+ ].join("\n");
1091
1254
  }
1092
1255
 
1093
1256
  function formatToolCall(call: ToolCall): string {
1094
- const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1095
- if (call.name === "read") {
1096
- const readPath = typeof args.path === "string" ? args.path : "";
1097
- const offset = typeof args.offset === "number" ? args.offset : undefined;
1098
- const limit = typeof args.limit === "number" ? args.limit : undefined;
1099
- const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1100
- return `read ${readPath}${range}`.trim();
1101
- }
1102
- if (call.name === "bash") {
1103
- const command = typeof args.command === "string" ? args.command : "";
1104
- return `bash ${shorten(command, 120)}`.trim();
1105
- }
1106
- return call.name;
1257
+ const args =
1258
+ call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1259
+ if (call.name === "read") {
1260
+ const readPath = typeof args.path === "string" ? args.path : "";
1261
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1262
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1263
+ const range =
1264
+ offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1265
+ return `read ${readPath}${range}`.trim();
1266
+ }
1267
+ if (call.name === "bash") {
1268
+ const command = typeof args.command === "string" ? args.command : "";
1269
+ return `bash ${shorten(command, 120)}`.trim();
1270
+ }
1271
+ return call.name;
1107
1272
  }
1108
1273
 
1109
1274
  function renderAnswer(details: LibrarianDetails): string {
1110
- if (details.error) return details.error;
1111
- return details.status === "running" ? "(searching GitHub...)" : "(no output)";
1275
+ if (details.error) return details.error;
1276
+ return details.status === "running" ? "(searching GitHub...)" : "(no output)";
1112
1277
  }
1113
1278
 
1114
1279
  export const __test__ = {
1115
- buildLibrarianCandidates,
1116
- findAvailableModel,
1117
- isModelAvailabilityError,
1118
- parseModelPreference,
1119
- resolveThinkingLevel,
1120
- aggregateAssistantUsage,
1121
- addAssistantMessageUsage,
1122
- addSessionEventUsage,
1123
- inspectFinalAssistant,
1124
- classifyRunFailure,
1125
- resolveLibrarianTerminalOutcome,
1126
- createLibrarianAbortController,
1127
- cleanupExpiredCache,
1128
- cleanupExpiredRepos,
1129
- cleanupLockOwnershipError,
1130
- tryAcquireCleanupLock,
1131
- heartbeatCleanupLock,
1132
- releaseCleanupLock,
1133
- CLEANUP_LOCK_FILE,
1134
- CLEANUP_LOCK_STALE_MS,
1135
- CLEANUP_LOCK_MAX_LEASE_MS,
1136
- isCleanupLockLeaseExpired,
1280
+ buildLibrarianCandidates,
1281
+ findAvailableModel,
1282
+ isModelAvailabilityError,
1283
+ parseModelPreference,
1284
+ resolveThinkingLevel,
1285
+ aggregateAssistantUsage,
1286
+ addAssistantMessageUsage,
1287
+ addSessionEventUsage,
1288
+ inspectFinalAssistant,
1289
+ classifyRunFailure,
1290
+ resolveLibrarianTerminalOutcome,
1291
+ createLibrarianAbortController,
1292
+ cleanupExpiredCache,
1293
+ cleanupExpiredRepos,
1294
+ cleanupLockOwnershipError,
1295
+ tryAcquireCleanupLock,
1296
+ heartbeatCleanupLock,
1297
+ releaseCleanupLock,
1298
+ CLEANUP_LOCK_FILE,
1299
+ CLEANUP_LOCK_STALE_MS,
1300
+ CLEANUP_LOCK_MAX_LEASE_MS,
1301
+ isCleanupLockLeaseExpired,
1137
1302
  };
1138
1303
 
1139
1304
  export default function librarianExtension(pi: ExtensionAPI) {
1140
- let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
1141
- let modelPreference: string | undefined;
1142
- let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
1143
-
1144
- pi.on("session_start", async () => {
1145
- const preferences = await readLibrarianPreferences();
1146
- cachePreference = preferences.cacheMode;
1147
- modelPreference = preferences.model;
1148
- thinkingPreference = preferences.thinkingLevel;
1149
- });
1150
-
1151
- const currentPreferences = (): LibrarianPreferences => ({
1152
- cacheMode: cachePreference,
1153
- ...(modelPreference ? { model: modelPreference } : {}),
1154
- thinkingLevel: thinkingPreference,
1155
- });
1156
-
1157
- const savePreferences = async (preferences: LibrarianPreferences): Promise<string | undefined> => {
1158
- cachePreference = preferences.cacheMode;
1159
- modelPreference = preferences.model;
1160
- thinkingPreference = preferences.thinkingLevel;
1161
- try {
1162
- await writeLibrarianPreferences(preferences);
1163
- return undefined;
1164
- } catch (error) {
1165
- const message = error instanceof Error ? error.message : String(error);
1166
- return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
1167
- }
1168
- };
1169
-
1170
- pi.registerCommand("librarian-config", {
1171
- description: "Configure Librarian subagent model and thinking defaults",
1172
- getArgumentCompletions: (prefix) => {
1173
- const parts = prefix.trim().split(/\s+/).filter(Boolean);
1174
- if (parts.length <= 1) {
1175
- const commands = ["status", "model", "thinking", "clear"];
1176
- const query = parts[0]?.toLowerCase() ?? "";
1177
- return commands.filter((command) => command.startsWith(query)).map((value) => ({ value, label: value }));
1178
- }
1179
- if (parts[0]?.toLowerCase() === "thinking") {
1180
- const query = parts[1]?.toLowerCase() ?? "";
1181
- return [...THINKING_LEVELS, "auto"].filter((level) => level.startsWith(query)).map((value) => ({ value, label: value }));
1182
- }
1183
- return null;
1184
- },
1185
- handler: async (args, ctx) => {
1186
- const tokens = args.trim().split(/\s+/).filter(Boolean);
1187
- const [command = "status", ...rest] = tokens;
1188
- const action = command.toLowerCase();
1189
-
1190
- if (action === "status" || action === "show") {
1191
- notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
1192
- return;
1193
- }
1194
-
1195
- if (action === "model") {
1196
- const value = rest.join(" ").trim();
1197
- if (!value) {
1198
- notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto>", "warning");
1199
- return;
1200
- }
1201
- const normalized = value.toLowerCase();
1202
- if (normalized === "current" || normalized.startsWith("current:")) {
1203
- notifyCommand(ctx, "Librarian model=current is no longer supported. Use /librarian-config model auto or choose an explicit provider/model.", "warning");
1204
- return;
1205
- }
1206
- const parsedModel = parseModelPreference(value);
1207
- const next = normalized === "auto" || normalized === "clear" || normalized === "default"
1208
- ? { ...currentPreferences(), model: undefined }
1209
- : {
1210
- ...currentPreferences(),
1211
- model: parsedModel.model,
1212
- thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
1213
- };
1214
- const warning = await savePreferences(next);
1215
- notifyCommand(ctx, `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1216
- return;
1217
- }
1218
-
1219
- if (action === "thinking" || action === "think" || action === "thinking-level") {
1220
- const value = rest[0]?.trim().toLowerCase();
1221
- if (!value) {
1222
- notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1223
- return;
1224
- }
1225
- const thinkingLevel = value === "auto" || value === "clear" || value === "default"
1226
- ? DEFAULT_THINKING_LEVEL
1227
- : parseThinkingLevel(value);
1228
- if (!thinkingLevel) {
1229
- notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1230
- return;
1231
- }
1232
- const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
1233
- notifyCommand(ctx, `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1234
- return;
1235
- }
1236
-
1237
- if (action === "clear" || action === "reset") {
1238
- const target = rest[0]?.trim().toLowerCase() || "all";
1239
- let next: LibrarianPreferences;
1240
- if (target === "all") next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
1241
- else if (target === "model") next = { ...currentPreferences(), model: undefined };
1242
- else if (target === "thinking" || target === "thinking-level") next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
1243
- else {
1244
- notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
1245
- return;
1246
- }
1247
- const warning = await savePreferences(next);
1248
- notifyCommand(ctx, `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1249
- return;
1250
- }
1251
-
1252
- notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]", "warning");
1253
- },
1254
- });
1255
-
1256
- pi.registerCommand("librarian-cache", {
1257
- description: "Toggle Librarian local checkout cache for future librarian calls",
1258
- getArgumentCompletions: (prefix) => {
1259
- const commands = ["on", "off", "toggle", "status"];
1260
- const query = prefix.trim().toLowerCase();
1261
- const matches = commands.filter((command) => command.startsWith(query));
1262
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1263
- },
1264
- handler: async (args, ctx) => {
1265
- const action = args.trim().toLowerCase() || "status";
1266
- const cacheRoot = getUserCacheReposRoot();
1267
- const configPath = getCacheConfigPath();
1268
-
1269
- const setPreference = async (mode: CacheMode): Promise<string | undefined> => {
1270
- cachePreference = mode;
1271
- try {
1272
- await writeCachePreference(mode);
1273
- return undefined;
1274
- } catch (error) {
1275
- const message = error instanceof Error ? error.message : String(error);
1276
- return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1277
- }
1278
- };
1279
-
1280
- const formatSetMessage = (mode: CacheMode, warning?: string): string => {
1281
- const main = mode === "enabled"
1282
- ? `Librarian cache enabled. Future librarian calls will reuse local checkouts under ${cacheRoot}.`
1283
- : "Librarian cache disabled. Future librarian calls will use GitHub API/search and temporary fetched files only.";
1284
- return warning ? `${main} ${warning}` : main;
1285
- };
1286
-
1287
- if (action === "on" || action === "enable") {
1288
- const warning = await setPreference("enabled");
1289
- notifyCommand(ctx, formatSetMessage("enabled", warning), warning ? "warning" : "info");
1290
- return;
1291
- }
1292
-
1293
- if (action === "off" || action === "disable") {
1294
- const warning = await setPreference("disabled");
1295
- notifyCommand(ctx, formatSetMessage("disabled", warning), warning ? "warning" : "info");
1296
- return;
1297
- }
1298
-
1299
- if (action === "toggle") {
1300
- const next = cachePreference === "enabled" ? "disabled" : "enabled";
1301
- const warning = await setPreference(next);
1302
- notifyCommand(ctx, formatSetMessage(next, warning), warning ? "warning" : "info");
1303
- return;
1304
- }
1305
-
1306
- if (action === "status") {
1307
- notifyCommand(
1308
- ctx,
1309
- `Librarian cache is ${formatCachePreference(cachePreference)}. Cache directory: ${cacheRoot}. Config: ${configPath}. Repos unused for ${CACHE_TTL_DAYS} days are removed lazily.`,
1310
- );
1311
- return;
1312
- }
1313
-
1314
- notifyCommand(ctx, "Usage: /librarian-cache on | off | toggle | status", "warning");
1315
- },
1316
- });
1317
-
1318
- pi.on("tool_result", async (event) => {
1319
- if (event.toolName !== "librarian") return undefined;
1320
- const details = event.details as LibrarianDetails | undefined;
1321
- if (details?.status !== "error") return undefined;
1322
- return { isError: true };
1323
- });
1324
-
1325
- pi.registerTool({
1326
- name: "librarian",
1327
- label: "Librarian",
1328
- description:
1329
- "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.",
1330
- promptSnippet:
1331
- "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.",
1332
- promptGuidelines: [
1333
- "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
1334
- "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
1335
- "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
1336
- ],
1337
- parameters: LibrarianParams,
1338
-
1339
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1340
- const rawQuery = (params as { query?: unknown }).query;
1341
- const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
1342
- if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
1343
-
1344
- const repos = asStringArray((params as { repos?: unknown }).repos);
1345
- const owners = asStringArray((params as { owners?: unknown }).owners);
1346
- const maxSearchResults = clampNumber(
1347
- (params as { maxSearchResults?: unknown }).maxSearchResults,
1348
- 1,
1349
- MAX_SEARCH_RESULTS,
1350
- DEFAULT_MAX_SEARCH_RESULTS,
1351
- );
1352
- const explicitModel = parseModelPreference((params as { model?: unknown }).model);
1353
- const thinkingLevel =
1354
- parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
1355
- explicitModel.thinkingLevel ??
1356
- thinkingPreference;
1357
- const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1358
- const selectedModel = candidates[0];
1359
-
1360
- const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
1361
- await fs.mkdir(workspaceBase, { recursive: true });
1362
- const workspace = await fs.mkdtemp(path.join(workspaceBase, "run-"));
1363
- await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
1364
-
1365
- const cacheRoot = getUserCacheReposRoot();
1366
- let cacheDecision = resolveCacheDecision(cachePreference);
1367
- let cleanup: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
1368
- if (cacheDecision.enabled) {
1369
- try {
1370
- await fs.mkdir(cacheRoot, { recursive: true });
1371
- cleanup = await cleanupExpiredCache(cacheRoot);
1372
- } catch (error) {
1373
- const message = error instanceof Error ? error.message : String(error);
1374
- cacheDecision = {
1375
- enabled: false,
1376
- reason: `cache setup failed (${message}); using GitHub API/temp files only`,
1377
- };
1378
- cleanup = { deleted: 0, errors: [`cache setup: ${message}`] };
1379
- }
1380
- }
1381
-
1382
- const details: LibrarianDetails = {
1383
- status: "running",
1384
- workspace,
1385
- cache: {
1386
- mode: cacheDecision.enabled ? "enabled" : "disabled",
1387
- root: cacheRoot,
1388
- ttlDays: CACHE_TTL_DAYS,
1389
- cleanupDeleted: cleanup.deleted,
1390
- cleanupErrors: cleanup.errors,
1391
- decisionReason: cacheDecision.reason,
1392
- },
1393
- model: selectedModel.details,
1394
- turns: 0,
1395
- toolCalls: [],
1396
- startedAt: Date.now(),
1397
- };
1398
-
1399
- let lastContent = "(searching GitHub...)";
1400
- let session: { abort: () => Promise<void>; dispose: () => void; state: { messages: unknown[] }; prompt: Function } | undefined;
1401
- let unsubscribe: (() => void) | undefined;
1402
- let runTimeout: NodeJS.Timeout | undefined;
1403
- let abortListenerAdded = false;
1404
- const usage = createEmptyUsage();
1405
-
1406
- const emit = (force = false) => {
1407
- void force;
1408
- onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1409
- };
1410
-
1411
- const abortController = createLibrarianAbortController(
1412
- () => void session?.abort(),
1413
- () => {
1414
- details.status = "aborted";
1415
- details.endedAt = Date.now();
1416
- lastContent = "Aborted";
1417
- emit(true);
1418
- },
1419
- );
1420
- const abortFromCaller = () => abortController.abortFromCaller();
1421
-
1422
- if (signal?.aborted) abortFromCaller();
1423
- if (signal && !signal.aborted) {
1424
- signal.addEventListener("abort", abortFromCaller);
1425
- abortListenerAdded = true;
1426
- }
1427
-
1428
- try {
1429
- emit(true);
1430
-
1431
- const systemPrompt = buildSystemPrompt({
1432
- workspace,
1433
- maxSearchResults,
1434
- cacheEnabled: cacheDecision.enabled,
1435
- cacheRoot,
1436
- });
1437
-
1438
- // Keep the research session from inheriting user/project tool, shell,
1439
- // retry, or provider-request settings. The host model runtime is still
1440
- // passed explicitly below so the selected provider/auth policy is kept.
1441
- const isolatedSettingsManager = SettingsManager.inMemory({});
1442
- const resourceLoader = new DefaultResourceLoader({
1443
- cwd: workspace,
1444
- agentDir: getAgentDir(),
1445
- settingsManager: isolatedSettingsManager,
1446
- noExtensions: true,
1447
- noSkills: true,
1448
- noPromptTemplates: true,
1449
- noThemes: true,
1450
- noContextFiles: true,
1451
- extensionFactories: [
1452
- createLibrarianRuntimeGuardExtension({
1453
- maxTurns: MAX_TURNS,
1454
- workspace,
1455
- cacheRoot,
1456
- cacheEnabled: cacheDecision.enabled,
1457
- }),
1458
- ],
1459
- systemPromptOverride: () => systemPrompt,
1460
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
1461
- agentsFilesOverride: () => ({ agentsFiles: [] }),
1462
- });
1463
-
1464
- await resourceLoader.reload();
1465
-
1466
- const runAttempt = async (
1467
- candidate: LibrarianCandidate,
1468
- ): Promise<ReturnType<typeof inspectFinalAssistant>> => {
1469
- details.model = candidate.details;
1470
- details.turns = 0;
1471
- details.toolCalls = [];
1472
- emit(true);
1473
-
1474
- const created = await createAgentSession({
1475
- cwd: workspace,
1476
- ...getModelRuntimeOption(ctx),
1477
- resourceLoader,
1478
- settingsManager: isolatedSettingsManager,
1479
- sessionManager: SessionManager.inMemory(workspace),
1480
- model: candidate.model,
1481
- thinkingLevel: candidate.details.thinkingLevel,
1482
- tools: ["read", "bash"],
1483
- });
1484
-
1485
- session = created.session as typeof session;
1486
- if (abortController.callerAborted) abortController.abortForCleanup();
1487
- unsubscribe = (created.session as any).subscribe((event: any) => {
1488
- addSessionEventUsage(usage, event);
1489
- switch (event.type) {
1490
- case "turn_end":
1491
- details.turns += 1;
1492
- emit();
1493
- break;
1494
- case "tool_execution_start":
1495
- details.toolCalls.push({
1496
- id: event.toolCallId,
1497
- name: event.toolName,
1498
- args: event.args,
1499
- startedAt: Date.now(),
1500
- });
1501
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1502
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1503
- }
1504
- emit(true);
1505
- break;
1506
- case "tool_execution_end": {
1507
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1508
- if (call) {
1509
- call.endedAt = Date.now();
1510
- call.isError = event.isError;
1511
- }
1512
- emit(true);
1513
- break;
1514
- }
1515
- }
1516
- });
1517
-
1518
- try {
1519
- if (!abortController.callerAborted) {
1520
- const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1521
- expandPromptTemplates: false,
1522
- });
1523
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1524
- runTimeout = setTimeout(() => {
1525
- abortController.abortForCleanup();
1526
- reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1527
- }, MAX_RUN_MS);
1528
- });
1529
- await Promise.race([promptPromise, timeoutPromise]);
1530
- }
1531
-
1532
- return inspectFinalAssistant(session?.state.messages ?? []);
1533
- } finally {
1534
- if (runTimeout) {
1535
- clearTimeout(runTimeout);
1536
- runTimeout = undefined;
1537
- }
1538
- unsubscribe?.();
1539
- unsubscribe = undefined;
1540
- session?.dispose();
1541
- session = undefined;
1542
- }
1543
- };
1544
-
1545
- let lastOutcome: ReturnType<typeof inspectFinalAssistant> | undefined;
1546
- let attemptErrorReason: string | undefined;
1547
- for (let index = 0; index < candidates.length; index++) {
1548
- if (abortController.callerAborted) break;
1549
- const candidate =
1550
- index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1551
-
1552
- let attempt: ReturnType<typeof inspectFinalAssistant>;
1553
- try {
1554
- attempt = await runAttempt(candidate);
1555
- } catch (error) {
1556
- if (abortController.callerAborted) throw error;
1557
- // Some providers throw (rather than carry the error on the assistant
1558
- // message) when a model is unavailable; treat that like a
1559
- // message-carried availability error and fall back to the next model.
1560
- const message = error instanceof Error ? error.message : String(error);
1561
- if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1562
- lastOutcome = undefined;
1563
- attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1564
- continue;
1565
- }
1566
- throw error;
1567
- }
1568
-
1569
- lastOutcome = attempt;
1570
- if (abortController.callerAborted || attempt.ok) break;
1571
-
1572
- attemptErrorReason = attempt.stopReason ? attempt.reason : describeNoAnswerReason(attempt.message, details.turns);
1573
- const canFallBack =
1574
- index < candidates.length - 1 &&
1575
- attempt.stopReason === "error" &&
1576
- isModelAvailabilityError(attempt.errorMessage);
1577
- if (!canFallBack) break;
1578
- }
1579
-
1580
- const terminal = resolveLibrarianTerminalOutcome(lastOutcome, abortController.callerAborted, details.turns, attemptErrorReason);
1581
- details.status = terminal.status;
1582
- if (terminal.status === "error") {
1583
- lastContent = formatInternalFailure(details, terminal.reason);
1584
- details.error = terminal.reason;
1585
- } else {
1586
- lastContent = terminal.content;
1587
- }
1588
- details.endedAt = Date.now();
1589
- emit(true);
1590
-
1591
- return {
1592
- content: [{ type: "text", text: lastContent }],
1593
- details,
1594
- usage,
1595
- };
1596
- } catch (error) {
1597
- const failure = classifyRunFailure(error, abortController.callerAborted);
1598
- const { message } = failure;
1599
- details.status = failure.status;
1600
- details.error = failure.error;
1601
- details.endedAt = Date.now();
1602
- lastContent = message;
1603
- emit(true);
1604
-
1605
- return {
1606
- content: [{ type: "text", text: message }],
1607
- details,
1608
- usage,
1609
- };
1610
- } finally {
1611
- if (runTimeout) clearTimeout(runTimeout);
1612
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1613
- unsubscribe?.();
1614
- session?.dispose();
1615
- }
1616
- },
1617
-
1618
- renderCall(args, theme) {
1619
- const query = typeof (args as { query?: unknown })?.query === "string" ? (args as { query: string }).query : "";
1620
- const repos = Array.isArray((args as { repos?: unknown })?.repos) ? (args as { repos: unknown[] }).repos.length : 0;
1621
- const owners = Array.isArray((args as { owners?: unknown })?.owners)
1622
- ? (args as { owners: unknown[] }).owners.length
1623
- : 0;
1624
- return new Text(
1625
- `${theme.fg("muted", `repos:${repos} owners:${owners}`)} · ${theme.fg("toolOutput", shorten(query, 80))}`,
1626
- 0,
1627
- 0,
1628
- );
1629
- },
1630
-
1631
- renderResult(result, { expanded, isPartial }, theme) {
1632
- const details = result.details as LibrarianDetails | undefined;
1633
- if (!details) {
1634
- const first = result.content[0];
1635
- return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
1636
- }
1637
-
1638
- const status = isPartial ? "running" : details.status;
1639
- const icon =
1640
- status === "done"
1641
- ? theme.fg("success", "✓")
1642
- : status === "error"
1643
- ? theme.fg("error", "✗")
1644
- : status === "aborted"
1645
- ? theme.fg("warning", "◼")
1646
- : theme.fg("warning", "⏳");
1647
-
1648
- const cacheLabel =
1649
- details.cache.mode === "enabled"
1650
- ? theme.fg("success", "cache:on")
1651
- : theme.fg("muted", "cache:off");
1652
- const header = `${icon} ${theme.fg("toolTitle", theme.bold("librarian "))}${theme.fg(
1653
- "dim",
1654
- `${details.turns} turns • ${details.toolCalls.length} tools • `,
1655
- )}${cacheLabel}`;
1656
-
1657
- const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
1658
- const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
1659
- "dim",
1660
- `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
1661
- )}`;
1662
- const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
1663
- "dim",
1664
- `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
1665
- )}`;
1666
-
1667
- const answer =
1668
- (result.content[0]?.type === "text" ? result.content[0].text : renderAnswer(details)).trim() || "(no output)";
1669
-
1670
- const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
1671
- const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
1672
- return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
1673
- });
1674
- if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
1675
-
1676
- if (status === "running") {
1677
- const parts = [header, workspaceLine, modelLine, cacheLine];
1678
- if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1679
- parts.push("", theme.fg("muted", "Searching GitHub…"));
1680
- return new Text(parts.join("\n"), 0, 0);
1681
- }
1682
-
1683
- if (!expanded) {
1684
- const previewLines = answer.split("\n").slice(0, 18);
1685
- const parts = [header, workspaceLine, modelLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
1686
- if (answer.split("\n").length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
1687
- if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1688
- return new Text(parts.join("\n"), 0, 0);
1689
- }
1690
-
1691
- const container = new Container();
1692
- container.addChild(new Text(header, 0, 0));
1693
- container.addChild(new Text(workspaceLine, 0, 0));
1694
- container.addChild(new Text(modelLine, 0, 0));
1695
- container.addChild(new Text(cacheLine, 0, 0));
1696
- if (details.cache.cleanupErrors.length) {
1697
- container.addChild(
1698
- new Text(theme.fg("warning", `cache cleanup warnings: ${details.cache.cleanupErrors.length}`), 0, 0),
1699
- );
1700
- }
1701
- if (toolLines.length) {
1702
- container.addChild(new Spacer(1));
1703
- container.addChild(new Text([theme.fg("muted", "Tools:"), ...toolLines].join("\n"), 0, 0));
1704
- }
1705
- container.addChild(new Spacer(1));
1706
- container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
1707
- return container;
1708
- },
1709
- });
1305
+ let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
1306
+ let modelPreference: string | undefined;
1307
+ let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
1308
+
1309
+ pi.on("session_start", async () => {
1310
+ const preferences = await readLibrarianPreferences();
1311
+ cachePreference = preferences.cacheMode;
1312
+ modelPreference = preferences.model;
1313
+ thinkingPreference = preferences.thinkingLevel;
1314
+ });
1315
+
1316
+ const currentPreferences = (): LibrarianPreferences => ({
1317
+ cacheMode: cachePreference,
1318
+ ...(modelPreference ? { model: modelPreference } : {}),
1319
+ thinkingLevel: thinkingPreference,
1320
+ });
1321
+
1322
+ const savePreferences = async (
1323
+ preferences: LibrarianPreferences,
1324
+ ): Promise<string | undefined> => {
1325
+ cachePreference = preferences.cacheMode;
1326
+ modelPreference = preferences.model;
1327
+ thinkingPreference = preferences.thinkingLevel;
1328
+ try {
1329
+ await writeLibrarianPreferences(preferences);
1330
+ return undefined;
1331
+ } catch (error) {
1332
+ const message = error instanceof Error ? error.message : String(error);
1333
+ return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
1334
+ }
1335
+ };
1336
+
1337
+ pi.registerCommand("librarian-config", {
1338
+ description: "Configure Librarian subagent model and thinking defaults",
1339
+ getArgumentCompletions: (prefix) => {
1340
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
1341
+ if (parts.length <= 1) {
1342
+ const commands = ["status", "model", "thinking", "clear"];
1343
+ const query = parts[0]?.toLowerCase() ?? "";
1344
+ return commands
1345
+ .filter((command) => command.startsWith(query))
1346
+ .map((value) => ({ value, label: value }));
1347
+ }
1348
+ if (parts[0]?.toLowerCase() === "thinking") {
1349
+ const query = parts[1]?.toLowerCase() ?? "";
1350
+ return [...THINKING_LEVELS, "auto"]
1351
+ .filter((level) => level.startsWith(query))
1352
+ .map((value) => ({ value, label: value }));
1353
+ }
1354
+ return null;
1355
+ },
1356
+ handler: async (args, ctx) => {
1357
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
1358
+ const [command = "status", ...rest] = tokens;
1359
+ const action = command.toLowerCase();
1360
+
1361
+ if (action === "status" || action === "show") {
1362
+ notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
1363
+ return;
1364
+ }
1365
+
1366
+ if (action === "model") {
1367
+ const value = rest.join(" ").trim();
1368
+ if (!value) {
1369
+ notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto>", "warning");
1370
+ return;
1371
+ }
1372
+ const normalized = value.toLowerCase();
1373
+ if (normalized === "current" || normalized.startsWith("current:")) {
1374
+ notifyCommand(
1375
+ ctx,
1376
+ "Librarian model=current is no longer supported. Use /librarian-config model auto or choose an explicit provider/model.",
1377
+ "warning",
1378
+ );
1379
+ return;
1380
+ }
1381
+ const parsedModel = parseModelPreference(value);
1382
+ const next =
1383
+ normalized === "auto" || normalized === "clear" || normalized === "default"
1384
+ ? { ...currentPreferences(), model: undefined }
1385
+ : {
1386
+ ...currentPreferences(),
1387
+ model: parsedModel.model,
1388
+ thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
1389
+ };
1390
+ const warning = await savePreferences(next);
1391
+ notifyCommand(
1392
+ ctx,
1393
+ `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1394
+ warning ? "warning" : "info",
1395
+ );
1396
+ return;
1397
+ }
1398
+
1399
+ if (action === "thinking" || action === "think" || action === "thinking-level") {
1400
+ const value = rest[0]?.trim().toLowerCase();
1401
+ if (!value) {
1402
+ notifyCommand(
1403
+ ctx,
1404
+ `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1405
+ "warning",
1406
+ );
1407
+ return;
1408
+ }
1409
+ const thinkingLevel =
1410
+ value === "auto" || value === "clear" || value === "default"
1411
+ ? DEFAULT_THINKING_LEVEL
1412
+ : parseThinkingLevel(value);
1413
+ if (!thinkingLevel) {
1414
+ notifyCommand(
1415
+ ctx,
1416
+ `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1417
+ "warning",
1418
+ );
1419
+ return;
1420
+ }
1421
+ const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
1422
+ notifyCommand(
1423
+ ctx,
1424
+ `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1425
+ warning ? "warning" : "info",
1426
+ );
1427
+ return;
1428
+ }
1429
+
1430
+ if (action === "clear" || action === "reset") {
1431
+ const target = rest[0]?.trim().toLowerCase() || "all";
1432
+ let next: LibrarianPreferences;
1433
+ if (target === "all")
1434
+ next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
1435
+ else if (target === "model") next = { ...currentPreferences(), model: undefined };
1436
+ else if (target === "thinking" || target === "thinking-level")
1437
+ next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
1438
+ else {
1439
+ notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
1440
+ return;
1441
+ }
1442
+ const warning = await savePreferences(next);
1443
+ notifyCommand(
1444
+ ctx,
1445
+ `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1446
+ warning ? "warning" : "info",
1447
+ );
1448
+ return;
1449
+ }
1450
+
1451
+ notifyCommand(
1452
+ ctx,
1453
+ "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]",
1454
+ "warning",
1455
+ );
1456
+ },
1457
+ });
1458
+
1459
+ pi.registerCommand("librarian-cache", {
1460
+ description: "Toggle Librarian local checkout cache for future librarian calls",
1461
+ getArgumentCompletions: (prefix) => {
1462
+ const commands = ["on", "off", "toggle", "status"];
1463
+ const query = prefix.trim().toLowerCase();
1464
+ const matches = commands.filter((command) => command.startsWith(query));
1465
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1466
+ },
1467
+ handler: async (args, ctx) => {
1468
+ const action = args.trim().toLowerCase() || "status";
1469
+ const cacheRoot = getUserCacheReposRoot();
1470
+ const configPath = getCacheConfigPath();
1471
+
1472
+ const setPreference = async (mode: CacheMode): Promise<string | undefined> => {
1473
+ cachePreference = mode;
1474
+ try {
1475
+ await writeCachePreference(mode);
1476
+ return undefined;
1477
+ } catch (error) {
1478
+ const message = error instanceof Error ? error.message : String(error);
1479
+ return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1480
+ }
1481
+ };
1482
+
1483
+ const formatSetMessage = (mode: CacheMode, warning?: string): string => {
1484
+ const main =
1485
+ mode === "enabled"
1486
+ ? `Librarian cache enabled. Future librarian calls will reuse local checkouts under ${cacheRoot}.`
1487
+ : "Librarian cache disabled. Future librarian calls will use GitHub API/search and temporary fetched files only.";
1488
+ return warning ? `${main} ${warning}` : main;
1489
+ };
1490
+
1491
+ if (action === "on" || action === "enable") {
1492
+ const warning = await setPreference("enabled");
1493
+ notifyCommand(ctx, formatSetMessage("enabled", warning), warning ? "warning" : "info");
1494
+ return;
1495
+ }
1496
+
1497
+ if (action === "off" || action === "disable") {
1498
+ const warning = await setPreference("disabled");
1499
+ notifyCommand(ctx, formatSetMessage("disabled", warning), warning ? "warning" : "info");
1500
+ return;
1501
+ }
1502
+
1503
+ if (action === "toggle") {
1504
+ const next = cachePreference === "enabled" ? "disabled" : "enabled";
1505
+ const warning = await setPreference(next);
1506
+ notifyCommand(ctx, formatSetMessage(next, warning), warning ? "warning" : "info");
1507
+ return;
1508
+ }
1509
+
1510
+ if (action === "status") {
1511
+ notifyCommand(
1512
+ ctx,
1513
+ `Librarian cache is ${formatCachePreference(cachePreference)}. Cache directory: ${cacheRoot}. Config: ${configPath}. Repos unused for ${CACHE_TTL_DAYS} days are removed lazily.`,
1514
+ );
1515
+ return;
1516
+ }
1517
+
1518
+ notifyCommand(ctx, "Usage: /librarian-cache on | off | toggle | status", "warning");
1519
+ },
1520
+ });
1521
+
1522
+ pi.on("tool_result", async (event) => {
1523
+ if (event.toolName !== "librarian") return undefined;
1524
+ const details = event.details as LibrarianDetails | undefined;
1525
+ if (details?.status !== "error") return undefined;
1526
+ return { isError: true };
1527
+ });
1528
+
1529
+ pi.registerTool({
1530
+ name: "librarian",
1531
+ label: "Librarian",
1532
+ description:
1533
+ "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.",
1534
+ promptSnippet:
1535
+ "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.",
1536
+ promptGuidelines: [
1537
+ "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
1538
+ "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
1539
+ "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
1540
+ ],
1541
+ parameters: LibrarianParams,
1542
+
1543
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1544
+ const rawQuery = (params as { query?: unknown }).query;
1545
+ const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
1546
+ if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
1547
+
1548
+ const repos = asStringArray((params as { repos?: unknown }).repos);
1549
+ const owners = asStringArray((params as { owners?: unknown }).owners);
1550
+ const maxSearchResults = clampNumber(
1551
+ (params as { maxSearchResults?: unknown }).maxSearchResults,
1552
+ 1,
1553
+ MAX_SEARCH_RESULTS,
1554
+ DEFAULT_MAX_SEARCH_RESULTS,
1555
+ );
1556
+ const explicitModel = parseModelPreference((params as { model?: unknown }).model);
1557
+ const thinkingLevel =
1558
+ parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
1559
+ explicitModel.thinkingLevel ??
1560
+ thinkingPreference;
1561
+ const candidates = await buildLibrarianCandidates(
1562
+ ctx,
1563
+ explicitModel.model ?? modelPreference,
1564
+ thinkingLevel,
1565
+ );
1566
+ const selectedModel = candidates[0];
1567
+
1568
+ const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
1569
+ await fs.mkdir(workspaceBase, { recursive: true });
1570
+ const workspace = await fs.mkdtemp(path.join(workspaceBase, "run-"));
1571
+ await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
1572
+
1573
+ const cacheRoot = getUserCacheReposRoot();
1574
+ let cacheDecision = resolveCacheDecision(cachePreference);
1575
+ let cleanup: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
1576
+ if (cacheDecision.enabled) {
1577
+ try {
1578
+ await fs.mkdir(cacheRoot, { recursive: true });
1579
+ cleanup = await cleanupExpiredCache(cacheRoot);
1580
+ } catch (error) {
1581
+ const message = error instanceof Error ? error.message : String(error);
1582
+ cacheDecision = {
1583
+ enabled: false,
1584
+ reason: `cache setup failed (${message}); using GitHub API/temp files only`,
1585
+ };
1586
+ cleanup = { deleted: 0, errors: [`cache setup: ${message}`] };
1587
+ }
1588
+ }
1589
+
1590
+ const details: LibrarianDetails = {
1591
+ status: "running",
1592
+ workspace,
1593
+ cache: {
1594
+ mode: cacheDecision.enabled ? "enabled" : "disabled",
1595
+ root: cacheRoot,
1596
+ ttlDays: CACHE_TTL_DAYS,
1597
+ cleanupDeleted: cleanup.deleted,
1598
+ cleanupErrors: cleanup.errors,
1599
+ decisionReason: cacheDecision.reason,
1600
+ },
1601
+ model: selectedModel.details,
1602
+ turns: 0,
1603
+ toolCalls: [],
1604
+ startedAt: Date.now(),
1605
+ };
1606
+
1607
+ let lastContent = "(searching GitHub...)";
1608
+ let session:
1609
+ | {
1610
+ abort: () => Promise<void>;
1611
+ dispose: () => void;
1612
+ state: { messages: unknown[] };
1613
+ prompt: Function;
1614
+ }
1615
+ | undefined;
1616
+ let unsubscribe: (() => void) | undefined;
1617
+ let runTimeout: NodeJS.Timeout | undefined;
1618
+ let abortListenerAdded = false;
1619
+ const usage = createEmptyUsage();
1620
+
1621
+ const emit = () => {
1622
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1623
+ };
1624
+
1625
+ const abortController = createLibrarianAbortController(
1626
+ () => void session?.abort(),
1627
+ () => {
1628
+ details.status = "aborted";
1629
+ details.endedAt = Date.now();
1630
+ lastContent = "Aborted";
1631
+ emit();
1632
+ },
1633
+ );
1634
+ const abortFromCaller = () => abortController.abortFromCaller();
1635
+
1636
+ if (signal?.aborted) abortFromCaller();
1637
+ if (signal && !signal.aborted) {
1638
+ signal.addEventListener("abort", abortFromCaller);
1639
+ abortListenerAdded = true;
1640
+ }
1641
+
1642
+ try {
1643
+ emit();
1644
+
1645
+ const systemPrompt = buildSystemPrompt({
1646
+ workspace,
1647
+ maxSearchResults,
1648
+ cacheEnabled: cacheDecision.enabled,
1649
+ cacheRoot,
1650
+ });
1651
+
1652
+ // Keep the research session from inheriting user/project tool, shell,
1653
+ // retry, or provider-request settings. The host model runtime is still
1654
+ // passed explicitly below so the selected provider/auth policy is kept.
1655
+ const isolatedSettingsManager = SettingsManager.inMemory({});
1656
+ const resourceLoader = new DefaultResourceLoader({
1657
+ cwd: workspace,
1658
+ agentDir: getAgentDir(),
1659
+ settingsManager: isolatedSettingsManager,
1660
+ noExtensions: true,
1661
+ noSkills: true,
1662
+ noPromptTemplates: true,
1663
+ noThemes: true,
1664
+ noContextFiles: true,
1665
+ extensionFactories: [
1666
+ createLibrarianRuntimeGuardExtension({
1667
+ maxTurns: MAX_TURNS,
1668
+ workspace,
1669
+ cacheRoot,
1670
+ cacheEnabled: cacheDecision.enabled,
1671
+ }),
1672
+ ],
1673
+ systemPromptOverride: () => systemPrompt,
1674
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
1675
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
1676
+ });
1677
+
1678
+ await resourceLoader.reload();
1679
+
1680
+ const runAttempt = async (
1681
+ candidate: LibrarianCandidate,
1682
+ ): Promise<ReturnType<typeof inspectFinalAssistant>> => {
1683
+ details.model = candidate.details;
1684
+ details.turns = 0;
1685
+ details.toolCalls = [];
1686
+ emit();
1687
+
1688
+ const created = await createAgentSession({
1689
+ cwd: workspace,
1690
+ ...getModelRuntimeOption(ctx),
1691
+ resourceLoader,
1692
+ settingsManager: isolatedSettingsManager,
1693
+ sessionManager: SessionManager.inMemory(workspace),
1694
+ model: candidate.model,
1695
+ thinkingLevel: candidate.details.thinkingLevel,
1696
+ tools: ["read", "bash"],
1697
+ });
1698
+
1699
+ session = created.session as typeof session;
1700
+ if (abortController.callerAborted) abortController.abortForCleanup();
1701
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1702
+ addSessionEventUsage(usage, event);
1703
+ switch (event.type) {
1704
+ case "turn_end":
1705
+ details.turns += 1;
1706
+ emit();
1707
+ break;
1708
+ case "tool_execution_start":
1709
+ details.toolCalls.push({
1710
+ id: event.toolCallId,
1711
+ name: event.toolName,
1712
+ args: event.args,
1713
+ startedAt: Date.now(),
1714
+ });
1715
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1716
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1717
+ }
1718
+ emit();
1719
+ break;
1720
+ case "tool_execution_end": {
1721
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1722
+ if (call) {
1723
+ call.endedAt = Date.now();
1724
+ call.isError = event.isError;
1725
+ }
1726
+ emit();
1727
+ break;
1728
+ }
1729
+ }
1730
+ });
1731
+
1732
+ try {
1733
+ if (!abortController.callerAborted) {
1734
+ const promptPromise = created.session.prompt(
1735
+ buildUserPrompt(query, repos, owners, maxSearchResults, details.cache),
1736
+ {
1737
+ expandPromptTemplates: false,
1738
+ },
1739
+ );
1740
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1741
+ runTimeout = setTimeout(() => {
1742
+ abortController.abortForCleanup();
1743
+ reject(
1744
+ new Error(
1745
+ `Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1746
+ ),
1747
+ );
1748
+ }, MAX_RUN_MS);
1749
+ });
1750
+ await Promise.race([promptPromise, timeoutPromise]);
1751
+ }
1752
+
1753
+ return inspectFinalAssistant(session?.state.messages ?? []);
1754
+ } finally {
1755
+ if (runTimeout) {
1756
+ clearTimeout(runTimeout);
1757
+ runTimeout = undefined;
1758
+ }
1759
+ unsubscribe?.();
1760
+ unsubscribe = undefined;
1761
+ session?.dispose();
1762
+ session = undefined;
1763
+ }
1764
+ };
1765
+
1766
+ let lastOutcome: ReturnType<typeof inspectFinalAssistant> | undefined;
1767
+ let attemptErrorReason: string | undefined;
1768
+ for (let index = 0; index < candidates.length; index++) {
1769
+ if (abortController.callerAborted) break;
1770
+ const candidate =
1771
+ index === 0
1772
+ ? candidates[index]
1773
+ : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1774
+
1775
+ let attempt: ReturnType<typeof inspectFinalAssistant>;
1776
+ try {
1777
+ attempt = await runAttempt(candidate);
1778
+ } catch (error) {
1779
+ if (abortController.callerAborted) throw error;
1780
+ // Some providers throw (rather than carry the error on the assistant
1781
+ // message) when a model is unavailable; treat that like a
1782
+ // message-carried availability error and fall back to the next model.
1783
+ const message = error instanceof Error ? error.message : String(error);
1784
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1785
+ lastOutcome = undefined;
1786
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1787
+ continue;
1788
+ }
1789
+ throw error;
1790
+ }
1791
+
1792
+ lastOutcome = attempt;
1793
+ if (abortController.callerAborted || attempt.ok) break;
1794
+
1795
+ attemptErrorReason = attempt.stopReason
1796
+ ? attempt.reason
1797
+ : describeNoAnswerReason(attempt.message, details.turns);
1798
+ const canFallBack =
1799
+ index < candidates.length - 1 &&
1800
+ attempt.stopReason === "error" &&
1801
+ isModelAvailabilityError(attempt.errorMessage);
1802
+ if (!canFallBack) break;
1803
+ }
1804
+
1805
+ const terminal = resolveLibrarianTerminalOutcome(
1806
+ lastOutcome,
1807
+ abortController.callerAborted,
1808
+ details.turns,
1809
+ attemptErrorReason,
1810
+ );
1811
+ details.status = terminal.status;
1812
+ if (terminal.status === "error") {
1813
+ lastContent = formatInternalFailure(details, terminal.reason);
1814
+ details.error = terminal.reason;
1815
+ } else {
1816
+ lastContent = terminal.content;
1817
+ }
1818
+ details.endedAt = Date.now();
1819
+ emit();
1820
+
1821
+ return {
1822
+ content: [{ type: "text", text: lastContent }],
1823
+ details,
1824
+ usage,
1825
+ };
1826
+ } catch (error) {
1827
+ const failure = classifyRunFailure(error, abortController.callerAborted);
1828
+ const { message } = failure;
1829
+ details.status = failure.status;
1830
+ details.error = failure.error;
1831
+ details.endedAt = Date.now();
1832
+ lastContent = message;
1833
+ emit();
1834
+
1835
+ return {
1836
+ content: [{ type: "text", text: message }],
1837
+ details,
1838
+ usage,
1839
+ };
1840
+ } finally {
1841
+ if (runTimeout) clearTimeout(runTimeout);
1842
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1843
+ unsubscribe?.();
1844
+ session?.dispose();
1845
+ }
1846
+ },
1847
+
1848
+ renderCall(args, theme) {
1849
+ const query =
1850
+ typeof (args as { query?: unknown })?.query === "string"
1851
+ ? (args as { query: string }).query
1852
+ : "";
1853
+ const repos = Array.isArray((args as { repos?: unknown })?.repos)
1854
+ ? (args as { repos: unknown[] }).repos.length
1855
+ : 0;
1856
+ const owners = Array.isArray((args as { owners?: unknown })?.owners)
1857
+ ? (args as { owners: unknown[] }).owners.length
1858
+ : 0;
1859
+ return new Text(
1860
+ `${theme.fg("muted", `repos:${repos} owners:${owners}`)} · ${theme.fg("toolOutput", shorten(query, 80))}`,
1861
+ 0,
1862
+ 0,
1863
+ );
1864
+ },
1865
+
1866
+ renderResult(result, { expanded, isPartial }, theme) {
1867
+ const details = result.details as LibrarianDetails | undefined;
1868
+ if (!details) {
1869
+ const first = result.content[0];
1870
+ return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
1871
+ }
1872
+
1873
+ const status = isPartial ? "running" : details.status;
1874
+ const icon =
1875
+ status === "done"
1876
+ ? theme.fg("success", "✓")
1877
+ : status === "error"
1878
+ ? theme.fg("error", "✗")
1879
+ : status === "aborted"
1880
+ ? theme.fg("warning", "◼")
1881
+ : theme.fg("warning", "⏳");
1882
+
1883
+ const cacheLabel =
1884
+ details.cache.mode === "enabled"
1885
+ ? theme.fg("success", "cache:on")
1886
+ : theme.fg("muted", "cache:off");
1887
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("librarian "))}${theme.fg(
1888
+ "dim",
1889
+ `${details.turns} turns • ${details.toolCalls.length} tools • `,
1890
+ )}${cacheLabel}`;
1891
+
1892
+ const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
1893
+ const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
1894
+ "dim",
1895
+ `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
1896
+ )}`;
1897
+ const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
1898
+ "dim",
1899
+ `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
1900
+ )}`;
1901
+
1902
+ const answer =
1903
+ (result.content[0]?.type === "text"
1904
+ ? result.content[0].text
1905
+ : renderAnswer(details)
1906
+ ).trim() || "(no output)";
1907
+
1908
+ const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
1909
+ const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
1910
+ return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
1911
+ });
1912
+ if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
1913
+
1914
+ if (status === "running") {
1915
+ const parts = [header, workspaceLine, modelLine, cacheLine];
1916
+ if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1917
+ parts.push("", theme.fg("muted", "Searching GitHub…"));
1918
+ return new Text(parts.join("\n"), 0, 0);
1919
+ }
1920
+
1921
+ if (!expanded) {
1922
+ const previewLines = answer.split("\n").slice(0, 18);
1923
+ const parts = [
1924
+ header,
1925
+ workspaceLine,
1926
+ modelLine,
1927
+ cacheLine,
1928
+ "",
1929
+ theme.fg("toolOutput", previewLines.join("\n")),
1930
+ ];
1931
+ if (answer.split("\n").length > previewLines.length)
1932
+ parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
1933
+ if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1934
+ return new Text(parts.join("\n"), 0, 0);
1935
+ }
1936
+
1937
+ const container = new Container();
1938
+ container.addChild(new Text(header, 0, 0));
1939
+ container.addChild(new Text(workspaceLine, 0, 0));
1940
+ container.addChild(new Text(modelLine, 0, 0));
1941
+ container.addChild(new Text(cacheLine, 0, 0));
1942
+ if (details.cache.cleanupErrors.length) {
1943
+ container.addChild(
1944
+ new Text(
1945
+ theme.fg("warning", `cache cleanup warnings: ${details.cache.cleanupErrors.length}`),
1946
+ 0,
1947
+ 0,
1948
+ ),
1949
+ );
1950
+ }
1951
+ if (toolLines.length) {
1952
+ container.addChild(new Spacer(1));
1953
+ container.addChild(new Text([theme.fg("muted", "Tools:"), ...toolLines].join("\n"), 0, 0));
1954
+ }
1955
+ container.addChild(new Spacer(1));
1956
+ container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
1957
+ return container;
1958
+ },
1959
+ });
1710
1960
  }