@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.
@@ -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 LiveRenderContext {
14
- invalidate(): void;
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 renderTraceCallText(
157
- call: TraceCall,
158
- cacheDir: string,
159
- theme: Theme,
160
- spinnerFrame: string,
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("warning", spinnerFrame)
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
- "muted",
206
- ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (Ctrl+O to expand)`,
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, spinnerFrame));
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?: LiveRenderContext,
245
+ context?: LibrarianRenderContext,
244
246
  ): Container {
245
- const container = new 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, spinnerFrame)) {
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
 
@@ -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
- 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
- };
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: [{ type: "text", text: `Researching: ${shorten(params.query, 80)}` }],
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.1.1",
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": [