bermudis-pi-goodies 0.27.0 → 0.27.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
@@ -23,10 +23,17 @@ extensions. One entry point, thirteen independent features.
23
23
 
24
24
  ## Install
25
25
 
26
- After publishing the package to npm:
26
+ First install, pinned to a published version:
27
27
 
28
28
  ```bash
29
- pi install npm:bermudis-pi-goodies@0.27.0
29
+ pi install npm:bermudis-pi-goodies@0.27.2
30
+ ```
31
+
32
+ Picking up a new release on an existing install (`pi install` is not the
33
+ update path):
34
+
35
+ ```bash
36
+ pi update --extensions
30
37
  ```
31
38
 
32
39
  Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
@@ -431,10 +438,13 @@ Details worth knowing:
431
438
  path. (`deliverAs: "nextTurn"` would only queue in memory and die with
432
439
  the process.)
433
440
  - The footer's context-usage estimate reflects the raw session branch, not
434
- the lens output, so it over-reports while a side session is active — by
435
- the full uncompacted main history at worst, shrinking after compaction
436
- (the lens renders compaction summaries into the quote rather than
437
- resending summarized-away turns). Compaction summaries that cover side
441
+ the lens output, so it can over-report while a side session is active.
442
+ Threshold auto-compaction is vetoed when the **lensed side request**
443
+ fits the side model's window (including system/tool overhead and the
444
+ configured token reserve), rather than trusting the main model's usage.
445
+ The quoted main transcript still counts toward that window. Manual
446
+ `/compact` and actual context-overflow recovery remain available.
447
+ Compaction summaries that cover side
438
448
  turns are quoted with an explicit who-is-who caveat instead of passing as
439
449
  the consultant's own history.
440
450
  - Both quote directions carry an explicit untrusted-evidence preamble:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
package/side.ts CHANGED
@@ -28,11 +28,10 @@
28
28
  * limb that is ever resumed delivers only newer turns. v1 always starts a
29
29
  * fresh side limb, but the bookkeeping keeps that future-safe.
30
30
  *
31
- * Known limitation: pi's footer context-usage estimate (and its pre-send
32
- * compaction threshold) reflect the raw session branch, not the lens
33
- * output — the footer over-reports while a side session is active, and
34
- * premature auto-compaction is possible on large side consults (the lens
35
- * is compaction-aware and survives it; the main limb is never touched).
31
+ * Pi's footer context-usage estimate reflects the raw session branch, not
32
+ * the lens output, so the footer over-reports during side sessions. A
33
+ * session_before_compact guard vetoes threshold compaction when the lensed
34
+ * request fits; manual compaction and actual overflow recovery still run.
36
35
  * The side badge carries pi's own estimator applied to the lensed messages
37
36
  * ("· ~46k lensed") as the honest counterpoint to the inflated figure.
38
37
  */
@@ -44,11 +43,14 @@ import type {
44
43
  ExtensionCommandContext,
45
44
  ExtensionContext,
46
45
  SessionEntry,
46
+ ToolInfo,
47
47
  } from "@earendil-works/pi-coding-agent";
48
48
  import {
49
49
  ModelSelectorComponent,
50
+ calculateContextTokens,
50
51
  estimateTokens,
51
52
  sessionEntryToContextMessages,
53
+ shouldCompact,
52
54
  } from "@earendil-works/pi-coding-agent";
53
55
  import { describeError, extractTextParts } from "./json-file.ts";
54
56
  import { buildTrajectory, renderTrajectory } from "./copy-trajectory.ts";
@@ -321,6 +323,50 @@ export function estimateLensedContextTokens(
321
323
  return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
322
324
  }
323
325
 
326
+ /**
327
+ * Conservative side-request budget. Main-model usage cannot leak into this
328
+ * estimate: its assistant turns have already become quoted user text.
329
+ * Include system/tool overhead before the first side response, and trust
330
+ * valid SIDE usage as a floor (it includes provider-visible overhead).
331
+ */
332
+ export function estimateSideCompactionTokens(
333
+ messages: ContextEvent["messages"],
334
+ systemPrompt: string,
335
+ tools: readonly Pick<ToolInfo, "name" | "description" | "parameters">[],
336
+ rawBranch: readonly SessionEntry[],
337
+ ): number {
338
+ // Kept responses still carry pre-compaction usage. Match by source
339
+ // message identity, not timestamp: branch order is the provenance Pi uses.
340
+ const lastInvalidation = rawBranch.findLastIndex(
341
+ (entry) => entry.type === "compaction" || entry.type === "context_edit",
342
+ );
343
+ const validUsageMessages = new Set(
344
+ rawBranch
345
+ .slice(lastInvalidation + 1)
346
+ .flatMap((entry) => sessionEntryToContextMessages(entry)),
347
+ );
348
+ const estimated =
349
+ messages.reduce((sum, message) => sum + estimateTokens(message), 0) +
350
+ Math.ceil(systemPrompt.length / 4) +
351
+ Math.ceil(JSON.stringify(tools).length / 4);
352
+ let trailing = 0;
353
+ for (let i = messages.length - 1; i >= 0; i--) {
354
+ const message = messages[i]!;
355
+ if (
356
+ message.role === "assistant" &&
357
+ validUsageMessages.has(message) &&
358
+ message.stopReason !== "error" &&
359
+ message.stopReason !== "aborted" &&
360
+ message.usage
361
+ ) {
362
+ const usage = calculateContextTokens(message.usage);
363
+ if (usage > 0) return Math.max(estimated, usage + trailing);
364
+ }
365
+ trailing += estimateTokens(message);
366
+ }
367
+ return estimated;
368
+ }
369
+
324
370
  // ---------------------------------------------------------------------------
325
371
  // Handoff: what the main agent is given at exit
326
372
  // ---------------------------------------------------------------------------
@@ -627,6 +673,52 @@ let modelRegistryRef: ExtensionContext["modelRegistry"] | undefined;
627
673
  let lastBadgeCtx: ExtensionContext | undefined;
628
674
 
629
675
  export default function side(pi: ExtensionAPI): void {
676
+ // Pi checks the raw projection BEFORE emitting `context`. Recheck the
677
+ // same threshold against our lens at its supported cancellation boundary.
678
+ // Never veto manual compaction or a real provider overflow.
679
+ pi.on("session_before_compact", (event, ctx) => {
680
+ if (event.reason !== "threshold" || !ctx.model) return;
681
+ const raw = ctx.sessionManager.getBranch();
682
+ const markerIdx = findSideBoundary(raw);
683
+ const marker = raw[markerIdx];
684
+ if (marker?.type !== "custom" || !parseSideMarkerData(marker.data)) return;
685
+ const messages = buildLensMessages(
686
+ ctx.sessionManager.buildContextEntries(),
687
+ raw,
688
+ markerIdx,
689
+ [],
690
+ );
691
+ if (!messages || ctx.model.contextWindow <= 0) return;
692
+ const activeTools = new Set(pi.getActiveTools());
693
+ const tokens = estimateSideCompactionTokens(
694
+ messages,
695
+ ctx.getSystemPrompt(),
696
+ pi
697
+ .getAllTools()
698
+ .filter((tool) => activeTools.has(tool.name))
699
+ .map(({ name, description, parameters }) => ({
700
+ name,
701
+ description,
702
+ parameters,
703
+ })),
704
+ raw,
705
+ );
706
+ const needed = shouldCompact(
707
+ tokens,
708
+ ctx.model.contextWindow,
709
+ event.preparation.settings,
710
+ );
711
+ logGoodiesEvent({
712
+ type: "side_compaction_check",
713
+ raw_tokens: event.preparation.tokensBefore,
714
+ side_tokens: tokens,
715
+ context_window: ctx.model.contextWindow,
716
+ reserve_tokens: event.preparation.settings.reserveTokens,
717
+ cancelled: !needed,
718
+ });
719
+ if (!needed) return { cancel: true };
720
+ });
721
+
630
722
  // Lens: rewrite side-agent requests so the main trajectory arrives as an
631
723
  // attributed quote instead of the side model's own history. Detection uses
632
724
  // the raw branch (the marker is always an ancestor there); content uses
package/vision-core.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * vision-core — pure logic for the `vision` tool.
3
3
  *
4
- * No pi imports: structural types stand in for pi's registry/model objects so
5
- * this module (and its tests) run under plain `bun` without a pi install.
4
+ * No pi runtime imports: structural types stand in for registry/model objects.
5
+ * The request context uses pi's types so incomplete replay messages cannot
6
+ * silently cross the completion boundary.
6
7
  * index.ts wires these against the real pi APIs.
7
8
  */
8
9
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
10
  import { readFile, stat } from "node:fs/promises";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, extname, join, resolve } from "node:path";
13
+ import type { Context, Message } from "@earendil-works/pi-ai/compat";
12
14
 
13
15
  // --- structural stand-ins for pi types ---------------------------------------
14
16
 
@@ -16,6 +18,7 @@ import { dirname, extname, join, resolve } from "node:path";
16
18
  export interface ModelLike {
17
19
  id: string;
18
20
  provider: string;
21
+ api: string;
19
22
  input: string[];
20
23
  }
21
24
 
@@ -558,9 +561,10 @@ export const VISION_SYSTEM_PROMPT = [
558
561
  export function buildVisionContext(
559
562
  prompt: string,
560
563
  image: { data: string; mimeType: string },
564
+ model: ModelLike,
561
565
  history: StoredTurn[] = [],
562
- ): object {
563
- const past = history.flatMap((turn) => [
566
+ ): Context {
567
+ const past = history.flatMap<Message>((turn) => [
564
568
  {
565
569
  role: "user",
566
570
  content: [{ type: "text", text: turn.question }],
@@ -569,6 +573,21 @@ export function buildVisionContext(
569
573
  {
570
574
  role: "assistant",
571
575
  content: [{ type: "text", text: turn.answer }],
576
+ api: model.api,
577
+ provider: model.provider,
578
+ model: model.id,
579
+ stopReason: "stop",
580
+ // These are text-only replays, not the original provider responses.
581
+ // Zero usage makes Pi estimate this request's actual content instead of
582
+ // treating a previous request's token count as an authoritative prefix.
583
+ usage: {
584
+ input: 0,
585
+ output: 0,
586
+ cacheRead: 0,
587
+ cacheWrite: 0,
588
+ totalTokens: 0,
589
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
590
+ },
572
591
  timestamp: Date.now(),
573
592
  },
574
593
  ]);
@@ -644,7 +663,7 @@ export interface VisionToolDeps {
644
663
  /** pi-ai completeSimple (injected so tests stub it). */
645
664
  complete(
646
665
  model: ModelLike,
647
- context: object,
666
+ context: Context,
648
667
  options: {
649
668
  apiKey?: string;
650
669
  headers?: Record<string, string | null>;
@@ -760,6 +779,7 @@ export async function runVisionTool(
760
779
  data: image.data as string,
761
780
  mimeType: image.mimeType as string,
762
781
  },
782
+ transport.model,
763
783
  history,
764
784
  ),
765
785
  {
package/vision.ts CHANGED
@@ -133,6 +133,13 @@ function questionPreview(prompt: unknown, cap: number): string {
133
133
  return head;
134
134
  }
135
135
 
136
+ /** Expanded views show the raw truth: the whole prompt, uncapped — the cap
137
+ * is a compact-view aid, and ctrl+o exists to reveal what was actually
138
+ * asked (bash's expanded-command rule; multi-line renders multi-line). */
139
+ function fullQuestion(prompt: unknown): string {
140
+ return typeof prompt === "string" ? prompt : "";
141
+ }
142
+
136
143
  /** Answer text of a recorded vision result (its content is one text block). */
137
144
  function answerText(result: unknown): string {
138
145
  const content = (result as { content?: Array<{ text?: string }> } | undefined)
@@ -160,8 +167,10 @@ const visionBurstSpec = {
160
167
  // Read-view rule: info that is missing is skipped, never rendered as
161
168
  // junk (no `: ""` when a prompt is absent) — and long answers get the
162
169
  // same 12-line preview + "... N more lines" note as every other tool.
170
+ // Details render only when expanded, so questions show uncapped
171
+ // (same rule as bash's expanded details).
163
172
  const follow = e.args.followUp ? " (follow-up)" : "";
164
- const q = questionPreview(e.args.prompt, 90);
173
+ const q = fullQuestion(e.args.prompt);
165
174
  const label = `— ${shortenPath(e.args.path || "...")}${follow}${q ? `: "${q}"` : ""}`;
166
175
  if (!e.result) return `\n${theme.fg("warning", `${label} (pending)`)}`;
167
176
  const txt = answerText(e.result);
@@ -170,9 +179,14 @@ const visionBurstSpec = {
170
179
  })
171
180
  .join("");
172
181
  },
173
- soloHeader(args: any, theme: any) {
182
+ soloHeader(args: any, theme: any, ctx: any) {
174
183
  const follow = args.followUp ? theme.fg("muted", " (follow-up)") : "";
175
- const q = questionPreview(args.prompt, 90);
184
+ // Expanded shows the full question, uncapped — the 90-char preview is a
185
+ // compact-view aid, and the whole point of ctrl+o is to reveal what was
186
+ // actually asked (same rule as bash's expanded command).
187
+ const q = ctx?.expanded
188
+ ? fullQuestion(args.prompt)
189
+ : questionPreview(args.prompt, 90);
176
190
  return `${theme.fg("toolTitle", theme.bold("vision"))} ${theme.fg("accent", shortenPath(args.path || "..."))}${follow}${q ? ` ${theme.fg("toolOutput", `"${q}"`)}` : ""}`;
177
191
  },
178
192
  soloExpanded(entry: any, _args: any, theme: any) {
@@ -367,18 +381,14 @@ export default function (pi: ExtensionAPI): void {
367
381
  ) as Promise<{ content: ContentBlockLike[]; isError?: boolean }>,
368
382
  readRaw: readRawImage,
369
383
  complete: (model, context, options) =>
370
- completeSimple(
371
- model as never,
372
- context as never,
373
- {
374
- apiKey: options.apiKey,
375
- headers: options.headers,
376
- maxTokens: options.maxTokens,
377
- signal: options.signal,
378
- timeoutMs: 120_000,
379
- maxRetries: 1,
380
- } as never,
381
- ) as Promise<never>,
384
+ completeSimple(model as never, context, {
385
+ apiKey: options.apiKey,
386
+ headers: options.headers,
387
+ maxTokens: options.maxTokens,
388
+ signal: options.signal,
389
+ timeoutMs: 120_000,
390
+ maxRetries: 1,
391
+ } as never) as Promise<never>,
382
392
  },
383
393
  (text) =>
384
394
  onUpdate?.({