@vanillagreen/pi-claude-bridge 1.0.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/src/models.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Canonical selection + display order for the model picker.
2
+ // `resolveModelId` returns the first partial match, so `opus` resolves to the first-listed opus entry.
3
+ // Extracted from index.ts so tests can import without activating the extension.
4
+
5
+ export const MODEL_IDS_IN_ORDER = ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
6
+
7
+ // Project pi-ai's model entries down to the fields pi's registerProvider expects,
8
+ // and keep MODEL_IDS_IN_ORDER ordering. IDs missing from pi-ai are silently dropped.
9
+ export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
10
+ return MODEL_IDS_IN_ORDER
11
+ .map((id) => piAiModels.find((m) => m.id === id))
12
+ .filter((m) => m != null)
13
+ // Forward thinkingLevelMap so per-model overrides (e.g. opus-4-7 mapping
14
+ // xhigh→xhigh instead of xhigh→max) are visible to the effort lookup.
15
+ .map(({ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap }) => ({
16
+ id, name, reasoning, input, contextWindow, maxTokens, thinkingLevelMap,
17
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
18
+ }));
19
+ }
20
+
21
+ export function resolveModelId(models: Array<{ id: string }>, input: string): string {
22
+ const lower = input.toLowerCase();
23
+ const match = models.find((m) => m.id === lower || m.id.includes(lower));
24
+ return match ? match.id : input;
25
+ }
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join, resolve } from "path";
4
+
5
+ export interface PromptContextSettings {
6
+ includeAppendSystemPromptMd?: boolean;
7
+ includeProjectAgentsHook?: boolean;
8
+ includeTaskPanelHook?: boolean;
9
+ includeCavemanHook?: boolean;
10
+ }
11
+
12
+ export interface PromptContextAppend {
13
+ text?: string;
14
+ labels: string[];
15
+ }
16
+
17
+ function piUserDir(): string {
18
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
19
+ if (configured) return resolve(configured.replace(/^~(?=\/|$)/, homedir()));
20
+ return join(homedir(), ".pi", "agent");
21
+ }
22
+
23
+ function readTrimmed(path: string): string | undefined {
24
+ try {
25
+ if (!existsSync(path)) return undefined;
26
+ const content = readFileSync(path, "utf8").trim();
27
+ return content.length > 0 ? content : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function findProjectAppendSystem(startDir: string): string | undefined {
34
+ let current = resolve(startDir);
35
+ while (true) {
36
+ const candidate = join(current, ".pi", "APPEND_SYSTEM.md");
37
+ if (existsSync(candidate)) return candidate;
38
+ const parent = dirname(current);
39
+ if (parent === current) break;
40
+ current = parent;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ export function readAppendSystemPromptFiles(cwd: string): Array<{ label: string; content: string }> {
46
+ const files: Array<{ label: string; path: string }> = [
47
+ { label: "global APPEND_SYSTEM.md", path: join(piUserDir(), "APPEND_SYSTEM.md") },
48
+ ];
49
+ const projectPath = findProjectAppendSystem(cwd);
50
+ if (projectPath) files.push({ label: "project .pi/APPEND_SYSTEM.md", path: projectPath });
51
+
52
+ const seen = new Set<string>();
53
+ const output: Array<{ label: string; content: string }> = [];
54
+ for (const file of files) {
55
+ if (seen.has(file.path)) continue;
56
+ seen.add(file.path);
57
+ const content = readTrimmed(file.path);
58
+ if (content) output.push({ label: file.label, content });
59
+ }
60
+ return output;
61
+ }
62
+
63
+ function splitPromptBlocks(systemPrompt?: string): string[] {
64
+ return (systemPrompt ?? "")
65
+ .split(/\n{2,}/)
66
+ .map((block) => block.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ function extractHeadingSection(systemPrompt: string | undefined, headings: string[]): string | undefined {
71
+ if (!systemPrompt) return undefined;
72
+ let start = -1;
73
+ for (const heading of headings) {
74
+ const index = systemPrompt.indexOf(heading);
75
+ if (index >= 0 && (start < 0 || index < start)) start = index;
76
+ }
77
+ if (start < 0) return undefined;
78
+ const rest = systemPrompt.slice(start).trim();
79
+ const nextHeading = rest.slice(1).search(/\n##\s+/);
80
+ return (nextHeading >= 0 ? rest.slice(0, nextHeading + 1) : rest).trim();
81
+ }
82
+
83
+ function extractBlockByMarkers(systemPrompt: string | undefined, markers: RegExp[]): string | undefined {
84
+ for (const block of splitPromptBlocks(systemPrompt)) {
85
+ if (markers.some((marker) => marker.test(block))) return block;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ export function buildPromptContextAppend(systemPrompt: string | undefined, cwd: string, settings: PromptContextSettings): PromptContextAppend {
91
+ const parts: string[] = [];
92
+ const labels: string[] = [];
93
+
94
+ if (settings.includeAppendSystemPromptMd) {
95
+ for (const file of readAppendSystemPromptFiles(cwd)) {
96
+ parts.push(`### ${file.label}\n\n${file.content}`);
97
+ labels.push(file.label);
98
+ }
99
+ }
100
+
101
+ if (settings.includeProjectAgentsHook) {
102
+ const projectAgents = extractHeadingSection(systemPrompt, ["## Project Agents", "## Project Subagents"]);
103
+ if (projectAgents) {
104
+ parts.push(`### before_agent_start: project agents\n\n${projectAgents}`);
105
+ labels.push("project agents hook");
106
+ }
107
+ }
108
+
109
+ if (settings.includeTaskPanelHook) {
110
+ const taskReminder = extractBlockByMarkers(systemPrompt, [/^Task workflow reminder:/]);
111
+ if (taskReminder) {
112
+ parts.push(`### before_agent_start: task panel\n\n${taskReminder}`);
113
+ labels.push("task panel hook");
114
+ }
115
+ }
116
+
117
+ if (settings.includeCavemanHook) {
118
+ const caveman = extractBlockByMarkers(systemPrompt, [
119
+ /^Caveman communication mode active/m,
120
+ /^Token efficiency mode: terse smart caveman/m,
121
+ /^Caveman mode is active/m,
122
+ ]);
123
+ if (caveman) {
124
+ parts.push(`### before_agent_start: caveman\n\n${caveman}`);
125
+ labels.push("caveman hook");
126
+ }
127
+ }
128
+
129
+ if (parts.length === 0) return { labels };
130
+ return {
131
+ labels,
132
+ text: [
133
+ "## Forwarded Pi Context",
134
+ "The following content was explicitly enabled in pi-claude-bridge settings and comes from Pi prompt files or before_agent_start prompt hooks.",
135
+ ...parts,
136
+ ].join("\n\n"),
137
+ };
138
+ }
@@ -0,0 +1,80 @@
1
+ // Query state: QueryContext class + context stack.
2
+ //
3
+ // All per-query and per-turn mutable state lives here. Reentrant queries
4
+ // (subagents) push the parent context onto a stack and get a fresh instance.
5
+ // Adding a new field = one property on the class.
6
+ //
7
+ // Extracted from index.ts so tests can import without activating the extension.
8
+
9
+ import type { AssistantMessage, AssistantMessageEventStream, Model } from "@mariozechner/pi-ai";
10
+ import type { McpResult } from "./extract-tool-results.js";
11
+
12
+ export interface PendingToolCall {
13
+ toolName: string;
14
+ resolve: (result: McpResult) => void;
15
+ }
16
+
17
+ export class QueryContext {
18
+ // Query-scoped (fully isolated per query)
19
+ activeQuery: unknown | null = null;
20
+ currentPiStream: AssistantMessageEventStream | null = null;
21
+ latestCursor = 0;
22
+ pendingToolCalls = new Map<string, PendingToolCall>();
23
+ pendingResults = new Map<string, McpResult>();
24
+ turnToolCallIds: string[] = [];
25
+ nextHandlerIdx = 0;
26
+ deferredUserMessages: string[] = [];
27
+
28
+ // Per-turn (reset together)
29
+ turnOutput: AssistantMessage | null = null;
30
+ turnStarted = false;
31
+ turnSawStreamEvent = false;
32
+ turnSawToolCall = false;
33
+
34
+ get turnBlocks(): Array<any> {
35
+ if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
36
+ return this.turnOutput.content;
37
+ }
38
+
39
+ resetTurnState(model: Model<any>): void {
40
+ this.turnOutput = {
41
+ role: "assistant", content: [],
42
+ api: model.api, provider: model.provider, model: model.id,
43
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
44
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
45
+ stopReason: "stop", timestamp: Date.now(),
46
+ };
47
+ this.turnStarted = false;
48
+ this.turnSawStreamEvent = false;
49
+ this.turnSawToolCall = false;
50
+ // turnToolCallIds and nextHandlerIdx are NOT reset — they persist across
51
+ // tool-result delivery callbacks within the same assistant message.
52
+ }
53
+ }
54
+
55
+ let _ctx = new QueryContext();
56
+ const contextStack: QueryContext[] = [];
57
+
58
+ export function ctx(): QueryContext { return _ctx; }
59
+
60
+ export function stackDepth(): number { return contextStack.length; }
61
+
62
+ export function pushContext(): void {
63
+ if (!_ctx.activeQuery) throw new Error("pushContext() called with no active query");
64
+ contextStack.push(_ctx);
65
+ _ctx = new QueryContext();
66
+ }
67
+
68
+ export function popContext(): void {
69
+ if (contextStack.length === 0) throw new Error("popContext() called with empty stack");
70
+ const parent = contextStack[contextStack.length - 1];
71
+ parent.deferredUserMessages.push(..._ctx.deferredUserMessages);
72
+ _ctx = contextStack.pop()!;
73
+ }
74
+
75
+ // Test-only: drop all state so test files can start from a clean module.
76
+ // Not called from production.
77
+ export function resetStack(): void {
78
+ _ctx = new QueryContext();
79
+ contextStack.length = 0;
80
+ }
@@ -0,0 +1,38 @@
1
+ // Pure session-file integrity check. Returns an array of warning strings;
2
+ // callers decide how to surface them (debug log, piUI, diagDump, etc.).
3
+ // Extracted from index.ts so tests can import without activating the extension.
4
+
5
+ import { statSync, readFileSync } from "fs";
6
+
7
+ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: string, expectedRecordCount: number): string[] {
8
+ const warnings = [];
9
+ let st;
10
+ try {
11
+ st = statSync(jsonlPath);
12
+ } catch (e) {
13
+ warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
14
+ return warnings;
15
+ }
16
+ let content;
17
+ try {
18
+ content = readFileSync(jsonlPath, "utf8");
19
+ } catch (e) {
20
+ warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
21
+ return warnings;
22
+ }
23
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
24
+ if (lines.length !== expectedRecordCount) {
25
+ warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${lines.length} path=${jsonlPath} bytes=${content.length}`);
26
+ return warnings;
27
+ }
28
+ try {
29
+ const firstRec = JSON.parse(lines[0]);
30
+ const lastRec = JSON.parse(lines[lines.length - 1]);
31
+ if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
32
+ warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
33
+ }
34
+ } catch (e) {
35
+ warnings.push(`malformed JSONL — path=${jsonlPath} err=${e.message}`);
36
+ }
37
+ return warnings;
38
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Skills block extraction + MCP naming constants.
2
+ // Extracted from index.ts so tests can import without activating the extension.
3
+
4
+ export const MCP_SERVER_NAME = "custom-tools";
5
+ export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
6
+
7
+ // Extract skills block from pi's system prompt for forwarding to Claude Code.
8
+ export function extractSkillsBlock(systemPrompt?: string): string | undefined {
9
+ if (!systemPrompt) return undefined;
10
+ const startMarker = "The following skills provide specialized instructions for specific tasks.";
11
+ const endMarker = "</available_skills>";
12
+ const start = systemPrompt.indexOf(startMarker);
13
+ if (start === -1) return undefined;
14
+ const end = systemPrompt.indexOf(endMarker, start);
15
+ if (end === -1) return undefined;
16
+ return rewriteSkillsBlock(systemPrompt.slice(start, end + endMarker.length).trim());
17
+ }
18
+
19
+ export function rewriteSkillsBlock(skillsBlock: string): string {
20
+ return skillsBlock.replace(
21
+ "Use the read tool to load a skill's file",
22
+ `Use the read tool (mcp__${MCP_SERVER_NAME}__read) to load a skill's file`,
23
+ );
24
+ }
@@ -0,0 +1,42 @@
1
+ // TypeBox (JSON Schema) → Zod conversion used by buildMcpServers.
2
+ //
3
+ // Pi tools declare their parameters as TypeBox objects (i.e. JSON Schema at
4
+ // runtime). The Agent SDK's createSdkMcpServer requires Zod — its internal
5
+ // `Z0()` detects Zod via the `~standard` marker or `_def`/`_zod` properties
6
+ // and silently downgrades unrecognized schemas to
7
+ // `{type: "object", properties: {}}`, which leaves the model with no
8
+ // parameter info. This module bridges the two so MCP-exposed pi tools retain
9
+ // their schemas. If this breaks after an SDK update, check whether `Z0()`
10
+ // detection changed or createSdkMcpServer now accepts raw JSON Schema.
11
+
12
+ import { z } from "zod";
13
+
14
+ export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTypeAny {
15
+ let base: z.ZodTypeAny;
16
+ if (Array.isArray(prop.enum)) base = z.enum(prop.enum as [string, ...string[]]);
17
+ else switch (prop.type) {
18
+ case "string": base = z.string(); break;
19
+ case "number": case "integer": base = z.number(); break;
20
+ case "boolean": base = z.boolean(); break;
21
+ case "array": base = prop.items
22
+ ? z.array(jsonSchemaPropertyToZod(prop.items as Record<string, unknown>))
23
+ : z.array(z.unknown()); break;
24
+ case "object": base = z.record(z.string(), z.unknown()); break;
25
+ default: base = z.unknown();
26
+ }
27
+ if (typeof prop.description === "string") base = base.describe(prop.description);
28
+ return base;
29
+ }
30
+
31
+ export function jsonSchemaToZodShape(schema: unknown): Record<string, z.ZodTypeAny> {
32
+ const s = schema as Record<string, unknown>;
33
+ if (!s || s.type !== "object" || !s.properties) return {};
34
+ const props = s.properties as Record<string, Record<string, unknown>>;
35
+ const required = new Set(Array.isArray(s.required) ? s.required as string[] : []);
36
+ const shape: Record<string, z.ZodTypeAny> = {};
37
+ for (const [key, prop] of Object.entries(props)) {
38
+ const zodProp = jsonSchemaPropertyToZod(prop);
39
+ shape[key] = required.has(key) ? zodProp : zodProp.optional();
40
+ }
41
+ return shape;
42
+ }