@thurstonsand/pi-librarian 0.1.1 → 0.2.1

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.
@@ -2,7 +2,10 @@ import { Type } from "typebox";
2
2
  import { safeParseTypeBoxValue } from "../shared/typebox.ts";
3
3
 
4
4
  const GREP_MCP_URL = "https://mcp.grep.app";
5
- const GREP_SEARCH_TIMEOUT_MS = 30_000;
5
+ // Escalating per-attempt budgets: the upstream gateway kills stalled requests at ~15s, so
6
+ // early attempts abort fast (p90 success is ~500ms) instead of waiting out the full timeout.
7
+ const GREP_ATTEMPT_TIMEOUTS_MS = [3_000, 5_000, 7_000];
8
+ const GREP_RETRY_DELAY_MS = 300;
6
9
 
7
10
  export class GrepError extends Error {
8
11
  constructor(message: string) {
@@ -159,7 +162,12 @@ export function parseGrepMcpEvents(body: string): GrepCodeSearchResult {
159
162
  continue;
160
163
  }
161
164
  if (result.isError) {
162
- throw new GrepError("Grep MCP returned an error result.");
165
+ const message = (result.content ?? [])
166
+ .map((rawContent) => safeParseTypeBoxValue(MCP_TEXT_CONTENT_SCHEMA, rawContent)?.text)
167
+ .filter((text): text is string => text !== undefined)
168
+ .join("\n")
169
+ .trim();
170
+ throw new GrepError(message || "Grep MCP returned an error result.");
163
171
  }
164
172
 
165
173
  for (const rawContent of result.content ?? []) {
@@ -203,11 +211,45 @@ function buildGrepArguments(params: GrepCodeSearchParams): Record<string, unknow
203
211
  return args;
204
212
  }
205
213
 
206
- export async function searchCodeGrep(
214
+ function extractHtmlTitle(body: string): string | undefined {
215
+ const match = /<title[^>]*>(.*?)<\/title>/is.exec(body);
216
+ return match?.[1]?.replace(/\s+/g, " ").trim();
217
+ }
218
+
219
+ function formatHttpError(status: number, body: string): string {
220
+ return `Grep MCP returned ${status}: ${extractHtmlTitle(body) ?? body.slice(0, 200)}`;
221
+ }
222
+
223
+ function formatTransientError(error: unknown): string {
224
+ const cause =
225
+ error instanceof GrepTransientHttpError ? `returned ${error.status}` : "request failed";
226
+ return `Grep MCP ${cause} after retry (transient backend error). Retry, or simplify the query.`;
227
+ }
228
+
229
+ function delay(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
230
+ if (signal?.aborted) {
231
+ return Promise.reject(signal.reason);
232
+ }
233
+
234
+ return new Promise((resolve, reject) => {
235
+ const timeout = setTimeout(resolve, milliseconds);
236
+ signal?.addEventListener(
237
+ "abort",
238
+ () => {
239
+ clearTimeout(timeout);
240
+ reject(signal.reason);
241
+ },
242
+ { once: true },
243
+ );
244
+ });
245
+ }
246
+
247
+ async function searchCodeGrepAttempt(
207
248
  params: GrepCodeSearchParams,
208
249
  signal: AbortSignal | undefined,
250
+ timeoutMs: number,
209
251
  ): Promise<GrepCodeSearchResult> {
210
- const timeoutSignal = AbortSignal.timeout(GREP_SEARCH_TIMEOUT_MS);
252
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
211
253
  const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
212
254
  const response = await fetch(GREP_MCP_URL, {
213
255
  method: "POST",
@@ -230,8 +272,49 @@ export async function searchCodeGrep(
230
272
 
231
273
  const body = await response.text();
232
274
  if (!response.ok) {
233
- throw new GrepError(`Grep MCP returned ${response.status}: ${body.slice(0, 200)}`);
275
+ if (response.status >= 500) {
276
+ throw new GrepTransientHttpError(response.status, body);
277
+ }
278
+ throw new GrepError(formatHttpError(response.status, body));
234
279
  }
235
280
 
236
281
  return parseGrepMcpEvents(body);
237
282
  }
283
+
284
+ // Do not extend GrepError: GrepError is the deterministic no-retry signal.
285
+ class GrepTransientHttpError extends Error {
286
+ readonly status: number;
287
+
288
+ constructor(status: number, body: string) {
289
+ super(formatHttpError(status, body));
290
+ this.name = "GrepTransientHttpError";
291
+ this.status = status;
292
+ }
293
+ }
294
+
295
+ export async function searchCodeGrep(
296
+ params: GrepCodeSearchParams,
297
+ signal: AbortSignal | undefined,
298
+ ): Promise<GrepCodeSearchResult> {
299
+ if (signal?.aborted) {
300
+ throw signal.reason;
301
+ }
302
+
303
+ let transientError: unknown;
304
+ for (const [attempt, timeoutMs] of GREP_ATTEMPT_TIMEOUTS_MS.entries()) {
305
+ if (attempt > 0) {
306
+ await delay(GREP_RETRY_DELAY_MS, signal);
307
+ }
308
+
309
+ try {
310
+ return await searchCodeGrepAttempt(params, signal, timeoutMs);
311
+ } catch (error) {
312
+ if (error instanceof GrepError || signal?.aborted) {
313
+ throw error;
314
+ }
315
+ transientError = error;
316
+ }
317
+ }
318
+
319
+ throw new GrepError(formatTransientError(transientError));
320
+ }
@@ -21,7 +21,7 @@ Clones live under ${cacheDir}/repos/<owner>/<repo>. They are read-only research
21
21
 
22
22
  ## Citations
23
23
 
24
- - Only cite files you actually read in this run.
24
+ - Only cite files you actually read within context.
25
25
  - Cite as repo + file path + line range when you have one.
26
26
  - If evidence is partial, say what is confirmed and what remains uncertain.
27
27
  - If access fails (404/403, private repo), report that constraint plainly.
@@ -0,0 +1,40 @@
1
+ import {
2
+ DefaultResourceLoader,
3
+ getAgentDir,
4
+ type LoadExtensionsResult,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ export function stripExtensionHooks(result: LoadExtensionsResult): LoadExtensionsResult {
8
+ return {
9
+ ...result,
10
+ extensions: result.extensions.map((extension) => ({
11
+ ...extension,
12
+ handlers: new Map(),
13
+ })),
14
+ };
15
+ }
16
+
17
+ export interface LibrarianResourceLoaderOptions {
18
+ cacheDir: string;
19
+ extensionPaths: string[];
20
+ systemPromptOverride: (base: string | undefined) => string | undefined;
21
+ }
22
+
23
+ export function createLibrarianResourceLoader(
24
+ options: LibrarianResourceLoaderOptions,
25
+ ): DefaultResourceLoader {
26
+ return new DefaultResourceLoader({
27
+ cwd: options.cacheDir,
28
+ agentDir: getAgentDir(),
29
+ noExtensions: true,
30
+ ...(options.extensionPaths.length > 0
31
+ ? { additionalExtensionPaths: options.extensionPaths }
32
+ : {}),
33
+ noSkills: true,
34
+ noPromptTemplates: true,
35
+ noThemes: true,
36
+ extensionsOverride: stripExtensionHooks,
37
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
38
+ systemPromptOverride: options.systemPromptOverride,
39
+ });
40
+ }
@@ -1,22 +1,21 @@
1
1
  import fs from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
3
4
  import type { Api, Model } from "@earendil-works/pi-ai";
4
5
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
5
- import {
6
- createAgentSession,
7
- DefaultResourceLoader,
8
- getAgentDir,
9
- SessionManager,
10
- } from "@earendil-works/pi-coding-agent";
6
+ import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
7
+ import { type ExtraToolsResolution, LIBRARIAN_BASELINE_TOOL_NAMES } from "./extra-tools.ts";
11
8
  import type { GitHubClientProvider } from "./github.ts";
12
9
  import {
13
10
  buildLibrarianSystemPrompt,
14
11
  buildLibrarianUserPrompt,
15
12
  buildProvideResultsReminder,
16
13
  } from "./prompt.ts";
14
+ import { createLibrarianResourceLoader } from "./resource-loader.ts";
17
15
  import { renderFindingsMarkdown } from "./results.ts";
18
16
  import type { LibrarianSettings } from "./settings.ts";
19
17
  import { createCheckoutRepoTool } from "./tools/checkout-repo.ts";
18
+ import { LIBRARIAN_RUN_TOOL_NAMES } from "./tools/names.ts";
20
19
  import type { Findings } from "./tools/provide-results.ts";
21
20
  import { createProvideResultsTool } from "./tools/provide-results.ts";
22
21
  import { createReadGitHubFileTool } from "./tools/read-github-file.ts";
@@ -26,9 +25,12 @@ import { createSearchReposTool } from "./tools/search-repos.ts";
26
25
  import { asRepoToolDetails, summarizeToolResult } from "./trace.ts";
27
26
 
28
27
  const MAX_PROVIDE_RESULTS_REMINDERS = 3;
29
- const HARDCODED_EXCLUDED_TOOLS = ["write", "edit"];
28
+ const DEFAULT_EXCLUDED_TOOLS = ["write", "edit"];
29
+
30
+ class LibrarianRunError extends Error {}
30
31
 
31
32
  export type LibrarianRunStatus = "running" | "done" | "error" | "aborted";
33
+ type FailedLibrarianRunStatus = "error" | "aborted";
32
34
 
33
35
  export interface TraceCall {
34
36
  id: string;
@@ -49,6 +51,8 @@ export interface LibrarianRunDetails {
49
51
  findings?: Findings;
50
52
  checkouts: Record<string, string>;
51
53
  error?: string;
54
+ runId?: string;
55
+ debugSessionPath?: string;
52
56
  startedAt: number;
53
57
  endedAt?: number;
54
58
  }
@@ -57,14 +61,30 @@ export interface LibrarianRunOptions {
57
61
  query: string;
58
62
  repos: string[];
59
63
  owners: string[];
64
+ continueFrom: string | undefined;
60
65
  model: Model<Api>;
61
66
  thinkingLevel: ThinkingLevel;
62
67
  settings: LibrarianSettings;
68
+ extraTools: ExtraToolsResolution;
63
69
  githubClient: GitHubClientProvider;
64
70
  signal: AbortSignal | undefined;
65
71
  onUpdate: ((details: LibrarianRunDetails) => void) | undefined;
66
72
  }
67
73
 
74
+ async function openContinuedSession(
75
+ runId: string,
76
+ sessionsDir: string,
77
+ cacheDir: string,
78
+ fail: (status: FailedLibrarianRunStatus, content: string) => never,
79
+ ): Promise<SessionManager> {
80
+ const sessions = await SessionManager.listAll(sessionsDir);
81
+ const sessionFile = sessions.find((candidate) => candidate.id === runId)?.path;
82
+ if (!sessionFile) {
83
+ fail("error", `Librarian run not found: ${runId}`);
84
+ }
85
+ return SessionManager.open(sessionFile, sessionsDir, cacheDir);
86
+ }
87
+
68
88
  export async function runLibrarian(
69
89
  options: LibrarianRunOptions,
70
90
  ): Promise<AgentToolResult<LibrarianRunDetails>> {
@@ -88,26 +108,45 @@ export async function runLibrarian(
88
108
  options.onUpdate?.({ ...details, trace: [...details.trace] });
89
109
  };
90
110
 
91
- const finish = (
92
- status: LibrarianRunStatus,
93
- content: string,
94
- isError: boolean,
95
- ): AgentToolResult<LibrarianRunDetails> => {
111
+ const finalizeDetails = (status: LibrarianRunStatus, content: string): string => {
112
+ const suffixes = [
113
+ ...(details.runId ? [`run: ${details.runId}`] : []),
114
+ ...(details.debugSessionPath ? [`debug_session: ${details.debugSessionPath}`] : []),
115
+ ];
116
+ const resultContent = suffixes.length > 0 ? `${content}\n\n${suffixes.join("\n")}` : content;
96
117
  details.status = status;
97
118
  details.endedAt = Date.now();
98
- if (isError) {
119
+ if (status !== "done") {
99
120
  details.error = content.split("\n")[0] ?? content;
100
121
  }
101
122
  emit(true);
102
- return {
103
- content: [{ type: "text", text: content }],
104
- details: { ...details, trace: [...details.trace] },
105
- ...(isError ? { isError: true } : {}),
106
- };
123
+ return resultContent;
124
+ };
125
+
126
+ const finish = (
127
+ status: LibrarianRunStatus,
128
+ content: string,
129
+ ): AgentToolResult<LibrarianRunDetails> => ({
130
+ content: [{ type: "text", text: finalizeDetails(status, content) }],
131
+ details: { ...details, trace: [...details.trace] },
132
+ });
133
+
134
+ const fail = (status: FailedLibrarianRunStatus, content: string): never => {
135
+ throw new LibrarianRunError(finalizeDetails(status, content));
107
136
  };
108
137
 
109
138
  await fs.mkdir(options.settings.cacheDir, { recursive: true });
110
139
 
140
+ const sessionsDir = path.join(options.settings.cacheDir, "sessions");
141
+ const sessionManager = options.continueFrom
142
+ ? await openContinuedSession(options.continueFrom, sessionsDir, options.settings.cacheDir, fail)
143
+ : SessionManager.create(options.settings.cacheDir, sessionsDir);
144
+ details.runId = sessionManager.getSessionId();
145
+ const debugSessionPath = sessionManager.getSessionFile();
146
+ if (options.settings.debug.persistRuns && debugSessionPath) {
147
+ details.debugSessionPath = debugSessionPath;
148
+ }
149
+
111
150
  let findings: Findings | undefined;
112
151
  const repoTools = [
113
152
  createSearchReposTool(options.githubClient),
@@ -120,21 +159,27 @@ export async function runLibrarian(
120
159
  }),
121
160
  ];
122
161
 
123
- const resourceLoader = new DefaultResourceLoader({
124
- cwd: options.settings.cacheDir,
125
- agentDir: getAgentDir(),
126
- noExtensions: true,
127
- ...(options.settings.extensions.length > 0
128
- ? { additionalExtensionPaths: options.settings.extensions }
129
- : {}),
130
- noSkills: true,
131
- noPromptTemplates: true,
132
- noThemes: true,
162
+ const resourceLoader = createLibrarianResourceLoader({
163
+ cacheDir: options.settings.cacheDir,
164
+ extensionPaths: options.extraTools.extensionPaths,
133
165
  systemPromptOverride: () => buildLibrarianSystemPrompt(options.settings.cacheDir),
134
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
135
166
  });
136
167
  await resourceLoader.reload();
137
168
 
169
+ const extensionLoadErrors = resourceLoader.getExtensions().errors;
170
+ for (const [index, error] of extensionLoadErrors.entries()) {
171
+ const now = Date.now();
172
+ details.trace.push({
173
+ id: `extension-load-${index}`,
174
+ name: "load_extension",
175
+ args: { path: error.path },
176
+ startedAt: now,
177
+ endedAt: now,
178
+ isError: true,
179
+ resultSummary: error.error,
180
+ });
181
+ }
182
+
138
183
  emit(true);
139
184
 
140
185
  let session: AgentSession | undefined;
@@ -145,20 +190,21 @@ export async function runLibrarian(
145
190
  const created = await createAgentSession({
146
191
  cwd: options.settings.cacheDir,
147
192
  resourceLoader,
148
- sessionManager: SessionManager.inMemory(options.settings.cacheDir),
193
+ sessionManager,
149
194
  model: options.model,
150
195
  thinkingLevel: options.thinkingLevel,
151
196
  customTools: repoTools,
152
- excludeTools: [...HARDCODED_EXCLUDED_TOOLS, ...options.settings.disabledTools],
197
+ excludeTools: DEFAULT_EXCLUDED_TOOLS.filter(
198
+ (toolName) => !options.extraTools.toolNames.includes(toolName),
199
+ ),
153
200
  });
154
201
  session = created.session;
155
202
 
156
- const excluded = new Set([...HARDCODED_EXCLUDED_TOOLS, ...options.settings.disabledTools]);
157
- const allToolNames = session
158
- .getAllTools()
159
- .map((tool) => tool.name)
160
- .filter((name) => !excluded.has(name));
161
- session.setActiveToolsByName(allToolNames);
203
+ session.setActiveToolsByName([
204
+ ...LIBRARIAN_BASELINE_TOOL_NAMES,
205
+ ...LIBRARIAN_RUN_TOOL_NAMES,
206
+ ...options.extraTools.toolNames,
207
+ ]);
162
208
 
163
209
  unsubscribe = session.subscribe((event: AgentSessionEvent) => {
164
210
  switch (event.type) {
@@ -198,7 +244,7 @@ export async function runLibrarian(
198
244
  });
199
245
 
200
246
  if (options.signal?.aborted) {
201
- return finish("aborted", "Librarian run aborted before it started.", true);
247
+ fail("aborted", "Librarian run aborted before it started.");
202
248
  }
203
249
 
204
250
  const activeSession = session;
@@ -214,30 +260,34 @@ export async function runLibrarian(
214
260
  let reminders = 0;
215
261
  while (!findings && !options.signal?.aborted && reminders < MAX_PROVIDE_RESULTS_REMINDERS) {
216
262
  reminders += 1;
217
- await session.prompt(buildProvideResultsReminder(), { expandPromptTemplates: false });
263
+ await session.prompt(buildProvideResultsReminder(), {
264
+ expandPromptTemplates: false,
265
+ });
218
266
  }
219
267
 
220
268
  if (options.signal?.aborted) {
221
- return finish("aborted", "Librarian run aborted.", true);
269
+ fail("aborted", "Librarian run aborted.");
222
270
  }
223
271
 
224
272
  if (findings) {
225
273
  details.findings = findings;
226
- return finish("done", renderFindingsMarkdown(findings, details.checkouts), false);
274
+ return finish("done", renderFindingsMarkdown(findings, details.checkouts));
227
275
  }
228
276
 
229
277
  const lastText = session.getLastAssistantText() ?? "";
230
- return finish(
278
+ fail(
231
279
  "error",
232
280
  `Librarian did not report structured findings after ${MAX_PROVIDE_RESULTS_REMINDERS} reminders.${lastText ? `\n\nRaw final message:\n${lastText}` : ""}`,
233
- true,
234
281
  );
235
282
  } catch (error) {
283
+ if (error instanceof LibrarianRunError) {
284
+ throw error;
285
+ }
236
286
  if (options.signal?.aborted) {
237
- return finish("aborted", "Librarian run aborted.", true);
287
+ fail("aborted", "Librarian run aborted.");
238
288
  }
239
289
  const message = error instanceof Error ? error.message : String(error);
240
- return finish("error", `Librarian run failed: ${message}`, true);
290
+ fail("error", `Librarian run failed: ${message}`);
241
291
  } finally {
242
292
  if (onAbort) {
243
293
  options.signal?.removeEventListener("abort", onAbort);
@@ -245,4 +295,6 @@ export async function runLibrarian(
245
295
  unsubscribe?.();
246
296
  session?.dispose();
247
297
  }
298
+
299
+ throw new Error("Unreachable librarian run state.");
248
300
  }
@@ -18,8 +18,13 @@ const LIBRARIAN_FILE_SETTINGS_SCHEMA = Type.Object({
18
18
  model: Type.Optional(Type.String()),
19
19
  thinkingLevel: Type.Optional(THINKING_LEVEL_SCHEMA),
20
20
  extensions: Type.Optional(Type.Array(Type.String())),
21
- disabledTools: Type.Optional(Type.Array(Type.String())),
21
+ tools: Type.Optional(Type.Array(Type.String())),
22
22
  cacheDir: Type.Optional(Type.String()),
23
+ debug: Type.Optional(
24
+ Type.Object({
25
+ persistRuns: Type.Optional(Type.Boolean()),
26
+ }),
27
+ ),
23
28
  });
24
29
 
25
30
  const ROOT_SETTINGS_SCHEMA = Type.Object({
@@ -43,8 +48,11 @@ export interface LibrarianSettings {
43
48
  model: ModelReference | undefined;
44
49
  thinkingLevel: ThinkingLevel | undefined;
45
50
  extensions: string[];
46
- disabledTools: string[];
51
+ tools: string[];
47
52
  cacheDir: string;
53
+ debug: {
54
+ persistRuns: boolean;
55
+ };
48
56
  }
49
57
 
50
58
  export function getDefaultCacheDir(): string {
@@ -102,13 +110,24 @@ function normalizeExtensionPaths(values: string[] | undefined): string[] {
102
110
  .map(expandHome);
103
111
  }
104
112
 
113
+ function normalizeToolNames(values: string[] | undefined): string[] {
114
+ if (!values) {
115
+ return [];
116
+ }
117
+
118
+ return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
119
+ }
120
+
105
121
  export function resolveLibrarianSettings(fileSettings: LibrarianFileSettings): LibrarianSettings {
106
122
  return {
107
123
  model: parseModelReference(fileSettings.model),
108
124
  thinkingLevel: fileSettings.thinkingLevel,
109
125
  extensions: normalizeExtensionPaths(fileSettings.extensions),
110
- disabledTools: fileSettings.disabledTools ?? [],
126
+ tools: normalizeToolNames(fileSettings.tools),
111
127
  cacheDir: normalizeCacheDir(fileSettings.cacheDir),
128
+ debug: {
129
+ persistRuns: fileSettings.debug?.persistRuns ?? false,
130
+ },
112
131
  };
113
132
  }
114
133
 
@@ -64,24 +64,9 @@ export function createCheckoutRepoTool(cacheDir: string) {
64
64
  : error instanceof Error
65
65
  ? error.message
66
66
  : String(error);
67
- return {
68
- content: [
69
- {
70
- type: "text",
71
- text: `${message}\nIf the repo name is uncertain, resolve it with search_repos first.`,
72
- },
73
- ],
74
- details: {
75
- kind: "checkout_repo",
76
- repo: params.repo,
77
- path: "",
78
- headSha: "",
79
- ref: params.ref ?? "",
80
- fileCount: 0,
81
- reusedClone: false,
82
- },
83
- isError: true,
84
- };
67
+ throw new Error(
68
+ `${message}\nIf the repo name is uncertain, resolve it with search_repos first.`,
69
+ );
85
70
  }
86
71
  },
87
72
  });
@@ -3,10 +3,16 @@ import { type Static, Type } from "typebox";
3
3
  import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
4
4
 
5
5
  export const LocationSchema = Type.Object({
6
- repo: Type.String({ description: "Repository as owner/repo or a repository URL." }),
7
- file: Type.String({ description: "File path within the repository." }),
6
+ repo: Type.String({
7
+ description: "Repository as owner/repo or a repository URL.",
8
+ }),
9
+ file: Type.String({
10
+ description: "Validated file path within the repository.",
11
+ }),
8
12
  lines: Type.Optional(
9
- Type.String({ description: 'Line range like "80-140" or a single line like "42".' }),
13
+ Type.String({
14
+ description: 'Line range like "80-140" or a single line like "42".',
15
+ }),
10
16
  ),
11
17
  note: Type.String({ description: "Relevance of the file." }),
12
18
  });
@@ -16,7 +22,8 @@ export const FindingsSchema = Type.Object({
16
22
  description: "1-3 sentence direct answer to the query. No preamble.",
17
23
  }),
18
24
  locations: Type.Array(LocationSchema, {
19
- description: "Evidence locations.",
25
+ description:
26
+ "Evidence source-file locations. Include only repository files you validated by reading.",
20
27
  }),
21
28
  description: Type.Optional(
22
29
  Type.String({
@@ -50,9 +57,15 @@ export function createProvideResultsTool(onFindings: (findings: Findings) => voi
50
57
  onFindings(params);
51
58
  return {
52
59
  content: [
53
- { type: "text", text: "Findings recorded. You are done; do not call more tools." },
60
+ {
61
+ type: "text",
62
+ text: "Findings recorded. You are done; do not call more tools.",
63
+ },
54
64
  ],
55
- details: { kind: "provide_results", locationCount: params.locations.length },
65
+ details: {
66
+ kind: "provide_results",
67
+ locationCount: params.locations.length,
68
+ },
56
69
  terminate: true,
57
70
  };
58
71
  },
@@ -22,8 +22,10 @@ const ReadGitHubFileParams = Type.Object({
22
22
  Type.String({ description: "Branch, tag, or commit sha. Default: the default branch." }),
23
23
  ),
24
24
  range: Type.Optional(
25
- Type.Tuple([Type.Number({ minimum: 1 }), Type.Number({ minimum: 1 })], {
25
+ Type.Array(Type.Number({ minimum: 1 }), {
26
26
  description: "[startLine, endLine] (1-based, inclusive) for large files.",
27
+ minItems: 2,
28
+ maxItems: 2,
27
29
  }),
28
30
  ),
29
31
  });
@@ -42,6 +44,11 @@ export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
42
44
  parameters: ReadGitHubFileParams,
43
45
 
44
46
  async execute(_toolCallId, params) {
47
+ const range = params.range ? (params.range as [number, number]) : undefined;
48
+ if (range && range[1] < range[0]) {
49
+ throw new Error("range end must be greater than or equal to range start.");
50
+ }
51
+
45
52
  try {
46
53
  const github = await githubClient();
47
54
  const contents = await github.readContents({
@@ -72,9 +79,9 @@ export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
72
79
  let end = allLines.length;
73
80
  let clipNote = "";
74
81
 
75
- if (params.range) {
76
- start = Math.min(params.range[0], allLines.length);
77
- end = Math.min(params.range[1], allLines.length);
82
+ if (range) {
83
+ start = Math.min(range[0], allLines.length);
84
+ end = Math.min(range[1], allLines.length);
78
85
  } else if (allLines.length > MAX_LINES_WITHOUT_RANGE) {
79
86
  end = MAX_LINES_WITHOUT_RANGE;
80
87
  clipNote = `\n... clipped at line ${MAX_LINES_WITHOUT_RANGE} of ${allLines.length}; pass range to read further.`;
@@ -104,18 +111,7 @@ export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
104
111
  : error instanceof Error
105
112
  ? error.message
106
113
  : String(error);
107
- return {
108
- content: [{ type: "text", text: message }],
109
- details: {
110
- kind: "read_github_file",
111
- owner: params.owner,
112
- repo: params.repo,
113
- path: params.path,
114
- lineCount: 0,
115
- isDirectory: false,
116
- },
117
- isError: true,
118
- };
114
+ throw new Error(message);
119
115
  }
120
116
  },
121
117
  });
@@ -14,12 +14,11 @@ export interface SearchCodeDetails {
14
14
  const SearchCodeParams = Type.Object({
15
15
  pattern: Type.String({
16
16
  description:
17
- "Public code search pattern. Literal by default; set regex: true to treat it as a regular expression.",
17
+ "Public code search pattern. Use literal code patterns as they appear in files, not keywords: e.g. `useState(` rather than `react hook state`.",
18
18
  }),
19
19
  regex: Type.Optional(
20
20
  Type.Boolean({
21
- description:
22
- "Treat pattern as a regular expression. Grep supports multi-line regex with (?s).",
21
+ description: "Treat `pattern` as a regular expression instead of literals.",
23
22
  }),
24
23
  ),
25
24
  repo: Type.Optional(
@@ -62,6 +61,7 @@ export const searchCodeTool = defineTool<typeof SearchCodeParams, SearchCodeDeta
62
61
  description: "Cross-repo code search over public source code.",
63
62
  promptSnippet: "Search code across repos",
64
63
  promptGuidelines: [
64
+ "When `regex` is true, `pattern` can also use `(?s)` for multi-line patterns.",
65
65
  "Results are CANDIDATES to verify via checkout_repo/read_github_file — never cite them directly.",
66
66
  "This searches public GitHub code only. Use search_github_code when private repository access matters.",
67
67
  ],
@@ -85,16 +85,7 @@ export function createSearchReposTool(githubClient: GitHubClientProvider) {
85
85
  : error instanceof Error
86
86
  ? error.message
87
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
- };
88
+ throw new Error(message);
98
89
  }
99
90
  },
100
91
  });