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 +16 -6
- package/package.json +1 -1
- package/side.ts +97 -5
- package/vision-core.ts +25 -5
- package/vision.ts +25 -15
package/README.md
CHANGED
|
@@ -23,10 +23,17 @@ extensions. One entry point, thirteen independent features.
|
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
First install, pinned to a published version:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
pi install npm:bermudis-pi-goodies@0.27.
|
|
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-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
5
|
-
*
|
|
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
|
-
):
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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?.({
|