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 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, nine independent features.
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.2.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "A bundle of small, frequently-used Pi extensions.",
6
6
  "keywords": ["pi-package"],
@@ -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) requestRender?.();
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);