pi-bro 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +5 -1
  2. package/bro.ts +99 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -299,6 +299,8 @@ pi -e npm:pi-bro
299
299
  | `/bro` | Create a new plain-language explanation of the latest completed assistant response. |
300
300
  | `/bro simplify` | Same as `/bro`. |
301
301
  | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
302
+ | `/bro usage` | Show current Agy resource limits. |
303
+ | `/bro usage --provider agy` | Same as `/bro usage`, with the provider stated explicitly. |
302
304
  | `/bro help` | Open the built-in guide. |
303
305
 
304
306
  ### Modal controls
@@ -341,6 +343,8 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
341
343
 
342
344
  - **External requests**: Bro sends the latest completed assistant response to
343
345
  Agy and its configured model provider.
346
+ - **Usage checks**: `/bro usage` checks your authenticated Agy limits without
347
+ sending an assistant response or running a model turn.
344
348
  - **Context isolation**: Bro does not add explanations to Pi's conversation
345
349
  history, session files, or main-agent context.
346
350
  - **Memory cache**: The latest explanation is stored only in process memory for
@@ -356,7 +360,7 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
356
360
 
357
361
  ## Current limits
358
362
 
359
- - Supports only Agy/Gemini in v0.1.
363
+ - Uses Agy as its only provider.
360
364
  - Keeps only the latest explanation in memory.
361
365
  - Does not store history or export directly to files.
362
366
  - Mouse-wheel and trackpad scrolling work in Pi's fullscreen mode
package/bro.ts CHANGED
@@ -25,6 +25,7 @@ type TuiLike = { requestRender(): void };
25
25
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
26
26
  type AssistantSource = { id: string; text: string };
27
27
  type BroResult = { source: AssistantSource; text: string };
28
+ type ModalResult = { source?: AssistantSource; text: string };
28
29
  type AgyEvent = {
29
30
  event?: string;
30
31
  step_update?: { step_type?: string; text_delta?: unknown };
@@ -42,9 +43,57 @@ export function wheelDelta(data: string): number {
42
43
  const COMMANDS = [
43
44
  { value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
44
45
  { value: "open", label: "open", description: "Reopen the last explanation" },
46
+ { value: "usage", label: "usage", description: "Show current Agy usage" },
45
47
  { value: "help", label: "help", description: "Learn what Bro does and what it can access" },
46
48
  ];
47
49
 
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null;
52
+ }
53
+
54
+ export function formatAgyUsage(value: unknown): string {
55
+ if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
56
+ throw new Error("Agy returned invalid usage data.");
57
+ }
58
+
59
+ const groups = new Map<string, string[]>();
60
+ for (const line of value.response.trim().split("\n")) {
61
+ const [group, limit, remaining, resetTime] = line.split("\t");
62
+ if (!group || !limit || !remaining) throw new Error("Agy returned invalid usage data.");
63
+ const reset = resetTime ? new Date(resetTime) : undefined;
64
+ const resetText = reset && !Number.isNaN(reset.getTime()) ? ` — resets ${reset.toLocaleString()}` : "";
65
+ const items = groups.get(group) ?? [];
66
+ items.push(`- **${limit}:** ${remaining}${resetText}`);
67
+ groups.set(group, items);
68
+ }
69
+ if (!groups.size) throw new Error("Agy returned no usage information.");
70
+
71
+ const sections = [...groups].map(([group, items]) => `## ${group}\n\n${items.join("\n")}`);
72
+ return `# Agy usage\n\n${sections.join("\n\n")}`;
73
+ }
74
+
75
+ async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
76
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
77
+ try {
78
+ const result = await pi.exec(
79
+ "agy",
80
+ ["-p", "/usage", "--output-format", "json", "--print-timeout", "30s", "--sandbox"],
81
+ { cwd: runDirectory, signal, timeout: 35_000 },
82
+ );
83
+ if (signal.aborted) throw new Error("Canceled.");
84
+ if (result.killed) throw new Error("Agy usage check timed out.");
85
+ if (result.code !== 0) throw new Error(result.stderr.trim() || `Agy exited with code ${result.code}.`);
86
+ try {
87
+ return formatAgyUsage(JSON.parse(result.stdout));
88
+ } catch (error) {
89
+ if (error instanceof SyntaxError) throw new Error("Agy returned invalid usage data.");
90
+ throw error;
91
+ }
92
+ } finally {
93
+ await rm(runDirectory, { recursive: true, force: true });
94
+ }
95
+ }
96
+
48
97
  function latestAssistant(ctx: ExtensionCommandContext): AssistantSource | undefined {
49
98
  const branch = ctx.sessionManager.getBranch();
50
99
 
@@ -208,6 +257,7 @@ Bro turns the latest completed assistant response into a clear, plain-language e
208
257
 
209
258
  - \`/bro\` or \`/bro simplify\` — create a new explanation
210
259
  - \`/bro open\` — reopen the last explanation
260
+ - \`/bro usage\` or \`/bro usage --provider agy\` — show current Agy usage
211
261
  - \`/bro help\` — show this guide
212
262
 
213
263
  ## Controls
@@ -228,6 +278,8 @@ Bro does not add explanations to Pi's conversation history, session files, or ma
228
278
 
229
279
  Bro sends the assistant response to an external simplifier (currently Agy with a Gemini model). Agy and the model provider may retain request data or logs under their own policies.
230
280
 
281
+ \`/bro usage\` checks your authenticated Agy limits without sending an assistant response or running a model turn.
282
+
231
283
  Pressing **C** copies the explanation to your system clipboard, where your operating system or clipboard manager may retain it.
232
284
 
233
285
  ## Custom prompt
@@ -263,8 +315,8 @@ class BroModal implements Focusable {
263
315
  private readonly onDispose: () => void,
264
316
  ) {}
265
317
 
266
- setLoading(): void {
267
- this.setContent("loading", `**${LOADING_TEXT}**`, "", false, false);
318
+ setLoading(text = LOADING_TEXT): void {
319
+ this.setContent("loading", `**${text}**`, "", false, false);
268
320
  }
269
321
 
270
322
  setStreaming(text: string): void {
@@ -280,7 +332,7 @@ class BroModal implements Focusable {
280
332
  }
281
333
 
282
334
  setError(message: string): void {
283
- this.setContent("error", `# Bro could not simplify this\n\n${message}`, "", false, true);
335
+ this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
284
336
  }
285
337
 
286
338
  private setContent(
@@ -320,7 +372,9 @@ class BroModal implements Focusable {
320
372
  private controls(): string {
321
373
  if (this.kind === "loading") return "Esc cancel";
322
374
  if (this.kind === "streaming") return "Simplifying… · ↑/↓ scroll · Esc cancel";
323
- if (this.kind === "result") return "↑/↓ scroll · C copy · R simplify again · Esc close";
375
+ if (this.kind === "result") {
376
+ return `↑/↓ scroll · C copy${this.retryable ? " · R simplify again" : ""} · Esc close`;
377
+ }
324
378
  if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
325
379
  if (this.kind === "error") return "R try again · Esc close";
326
380
  return "Esc close";
@@ -411,13 +465,15 @@ interface BroModalOptions {
411
465
  text?: string;
412
466
  kind?: "help" | "empty";
413
467
  copyable?: boolean;
414
- result?: BroResult;
468
+ result?: ModalResult;
415
469
  run?: (
416
470
  signal: AbortSignal,
417
471
  source?: AssistantSource,
418
472
  onProgress?: (text: string) => void,
419
- ) => Promise<BroResult>;
420
- onResult?: (result: BroResult) => void;
473
+ ) => Promise<ModalResult>;
474
+ onResult?: (result: ModalResult) => void;
475
+ loadingText?: string;
476
+ retryable?: boolean;
421
477
  }
422
478
 
423
479
  async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
@@ -459,7 +515,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
459
515
  const previous = current;
460
516
  const nextController = new AbortController();
461
517
  controller = nextController;
462
- modal.setLoading();
518
+ modal.setLoading(options.loadingText);
463
519
 
464
520
  void options
465
521
  .run(nextController.signal, source, (text) => {
@@ -470,14 +526,14 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
470
526
  if (closed || nextController.signal.aborted) return;
471
527
  current = result;
472
528
  options.onResult?.(result);
473
- modal.setResult(result.text, true);
529
+ modal.setResult(result.text, options.retryable ?? true);
474
530
  })
475
531
  .catch((error) => {
476
532
  if (closed || nextController.signal.aborted) return;
477
533
  const message = error instanceof Error ? error.message : String(error);
478
534
  if (previous) {
479
535
  current = previous;
480
- modal.setResult(previous.text, true, `Retry failed: ${message}`);
536
+ modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`);
481
537
  } else {
482
538
  modal.setError(message);
483
539
  }
@@ -490,7 +546,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
490
546
  if (options.text !== undefined) {
491
547
  modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
492
548
  } else if (current) {
493
- modal.setResult(current.text, Boolean(options.run));
549
+ modal.setResult(current.text, options.retryable ?? Boolean(options.run));
494
550
  } else {
495
551
  execute();
496
552
  }
@@ -512,21 +568,45 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
512
568
 
513
569
  export default function bro(pi: ExtensionAPI) {
514
570
  let lastResult: BroResult | undefined;
571
+ const remember = (result: ModalResult) => {
572
+ if (result.source) lastResult = { source: result.source, text: result.text };
573
+ };
515
574
 
516
575
  pi.on("session_start", async () => {
517
576
  lastResult = undefined;
518
577
  });
519
578
 
520
579
  pi.registerCommand("bro", {
521
- description: "Simplify, reopen, or learn about Bro explanations",
580
+ description: "Simplify responses, reopen explanations, or show Agy usage",
522
581
  getArgumentCompletions: (prefix) => {
523
582
  const normalized = prefix.trim().toLowerCase();
524
583
  const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
525
584
  return matches.length ? matches : null;
526
585
  },
527
586
  handler: async (args, ctx) => {
528
- const action = args.trim().toLowerCase();
529
- if (action === "help") {
587
+ const normalized = args.trim().toLowerCase();
588
+ const parts = normalized ? normalized.split(/\s+/) : [];
589
+ const action = parts[0] ?? "";
590
+
591
+ if (action === "usage") {
592
+ const valid = parts.length === 1 || (parts.length === 3 && parts[1] === "--provider" && parts[2] === "agy");
593
+ if (!valid) {
594
+ ctx.ui.notify("Use /bro usage or /bro usage --provider agy.", "warning");
595
+ return;
596
+ }
597
+ try {
598
+ await showBroModal(ctx, {
599
+ loadingText: "Checking Agy usage…",
600
+ retryable: false,
601
+ run: async (signal) => ({ text: await checkAgyUsage(pi, signal) }),
602
+ });
603
+ } catch (error) {
604
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
605
+ }
606
+ return;
607
+ }
608
+
609
+ if (normalized === "help") {
530
610
  await showBroModal(ctx, { text: helpText(), kind: "help", copyable: true });
531
611
  return;
532
612
  }
@@ -545,7 +625,7 @@ export default function bro(pi: ExtensionAPI) {
545
625
  return { source: target, text: await simplify(target.text, signal, onProgress) };
546
626
  };
547
627
 
548
- if (action === "open") {
628
+ if (normalized === "open") {
549
629
  if (!lastResult) {
550
630
  await showBroModal(ctx, {
551
631
  text: "# Nothing to open yet\n\nRun `/bro` after an assistant response.",
@@ -557,24 +637,20 @@ export default function bro(pi: ExtensionAPI) {
557
637
  await showBroModal(ctx, {
558
638
  result: lastResult,
559
639
  run,
560
- onResult: (result) => {
561
- lastResult = result;
562
- },
640
+ onResult: remember,
563
641
  });
564
642
  return;
565
643
  }
566
644
 
567
- if (action && action !== "simplify") {
568
- ctx.ui.notify(`Unknown action "${action}". Use simplify, open, or help.`, "warning");
645
+ if (normalized && normalized !== "simplify") {
646
+ ctx.ui.notify(`Unknown action "${normalized}". Use simplify, open, usage, or help.`, "warning");
569
647
  return;
570
648
  }
571
649
 
572
650
  try {
573
651
  await showBroModal(ctx, {
574
652
  run,
575
- onResult: (result) => {
576
- lastResult = result;
577
- },
653
+ onResult: remember,
578
654
  });
579
655
  } catch (error) {
580
656
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An Earendil Pi extension that simplifies the latest assistant response in a separate, context-isolated window.",
5
5
  "type": "module",
6
6
  "license": "MIT",