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