aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/rename-pi.ts
blob: 0a98bf886844fa39dd3122b62a89566a8e5a6fb6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

/**
 * rename-pi — sanitize "pi" -> "the assistant" for the claude-proxy. (ToS violatooooor)
 *
 * WHY TWO HOOKS:
 *   The claude-proxy classifies requests by system-prompt content; bare "pi"
 *   references get billed as extra usage. Renaming must therefore reach EVERY
 *   outbound request.
 *
 *   `before_agent_start` only fires when a fresh user message is sent, and the
 *   system prompt it returns is later CLOBBERED whenever the prompt is rebuilt
 *   mid-run (e.g. pi-goal's create_goal -> setActiveTools -> _rebuildSystemPrompt
 *   regenerates the raw prompt with "pi"/"pi-goal" and the "Pi documentation"
 *   section). Goal continuations then ship ~40 un-sanitized "pi" refs and get
 *   billed as extra usage. That was the bug.
 *
 *   `before_provider_request` fires at the single choke point for ALL provider
 *   calls (typed turns, goal continuations, subagents, summarization), with the
 *   fully-built request payload, and can replace it. Sanitizing `payload.system`
 *   here is leak-proof by construction — no lifecycle rebuild can bypass it.
 *
 * We keep both: before_agent_start preserves the renamed prompt in-session/for
 * display; before_provider_request guarantees the wire is always sanitized.
 * The rewrite is idempotent ("the assistant" contains no bare "pi"), so running
 * both never double-mangles.
 *
 * Safety: only touches `payload.system` (string or Anthropic text-block array),
 * matching the original systemPrompt-only behavior. Other providers / shapes
 * are left untouched. Handler is defensive and never throws (the runner also
 * catches, but we belt-and-suspenders it).
 */

// Word-boundary-aware: only bare "pi"/"Pi" as standalone words. "the assistant"
// for grammatically correct output. Skips possessives / compounds mid-word.
function sanitize(text: string): string {
  return text.replace(/\bpi\b/g, "the assistant").replace(/\bPi\b/g, "The assistant");
}

// Sanitize the outbound request payload's system prompt in place (Anthropic
// `system` is an array of {type:"text", text} blocks; some APIs use a string).
// Returns a count of blocks changed, for optional debug.
function sanitizePayloadSystem(payload: any): number {
  let changed = 0;
  if (!payload || typeof payload !== "object") return changed;
  const sys = payload.system;
  if (typeof sys === "string") {
    const next = sanitize(sys);
    if (next !== sys) {
      payload.system = next;
      changed++;
    }
  } else if (Array.isArray(sys)) {
    for (const block of sys) {
      if (block && typeof block.text === "string") {
        const next = sanitize(block.text);
        if (next !== block.text) {
          block.text = next;
          changed++;
        }
      }
    }
  }
  return changed;
}

export default function (pi: ExtensionAPI) {
  // (1) In-session / display parity: rename the prompt shown to the model when a
  // fresh user turn starts. (Leaky on its own — see header — hence hook 2.)
  pi.on("before_agent_start", async (event, _ctx) => {
    return { systemPrompt: sanitize(event.systemPrompt) };
  });

  // (2) Wire guarantee: rewrite the system prompt on the actual outbound payload
  // for every provider request, regardless of which code path built it.
  pi.on("before_provider_request", async (event, _ctx) => {
    const payload = (event as any).payload;
    const changed = sanitizePayloadSystem(payload);
    if (changed && process.env.PI_SANITIZE_DEBUG) {
      try {
        process.stderr.write(`[rename-pi] sanitized ${changed} system block(s) on provider request\n`);
      } catch {
        /* ignore */
      }
    }
    return payload;
  });
}