bermudis-pi-goodies 0.2.0 → 0.3.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.
- package/README.md +3 -2
- package/copy-trajectory.ts +166 -0
- package/index.ts +3 -0
- package/package.json +1 -1
- package/provider-balance.ts +133 -1
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# bermudis-pi-goodies
|
|
2
2
|
|
|
3
3
|
A bundle of small, frequently-used [Pi](https://github.com/earendil-works/pi)
|
|
4
|
-
extensions. One entry point,
|
|
4
|
+
extensions. One entry point, ten independent features.
|
|
5
5
|
|
|
6
6
|
| Feature | Command / hook | What it does |
|
|
7
7
|
|---------|----------------|--------------|
|
|
8
8
|
| `copy-with-model` | `/copy-with-model` | Copy last assistant message to the clipboard in a code fence tagged with the model name. |
|
|
9
|
+
| `copy-trajectory` | `/copy-trajectory [thinking]` | Copy the whole conversation (user + assistant text, tool calls stripped) to the clipboard; `thinking` also includes assistant thinking blocks. |
|
|
9
10
|
| `name-with-ai` | `/name-with-ai [name]` | Generate a short session name from the first user message (or set one manually). |
|
|
10
11
|
| `zed` | `/z` | Open Zed editor on the current working directory. |
|
|
11
12
|
| `prefer-tools` | hook (no command) | Nudge toward modern CLIs: `rg` over `grep`, `fd` over `find`, `uv` over bare `python`/`pip`/`pytest`/`mypy`. |
|
|
@@ -20,7 +21,7 @@ extensions. One entry point, nine independent features.
|
|
|
20
21
|
After publishing the package to npm:
|
|
21
22
|
|
|
22
23
|
```bash
|
|
23
|
-
pi install npm:bermudis-pi-goodies@0.
|
|
24
|
+
pi install npm:bermudis-pi-goodies@0.3.0
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Remove any old `bermudis-pi-goodies.ts` symlink before reloading Pi. Each
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* copy-trajectory — /copy-trajectory
|
|
3
|
+
*
|
|
4
|
+
* Copies the current session's trajectory to the system clipboard as plain
|
|
5
|
+
* text, keeping only the human-readable conversation: user messages and
|
|
6
|
+
* assistant text. Tool calls, tool results, and session metadata are stripped.
|
|
7
|
+
*
|
|
8
|
+
* /copy-trajectory copy user + assistant text
|
|
9
|
+
* /copy-trajectory thinking also include assistant thinking blocks
|
|
10
|
+
*
|
|
11
|
+
* Uses ctx.sessionManager.getBranch() for the active (compaction-aware) branch
|
|
12
|
+
* and @earendil-works/pi-coding-agent's copyToClipboard for the clipboard write.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
copyToClipboard,
|
|
17
|
+
type ExtensionAPI,
|
|
18
|
+
type SessionEntry,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
22
|
+
typeof v === "object" && v !== null;
|
|
23
|
+
|
|
24
|
+
/** Extract `text` content blocks from a message body (string or block array). */
|
|
25
|
+
const extractTextParts = (content: unknown): string[] => {
|
|
26
|
+
if (typeof content === "string") return [content];
|
|
27
|
+
if (!Array.isArray(content)) return [];
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
for (const block of content) {
|
|
30
|
+
if (!isRecord(block)) continue;
|
|
31
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
32
|
+
parts.push(block.text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return parts;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Extract `thinking` content blocks from an assistant message body. */
|
|
39
|
+
const extractThinkingParts = (content: unknown): string[] => {
|
|
40
|
+
if (!Array.isArray(content)) return [];
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
for (const block of content) {
|
|
43
|
+
if (!isRecord(block)) continue;
|
|
44
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
45
|
+
parts.push(block.thinking);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Turn = {
|
|
52
|
+
role: "User" | "Assistant";
|
|
53
|
+
/** For assistant turns: the model that actually served the reply (or the
|
|
54
|
+
* requested model when the provider doesn't report the resolved one). */
|
|
55
|
+
model?: string;
|
|
56
|
+
body: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Build the readable turns from a branch of session entries. */
|
|
60
|
+
function buildTrajectory(
|
|
61
|
+
entries: readonly SessionEntry[],
|
|
62
|
+
includeThinking: boolean,
|
|
63
|
+
): Turn[] {
|
|
64
|
+
const turns: Turn[] = [];
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
// getBranch() returns the full SessionEntry union; the `message` member
|
|
68
|
+
// is discriminated by `type: "message"`.
|
|
69
|
+
if (entry.type !== "message") continue;
|
|
70
|
+
const message = entry.message;
|
|
71
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
72
|
+
|
|
73
|
+
const chunks: string[] = [...extractTextParts(message.content)];
|
|
74
|
+
|
|
75
|
+
let model: string | undefined;
|
|
76
|
+
if (message.role === "assistant") {
|
|
77
|
+
// AssistantMessage carries both the requested `model` and, when the
|
|
78
|
+
// provider echoes it back, `responseModel` (the model that actually ran).
|
|
79
|
+
model = message.responseModel ?? message.model;
|
|
80
|
+
if (includeThinking) {
|
|
81
|
+
for (const t of extractThinkingParts(message.content)) {
|
|
82
|
+
chunks.push(
|
|
83
|
+
t
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => `> ${line}`)
|
|
86
|
+
.join("\n"),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const body = chunks.join("\n").trim();
|
|
93
|
+
if (!body) continue; // skip empty / tool-only turns
|
|
94
|
+
|
|
95
|
+
turns.push({
|
|
96
|
+
role: message.role === "user" ? "User" : "Assistant",
|
|
97
|
+
model,
|
|
98
|
+
body,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return turns;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const renderTrajectory = (turns: readonly Turn[]): string =>
|
|
106
|
+
turns
|
|
107
|
+
.map((t) => {
|
|
108
|
+
const header =
|
|
109
|
+
t.role === "Assistant" && t.model
|
|
110
|
+
? `## Assistant (${t.model})`
|
|
111
|
+
: `## ${t.role}`;
|
|
112
|
+
return `${header}\n\n${t.body}`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n\n");
|
|
115
|
+
|
|
116
|
+
export default function (pi: ExtensionAPI) {
|
|
117
|
+
pi.registerCommand("copy-trajectory", {
|
|
118
|
+
description:
|
|
119
|
+
"Copy the conversation (user + assistant text, no tool calls) to the clipboard",
|
|
120
|
+
getArgumentCompletions: (prefix) => {
|
|
121
|
+
const opts = ["thinking"].filter((o) => o.startsWith(prefix));
|
|
122
|
+
return opts.length > 0 ? opts.map((o) => ({ value: o, label: o })) : null;
|
|
123
|
+
},
|
|
124
|
+
handler: async (args, ctx) => {
|
|
125
|
+
// Don't snapshot a half-streamed message.
|
|
126
|
+
await ctx.waitForIdle();
|
|
127
|
+
|
|
128
|
+
const arg = args.trim();
|
|
129
|
+
if (arg !== "" && arg !== "thinking") {
|
|
130
|
+
ctx.ui.notify(
|
|
131
|
+
`Unknown argument ${JSON.stringify(arg)}. Usage: /copy-trajectory [thinking]`,
|
|
132
|
+
"warning",
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const includeThinking = arg === "thinking";
|
|
137
|
+
|
|
138
|
+
const turns = buildTrajectory(
|
|
139
|
+
ctx.sessionManager.getBranch(),
|
|
140
|
+
includeThinking,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (turns.length === 0) {
|
|
144
|
+
ctx.ui.notify("No messages to copy yet", "warning");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const text = renderTrajectory(turns);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await copyToClipboard(text);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
ctx.ui.notify(
|
|
154
|
+
`Failed to copy: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
"error",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
`Copied ${turns.length} message${turns.length === 1 ? "" : "s"} (${text.length.toLocaleString()} chars) to clipboard`,
|
|
162
|
+
"info",
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Composes independent modules, each registering its own commands/hooks
|
|
5
5
|
* against the shared ExtensionAPI:
|
|
6
6
|
* - copy-with-model /copy-with-model copy last reply tagged with the model
|
|
7
|
+
* - copy-trajectory /copy-trajectory copy the whole conversation (text only) to the clipboard
|
|
7
8
|
* - name-with-ai /name-with-ai generate a session name via the model
|
|
8
9
|
* - zed /z open Zed on cwd
|
|
9
10
|
* - prefer-tools hook block legacy tools (use trash/rg/fd/uv)
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
* - tps hook notify tokens/sec and usage at each agent turn end
|
|
15
16
|
*/
|
|
16
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import copyTrajectory from "./copy-trajectory.ts";
|
|
17
19
|
import copyWithModel from "./copy-with-model.ts";
|
|
18
20
|
import fixedDefaults from "./fixed-defaults.ts";
|
|
19
21
|
import kilo from "./kilo.ts";
|
|
@@ -26,6 +28,7 @@ import preferTools from "./prefer-tools.ts";
|
|
|
26
28
|
|
|
27
29
|
export default function bermudisPiGoodies(pi: ExtensionAPI): void {
|
|
28
30
|
copyWithModel(pi);
|
|
31
|
+
copyTrajectory(pi);
|
|
29
32
|
nameWithAi(pi);
|
|
30
33
|
zed(pi);
|
|
31
34
|
preferTools(pi);
|
package/package.json
CHANGED
package/provider-balance.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ExtensionContext,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { readFileSync, renameSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
8
10
|
|
|
9
11
|
const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai";
|
|
10
12
|
const KILO_BALANCE_ENDPOINT = `${KILO_API_BASE}/api/profile/balance`;
|
|
@@ -23,6 +25,23 @@ const BALANCE_FETCH_TIMEOUT_MS = 5_000;
|
|
|
23
25
|
/** Refresh the footer balance every Nth turn end during a run. See turn_end handler. */
|
|
24
26
|
const REFRESH_EVERY_N_TURNS = 5;
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Balance cache shared across every pi process on the machine, keyed by
|
|
30
|
+
* provider. Two motivations:
|
|
31
|
+
*
|
|
32
|
+
* 1. The user runs several pi instances against the same metered account
|
|
33
|
+
* (quota/credits are per-account, not per-session), so every session may
|
|
34
|
+
* show the freshest reading any instance fetched.
|
|
35
|
+
* 2. On session switch pi tears this runtime down and reloads the extension
|
|
36
|
+
* for the new session (session_shutdown reason "resume" -> session_start
|
|
37
|
+
* reason "resume"), wiping in-memory state. Without a shared cache the new
|
|
38
|
+
* session's footer is blank/stale until its own first fetch lands, which
|
|
39
|
+
* can be agent_settled or the 5th turn_end.
|
|
40
|
+
*/
|
|
41
|
+
const BALANCE_CACHE_FILE = `${process.env.HOME ?? ""}/.pi/agent/cache/provider-balance.json`;
|
|
42
|
+
/** Ignore cache entries older than this; stale balances mislead. */
|
|
43
|
+
const BALANCE_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
44
|
+
|
|
26
45
|
interface BalanceAdapter {
|
|
27
46
|
fetch(token: string, signal: AbortSignal): Promise<Balance>;
|
|
28
47
|
requiresOAuth?: boolean;
|
|
@@ -538,6 +557,100 @@ const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
|
|
|
538
557
|
"openai-codex": { fetch: fetchCodexQuota, requiresOAuth: true },
|
|
539
558
|
};
|
|
540
559
|
|
|
560
|
+
// --- Shared balance cache ---------------------------------------------------
|
|
561
|
+
|
|
562
|
+
interface BalanceCacheEntry {
|
|
563
|
+
fetchedAt: number;
|
|
564
|
+
balance: Balance;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
type BalanceCache = Record<string, BalanceCacheEntry>;
|
|
568
|
+
|
|
569
|
+
function parseCachedBalance(value: unknown): Balance | null {
|
|
570
|
+
if (!Array.isArray(value)) return null;
|
|
571
|
+
const segments: BalanceSegment[] = [];
|
|
572
|
+
for (const candidate of value) {
|
|
573
|
+
const segment = asRecord(candidate);
|
|
574
|
+
if (!segment) return null;
|
|
575
|
+
const credits = numericProperty(segment, "credits");
|
|
576
|
+
const quotaRecord = asRecord(segment.quota);
|
|
577
|
+
const remainingPercent = numericProperty(quotaRecord, "remainingPercent");
|
|
578
|
+
if (
|
|
579
|
+
credits === null &&
|
|
580
|
+
(quotaRecord === null || remainingPercent === null)
|
|
581
|
+
) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const label = stringProperty(segment, "label") ?? undefined;
|
|
585
|
+
const windowSeconds = numericProperty(quotaRecord, "windowSeconds");
|
|
586
|
+
const resetAt = numericProperty(quotaRecord, "resetAt");
|
|
587
|
+
segments.push({
|
|
588
|
+
...(label !== undefined ? { label } : {}),
|
|
589
|
+
...(credits !== null ? { credits } : {}),
|
|
590
|
+
...(quotaRecord !== null && remainingPercent !== null
|
|
591
|
+
? {
|
|
592
|
+
quota: {
|
|
593
|
+
remainingPercent,
|
|
594
|
+
...(windowSeconds !== null ? { windowSeconds } : {}),
|
|
595
|
+
...(resetAt !== null ? { resetAt } : {}),
|
|
596
|
+
},
|
|
597
|
+
}
|
|
598
|
+
: {}),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return segments;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Read the freshest non-expired cached balance for a provider. */
|
|
605
|
+
export function readCachedBalance(
|
|
606
|
+
provider: string,
|
|
607
|
+
nowMs = Date.now(),
|
|
608
|
+
): Balance | null {
|
|
609
|
+
let raw: string;
|
|
610
|
+
try {
|
|
611
|
+
raw = readFileSync(BALANCE_CACHE_FILE, "utf8");
|
|
612
|
+
} catch {
|
|
613
|
+
return null; // Missing/unreadable cache is a normal cold start.
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const entry = asRecord(asRecord(JSON.parse(raw))?.[provider]);
|
|
618
|
+
const fetchedAt = numericProperty(entry, "fetchedAt");
|
|
619
|
+
if (entry === null || fetchedAt === null) return null;
|
|
620
|
+
if (nowMs - fetchedAt >= BALANCE_CACHE_TTL_MS) return null;
|
|
621
|
+
return parseCachedBalance(entry.balance);
|
|
622
|
+
} catch {
|
|
623
|
+
return null; // Corrupt cache must never break the footer.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Persist a fresh reading so other live sessions and future freshly-loaded
|
|
629
|
+
* sessions render it instantly. Write is atomic (tmp + rename) because
|
|
630
|
+
* several pi processes can fetch concurrently.
|
|
631
|
+
*/
|
|
632
|
+
function writeCachedBalance(provider: string, balance: Balance): void {
|
|
633
|
+
let cache: BalanceCache = {};
|
|
634
|
+
try {
|
|
635
|
+
const existing = asRecord(
|
|
636
|
+
JSON.parse(readFileSync(BALANCE_CACHE_FILE, "utf8")),
|
|
637
|
+
);
|
|
638
|
+
if (existing) cache = existing as BalanceCache;
|
|
639
|
+
} catch {
|
|
640
|
+
// Missing or corrupt cache: start fresh rather than failing the write.
|
|
641
|
+
}
|
|
642
|
+
cache[provider] = { fetchedAt: Date.now(), balance };
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
mkdirSync(dirname(BALANCE_CACHE_FILE), { recursive: true });
|
|
646
|
+
const tempFile = `${BALANCE_CACHE_FILE}.${process.pid}.tmp`;
|
|
647
|
+
writeFileSync(tempFile, JSON.stringify(cache));
|
|
648
|
+
renameSync(tempFile, BALANCE_CACHE_FILE);
|
|
649
|
+
} catch {
|
|
650
|
+
// The cache is an accelerator only; the footer works without it.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
541
654
|
type FooterSession = ConstructorParameters<typeof FooterComponent>[0];
|
|
542
655
|
type FooterFactory = NonNullable<
|
|
543
656
|
Parameters<ExtensionContext["ui"]["setFooter"]>[0]
|
|
@@ -726,6 +839,16 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
726
839
|
const providerId = provider;
|
|
727
840
|
const adapter = providerId ? BALANCE_ADAPTERS[providerId] : undefined;
|
|
728
841
|
if (!adapter || !providerId) return;
|
|
842
|
+
|
|
843
|
+
// Paint the freshest known value for this account immediately: another
|
|
844
|
+
// live pi instance may have fetched seconds ago, and on session switch
|
|
845
|
+
// this is what keeps the new session's footer warm instead of blank until
|
|
846
|
+
// its own first fetch lands.
|
|
847
|
+
const cached = readCachedBalance(providerId);
|
|
848
|
+
if (cached) {
|
|
849
|
+
balance = cached;
|
|
850
|
+
requestRender?.();
|
|
851
|
+
}
|
|
729
852
|
if (
|
|
730
853
|
adapter.requiresOAuth &&
|
|
731
854
|
(!model ||
|
|
@@ -739,7 +862,10 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
739
862
|
const token = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
740
863
|
if (!token || generation !== refreshGeneration) return;
|
|
741
864
|
balance = await adapter.fetch(token, controller.signal);
|
|
742
|
-
if (generation === refreshGeneration)
|
|
865
|
+
if (generation === refreshGeneration) {
|
|
866
|
+
writeCachedBalance(providerId, balance);
|
|
867
|
+
requestRender?.();
|
|
868
|
+
}
|
|
743
869
|
} catch (error) {
|
|
744
870
|
if (generation !== refreshGeneration || controller.signal.aborted) return;
|
|
745
871
|
// This is a best-effort background refresh. Writing to stdout/stderr while
|
|
@@ -793,6 +919,12 @@ export default function providerBalance(pi: ExtensionAPI): void {
|
|
|
793
919
|
});
|
|
794
920
|
}
|
|
795
921
|
|
|
922
|
+
// Fires for startup, reload, and every session switch/new/fork: pi tears
|
|
923
|
+
// the old runtime down (session_shutdown) and starts a fresh one, so this
|
|
924
|
+
// is both our initializer and our "user switched sessions" signal.
|
|
925
|
+
// refreshBalance seeds from the shared cache first, so a session resumed
|
|
926
|
+
// mid-run elsewhere shows the other instance's last reading immediately
|
|
927
|
+
// instead of going stale until agent_settled or the 5th turn_end.
|
|
796
928
|
pi.on("session_start", (_event, ctx) => {
|
|
797
929
|
activeContext = ctx;
|
|
798
930
|
activeThinkingLevel = restoredThinkingLevel(ctx);
|