@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.
- package/AGENTS.md +1 -1
- package/CONTEXT.md +10 -2
- package/README.md +31 -7
- package/RELEASE.md +33 -0
- package/docs/designs/02-continuable-runs.md +102 -0
- package/docs/designs/04-librarian-extra-tools.md +138 -0
- package/extensions/librarian/extra-tools.ts +130 -0
- package/extensions/librarian/github.ts +14 -6
- package/extensions/librarian/grep-app.ts +88 -5
- package/extensions/librarian/prompt.ts +1 -1
- package/extensions/librarian/resource-loader.ts +40 -0
- package/extensions/librarian/run.ts +97 -45
- package/extensions/librarian/settings.ts +22 -3
- package/extensions/librarian/tools/checkout-repo.ts +3 -18
- package/extensions/librarian/tools/provide-results.ts +19 -6
- package/extensions/librarian/tools/read-github-file.ts +12 -16
- package/extensions/librarian/tools/search-code.ts +3 -3
- package/extensions/librarian/tools/search-repos.ts +1 -10
- package/extensions/librarian/view.ts +70 -50
- package/extensions/librarian.ts +36 -21
- package/images/librarian-results.png +0 -0
- package/images/librarian-run.png +0 -0
- package/package.json +3 -2
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
3
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";
|
|
4
|
+
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { type Component, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { LibrarianRunDetails, TraceCall } from "./run.ts";
|
|
7
7
|
import { LIBRARIAN_TOOL_NAMES } from "./tools/names.ts";
|
|
8
8
|
|
|
9
9
|
const COLLAPSED_TRACE_CALLS = 3;
|
|
10
|
+
// Same frames and cadence as pi's Working... loader (pi-tui loader.ts defaults).
|
|
10
11
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
|
-
const SPINNER_INTERVAL_MS = 80;
|
|
12
|
-
|
|
13
|
-
interface
|
|
14
|
-
|
|
15
|
-
state: {
|
|
16
|
-
librarianSpinnerFrame?: number;
|
|
17
|
-
librarianSpinnerInterval?: ReturnType<typeof setInterval>;
|
|
18
|
-
};
|
|
12
|
+
export const SPINNER_INTERVAL_MS = 80;
|
|
13
|
+
|
|
14
|
+
interface LibrarianRenderContext {
|
|
15
|
+
lastComponent: Component | undefined;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
export function formatDuration(milliseconds: number): string {
|
|
@@ -153,18 +150,17 @@ export function formatTraceLine(call: TraceCall, cacheDir: string): TraceLine {
|
|
|
153
150
|
}
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
): string {
|
|
153
|
+
function currentSpinnerFrame(): string {
|
|
154
|
+
return SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length] ?? "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderTraceCallText(call: TraceCall, cacheDir: string, theme: Theme): string {
|
|
162
158
|
const { verb, subject } = formatTraceLine(call, cacheDir);
|
|
163
159
|
const running = call.endedAt === undefined;
|
|
164
160
|
const icon = call.isError
|
|
165
161
|
? theme.fg("error", "✗")
|
|
166
162
|
: running
|
|
167
|
-
? theme.fg("
|
|
163
|
+
? theme.fg("accent", currentSpinnerFrame())
|
|
168
164
|
: theme.fg("success", "✓");
|
|
169
165
|
const duration = formatDuration((call.endedAt ?? Date.now()) - call.startedAt);
|
|
170
166
|
const summary = call.resultSummary
|
|
@@ -189,83 +185,103 @@ function renderFooter(details: LibrarianRunDetails, theme: Theme): string {
|
|
|
189
185
|
);
|
|
190
186
|
}
|
|
191
187
|
|
|
188
|
+
function collapsedFindingsHiddenText(details: LibrarianRunDetails): string | undefined {
|
|
189
|
+
const findings = details.findings;
|
|
190
|
+
if (!findings) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const hiddenParts: string[] = [];
|
|
195
|
+
if (findings.locations.length > 0) {
|
|
196
|
+
hiddenParts.push(
|
|
197
|
+
`${findings.locations.length} location${findings.locations.length === 1 ? "" : "s"}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (findings.description?.trim()) {
|
|
201
|
+
hiddenParts.push("details");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return hiddenParts.length > 0 ? `${hiddenParts.join(" and ")} hidden` : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isLibrarianRunDetails(value: unknown): value is LibrarianRunDetails {
|
|
208
|
+
return (
|
|
209
|
+
value !== null &&
|
|
210
|
+
typeof value === "object" &&
|
|
211
|
+
typeof (value as { status?: unknown }).status === "string" &&
|
|
212
|
+
typeof (value as { query?: unknown }).query === "string" &&
|
|
213
|
+
typeof (value as { modelLabel?: unknown }).modelLabel === "string" &&
|
|
214
|
+
Array.isArray((value as { trace?: unknown }).trace)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
function renderTrace(
|
|
193
219
|
details: LibrarianRunDetails,
|
|
194
220
|
expanded: boolean,
|
|
195
221
|
cacheDir: string,
|
|
196
222
|
theme: Theme,
|
|
197
|
-
spinnerFrame: string,
|
|
198
223
|
): string[] {
|
|
199
224
|
const lines: string[] = [];
|
|
200
225
|
const calls = expanded ? details.trace : details.trace.slice(-COLLAPSED_TRACE_CALLS);
|
|
201
226
|
const hiddenCount = details.trace.length - calls.length;
|
|
202
227
|
if (hiddenCount > 0) {
|
|
203
228
|
lines.push(
|
|
204
|
-
theme.fg(
|
|
205
|
-
"
|
|
206
|
-
|
|
207
|
-
),
|
|
229
|
+
theme.fg("muted", ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (`) +
|
|
230
|
+
keyHint("app.tools.expand", "to expand") +
|
|
231
|
+
theme.fg("muted", ")"),
|
|
208
232
|
);
|
|
209
233
|
}
|
|
210
234
|
for (const call of calls) {
|
|
211
|
-
lines.push(renderTraceCallText(call, cacheDir, theme
|
|
235
|
+
lines.push(renderTraceCallText(call, cacheDir, theme));
|
|
212
236
|
}
|
|
213
237
|
return lines;
|
|
214
238
|
}
|
|
215
239
|
|
|
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
240
|
export function renderLibrarianResult(
|
|
239
241
|
result: AgentToolResult<LibrarianRunDetails>,
|
|
240
242
|
options: { expanded: boolean; isPartial: boolean },
|
|
241
243
|
theme: Theme,
|
|
242
244
|
cacheDir: string,
|
|
243
|
-
context?:
|
|
245
|
+
context?: LibrarianRenderContext,
|
|
244
246
|
): Container {
|
|
245
|
-
const container =
|
|
247
|
+
const container =
|
|
248
|
+
context?.lastComponent instanceof Container ? context.lastComponent : new Container();
|
|
249
|
+
container.clear();
|
|
246
250
|
const details = result.details;
|
|
247
251
|
|
|
248
|
-
if (!details) {
|
|
252
|
+
if (!isLibrarianRunDetails(details)) {
|
|
249
253
|
const firstText = result.content.find((part) => part.type === "text");
|
|
250
254
|
container.addChild(
|
|
251
255
|
new Text(firstText && "text" in firstText ? firstText.text : "(no output)", 0, 0),
|
|
252
256
|
);
|
|
257
|
+
container.invalidate();
|
|
253
258
|
return container;
|
|
254
259
|
}
|
|
255
260
|
|
|
256
261
|
const running = options.isPartial || details.status === "running";
|
|
257
|
-
const spinnerFrame = updateLiveRender(context, running);
|
|
258
262
|
container.addChild(new Text(renderQuestion(details, options.expanded, theme), 0, 0));
|
|
259
263
|
container.addChild(new Spacer(1));
|
|
260
264
|
|
|
261
265
|
if (running || !details.findings) {
|
|
262
|
-
for (const line of renderTrace(details, options.expanded, cacheDir, theme
|
|
266
|
+
for (const line of renderTrace(details, options.expanded, cacheDir, theme)) {
|
|
263
267
|
container.addChild(new Text(line, 0, 0));
|
|
264
268
|
}
|
|
265
269
|
}
|
|
266
270
|
|
|
267
271
|
if (!running) {
|
|
268
272
|
if (details.findings) {
|
|
273
|
+
const hiddenText = options.expanded ? undefined : collapsedFindingsHiddenText(details);
|
|
274
|
+
if (hiddenText) {
|
|
275
|
+
container.addChild(
|
|
276
|
+
new Text(
|
|
277
|
+
theme.fg("muted", `(${hiddenText}, `) +
|
|
278
|
+
keyHint("app.tools.expand", "to expand") +
|
|
279
|
+
theme.fg("muted", ")"),
|
|
280
|
+
0,
|
|
281
|
+
0,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
269
285
|
const markdown = buildFindingsMarkdown(details, options.expanded);
|
|
270
286
|
container.addChild(new Markdown(markdown, 0, 0, getMarkdownTheme()));
|
|
271
287
|
} else if (details.error) {
|
|
@@ -275,7 +291,11 @@ export function renderLibrarianResult(
|
|
|
275
291
|
}
|
|
276
292
|
|
|
277
293
|
container.addChild(new Spacer(1));
|
|
294
|
+
if (!running && details.runId) {
|
|
295
|
+
container.addChild(new Text(theme.fg("muted", `run ${details.runId}`), 0, 0));
|
|
296
|
+
}
|
|
278
297
|
container.addChild(new Text(renderFooter(details, theme), 0, 0));
|
|
298
|
+
container.invalidate();
|
|
279
299
|
return container;
|
|
280
300
|
}
|
|
281
301
|
|
package/extensions/librarian.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-
|
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { applyAttachState, readAttachState, setAttachState } from "./librarian/attach.ts";
|
|
5
|
+
import { collectExtraToolWarnings, resolveExtraTools } from "./librarian/extra-tools.ts";
|
|
5
6
|
import { createGitHubClientProvider } from "./librarian/github.ts";
|
|
6
7
|
import { resolveLibrarianModel } from "./librarian/model.ts";
|
|
7
8
|
import { type LibrarianRunDetails, runLibrarian, type TraceCall } from "./librarian/run.ts";
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
formatTraceLine,
|
|
17
18
|
renderLibrarianCall,
|
|
18
19
|
renderLibrarianResult,
|
|
20
|
+
SPINNER_INTERVAL_MS,
|
|
19
21
|
shorten,
|
|
20
22
|
} from "./librarian/view.ts";
|
|
21
23
|
|
|
@@ -36,6 +38,12 @@ const LibrarianParams = Type.Object({
|
|
|
36
38
|
maxItems: 20,
|
|
37
39
|
}),
|
|
38
40
|
),
|
|
41
|
+
continue_from: Type.Optional(
|
|
42
|
+
Type.String({
|
|
43
|
+
description:
|
|
44
|
+
"Run id from an earlier librarian result. Pass this for follow-up questions for that prior run.",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
export default function librarianExtension(pi: ExtensionAPI): void {
|
|
@@ -84,41 +92,33 @@ export default function librarianExtension(pi: ExtensionAPI): void {
|
|
|
84
92
|
const thinkingLevel = settings.thinkingLevel ?? pi.getThinkingLevel();
|
|
85
93
|
const resolution = resolveLibrarianModel(ctx, settings.model, thinkingLevel);
|
|
86
94
|
if (!resolution) {
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
};
|
|
95
|
+
throw new Error(
|
|
96
|
+
"No model available for the librarian. Configure librarian.model or select a session model.",
|
|
97
|
+
);
|
|
107
98
|
}
|
|
108
99
|
|
|
100
|
+
const extraTools = resolveExtraTools(pi.getAllTools(), settings);
|
|
101
|
+
|
|
109
102
|
return runLibrarian({
|
|
110
103
|
query: params.query,
|
|
111
104
|
repos: params.repos ?? [],
|
|
112
105
|
owners: params.owners ?? [],
|
|
106
|
+
continueFrom: params.continue_from,
|
|
113
107
|
model: resolution.model,
|
|
114
108
|
thinkingLevel: resolution.thinkingLevel,
|
|
115
109
|
settings,
|
|
110
|
+
extraTools,
|
|
116
111
|
githubClient,
|
|
117
112
|
signal,
|
|
118
113
|
onUpdate: onUpdate
|
|
119
114
|
? (details) => {
|
|
120
115
|
onUpdate({
|
|
121
|
-
content: [
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text",
|
|
119
|
+
text: `Researching: ${shorten(params.query, 80)}`,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
122
|
details,
|
|
123
123
|
});
|
|
124
124
|
}
|
|
@@ -131,6 +131,16 @@ export default function librarianExtension(pi: ExtensionAPI): void {
|
|
|
131
131
|
},
|
|
132
132
|
|
|
133
133
|
renderResult(result, options, theme, context) {
|
|
134
|
+
// Live spinner/elapsed ticking, same pattern and cadence as pi's bash
|
|
135
|
+
// tool and Working... loader: re-render on an interval while streaming.
|
|
136
|
+
const state = context.state as { interval: NodeJS.Timeout | undefined };
|
|
137
|
+
if (options.isPartial && !state.interval) {
|
|
138
|
+
state.interval = setInterval(() => context.invalidate(), SPINNER_INTERVAL_MS);
|
|
139
|
+
}
|
|
140
|
+
if ((!options.isPartial || context.isError) && state.interval) {
|
|
141
|
+
clearInterval(state.interval);
|
|
142
|
+
state.interval = undefined;
|
|
143
|
+
}
|
|
134
144
|
return renderLibrarianResult(result, options, theme, settings.cacheDir, context);
|
|
135
145
|
},
|
|
136
146
|
});
|
|
@@ -177,5 +187,10 @@ export default function librarianExtension(pi: ExtensionAPI): void {
|
|
|
177
187
|
|
|
178
188
|
pi.on("session_start", async (_event: SessionStartEvent, ctx) => {
|
|
179
189
|
applyAttachState(pi, readAttachState(ctx));
|
|
190
|
+
|
|
191
|
+
const warnings = await collectExtraToolWarnings(pi.getAllTools(), settings);
|
|
192
|
+
for (const warning of warnings) {
|
|
193
|
+
ctx.ui.notify(warning.message, "warning");
|
|
194
|
+
}
|
|
180
195
|
});
|
|
181
196
|
}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thurstonsand/pi-librarian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "GitHub research subagent for pi: deep-dive specific repos, discover across the ecosystem",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"CONTEXT.md",
|
|
26
26
|
"DEV.md",
|
|
27
27
|
"AGENTS.md",
|
|
28
|
-
"docs"
|
|
28
|
+
"docs",
|
|
29
|
+
"images"
|
|
29
30
|
],
|
|
30
31
|
"pi": {
|
|
31
32
|
"extensions": [
|