@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,237 @@
1
+ import { Type } from "typebox";
2
+ import { safeParseTypeBoxValue } from "../shared/typebox.ts";
3
+
4
+ const GREP_MCP_URL = "https://mcp.grep.app";
5
+ const GREP_SEARCH_TIMEOUT_MS = 30_000;
6
+
7
+ export class GrepError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "GrepError";
11
+ }
12
+ }
13
+
14
+ export interface GrepCodeSearchParams {
15
+ query: string;
16
+ matchCase?: boolean;
17
+ matchWholeWords?: boolean;
18
+ useRegexp?: boolean;
19
+ repo?: string;
20
+ path?: string;
21
+ language?: string[];
22
+ }
23
+
24
+ export interface GrepSnippet {
25
+ lineNumber: number;
26
+ content: string;
27
+ }
28
+
29
+ export interface GrepCodeMatch {
30
+ repo: string;
31
+ path: string;
32
+ url: string | undefined;
33
+ license: string | undefined;
34
+ snippets: GrepSnippet[];
35
+ }
36
+
37
+ export interface GrepCodeSearchResult {
38
+ matches: GrepCodeMatch[];
39
+ }
40
+
41
+ const MCP_TEXT_CONTENT_SCHEMA = Type.Object({
42
+ type: Type.Literal("text"),
43
+ text: Type.String(),
44
+ });
45
+
46
+ const MCP_CALL_RESULT_SCHEMA = Type.Object({
47
+ content: Type.Optional(Type.Array(Type.Unknown())),
48
+ isError: Type.Optional(Type.Boolean()),
49
+ });
50
+
51
+ const MCP_ERROR_SCHEMA = Type.Object({
52
+ message: Type.String(),
53
+ });
54
+
55
+ const MCP_RESPONSE_SCHEMA = Type.Object({
56
+ result: Type.Optional(Type.Unknown()),
57
+ error: Type.Optional(Type.Unknown()),
58
+ });
59
+
60
+ function parseHeader(line: string, name: string): string | undefined {
61
+ const prefix = `${name}: `;
62
+ return line.startsWith(prefix) ? line.slice(prefix.length).trim() : undefined;
63
+ }
64
+
65
+ export function parseGrepSearchText(text: string): GrepCodeMatch | undefined {
66
+ if (text.trim() === "No results found for your query.") {
67
+ return undefined;
68
+ }
69
+
70
+ let repo: string | undefined;
71
+ let path: string | undefined;
72
+ let url: string | undefined;
73
+ let license: string | undefined;
74
+ const snippets: GrepSnippet[] = [];
75
+ let currentSnippet: { lineNumber: number; lines: string[] } | undefined;
76
+
77
+ function finishSnippet(): void {
78
+ if (!currentSnippet) {
79
+ return;
80
+ }
81
+
82
+ snippets.push({
83
+ lineNumber: currentSnippet.lineNumber,
84
+ content: currentSnippet.lines.join("\n").trimEnd(),
85
+ });
86
+ currentSnippet = undefined;
87
+ }
88
+
89
+ for (const line of text.split("\n")) {
90
+ const parsedRepo = parseHeader(line, "Repository");
91
+ if (parsedRepo) {
92
+ repo = parsedRepo;
93
+ continue;
94
+ }
95
+
96
+ const parsedPath = parseHeader(line, "Path");
97
+ if (parsedPath) {
98
+ path = parsedPath;
99
+ continue;
100
+ }
101
+
102
+ const parsedUrl = parseHeader(line, "URL");
103
+ if (parsedUrl) {
104
+ url = parsedUrl;
105
+ continue;
106
+ }
107
+
108
+ const parsedLicense = parseHeader(line, "License");
109
+ if (parsedLicense) {
110
+ license = parsedLicense === "Unknown" ? undefined : parsedLicense;
111
+ continue;
112
+ }
113
+
114
+ const snippetStart = /^--- Snippet \d+ \(Line (\d+)\) ---$/.exec(line);
115
+ if (snippetStart) {
116
+ finishSnippet();
117
+ currentSnippet = { lineNumber: Number(snippetStart[1]), lines: [] };
118
+ continue;
119
+ }
120
+
121
+ if (currentSnippet) {
122
+ currentSnippet.lines.push(line.startsWith("> ") ? line.slice(2) : line);
123
+ }
124
+ }
125
+
126
+ finishSnippet();
127
+
128
+ if (!repo || !path) {
129
+ return undefined;
130
+ }
131
+
132
+ return { repo, path, url, license, snippets };
133
+ }
134
+
135
+ export function parseGrepMcpEvents(body: string): GrepCodeSearchResult {
136
+ const matches: GrepCodeMatch[] = [];
137
+
138
+ for (const rawLine of body.split("\n")) {
139
+ const line = rawLine.trimEnd();
140
+ if (!line.startsWith("data: ")) {
141
+ continue;
142
+ }
143
+
144
+ const response = safeParseTypeBoxValue(
145
+ MCP_RESPONSE_SCHEMA,
146
+ JSON.parse(line.slice("data: ".length)),
147
+ );
148
+ if (!response) {
149
+ continue;
150
+ }
151
+
152
+ const error = safeParseTypeBoxValue(MCP_ERROR_SCHEMA, response.error);
153
+ if (error) {
154
+ throw new GrepError(error.message);
155
+ }
156
+
157
+ const result = safeParseTypeBoxValue(MCP_CALL_RESULT_SCHEMA, response.result);
158
+ if (!result) {
159
+ continue;
160
+ }
161
+ if (result.isError) {
162
+ throw new GrepError("Grep MCP returned an error result.");
163
+ }
164
+
165
+ for (const rawContent of result.content ?? []) {
166
+ const content = safeParseTypeBoxValue(MCP_TEXT_CONTENT_SCHEMA, rawContent);
167
+ if (!content) {
168
+ continue;
169
+ }
170
+
171
+ const match = parseGrepSearchText(content.text);
172
+ if (match) {
173
+ matches.push(match);
174
+ }
175
+ }
176
+ }
177
+
178
+ return { matches };
179
+ }
180
+
181
+ function buildGrepArguments(params: GrepCodeSearchParams): Record<string, unknown> {
182
+ const args: Record<string, unknown> = { query: params.query };
183
+
184
+ if (params.matchCase) {
185
+ args.matchCase = true;
186
+ }
187
+ if (params.matchWholeWords) {
188
+ args.matchWholeWords = true;
189
+ }
190
+ if (params.useRegexp) {
191
+ args.useRegexp = true;
192
+ }
193
+ if (params.repo) {
194
+ args.repo = params.repo;
195
+ }
196
+ if (params.path) {
197
+ args.path = params.path;
198
+ }
199
+ if (params.language && params.language.length > 0) {
200
+ args.language = params.language;
201
+ }
202
+
203
+ return args;
204
+ }
205
+
206
+ export async function searchCodeGrep(
207
+ params: GrepCodeSearchParams,
208
+ signal: AbortSignal | undefined,
209
+ ): Promise<GrepCodeSearchResult> {
210
+ const timeoutSignal = AbortSignal.timeout(GREP_SEARCH_TIMEOUT_MS);
211
+ const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
212
+ const response = await fetch(GREP_MCP_URL, {
213
+ method: "POST",
214
+ headers: {
215
+ accept: "application/json, text/event-stream",
216
+ "content-type": "application/json",
217
+ "user-agent": "pi-librarian",
218
+ },
219
+ body: JSON.stringify({
220
+ jsonrpc: "2.0",
221
+ id: 1,
222
+ method: "tools/call",
223
+ params: {
224
+ name: "searchGitHub",
225
+ arguments: buildGrepArguments(params),
226
+ },
227
+ }),
228
+ signal: requestSignal,
229
+ });
230
+
231
+ const body = await response.text();
232
+ if (!response.ok) {
233
+ throw new GrepError(`Grep MCP returned ${response.status}: ${body.slice(0, 200)}`);
234
+ }
235
+
236
+ return parseGrepMcpEvents(body);
237
+ }
@@ -0,0 +1,59 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ModelReference } from "./settings.ts";
5
+
6
+ export type LibrarianModelSource = "configured" | "current";
7
+
8
+ export interface LibrarianModelResolution {
9
+ model: Model<Api>;
10
+ thinkingLevel: ThinkingLevel;
11
+ source: LibrarianModelSource;
12
+ }
13
+
14
+ function isAlias(modelId: string): boolean {
15
+ return modelId.endsWith("-latest") || !/-\d{8}$/.test(modelId);
16
+ }
17
+
18
+ function bestModelMatch(models: Model<Api>[], pattern: string): Model<Api> | undefined {
19
+ const normalizedPattern = pattern.toLowerCase();
20
+ const exactMatches = models.filter((model) => model.id.toLowerCase() === normalizedPattern);
21
+ if (exactMatches.length === 1) {
22
+ return exactMatches[0];
23
+ }
24
+
25
+ const partialMatches = models.filter(
26
+ (model) =>
27
+ model.id.toLowerCase().includes(normalizedPattern) ||
28
+ model.name?.toLowerCase().includes(normalizedPattern),
29
+ );
30
+ if (partialMatches.length === 0) {
31
+ return undefined;
32
+ }
33
+
34
+ const aliases = partialMatches.filter((model) => isAlias(model.id));
35
+ const candidates = aliases.length > 0 ? aliases : partialMatches;
36
+ return candidates.toSorted((a, b) => b.id.localeCompare(a.id))[0];
37
+ }
38
+
39
+ export function resolveLibrarianModel(
40
+ ctx: ExtensionContext,
41
+ configuredModel: ModelReference | undefined,
42
+ thinkingLevel: ThinkingLevel,
43
+ ): LibrarianModelResolution | undefined {
44
+ if (configuredModel) {
45
+ const providerModels = ctx.modelRegistry
46
+ .getAvailable()
47
+ .filter((model) => model.provider === configuredModel.provider);
48
+ const match = bestModelMatch(providerModels, configuredModel.modelId);
49
+ if (match) {
50
+ return { model: match, thinkingLevel, source: "configured" };
51
+ }
52
+ }
53
+
54
+ if (!ctx.model) {
55
+ return undefined;
56
+ }
57
+
58
+ return { model: ctx.model, thinkingLevel, source: "current" };
59
+ }
@@ -0,0 +1,54 @@
1
+ export function buildLibrarianSystemPrompt(cacheDir: string): string {
2
+ return `You are the Librarian, a codebase-understanding agent. You answer questions about large, multi-repository codebases using the provided tools. You run as an isolated subagent and your answer is relayed verbatim back to the main agent.
3
+
4
+ ## Search Strategy
5
+
6
+ Pick the cheapest instrument that answers the question:
7
+
8
+ - Question about a SPECIFIC repo (behavior, implementation, flow): checkout_repo first, then grep/read/find/ls on the local clone. This is your workhorse. Use \`git -C <path> log -S <term> / blame / diff\` for history questions.
9
+ - "What's out there / what's popular": search_repos (stars are the popularity signal), plus web search when available for ecosystem context. Read READMEs/docs via read_github_file.
10
+ - "Who does X across public repos": search_code to gather candidates, then verify the promising ones via checkout_repo or read_github_file.
11
+ - Private/authenticated GitHub code search: search_github_code to gather candidates, then verify with checkout_repo or read_github_file.
12
+ - Single-file peek (a package.json, one README): read_github_file — don't clone for one file.
13
+
14
+ search_code and search_github_code results are CANDIDATES, not evidence. Never cite a search hit you have not verified by reading the file.
15
+
16
+ Work with common sense: start with the most informative call, expand only when needed, and stop as soon as you have enough evidence to answer confidently. Prefer parallel tool calls for independent lookups.
17
+
18
+ ## Workspace
19
+
20
+ Clones live under ${cacheDir}/repos/<owner>/<repo>. They are read-only research material: never modify, commit, or push. Keep bash usage to inspection (git log/blame/diff, wc, jq); file content questions go through grep/read.
21
+
22
+ ## Citations
23
+
24
+ - Only cite files you actually read in this run.
25
+ - Cite as repo + file path + line range when you have one.
26
+ - If evidence is partial, say what is confirmed and what remains uncertain.
27
+ - If access fails (404/403, private repo), report that constraint plainly.
28
+
29
+ ## Finishing
30
+
31
+ - You MUST end by calling provide_results — exactly once, after your research is complete:
32
+ - Calling provide_results forcefully ends your turn, so it must be the LAST thing you do, by itself. Do not call it before you are ready to answer.
33
+ - Do not write your findings as a plain message; they only count via provide_results.`;
34
+ }
35
+
36
+ export function buildLibrarianUserPrompt(query: string, repos: string[], owners: string[]): string {
37
+ const lines = [`Research query: ${query}`];
38
+ if (repos.length > 0) {
39
+ lines.push(`Repository scope: ${repos.join(", ")}`);
40
+ }
41
+ if (owners.length > 0) {
42
+ lines.push(`Owner scope: ${owners.join(", ")}`);
43
+ }
44
+ if (repos.length > 0 || owners.length > 0) {
45
+ lines.push(
46
+ "Treat the scope as the grounding point for the research, not a hard boundary. Start there, but go beyond it when outside repositories or owners are useful to answer the question accurately.",
47
+ );
48
+ }
49
+ return lines.join("\n");
50
+ }
51
+
52
+ export function buildProvideResultsReminder(): string {
53
+ return "You have not called provide_results yet. Call it now with your findings (summary, locations, optional description). If you could not answer the query, say so in the summary and cite whatever partial evidence you have.";
54
+ }
@@ -0,0 +1,63 @@
1
+ import type { Findings } from "./tools/provide-results.ts";
2
+
3
+ function githubOwnerRepo(repo: string): string | undefined {
4
+ if (/^[^/]+\/[^/]+$/.test(repo)) {
5
+ return repo;
6
+ }
7
+
8
+ try {
9
+ const url = new URL(repo);
10
+ if (url.hostname !== "github.com" && url.hostname !== "www.github.com") {
11
+ return undefined;
12
+ }
13
+ const [owner, name] = url.pathname.replace(/^\/+/, "").split("/");
14
+ return owner && name ? `${owner}/${name.replace(/\.git$/, "")}` : undefined;
15
+ } catch {
16
+ return undefined;
17
+ }
18
+ }
19
+
20
+ function locationUrl(
21
+ repo: string,
22
+ file: string,
23
+ lines: string | undefined,
24
+ sha: string | undefined,
25
+ ): string | undefined {
26
+ const ownerRepo = githubOwnerRepo(repo);
27
+ if (!ownerRepo) {
28
+ return undefined;
29
+ }
30
+
31
+ const refSegment = sha ? sha.slice(0, 12) : "HEAD";
32
+ const anchor = lines ? `#L${lines.replace(/[^0-9-]/g, "").replace("-", "-L")}` : "";
33
+ return `https://github.com/${ownerRepo}/blob/${refSegment}/${file}${anchor}`;
34
+ }
35
+
36
+ export function renderFindingsMarkdown(
37
+ findings: Findings,
38
+ checkouts: Record<string, string>,
39
+ ): string {
40
+ const sections = [findings.summary];
41
+
42
+ if (findings.locations.length > 0) {
43
+ const bullets = findings.locations.map((location) => {
44
+ const lineSuffix = location.lines ? `:${location.lines}` : "";
45
+ const ownerRepo = githubOwnerRepo(location.repo);
46
+ const url = locationUrl(
47
+ location.repo,
48
+ location.file,
49
+ location.lines,
50
+ ownerRepo ? checkouts[ownerRepo] : undefined,
51
+ );
52
+ const citation = `- \`${location.repo}/${location.file}${lineSuffix}\` — ${location.note}`;
53
+ return url ? `${citation}\n ${url}` : citation;
54
+ });
55
+ sections.push(`## Locations\n\n${bullets.join("\n")}`);
56
+ }
57
+
58
+ if (findings.description) {
59
+ sections.push(findings.description);
60
+ }
61
+
62
+ return sections.join("\n\n");
63
+ }
@@ -0,0 +1,248 @@
1
+ import fs from "node:fs/promises";
2
+ import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
4
+ 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";
11
+ import type { GitHubClientProvider } from "./github.ts";
12
+ import {
13
+ buildLibrarianSystemPrompt,
14
+ buildLibrarianUserPrompt,
15
+ buildProvideResultsReminder,
16
+ } from "./prompt.ts";
17
+ import { renderFindingsMarkdown } from "./results.ts";
18
+ import type { LibrarianSettings } from "./settings.ts";
19
+ import { createCheckoutRepoTool } from "./tools/checkout-repo.ts";
20
+ import type { Findings } from "./tools/provide-results.ts";
21
+ import { createProvideResultsTool } from "./tools/provide-results.ts";
22
+ import { createReadGitHubFileTool } from "./tools/read-github-file.ts";
23
+ import { searchCodeTool } from "./tools/search-code.ts";
24
+ import { createSearchGitHubCodeTool } from "./tools/search-github-code.ts";
25
+ import { createSearchReposTool } from "./tools/search-repos.ts";
26
+ import { asRepoToolDetails, summarizeToolResult } from "./trace.ts";
27
+
28
+ const MAX_PROVIDE_RESULTS_REMINDERS = 3;
29
+ const HARDCODED_EXCLUDED_TOOLS = ["write", "edit"];
30
+
31
+ export type LibrarianRunStatus = "running" | "done" | "error" | "aborted";
32
+
33
+ export interface TraceCall {
34
+ id: string;
35
+ name: string;
36
+ args: unknown;
37
+ startedAt: number;
38
+ endedAt?: number;
39
+ isError?: boolean;
40
+ resultSummary?: string;
41
+ }
42
+
43
+ export interface LibrarianRunDetails {
44
+ status: LibrarianRunStatus;
45
+ query: string;
46
+ modelLabel: string;
47
+ thinkingLevel: ThinkingLevel;
48
+ trace: TraceCall[];
49
+ findings?: Findings;
50
+ checkouts: Record<string, string>;
51
+ error?: string;
52
+ startedAt: number;
53
+ endedAt?: number;
54
+ }
55
+
56
+ export interface LibrarianRunOptions {
57
+ query: string;
58
+ repos: string[];
59
+ owners: string[];
60
+ model: Model<Api>;
61
+ thinkingLevel: ThinkingLevel;
62
+ settings: LibrarianSettings;
63
+ githubClient: GitHubClientProvider;
64
+ signal: AbortSignal | undefined;
65
+ onUpdate: ((details: LibrarianRunDetails) => void) | undefined;
66
+ }
67
+
68
+ export async function runLibrarian(
69
+ options: LibrarianRunOptions,
70
+ ): Promise<AgentToolResult<LibrarianRunDetails>> {
71
+ const details: LibrarianRunDetails = {
72
+ status: "running",
73
+ query: options.query,
74
+ modelLabel: `${options.model.provider}/${options.model.id}`,
75
+ thinkingLevel: options.thinkingLevel,
76
+ trace: [],
77
+ checkouts: {},
78
+ startedAt: Date.now(),
79
+ };
80
+
81
+ let lastEmit = 0;
82
+ const emit = (force = false) => {
83
+ const now = Date.now();
84
+ if (!force && now - lastEmit < 120) {
85
+ return;
86
+ }
87
+ lastEmit = now;
88
+ options.onUpdate?.({ ...details, trace: [...details.trace] });
89
+ };
90
+
91
+ const finish = (
92
+ status: LibrarianRunStatus,
93
+ content: string,
94
+ isError: boolean,
95
+ ): AgentToolResult<LibrarianRunDetails> => {
96
+ details.status = status;
97
+ details.endedAt = Date.now();
98
+ if (isError) {
99
+ details.error = content.split("\n")[0] ?? content;
100
+ }
101
+ emit(true);
102
+ return {
103
+ content: [{ type: "text", text: content }],
104
+ details: { ...details, trace: [...details.trace] },
105
+ ...(isError ? { isError: true } : {}),
106
+ };
107
+ };
108
+
109
+ await fs.mkdir(options.settings.cacheDir, { recursive: true });
110
+
111
+ let findings: Findings | undefined;
112
+ const repoTools = [
113
+ createSearchReposTool(options.githubClient),
114
+ searchCodeTool,
115
+ createSearchGitHubCodeTool(options.githubClient),
116
+ createCheckoutRepoTool(options.settings.cacheDir),
117
+ createReadGitHubFileTool(options.githubClient),
118
+ createProvideResultsTool((payload) => {
119
+ findings = payload;
120
+ }),
121
+ ];
122
+
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,
133
+ systemPromptOverride: () => buildLibrarianSystemPrompt(options.settings.cacheDir),
134
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
135
+ });
136
+ await resourceLoader.reload();
137
+
138
+ emit(true);
139
+
140
+ let session: AgentSession | undefined;
141
+ let unsubscribe: (() => void) | undefined;
142
+ let onAbort: (() => void) | undefined;
143
+
144
+ try {
145
+ const created = await createAgentSession({
146
+ cwd: options.settings.cacheDir,
147
+ resourceLoader,
148
+ sessionManager: SessionManager.inMemory(options.settings.cacheDir),
149
+ model: options.model,
150
+ thinkingLevel: options.thinkingLevel,
151
+ customTools: repoTools,
152
+ excludeTools: [...HARDCODED_EXCLUDED_TOOLS, ...options.settings.disabledTools],
153
+ });
154
+ session = created.session;
155
+
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);
162
+
163
+ unsubscribe = session.subscribe((event: AgentSessionEvent) => {
164
+ switch (event.type) {
165
+ case "tool_execution_start": {
166
+ details.trace.push({
167
+ id: event.toolCallId,
168
+ name: event.toolName,
169
+ args: event.args,
170
+ startedAt: Date.now(),
171
+ });
172
+ emit(true);
173
+ break;
174
+ }
175
+ case "tool_execution_end": {
176
+ const call = details.trace.find((traced) => traced.id === event.toolCallId);
177
+ if (!call) {
178
+ break;
179
+ }
180
+ call.endedAt = Date.now();
181
+ if (event.isError) {
182
+ call.isError = true;
183
+ }
184
+ const summary = summarizeToolResult(event.result, event.isError === true);
185
+ if (summary !== undefined) {
186
+ call.resultSummary = summary;
187
+ }
188
+ const repoDetails = asRepoToolDetails(event.result?.details);
189
+ if (repoDetails?.kind === "checkout_repo" && !event.isError) {
190
+ details.checkouts[repoDetails.repo] = repoDetails.headSha;
191
+ }
192
+ emit(true);
193
+ break;
194
+ }
195
+ default:
196
+ emit();
197
+ }
198
+ });
199
+
200
+ if (options.signal?.aborted) {
201
+ return finish("aborted", "Librarian run aborted before it started.", true);
202
+ }
203
+
204
+ const activeSession = session;
205
+ onAbort = () => {
206
+ void activeSession.abort();
207
+ };
208
+ options.signal?.addEventListener("abort", onAbort);
209
+
210
+ await session.prompt(buildLibrarianUserPrompt(options.query, options.repos, options.owners), {
211
+ expandPromptTemplates: false,
212
+ });
213
+
214
+ let reminders = 0;
215
+ while (!findings && !options.signal?.aborted && reminders < MAX_PROVIDE_RESULTS_REMINDERS) {
216
+ reminders += 1;
217
+ await session.prompt(buildProvideResultsReminder(), { expandPromptTemplates: false });
218
+ }
219
+
220
+ if (options.signal?.aborted) {
221
+ return finish("aborted", "Librarian run aborted.", true);
222
+ }
223
+
224
+ if (findings) {
225
+ details.findings = findings;
226
+ return finish("done", renderFindingsMarkdown(findings, details.checkouts), false);
227
+ }
228
+
229
+ const lastText = session.getLastAssistantText() ?? "";
230
+ return finish(
231
+ "error",
232
+ `Librarian did not report structured findings after ${MAX_PROVIDE_RESULTS_REMINDERS} reminders.${lastText ? `\n\nRaw final message:\n${lastText}` : ""}`,
233
+ true,
234
+ );
235
+ } catch (error) {
236
+ if (options.signal?.aborted) {
237
+ return finish("aborted", "Librarian run aborted.", true);
238
+ }
239
+ const message = error instanceof Error ? error.message : String(error);
240
+ return finish("error", `Librarian run failed: ${message}`, true);
241
+ } finally {
242
+ if (onAbort) {
243
+ options.signal?.removeEventListener("abort", onAbort);
244
+ }
245
+ unsubscribe?.();
246
+ session?.dispose();
247
+ }
248
+ }