@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.
- package/LICENSE +2 -0
- package/README.md +9 -4
- package/extensions/agent-workflow-audit/index.ts +1398 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +853 -767
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +1 -1
- package/extensions/code-reviewer/index.ts +1598 -1350
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +55 -51
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/index.ts +312 -304
- package/extensions/context-cap/package.json +1 -1
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/index.ts +1854 -1735
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +41 -41
- package/extensions/dynamic-context-pruning/README.md +6 -38
- package/extensions/dynamic-context-pruning/index.ts +2124 -1980
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +71 -21
- package/extensions/fast/index.ts +513 -313
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +88 -88
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/index.ts +1669 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/index.ts +994 -1012
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/index.ts +1731 -1625
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/index.ts +658 -608
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/index.ts +282 -264
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +284 -265
- package/extensions/triage-comments/README.md +2 -2
- package/extensions/triage-comments/index.ts +2294 -1882
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +6 -3
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- 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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
provider: string;
|
|
49
|
+
id: string;
|
|
50
|
+
reasoning?: boolean;
|
|
51
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
type ModelSelectionContext = {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
+
return `${model.provider}/${model.id}`.toLowerCase();
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
function hasModelScope(ctx: ModelSelectionContext): boolean {
|
|
61
|
-
|
|
65
|
+
return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
function isModelInScope(ctx: ModelSelectionContext, model: any): boolean {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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: {
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
219
|
+
if (process.platform === "darwin") {
|
|
220
|
+
return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
|
|
221
|
+
}
|
|
210
222
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
311
|
+
pid: number;
|
|
312
|
+
hostname: string;
|
|
313
|
+
token: string;
|
|
314
|
+
acquiredAt: string;
|
|
293
315
|
};
|
|
294
316
|
|
|
295
317
|
type CleanupLock = {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
handle: FileHandle;
|
|
319
|
+
path: string;
|
|
320
|
+
owner: CleanupLockOwner;
|
|
321
|
+
identity: Pick<Stats, "dev" | "ino">;
|
|
300
322
|
};
|
|
301
323
|
|
|
302
|
-
function sameFileIdentity(
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async function removeLockPathIfOwned(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async function cleanupExpiredRepos(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
567
|
+
return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
|
|
523
568
|
}
|
|
524
569
|
|
|
525
570
|
type LibrarianPreferences = {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
571
|
+
cacheMode: CacheMode;
|
|
572
|
+
model?: string;
|
|
573
|
+
thinkingLevel: ThinkingLevel;
|
|
529
574
|
};
|
|
530
575
|
|
|
531
576
|
function parseCacheMode(value: unknown): CacheMode | undefined {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
696
|
+
if (preference === "enabled") {
|
|
697
|
+
return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
|
|
698
|
+
}
|
|
649
699
|
|
|
650
|
-
|
|
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
|
-
|
|
704
|
+
return preference === "enabled" ? "on" : "off";
|
|
655
705
|
}
|
|
656
706
|
|
|
657
707
|
function formatLibrarianPreferences(preferences: LibrarianPreferences): string {
|
|
658
|
-
|
|
708
|
+
return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
|
|
659
709
|
}
|
|
660
710
|
|
|
661
|
-
function notifyCommand(
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
721
|
+
console.error(message);
|
|
668
722
|
}
|
|
669
723
|
|
|
670
724
|
function modelRef(model: any): string {
|
|
671
|
-
|
|
725
|
+
return `${model.provider}/${model.id}`;
|
|
672
726
|
}
|
|
673
727
|
|
|
674
728
|
function modelCostScore(model: any): number {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
707
|
-
|
|
760
|
+
ctx: ModelSelectionContext,
|
|
761
|
+
modelPreference: string,
|
|
708
762
|
): Promise<any | undefined> {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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(
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
825
|
+
ctx: ModelSelectionContext,
|
|
826
|
+
modelPreference: string | undefined,
|
|
827
|
+
thinkingLevel: ThinkingLevel,
|
|
764
828
|
): Promise<LibrarianCandidate[]> {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
function getBlockedBashReason(
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
972
|
+
maxTurns: number;
|
|
973
|
+
workspace: string;
|
|
974
|
+
cacheRoot: string;
|
|
975
|
+
cacheEnabled: boolean;
|
|
884
976
|
}): ExtensionFactory {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1039
|
+
workspace: string;
|
|
1040
|
+
maxSearchResults: number;
|
|
1041
|
+
cacheEnabled: boolean;
|
|
1042
|
+
cacheRoot: string;
|
|
944
1043
|
}): string {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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(
|
|
954
|
-
|
|
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
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
):
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
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
|
}
|