bermudis-pi-goodies 0.23.0 → 0.23.2

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 CHANGED
@@ -25,7 +25,7 @@ extensions. One entry point, twelve independent features.
25
25
  After publishing the package to npm:
26
26
 
27
27
  ```bash
28
- pi install npm:bermudis-pi-goodies@0.23.0
28
+ pi install npm:bermudis-pi-goodies@0.23.2
29
29
  ```
30
30
 
31
31
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
package/clean-tui.ts CHANGED
@@ -44,7 +44,12 @@ import { Box, Container, Text } from "@earendil-works/pi-tui";
44
44
  import { homedir } from "node:os";
45
45
  import { readFileSync } from "node:fs";
46
46
  import { completeSimple } from "@earendil-works/pi-ai/compat";
47
- import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
47
+ import type {
48
+ Api,
49
+ AssistantMessage,
50
+ Model,
51
+ ThinkingLevel,
52
+ } from "@earendil-works/pi-ai";
48
53
  import { logGoodiesEvent, setGoodiesLogPathForTesting } from "./goodies-log.ts";
49
54
  import { describeError } from "./json-file.ts";
50
55
  import {
@@ -596,8 +601,8 @@ async function summarizeViaProvider(
596
601
  signal: AbortSignal,
597
602
  ): Promise<string> {
598
603
  const t = await resolveSummaryTransport();
599
- const response = await completeSimple(
600
- t.model,
604
+ const response = await completeSummaryTurn(
605
+ t,
601
606
  {
602
607
  messages: [
603
608
  {
@@ -609,13 +614,7 @@ async function summarizeViaProvider(
609
614
  },
610
615
  ],
611
616
  },
612
- {
613
- apiKey: t.apiKey,
614
- headers: t.headers,
615
- maxTokens: SUMMARY_MAX_TOKENS,
616
- signal,
617
- reasoning: summaryReasoning(t.model),
618
- },
617
+ signal,
619
618
  );
620
619
  return convertSummaryResponse(response, t.label);
621
620
  }
@@ -625,8 +624,8 @@ async function summarizeThinkingViaProvider(
625
624
  signal: AbortSignal,
626
625
  ): Promise<string> {
627
626
  const t = await resolveSummaryTransport();
628
- const response = await completeSimple(
629
- t.model,
627
+ const response = await completeSummaryTurn(
628
+ t,
630
629
  {
631
630
  messages: [
632
631
  {
@@ -636,13 +635,7 @@ async function summarizeThinkingViaProvider(
636
635
  },
637
636
  ],
638
637
  },
639
- {
640
- apiKey: t.apiKey,
641
- headers: t.headers,
642
- maxTokens: SUMMARY_MAX_TOKENS,
643
- signal,
644
- reasoning: summaryReasoning(t.model),
645
- },
638
+ signal,
646
639
  );
647
640
  return convertSummaryResponse(response, t.label);
648
641
  }
@@ -654,6 +647,25 @@ async function summarizeThinkingViaProvider(
654
647
  * thing a raw endpoint could never do. Safe to call fire-and-forget
655
648
  * (pi-codex makes OAuth-refreshing calls the same way).
656
649
  */
650
+ /**
651
+ * Summaries are best-effort: an unconfigured model (or a session whose model
652
+ * registry never arrived) means the feature is OFF, not failing. Those
653
+ * conditions throw this sentinel so every failure path can drop them
654
+ * silently — no log line, no backoff, no pause widget. Config can flip
655
+ * underneath a session at any moment (another pi session rewrites
656
+ * goodies.json on every write), so this must be checked at the failure
657
+ * boundary, not only at the enqueue gates.
658
+ */
659
+ function summariesOffError(reason: string): Error {
660
+ const err = new Error(reason);
661
+ err.name = "SummariesOffError";
662
+ return err;
663
+ }
664
+
665
+ function isSummariesOffError(err: unknown): boolean {
666
+ return (err as Error | undefined)?.name === "SummariesOffError";
667
+ }
668
+
657
669
  async function resolveSummaryTransport(): Promise<{
658
670
  model: Model<Api>;
659
671
  label: string;
@@ -661,10 +673,10 @@ async function resolveSummaryTransport(): Promise<{
661
673
  headers?: Record<string, string | null>;
662
674
  }> {
663
675
  const configured = getSummaryModel();
664
- if (!configured) throw new Error("no summary model configured");
676
+ if (!configured) throw summariesOffError("no summary model configured");
665
677
  const registry = summaryModelRegistry;
666
678
  if (!registry)
667
- throw new Error("model registry not captured yet this session");
679
+ throw summariesOffError("model registry not captured yet this session");
668
680
  const found = findSummaryModel(registry, configured);
669
681
  if (!found)
670
682
  throw new Error(`summary model "${configured}" not found in registry`);
@@ -748,6 +760,46 @@ function summaryReasoning(model: Model<Api>): ThinkingLevel | undefined {
748
760
  return "minimal";
749
761
  }
750
762
 
763
+ /**
764
+ * Run one summary completion through pi-ai, shared by both summary kinds so
765
+ * the reasoning_effort compatibility retry lives in exactly one place.
766
+ *
767
+ * Some OpenAI-compatible endpoints validate reasoning_effort against their
768
+ * own enum and reject "minimal" outright — observed on Command Code
769
+ * (commandcode/poolside/*): 400 invalid_request_error, param:"reasoning_effort",
770
+ * accepted values low|medium|high|xhigh|max. "low" is the floor of every known
771
+ * enum, so retry once there before surfacing the failure; an endpoint that
772
+ * rejects "low" too would pause as before.
773
+ */
774
+ async function completeSummaryTurn(
775
+ t: Awaited<ReturnType<typeof resolveSummaryTransport>>,
776
+ context: Parameters<typeof completeSimple>[1],
777
+ signal: AbortSignal,
778
+ ): Promise<AssistantMessage> {
779
+ const options = (reasoning: ThinkingLevel | undefined) => ({
780
+ apiKey: t.apiKey,
781
+ headers: t.headers,
782
+ maxTokens: SUMMARY_MAX_TOKENS,
783
+ signal,
784
+ reasoning,
785
+ });
786
+ // completeSimple does NOT throw for HTTP errors — it returns an
787
+ // AssistantMessage with stopReason:"error" + errorMessage, so the
788
+ // compatibility check inspects the response, not a catch block.
789
+ const response = await completeSimple(
790
+ t.model,
791
+ context,
792
+ options(summaryReasoning(t.model)),
793
+ );
794
+ if (
795
+ response.stopReason === "error" &&
796
+ (response.errorMessage ?? "").includes("reasoning_effort")
797
+ ) {
798
+ return await completeSimple(t.model, context, options("low"));
799
+ }
800
+ return response;
801
+ }
802
+
751
803
  // Summaries are best-effort polish over the heuristic hint, but failures must
752
804
  // not be silent: every request lands in the structured log with its outcome,
753
805
  // so request volume and a broken provider/key/model choice are queryable
@@ -935,8 +987,14 @@ function startSummaryRequest(cmd: string): void {
935
987
  // concurrency slot with zero log output.
936
988
  if (!signal.aborted) pendingSummaries.delete(cmd);
937
989
  // Switching sessions aborts in-flight summaries deliberately: that is
938
- // not a provider failure — neither penalize nor log it.
939
- if (signal.aborted || (result.err as Error)?.name === "AbortError")
990
+ // not a provider failure — neither penalize nor log it. Same for the
991
+ // feature being switched off underneath the request (config rewrite by
992
+ // another session): drop silently, no backoff, no pause widget.
993
+ if (
994
+ signal.aborted ||
995
+ (result.err as Error)?.name === "AbortError" ||
996
+ isSummariesOffError(result.err)
997
+ )
940
998
  return;
941
999
  const pauseMs = noteSummaryFailure(result.err);
942
1000
  logSummaryFailure(
@@ -1069,6 +1127,14 @@ async function summarizeWithRetries(job: {
1069
1127
 
1070
1128
  /** Start queued requests while capacity allows and no backoff is active. */
1071
1129
  function drainSummaryQueue(): void {
1130
+ // The feature can be switched off between enqueue and drain — config is
1131
+ // re-read from disk on every write and any pi session can rewrite it. Off
1132
+ // means off: drop the deferred requests instead of draining them into
1133
+ // requests that resolveSummaryTransport can only refuse.
1134
+ if (!getSummaryModel()) {
1135
+ summaryRequestQueue.length = 0;
1136
+ return;
1137
+ }
1072
1138
  while (
1073
1139
  summaryRequestQueue.length > 0 &&
1074
1140
  pendingSummaries.size < SUMMARY_MAX_INFLIGHT &&
@@ -1321,7 +1387,11 @@ function maybeRequestThinkingSummary(text: string): void {
1321
1387
  }
1322
1388
  return;
1323
1389
  }
1324
- if (!signal.aborted && (result.err as Error)?.name !== "AbortError") {
1390
+ if (
1391
+ !signal.aborted &&
1392
+ (result.err as Error)?.name !== "AbortError" &&
1393
+ !isSummariesOffError(result.err)
1394
+ ) {
1325
1395
  const pauseMs = noteSummaryFailure(result.err);
1326
1396
  logSummaryFailure(
1327
1397
  text,
package/goodies.ts CHANGED
@@ -341,13 +341,18 @@ export function completeGoodiesArguments(
341
341
  if (verb === "summary-model") {
342
342
  // Single value token (model ids contain no spaces): "off"/"default"
343
343
  // clear the setting; anything else matches the catalogue, ranked.
344
+ // Values MUST carry the verb: pi applies a selection by replacing the
345
+ // whole argument-text span with item.value (both its slash-argument
346
+ // path and the forced-Tab wrapper use prefix: argumentText), so bare
347
+ // values wipe "summary-model" off the line. enable/disable and
348
+ // thinking-summaries already follow this pattern.
344
349
  const q = valuePrefix.toLowerCase();
345
350
  const items: AutocompleteItem[] = [];
346
351
  for (const w of ["off", "default"]) {
347
- if (w.startsWith(q)) items.push({ value: w, label: w });
352
+ if (w.startsWith(q)) items.push({ value: `${verb} ${w}`, label: w });
348
353
  }
349
354
  for (const c of rankCandidates(completionModels, q).slice(0, 20)) {
350
- items.push({ value: c, label: c });
355
+ items.push({ value: `${verb} ${c}`, label: c });
351
356
  }
352
357
  return items.length ? items : null;
353
358
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",