@thurstonsand/pi-librarian 0.2.0 → 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/README.md +13 -7
- package/RELEASE.md +13 -0
- package/extensions/librarian/github.ts +14 -6
- package/extensions/librarian/grep-app.ts +88 -5
- package/extensions/librarian/run.ts +10 -1
- package/extensions/librarian/settings.ts +11 -0
- package/extensions/librarian/tools/search-code.ts +3 -3
- package/extensions/librarian/view.ts +55 -49
- package/extensions/librarian.ts +11 -0
- package/images/librarian-results.png +0 -0
- package/images/librarian-run.png +0 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A GitHub research subagent for the [pi coding agent](https://github.com/badlogic/pi-mono) inspired by [Amp](https://ampcode.com/): deep-dive questions about specific repos ("how does drizzle-orm implement prepared statements?") and discovery across the ecosystem ("compare the most popular TypeScript SQL ORMs").
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
## How it works
|
|
6
10
|
|
|
7
11
|
The `librarian` tool spawns a nested research agent with purpose-built tools:
|
|
@@ -39,17 +43,19 @@ In pi's global `settings.json`:
|
|
|
39
43
|
"tools": ["search_web", "fetch_web"],
|
|
40
44
|
"extensions": ["~/.pi/agent/extensions/parallel-web-tools"],
|
|
41
45
|
"cacheDir": "/tmp/pi-librarian",
|
|
46
|
+
"debug": { "persistRuns": false },
|
|
42
47
|
},
|
|
43
48
|
}
|
|
44
49
|
```
|
|
45
50
|
|
|
46
|
-
| Setting
|
|
47
|
-
|
|
|
48
|
-
| `model`
|
|
49
|
-
| `thinkingLevel`
|
|
50
|
-
| `tools`
|
|
51
|
-
| `extensions`
|
|
52
|
-
| `cacheDir`
|
|
51
|
+
| Setting | Recommended | Default |
|
|
52
|
+
| ------------------- | ----------------------------------------------- | ------------------------------ |
|
|
53
|
+
| `model` | `openai-codex/gpt-5.5` | current session model |
|
|
54
|
+
| `thinkingLevel` | `off` | current session thinking level |
|
|
55
|
+
| `tools` | names of extra tools to activate, when needed | `[]` |
|
|
56
|
+
| `extensions` | escape hatch paths for tools not loaded in pi | `[]` |
|
|
57
|
+
| `cacheDir` | `/tmp/pi-librarian` | `/tmp/pi-librarian` |
|
|
58
|
+
| `debug.persistRuns` | persist nested session file paths for debugging | `false` |
|
|
53
59
|
|
|
54
60
|
`librarian.tools` is the activation gate for extra tools. `librarian.extensions` only adds extension paths to the search space when a named tool is not already loaded in the main pi session; listing an extension path does not activate every tool in that bundle. Librarian runs exclude `write` and `edit`.
|
|
55
61
|
|
package/RELEASE.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
# Release notes
|
|
4
4
|
|
|
5
|
+
## 0.2.1
|
|
6
|
+
|
|
7
|
+
Fixes TUI rendering corruption.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added `librarian.debug.persistRuns` to keep nested run session files around for debugging.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed librarian trace rendering leaving stale "N earlier calls" duplicates in the TUI, caused by unsilenced `@octokit/request` deprecation warnings writing to the console.
|
|
16
|
+
- Fixed `search_code` retries riding out a fixed 30s-per-attempt budget; retries now use an escalating timeout schedule so a stalled request recovers in seconds instead of up to a minute.
|
|
17
|
+
|
|
5
18
|
## 0.2.0
|
|
6
19
|
|
|
7
20
|
Adds continuable librarian runs and opt-in for extra tools.
|
|
@@ -45,18 +45,26 @@ export async function resolveGitHubToken(): Promise<string | undefined> {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Any console output corrupts pi's interactive TUI (its differential renderer
|
|
49
|
+
// cannot recover from writes it did not make), so octokit must never log.
|
|
50
|
+
// The top-level `log` alone is not enough: @octokit/request resolves its logger
|
|
51
|
+
// from the per-request options (`request.log || console`) when emitting API
|
|
52
|
+
// deprecation warnings, so the silent logger has to be passed at both levels.
|
|
53
|
+
const SILENT_OCTOKIT_LOG = {
|
|
54
|
+
debug: () => {},
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
};
|
|
59
|
+
|
|
48
60
|
export function createGitHubClient(token: string | undefined): GitHubClient {
|
|
49
61
|
return new GitHubClient(
|
|
50
62
|
new Octokit({
|
|
51
63
|
...(token ? { auth: token } : {}),
|
|
52
64
|
userAgent: "pi-librarian",
|
|
53
|
-
log:
|
|
54
|
-
debug: () => {},
|
|
55
|
-
info: () => {},
|
|
56
|
-
warn: () => {},
|
|
57
|
-
error: () => {},
|
|
58
|
-
},
|
|
65
|
+
log: SILENT_OCTOKIT_LOG,
|
|
59
66
|
request: {
|
|
67
|
+
log: SILENT_OCTOKIT_LOG,
|
|
60
68
|
headers: {
|
|
61
69
|
"x-github-api-version": "2022-11-28",
|
|
62
70
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -52,6 +52,7 @@ export interface LibrarianRunDetails {
|
|
|
52
52
|
checkouts: Record<string, string>;
|
|
53
53
|
error?: string;
|
|
54
54
|
runId?: string;
|
|
55
|
+
debugSessionPath?: string;
|
|
55
56
|
startedAt: number;
|
|
56
57
|
endedAt?: number;
|
|
57
58
|
}
|
|
@@ -108,7 +109,11 @@ export async function runLibrarian(
|
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
const finalizeDetails = (status: LibrarianRunStatus, content: string): string => {
|
|
111
|
-
const
|
|
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;
|
|
112
117
|
details.status = status;
|
|
113
118
|
details.endedAt = Date.now();
|
|
114
119
|
if (status !== "done") {
|
|
@@ -137,6 +142,10 @@ export async function runLibrarian(
|
|
|
137
142
|
? await openContinuedSession(options.continueFrom, sessionsDir, options.settings.cacheDir, fail)
|
|
138
143
|
: SessionManager.create(options.settings.cacheDir, sessionsDir);
|
|
139
144
|
details.runId = sessionManager.getSessionId();
|
|
145
|
+
const debugSessionPath = sessionManager.getSessionFile();
|
|
146
|
+
if (options.settings.debug.persistRuns && debugSessionPath) {
|
|
147
|
+
details.debugSessionPath = debugSessionPath;
|
|
148
|
+
}
|
|
140
149
|
|
|
141
150
|
let findings: Findings | undefined;
|
|
142
151
|
const repoTools = [
|
|
@@ -20,6 +20,11 @@ const LIBRARIAN_FILE_SETTINGS_SCHEMA = Type.Object({
|
|
|
20
20
|
extensions: Type.Optional(Type.Array(Type.String())),
|
|
21
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({
|
|
@@ -45,6 +50,9 @@ export interface LibrarianSettings {
|
|
|
45
50
|
extensions: string[];
|
|
46
51
|
tools: string[];
|
|
47
52
|
cacheDir: string;
|
|
53
|
+
debug: {
|
|
54
|
+
persistRuns: boolean;
|
|
55
|
+
};
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export function getDefaultCacheDir(): string {
|
|
@@ -117,6 +125,9 @@ export function resolveLibrarianSettings(fileSettings: LibrarianFileSettings): L
|
|
|
117
125
|
extensions: normalizeExtensionPaths(fileSettings.extensions),
|
|
118
126
|
tools: normalizeToolNames(fileSettings.tools),
|
|
119
127
|
cacheDir: normalizeCacheDir(fileSettings.cacheDir),
|
|
128
|
+
debug: {
|
|
129
|
+
persistRuns: fileSettings.debug?.persistRuns ?? false,
|
|
130
|
+
},
|
|
120
131
|
};
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -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.
|
|
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
|
],
|
|
@@ -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,6 +185,25 @@ 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
|
+
|
|
192
207
|
function isLibrarianRunDetails(value: unknown): value is LibrarianRunDetails {
|
|
193
208
|
return (
|
|
194
209
|
value !== null &&
|
|
@@ -205,55 +220,33 @@ function renderTrace(
|
|
|
205
220
|
expanded: boolean,
|
|
206
221
|
cacheDir: string,
|
|
207
222
|
theme: Theme,
|
|
208
|
-
spinnerFrame: string,
|
|
209
223
|
): string[] {
|
|
210
224
|
const lines: string[] = [];
|
|
211
225
|
const calls = expanded ? details.trace : details.trace.slice(-COLLAPSED_TRACE_CALLS);
|
|
212
226
|
const hiddenCount = details.trace.length - calls.length;
|
|
213
227
|
if (hiddenCount > 0) {
|
|
214
228
|
lines.push(
|
|
215
|
-
theme.fg(
|
|
216
|
-
"
|
|
217
|
-
|
|
218
|
-
),
|
|
229
|
+
theme.fg("muted", ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (`) +
|
|
230
|
+
keyHint("app.tools.expand", "to expand") +
|
|
231
|
+
theme.fg("muted", ")"),
|
|
219
232
|
);
|
|
220
233
|
}
|
|
221
234
|
for (const call of calls) {
|
|
222
|
-
lines.push(renderTraceCallText(call, cacheDir, theme
|
|
235
|
+
lines.push(renderTraceCallText(call, cacheDir, theme));
|
|
223
236
|
}
|
|
224
237
|
return lines;
|
|
225
238
|
}
|
|
226
239
|
|
|
227
|
-
function updateLiveRender(context: LiveRenderContext | undefined, running: boolean): string {
|
|
228
|
-
if (!context) {
|
|
229
|
-
return (
|
|
230
|
-
SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length] ?? ""
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
context.state.librarianSpinnerFrame ??= 0;
|
|
235
|
-
if (running && !context.state.librarianSpinnerInterval) {
|
|
236
|
-
context.state.librarianSpinnerInterval = setInterval(() => {
|
|
237
|
-
context.state.librarianSpinnerFrame =
|
|
238
|
-
((context.state.librarianSpinnerFrame ?? 0) + 1) % SPINNER_FRAMES.length;
|
|
239
|
-
context.invalidate();
|
|
240
|
-
}, SPINNER_INTERVAL_MS);
|
|
241
|
-
} else if (!running && context.state.librarianSpinnerInterval) {
|
|
242
|
-
clearInterval(context.state.librarianSpinnerInterval);
|
|
243
|
-
delete context.state.librarianSpinnerInterval;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return SPINNER_FRAMES[context.state.librarianSpinnerFrame] ?? "";
|
|
247
|
-
}
|
|
248
|
-
|
|
249
240
|
export function renderLibrarianResult(
|
|
250
241
|
result: AgentToolResult<LibrarianRunDetails>,
|
|
251
242
|
options: { expanded: boolean; isPartial: boolean },
|
|
252
243
|
theme: Theme,
|
|
253
244
|
cacheDir: string,
|
|
254
|
-
context?:
|
|
245
|
+
context?: LibrarianRenderContext,
|
|
255
246
|
): Container {
|
|
256
|
-
const container =
|
|
247
|
+
const container =
|
|
248
|
+
context?.lastComponent instanceof Container ? context.lastComponent : new Container();
|
|
249
|
+
container.clear();
|
|
257
250
|
const details = result.details;
|
|
258
251
|
|
|
259
252
|
if (!isLibrarianRunDetails(details)) {
|
|
@@ -261,22 +254,34 @@ export function renderLibrarianResult(
|
|
|
261
254
|
container.addChild(
|
|
262
255
|
new Text(firstText && "text" in firstText ? firstText.text : "(no output)", 0, 0),
|
|
263
256
|
);
|
|
257
|
+
container.invalidate();
|
|
264
258
|
return container;
|
|
265
259
|
}
|
|
266
260
|
|
|
267
261
|
const running = options.isPartial || details.status === "running";
|
|
268
|
-
const spinnerFrame = updateLiveRender(context, running);
|
|
269
262
|
container.addChild(new Text(renderQuestion(details, options.expanded, theme), 0, 0));
|
|
270
263
|
container.addChild(new Spacer(1));
|
|
271
264
|
|
|
272
265
|
if (running || !details.findings) {
|
|
273
|
-
for (const line of renderTrace(details, options.expanded, cacheDir, theme
|
|
266
|
+
for (const line of renderTrace(details, options.expanded, cacheDir, theme)) {
|
|
274
267
|
container.addChild(new Text(line, 0, 0));
|
|
275
268
|
}
|
|
276
269
|
}
|
|
277
270
|
|
|
278
271
|
if (!running) {
|
|
279
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
|
+
}
|
|
280
285
|
const markdown = buildFindingsMarkdown(details, options.expanded);
|
|
281
286
|
container.addChild(new Markdown(markdown, 0, 0, getMarkdownTheme()));
|
|
282
287
|
} else if (details.error) {
|
|
@@ -290,6 +295,7 @@ export function renderLibrarianResult(
|
|
|
290
295
|
container.addChild(new Text(theme.fg("muted", `run ${details.runId}`), 0, 0));
|
|
291
296
|
}
|
|
292
297
|
container.addChild(new Text(renderFooter(details, theme), 0, 0));
|
|
298
|
+
container.invalidate();
|
|
293
299
|
return container;
|
|
294
300
|
}
|
|
295
301
|
|
package/extensions/librarian.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
formatTraceLine,
|
|
18
18
|
renderLibrarianCall,
|
|
19
19
|
renderLibrarianResult,
|
|
20
|
+
SPINNER_INTERVAL_MS,
|
|
20
21
|
shorten,
|
|
21
22
|
} from "./librarian/view.ts";
|
|
22
23
|
|
|
@@ -130,6 +131,16 @@ export default function librarianExtension(pi: ExtensionAPI): void {
|
|
|
130
131
|
},
|
|
131
132
|
|
|
132
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
|
+
}
|
|
133
144
|
return renderLibrarianResult(result, options, theme, settings.cacheDir, context);
|
|
134
145
|
},
|
|
135
146
|
});
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thurstonsand/pi-librarian",
|
|
3
|
-
"version": "0.2.
|
|
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": [
|