@thurstonsand/pi-librarian 0.1.0

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.
@@ -0,0 +1,101 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { GitHubApiError, type GitHubClientProvider } from "../github.ts";
4
+ import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
5
+
6
+ export interface SearchReposDetails {
7
+ kind: "search_repos";
8
+ query: string;
9
+ resultCount: number;
10
+ totalCount: number;
11
+ }
12
+
13
+ const SearchReposParams = Type.Object({
14
+ query: Type.String({
15
+ description:
16
+ "GitHub repository search query. Plain terms plus qualifiers like language:typescript, topic:orm, stars:>1000, org:vercel.",
17
+ }),
18
+ sort: Type.Optional(
19
+ Type.Union([Type.Literal("stars"), Type.Literal("updated"), Type.Literal("best-match")], {
20
+ description: "Result ordering. Default: stars.",
21
+ }),
22
+ ),
23
+ limit: Type.Optional(
24
+ Type.Number({
25
+ minimum: 1,
26
+ maximum: 50,
27
+ description: "Max results. Default: 10.",
28
+ }),
29
+ ),
30
+ });
31
+
32
+ export function createSearchReposTool(githubClient: GitHubClientProvider) {
33
+ return defineTool<typeof SearchReposParams, SearchReposDetails>({
34
+ name: LIBRARIAN_TOOL_NAMES.searchRepos,
35
+ label: "Search repos metadata",
36
+ description: "Discover GitHub repositories.",
37
+ promptSnippet: "Search repos metadata",
38
+ promptGuidelines: ["Use for questions like 'what are the popular X libraries'."],
39
+ parameters: SearchReposParams,
40
+
41
+ async execute(_toolCallId, params) {
42
+ const limit = params.limit ?? 10;
43
+ try {
44
+ const github = await githubClient();
45
+ const result = await github.searchRepositories({
46
+ query: params.query,
47
+ sort: params.sort ?? "stars",
48
+ limit,
49
+ });
50
+
51
+ const lines = result.hits.map((hit) => {
52
+ const meta: string[] = [`★${hit.stars}`];
53
+ if (hit.language) {
54
+ meta.push(hit.language);
55
+ }
56
+ if (hit.pushedAt) {
57
+ meta.push(`pushed ${hit.pushedAt.slice(0, 10)}`);
58
+ }
59
+ if (hit.archived) {
60
+ meta.push("ARCHIVED");
61
+ }
62
+ if (hit.fork) {
63
+ meta.push("fork");
64
+ }
65
+
66
+ const topics = hit.topics.length > 0 ? `\n topics: ${hit.topics.join(", ")}` : "";
67
+ const description = hit.description ? `\n ${hit.description}` : "";
68
+ return `${hit.repo} (${meta.join(" · ")})${description}${topics}`;
69
+ });
70
+
71
+ const header = `${result.totalCount} repositories match; showing ${result.hits.length}.`;
72
+ return {
73
+ content: [{ type: "text", text: [header, ...lines].join("\n\n") }],
74
+ details: {
75
+ kind: "search_repos",
76
+ query: params.query,
77
+ resultCount: result.hits.length,
78
+ totalCount: result.totalCount,
79
+ },
80
+ };
81
+ } catch (error) {
82
+ const message =
83
+ error instanceof GitHubApiError && error.retryAfterSeconds !== undefined
84
+ ? `${error.message} (retry after ~${error.retryAfterSeconds}s)`
85
+ : error instanceof Error
86
+ ? error.message
87
+ : String(error);
88
+ return {
89
+ content: [{ type: "text", text: message }],
90
+ details: {
91
+ kind: "search_repos",
92
+ query: params.query,
93
+ resultCount: 0,
94
+ totalCount: 0,
95
+ },
96
+ isError: true,
97
+ };
98
+ }
99
+ },
100
+ });
101
+ }
@@ -0,0 +1,76 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { CheckoutRepoDetails } from "./tools/checkout-repo.ts";
3
+ import { LIBRARIAN_RUN_TOOL_NAMES } from "./tools/names.ts";
4
+ import type { ProvideResultsDetails } from "./tools/provide-results.ts";
5
+ import type { ReadGitHubFileDetails } from "./tools/read-github-file.ts";
6
+ import type { SearchCodeDetails } from "./tools/search-code.ts";
7
+ import type { SearchGitHubCodeDetails } from "./tools/search-github-code.ts";
8
+ import type { SearchReposDetails } from "./tools/search-repos.ts";
9
+
10
+ type RepoToolDetails =
11
+ | SearchReposDetails
12
+ | SearchCodeDetails
13
+ | SearchGitHubCodeDetails
14
+ | CheckoutRepoDetails
15
+ | ReadGitHubFileDetails
16
+ | ProvideResultsDetails;
17
+
18
+ interface ToolResultShape {
19
+ content?: { type: string; text?: string }[];
20
+ details?: unknown;
21
+ }
22
+
23
+ export function asRepoToolDetails(details: unknown): RepoToolDetails | undefined {
24
+ if (
25
+ details &&
26
+ typeof details === "object" &&
27
+ "kind" in details &&
28
+ typeof details.kind === "string" &&
29
+ LIBRARIAN_RUN_TOOL_NAMES.includes(details.kind as (typeof LIBRARIAN_RUN_TOOL_NAMES)[number])
30
+ ) {
31
+ return details as RepoToolDetails;
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export function summarizeToolResult(
37
+ result: AgentToolResult<unknown> | ToolResultShape | undefined,
38
+ isError: boolean,
39
+ ): string | undefined {
40
+ if (!result || typeof result !== "object") {
41
+ return undefined;
42
+ }
43
+
44
+ const firstText = result.content?.find(
45
+ (part): part is { type: "text"; text: string } =>
46
+ part.type === "text" && "text" in part && typeof part.text === "string",
47
+ )?.text;
48
+ if (isError) {
49
+ return firstText?.split("\n")[0]?.slice(0, 120);
50
+ }
51
+
52
+ const details = asRepoToolDetails(result.details);
53
+ if (details) {
54
+ switch (details.kind) {
55
+ case "search_repos":
56
+ return `${details.resultCount} of ${details.totalCount} repos`;
57
+ case "search_code":
58
+ return `${details.matchCount} hits · ${details.repoCount} repos · ${details.backend}`;
59
+ case "search_github_code":
60
+ return `${details.matchCount} hits · ${details.repoCount} repos · github`;
61
+ case "checkout_repo":
62
+ return `${details.reusedClone ? "cached" : "cloned"} · ${details.fileCount} files @ ${details.headSha.slice(0, 7)}`;
63
+ case "read_github_file":
64
+ return details.isDirectory ? `${details.lineCount} entries` : `${details.lineCount} lines`;
65
+ case "provide_results":
66
+ return `${details.locationCount} location${details.locationCount === 1 ? "" : "s"}`;
67
+ }
68
+ }
69
+
70
+ if (firstText !== undefined) {
71
+ const lineCount = firstText.length === 0 ? 0 : firstText.split("\n").length;
72
+ return `${lineCount} lines`;
73
+ }
74
+
75
+ return undefined;
76
+ }
@@ -0,0 +1,307 @@
1
+ import path from "node:path";
2
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
5
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
6
+ import type { LibrarianRunDetails, TraceCall } from "./run.ts";
7
+ import { LIBRARIAN_TOOL_NAMES } from "./tools/names.ts";
8
+
9
+ const COLLAPSED_TRACE_CALLS = 3;
10
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+ const SPINNER_INTERVAL_MS = 80;
12
+
13
+ interface LiveRenderContext {
14
+ invalidate(): void;
15
+ state: {
16
+ librarianSpinnerFrame?: number;
17
+ librarianSpinnerInterval?: ReturnType<typeof setInterval>;
18
+ };
19
+ }
20
+
21
+ export function formatDuration(milliseconds: number): string {
22
+ if (milliseconds < 0) {
23
+ return "0.0s";
24
+ }
25
+ const seconds = milliseconds / 1000;
26
+ if (seconds >= 60) {
27
+ const minutes = Math.floor(seconds / 60);
28
+ return `${minutes}m ${Math.round(seconds - minutes * 60)}s`;
29
+ }
30
+ return `${seconds.toFixed(1)}s`;
31
+ }
32
+
33
+ export function shorten(text: string, max: number): string {
34
+ const normalized = text.replace(/\s+/g, " ").trim();
35
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max)}…`;
36
+ }
37
+
38
+ export function relativizeCachePath(rawPath: string, cacheDir: string): string {
39
+ const reposRoot = path.join(cacheDir, "repos") + path.sep;
40
+ if (!rawPath.startsWith(reposRoot)) {
41
+ return rawPath;
42
+ }
43
+
44
+ const underRepos = rawPath.slice(reposRoot.length);
45
+ const segments = underRepos.split(path.sep);
46
+ // repos/<owner>/<repo>/<file...> — drop the owner so the trace reads repo/file.
47
+ return segments.length > 1 ? segments.slice(1).join(path.sep) : underRepos;
48
+ }
49
+
50
+ function firstString(record: Record<string, unknown>, keys: string[]): string | undefined {
51
+ for (const key of keys) {
52
+ const value = record[key];
53
+ if (typeof value === "string" && value.trim().length > 0) {
54
+ return value;
55
+ }
56
+ }
57
+
58
+ for (const value of Object.values(record)) {
59
+ if (typeof value === "string" && value.trim().length > 0) {
60
+ return value;
61
+ }
62
+ }
63
+
64
+ return undefined;
65
+ }
66
+
67
+ export interface TraceLine {
68
+ verb: string;
69
+ subject: string;
70
+ }
71
+
72
+ export function formatTraceLine(call: TraceCall, cacheDir: string): TraceLine {
73
+ const args = (call.args && typeof call.args === "object" ? call.args : {}) as Record<
74
+ string,
75
+ unknown
76
+ >;
77
+
78
+ switch (call.name) {
79
+ case "read": {
80
+ const rawPath = typeof args.path === "string" ? args.path : "";
81
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
82
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
83
+ const range =
84
+ offset !== undefined || limit !== undefined
85
+ ? `:${offset ?? 1}${limit !== undefined ? `-${(offset ?? 1) + limit - 1}` : ""}`
86
+ : "";
87
+ return { verb: "read", subject: `${relativizeCachePath(rawPath, cacheDir)}${range}` };
88
+ }
89
+ case "bash": {
90
+ const command = typeof args.command === "string" ? args.command : "";
91
+ return { verb: "bash", subject: shorten(command, 120) };
92
+ }
93
+ case "grep": {
94
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
95
+ const searchPath = typeof args.path === "string" ? args.path : "";
96
+ const scope = searchPath ? ` ${relativizeCachePath(searchPath, cacheDir)}` : "";
97
+ return { verb: "grep", subject: `"${shorten(pattern, 60)}"${scope}` };
98
+ }
99
+ case "find": {
100
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
101
+ return { verb: "find", subject: shorten(pattern, 80) };
102
+ }
103
+ case "ls": {
104
+ const rawPath = typeof args.path === "string" ? args.path : ".";
105
+ return { verb: "ls", subject: relativizeCachePath(rawPath, cacheDir) };
106
+ }
107
+ case LIBRARIAN_TOOL_NAMES.searchRepos: {
108
+ const query = typeof args.query === "string" ? args.query : "";
109
+ return { verb: "search", subject: `repos ${shorten(query, 80)}` };
110
+ }
111
+ case LIBRARIAN_TOOL_NAMES.searchCode: {
112
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
113
+ const repo = typeof args.repo === "string" ? ` in ${shorten(args.repo, 40)}` : "";
114
+ return { verb: "search", subject: `code ${shorten(pattern, 60)}${repo}` };
115
+ }
116
+ case LIBRARIAN_TOOL_NAMES.searchGitHubCode: {
117
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
118
+ const repos = Array.isArray(args.repos)
119
+ ? args.repos
120
+ .map((repo) => {
121
+ if (!repo || typeof repo !== "object") {
122
+ return undefined;
123
+ }
124
+ const record = repo as Record<string, unknown>;
125
+ return typeof record.owner === "string" && typeof record.repo === "string"
126
+ ? `${record.owner}/${record.repo}`
127
+ : undefined;
128
+ })
129
+ .filter((repo): repo is string => repo !== undefined)
130
+ : [];
131
+ const owners = Array.isArray(args.owners) ? (args.owners as string[]) : [];
132
+ const scopeParts = [...repos, ...owners.map((owner) => `${owner}/*`)];
133
+ const scope = scopeParts.length > 0 ? ` in ${shorten(scopeParts.join(","), 40)}` : "";
134
+ return { verb: "search.gh", subject: `code ${shorten(pattern, 60)}${scope}` };
135
+ }
136
+ case LIBRARIAN_TOOL_NAMES.checkoutRepo: {
137
+ const repo = typeof args.repo === "string" ? args.repo : "";
138
+ const ref = typeof args.ref === "string" ? `@${args.ref}` : "";
139
+ return { verb: "checkout", subject: `${repo}${ref}` };
140
+ }
141
+ case LIBRARIAN_TOOL_NAMES.readGitHubFile: {
142
+ const owner = typeof args.owner === "string" ? args.owner : "";
143
+ const repo = typeof args.repo === "string" ? args.repo : "";
144
+ const filePath = typeof args.path === "string" ? args.path : "";
145
+ return { verb: "read.gh", subject: `${owner}/${repo}/${filePath}` };
146
+ }
147
+ case LIBRARIAN_TOOL_NAMES.provideResults:
148
+ return { verb: "results", subject: "" };
149
+ default: {
150
+ const subject = firstString(args, ["query", "url", "prompt", "objective"]);
151
+ return { verb: call.name, subject: subject ? shorten(subject, 80) : "" };
152
+ }
153
+ }
154
+ }
155
+
156
+ function renderTraceCallText(
157
+ call: TraceCall,
158
+ cacheDir: string,
159
+ theme: Theme,
160
+ spinnerFrame: string,
161
+ ): string {
162
+ const { verb, subject } = formatTraceLine(call, cacheDir);
163
+ const running = call.endedAt === undefined;
164
+ const icon = call.isError
165
+ ? theme.fg("error", "✗")
166
+ : running
167
+ ? theme.fg("warning", spinnerFrame)
168
+ : theme.fg("success", "✓");
169
+ const duration = formatDuration((call.endedAt ?? Date.now()) - call.startedAt);
170
+ const summary = call.resultSummary
171
+ ? theme.fg(call.isError ? "error" : "muted", ` (${shorten(call.resultSummary, 60)})`)
172
+ : "";
173
+
174
+ const subjectText = subject ? ` ${theme.fg("toolOutput", subject)}` : "";
175
+ return ` ${icon} ${theme.fg("dim", verb)}${subjectText}${summary} ${theme.fg("dim", `· ${duration}`)}`;
176
+ }
177
+
178
+ function renderQuestion(details: LibrarianRunDetails, expanded: boolean, theme: Theme): string {
179
+ return theme.fg("dim", expanded ? details.query : shorten(details.query, 120));
180
+ }
181
+
182
+ function renderFooter(details: LibrarianRunDetails, theme: Theme): string {
183
+ const callCount = details.trace.length;
184
+ const elapsed = formatDuration((details.endedAt ?? Date.now()) - details.startedAt);
185
+ const model = theme.fg("dim", `${details.modelLabel} (${details.thinkingLevel})`);
186
+ return theme.fg(
187
+ "muted",
188
+ `${callCount} tool call${callCount === 1 ? "" : "s"} · ${elapsed} · ${model}`,
189
+ );
190
+ }
191
+
192
+ function renderTrace(
193
+ details: LibrarianRunDetails,
194
+ expanded: boolean,
195
+ cacheDir: string,
196
+ theme: Theme,
197
+ spinnerFrame: string,
198
+ ): string[] {
199
+ const lines: string[] = [];
200
+ const calls = expanded ? details.trace : details.trace.slice(-COLLAPSED_TRACE_CALLS);
201
+ const hiddenCount = details.trace.length - calls.length;
202
+ if (hiddenCount > 0) {
203
+ lines.push(
204
+ theme.fg(
205
+ "muted",
206
+ ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (Ctrl+O to expand)`,
207
+ ),
208
+ );
209
+ }
210
+ for (const call of calls) {
211
+ lines.push(renderTraceCallText(call, cacheDir, theme, spinnerFrame));
212
+ }
213
+ return lines;
214
+ }
215
+
216
+ function updateLiveRender(context: LiveRenderContext | undefined, running: boolean): string {
217
+ if (!context) {
218
+ return (
219
+ SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length] ?? ""
220
+ );
221
+ }
222
+
223
+ context.state.librarianSpinnerFrame ??= 0;
224
+ if (running && !context.state.librarianSpinnerInterval) {
225
+ context.state.librarianSpinnerInterval = setInterval(() => {
226
+ context.state.librarianSpinnerFrame =
227
+ ((context.state.librarianSpinnerFrame ?? 0) + 1) % SPINNER_FRAMES.length;
228
+ context.invalidate();
229
+ }, SPINNER_INTERVAL_MS);
230
+ } else if (!running && context.state.librarianSpinnerInterval) {
231
+ clearInterval(context.state.librarianSpinnerInterval);
232
+ delete context.state.librarianSpinnerInterval;
233
+ }
234
+
235
+ return SPINNER_FRAMES[context.state.librarianSpinnerFrame] ?? "";
236
+ }
237
+
238
+ export function renderLibrarianResult(
239
+ result: AgentToolResult<LibrarianRunDetails>,
240
+ options: { expanded: boolean; isPartial: boolean },
241
+ theme: Theme,
242
+ cacheDir: string,
243
+ context?: LiveRenderContext,
244
+ ): Container {
245
+ const container = new Container();
246
+ const details = result.details;
247
+
248
+ if (!details) {
249
+ const firstText = result.content.find((part) => part.type === "text");
250
+ container.addChild(
251
+ new Text(firstText && "text" in firstText ? firstText.text : "(no output)", 0, 0),
252
+ );
253
+ return container;
254
+ }
255
+
256
+ const running = options.isPartial || details.status === "running";
257
+ const spinnerFrame = updateLiveRender(context, running);
258
+ container.addChild(new Text(renderQuestion(details, options.expanded, theme), 0, 0));
259
+ container.addChild(new Spacer(1));
260
+
261
+ if (running || !details.findings) {
262
+ for (const line of renderTrace(details, options.expanded, cacheDir, theme, spinnerFrame)) {
263
+ container.addChild(new Text(line, 0, 0));
264
+ }
265
+ }
266
+
267
+ if (!running) {
268
+ if (details.findings) {
269
+ const markdown = buildFindingsMarkdown(details, options.expanded);
270
+ container.addChild(new Markdown(markdown, 0, 0, getMarkdownTheme()));
271
+ } else if (details.error) {
272
+ container.addChild(new Spacer(1));
273
+ container.addChild(new Text(theme.fg("error", ` ${details.error}`), 0, 0));
274
+ }
275
+ }
276
+
277
+ container.addChild(new Spacer(1));
278
+ container.addChild(new Text(renderFooter(details, theme), 0, 0));
279
+ return container;
280
+ }
281
+
282
+ function buildFindingsMarkdown(details: LibrarianRunDetails, expanded: boolean): string {
283
+ const findings = details.findings;
284
+ if (!findings) {
285
+ return "";
286
+ }
287
+
288
+ const sections = [findings.summary];
289
+
290
+ if (findings.locations.length > 0) {
291
+ const bullets = findings.locations.map((location) => {
292
+ const lineSuffix = location.lines ? `:${location.lines}` : "";
293
+ return `- \`${location.repo}/${location.file}${lineSuffix}\` — ${location.note}`;
294
+ });
295
+ sections.push(bullets.join("\n"));
296
+ }
297
+
298
+ if (findings.description) {
299
+ sections.push(findings.description);
300
+ }
301
+
302
+ return expanded ? sections.join("\n\n") : findings.summary;
303
+ }
304
+
305
+ export function renderLibrarianCall(theme: Theme): Text {
306
+ return new Text(theme.fg("toolTitle", theme.bold("Librarian")), 0, 0);
307
+ }
@@ -0,0 +1,181 @@
1
+ import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { applyAttachState, readAttachState, setAttachState } from "./librarian/attach.ts";
5
+ import { createGitHubClientProvider } from "./librarian/github.ts";
6
+ import { resolveLibrarianModel } from "./librarian/model.ts";
7
+ import { type LibrarianRunDetails, runLibrarian, type TraceCall } from "./librarian/run.ts";
8
+ import { loadSettings } from "./librarian/settings.ts";
9
+ import { createCheckoutRepoTool } from "./librarian/tools/checkout-repo.ts";
10
+ import { ATTACHABLE_TOOL_NAMES } from "./librarian/tools/names.ts";
11
+ import { createReadGitHubFileTool } from "./librarian/tools/read-github-file.ts";
12
+ import { searchCodeTool } from "./librarian/tools/search-code.ts";
13
+ import { createSearchGitHubCodeTool } from "./librarian/tools/search-github-code.ts";
14
+ import { createSearchReposTool } from "./librarian/tools/search-repos.ts";
15
+ import {
16
+ formatTraceLine,
17
+ renderLibrarianCall,
18
+ renderLibrarianResult,
19
+ shorten,
20
+ } from "./librarian/view.ts";
21
+
22
+ const LibrarianParams = Type.Object({
23
+ query: Type.String({
24
+ description:
25
+ "The research question. Include everything you already know: symbols, error messages, desired output shape. Do not guess unknown details — say what is uncertain and let the librarian discover it.",
26
+ }),
27
+ repos: Type.Optional(
28
+ Type.Array(Type.String(), {
29
+ description: "Optional owner/repo scope for the research.",
30
+ maxItems: 20,
31
+ }),
32
+ ),
33
+ owners: Type.Optional(
34
+ Type.Array(Type.String(), {
35
+ description: "Optional owner/org scope for the research.",
36
+ maxItems: 20,
37
+ }),
38
+ ),
39
+ });
40
+
41
+ export default function librarianExtension(pi: ExtensionAPI): void {
42
+ const settings = loadSettings();
43
+ const githubClient = createGitHubClientProvider();
44
+
45
+ const attachableTools = [
46
+ createSearchReposTool(githubClient),
47
+ searchCodeTool,
48
+ createSearchGitHubCodeTool(githubClient),
49
+ createCheckoutRepoTool(settings.cacheDir),
50
+ createReadGitHubFileTool(githubClient),
51
+ ];
52
+
53
+ for (const tool of attachableTools) {
54
+ pi.registerTool({
55
+ ...tool,
56
+ description: `${tool.description} (Librarian tool, attached via /librarian: use for quick single lookups; delegate multi-step research to the librarian tool.)`,
57
+ renderCall(args, theme) {
58
+ const { verb, subject } = formatTraceLine(
59
+ { name: tool.name, args, id: "", startedAt: 0 } satisfies TraceCall,
60
+ settings.cacheDir,
61
+ );
62
+ return new Text(
63
+ `${theme.fg("toolTitle", theme.bold(verb))} ${theme.fg("toolOutput", subject)}`,
64
+ 0,
65
+ 0,
66
+ );
67
+ },
68
+ });
69
+ }
70
+
71
+ pi.registerTool<typeof LibrarianParams, LibrarianRunDetails>({
72
+ name: "librarian",
73
+ label: "Librarian",
74
+ description:
75
+ "Understand complex, multi-repo codebases, exploring cross-repo relationships, analyzing architectural patterns, finding implementations across codebases, understanding code evolution/commit history, diving deep on specific code bases, getting comprehensive feature explanations, and exploring end-to-end system design across within or across repositories.",
76
+ promptSnippet:
77
+ "librarian: research GitHub repos (implementation questions, ecosystem comparisons, usage examples) and return cited findings",
78
+ promptGuidelines: [
79
+ "If a single file or reference must be located, you may use other means (search for downloaded dependencies, web search, etc), but for deeper queries that will take multiple steps to resolve, prefer the librarian.",
80
+ ],
81
+ parameters: LibrarianParams,
82
+
83
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
84
+ const thinkingLevel = settings.thinkingLevel ?? pi.getThinkingLevel();
85
+ const resolution = resolveLibrarianModel(ctx, settings.model, thinkingLevel);
86
+ if (!resolution) {
87
+ return {
88
+ content: [
89
+ {
90
+ type: "text",
91
+ text: "No model available for the librarian. Configure librarian.model or select a session model.",
92
+ },
93
+ ],
94
+ details: {
95
+ status: "error",
96
+ query: params.query,
97
+ modelLabel: "(none)",
98
+ thinkingLevel,
99
+ trace: [],
100
+ checkouts: {},
101
+ startedAt: Date.now(),
102
+ endedAt: Date.now(),
103
+ error: "No model available.",
104
+ } satisfies LibrarianRunDetails,
105
+ isError: true,
106
+ };
107
+ }
108
+
109
+ return runLibrarian({
110
+ query: params.query,
111
+ repos: params.repos ?? [],
112
+ owners: params.owners ?? [],
113
+ model: resolution.model,
114
+ thinkingLevel: resolution.thinkingLevel,
115
+ settings,
116
+ githubClient,
117
+ signal,
118
+ onUpdate: onUpdate
119
+ ? (details) => {
120
+ onUpdate({
121
+ content: [{ type: "text", text: `Researching: ${shorten(params.query, 80)}` }],
122
+ details,
123
+ });
124
+ }
125
+ : undefined,
126
+ });
127
+ },
128
+
129
+ renderCall(_args, theme) {
130
+ return renderLibrarianCall(theme);
131
+ },
132
+
133
+ renderResult(result, options, theme, context) {
134
+ return renderLibrarianResult(result, options, theme, settings.cacheDir, context);
135
+ },
136
+ });
137
+
138
+ pi.registerCommand("librarian", {
139
+ description: "Attach/detach the librarian's GitHub tools in this session (on|off|status)",
140
+ getArgumentCompletions: (prefix) =>
141
+ ["on", "off", "status"]
142
+ .filter((option) => option.startsWith(prefix.trim()))
143
+ .map((option) => ({ value: option, label: option })),
144
+ async handler(args, ctx) {
145
+ const argument = args.trim().toLowerCase();
146
+ const currentlyAttached = readAttachState(ctx);
147
+
148
+ if (argument === "status") {
149
+ ctx.ui.notify(
150
+ currentlyAttached
151
+ ? `Librarian tools attached: ${ATTACHABLE_TOOL_NAMES.join(", ")}`
152
+ : "Librarian tools not attached. Run /librarian to attach.",
153
+ "info",
154
+ );
155
+ return;
156
+ }
157
+
158
+ const nextAttached =
159
+ argument === "on" ? true : argument === "off" ? false : !currentlyAttached;
160
+ if (nextAttached === currentlyAttached) {
161
+ ctx.ui.notify(
162
+ nextAttached ? "Librarian tools already attached." : "Librarian tools not attached.",
163
+ "info",
164
+ );
165
+ return;
166
+ }
167
+
168
+ setAttachState(pi, nextAttached);
169
+ ctx.ui.notify(
170
+ nextAttached
171
+ ? `Attached librarian tools: ${ATTACHABLE_TOOL_NAMES.join(", ")}`
172
+ : "Detached librarian tools.",
173
+ "info",
174
+ );
175
+ },
176
+ });
177
+
178
+ pi.on("session_start", async (_event: SessionStartEvent, ctx) => {
179
+ applyAttachState(pi, readAttachState(ctx));
180
+ });
181
+ }
@@ -0,0 +1,31 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ import { Value } from "typebox/value";
3
+
4
+ export function parseTypeBoxValue<T extends TSchema>(
5
+ schema: T,
6
+ value: unknown,
7
+ context: string,
8
+ ): Static<T> {
9
+ if (Value.Check(schema, value)) {
10
+ return value as Static<T>;
11
+ }
12
+
13
+ throw new Error(formatTypeBoxError(schema, value, context));
14
+ }
15
+
16
+ export function safeParseTypeBoxValue<T extends TSchema>(
17
+ schema: T,
18
+ value: unknown,
19
+ ): Static<T> | undefined {
20
+ return Value.Check(schema, value) ? (value as Static<T>) : undefined;
21
+ }
22
+
23
+ function formatTypeBoxError(schema: TSchema, value: unknown, context: string): string {
24
+ const firstError = Value.Errors(schema, value)[0];
25
+ if (!firstError) {
26
+ return `${context}: invalid value.`;
27
+ }
28
+
29
+ const path = firstError.instancePath.length > 0 ? firstError.instancePath : "/";
30
+ return `${context}: ${path} ${firstError.message}`;
31
+ }