@raoxxxwq/pi-codebuddy-sdk 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/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +65 -0
- package/src/agents-md.ts +54 -0
- package/src/askcodebuddy-ui.ts +90 -0
- package/src/cb-session-io.ts +205 -0
- package/src/config.ts +50 -0
- package/src/convert.ts +101 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index-types.ts +1 -0
- package/src/index.ts +1979 -0
- package/src/model-calibration.ts +129 -0
- package/src/models.ts +79 -0
- package/src/query-state.ts +80 -0
- package/src/sdk-gate.ts +17 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +155 -0
- package/src/typebox-to-zod.ts +160 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
|
|
5
|
+
export interface CalibrationEnvironment {
|
|
6
|
+
internetEnvironment: string;
|
|
7
|
+
codebuddyConfigDir: string;
|
|
8
|
+
codebuddyExecutable: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CapabilityMetric {
|
|
12
|
+
floor: number;
|
|
13
|
+
latest: number;
|
|
14
|
+
max: number;
|
|
15
|
+
observedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ModelCalibrationRecord {
|
|
19
|
+
modelId: string;
|
|
20
|
+
environment: CalibrationEnvironment;
|
|
21
|
+
capabilities: {
|
|
22
|
+
contextWindow?: CapabilityMetric;
|
|
23
|
+
maxTokens?: CapabilityMetric;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CalibrationCache {
|
|
28
|
+
version: 1;
|
|
29
|
+
records: Record<string, ModelCalibrationRecord>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CALIBRATION_CACHE_VERSION = 1;
|
|
33
|
+
export const DEFAULT_CALIBRATION_CACHE_PATH = join(homedir(), ".pi", "agent", "codebuddy-sdk-model-calibration.json");
|
|
34
|
+
|
|
35
|
+
function normalizedValue(value: string | undefined | null): string {
|
|
36
|
+
const trimmed = value?.trim();
|
|
37
|
+
return trimmed ? trimmed : "default";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCalibrationEnvironment(codebuddyExecutable?: string): CalibrationEnvironment {
|
|
41
|
+
return {
|
|
42
|
+
internetEnvironment: normalizedValue(process.env.CODEBUDDY_INTERNET_ENVIRONMENT),
|
|
43
|
+
codebuddyConfigDir: normalizedValue(process.env.CODEBUDDY_CONFIG_DIR),
|
|
44
|
+
codebuddyExecutable: normalizedValue(codebuddyExecutable),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildCalibrationKey(modelId: string, environment: CalibrationEnvironment): string {
|
|
49
|
+
return JSON.stringify({ modelId, ...environment });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadCalibrationCache(path = DEFAULT_CALIBRATION_CACHE_PATH): CalibrationCache {
|
|
53
|
+
if (!existsSync(path)) return { version: CALIBRATION_CACHE_VERSION, records: {} };
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<CalibrationCache>;
|
|
56
|
+
if (parsed.version !== CALIBRATION_CACHE_VERSION || !parsed.records || typeof parsed.records !== "object") {
|
|
57
|
+
return { version: CALIBRATION_CACHE_VERSION, records: {} };
|
|
58
|
+
}
|
|
59
|
+
return { version: CALIBRATION_CACHE_VERSION, records: parsed.records };
|
|
60
|
+
} catch {
|
|
61
|
+
return { version: CALIBRATION_CACHE_VERSION, records: {} };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveCalibrationCache(cache: CalibrationCache, path = DEFAULT_CALIBRATION_CACHE_PATH): void {
|
|
66
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
67
|
+
writeFileSync(path, JSON.stringify(cache, null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getCalibrationRecord(
|
|
71
|
+
cache: CalibrationCache,
|
|
72
|
+
modelId: string,
|
|
73
|
+
environment: CalibrationEnvironment,
|
|
74
|
+
): ModelCalibrationRecord | undefined {
|
|
75
|
+
return cache.records[buildCalibrationKey(modelId, environment)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getContextWindowCalibration(
|
|
79
|
+
cache: CalibrationCache,
|
|
80
|
+
modelId: string,
|
|
81
|
+
environment: CalibrationEnvironment,
|
|
82
|
+
): CapabilityMetric | undefined {
|
|
83
|
+
return getCalibrationRecord(cache, modelId, environment)?.capabilities.contextWindow;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function applyContextWindowCalibrations<T extends { id: string; contextWindow: number }>(
|
|
87
|
+
models: T[],
|
|
88
|
+
cache: CalibrationCache,
|
|
89
|
+
environment: CalibrationEnvironment,
|
|
90
|
+
): T[] {
|
|
91
|
+
return models.map((model) => {
|
|
92
|
+
const metric = getContextWindowCalibration(cache, model.id, environment);
|
|
93
|
+
if (!metric?.floor) return model;
|
|
94
|
+
return { ...model, contextWindow: metric.floor };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function recordObservedContextWindow(
|
|
99
|
+
cache: CalibrationCache,
|
|
100
|
+
modelId: string,
|
|
101
|
+
environment: CalibrationEnvironment,
|
|
102
|
+
observed: number,
|
|
103
|
+
): { changed: boolean; floorChanged: boolean; record: ModelCalibrationRecord } {
|
|
104
|
+
const key = buildCalibrationKey(modelId, environment);
|
|
105
|
+
const existing = cache.records[key];
|
|
106
|
+
const previous = existing?.capabilities.contextWindow;
|
|
107
|
+
const nextMetric: CapabilityMetric = {
|
|
108
|
+
floor: previous ? Math.min(previous.floor, observed) : observed,
|
|
109
|
+
latest: observed,
|
|
110
|
+
max: previous ? Math.max(previous.max, observed) : observed,
|
|
111
|
+
observedAt: new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
const nextRecord: ModelCalibrationRecord = {
|
|
114
|
+
modelId,
|
|
115
|
+
environment,
|
|
116
|
+
capabilities: {
|
|
117
|
+
...existing?.capabilities,
|
|
118
|
+
contextWindow: nextMetric,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
cache.records[key] = nextRecord;
|
|
122
|
+
const changed =
|
|
123
|
+
!previous ||
|
|
124
|
+
previous.floor !== nextMetric.floor ||
|
|
125
|
+
previous.latest !== nextMetric.latest ||
|
|
126
|
+
previous.max !== nextMetric.max;
|
|
127
|
+
const floorChanged = !previous || previous.floor !== nextMetric.floor;
|
|
128
|
+
return { changed, floorChanged, record: nextRecord };
|
|
129
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Dynamic model list from CodeBuddy SDK supportedModels().
|
|
2
|
+
import type { ModelInfo } from "@tencent-ai/agent-sdk";
|
|
3
|
+
|
|
4
|
+
export type PiModel = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
reasoning: boolean;
|
|
8
|
+
input: ("text" | "image")[];
|
|
9
|
+
contextWindow: number;
|
|
10
|
+
maxTokens: number;
|
|
11
|
+
thinkingLevelMap?: Record<string, string>;
|
|
12
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const CONSERVATIVE_DEFAULT_CONTEXT = 65_536;
|
|
16
|
+
const CONSERVATIVE_GEMINI_CONTEXT = 131_072;
|
|
17
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
18
|
+
|
|
19
|
+
function detectThinking(id: string): boolean {
|
|
20
|
+
return /claude|gemini|gpt-5|hy3|deepseek|glm/i.test(id);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function detectImages(id: string): boolean {
|
|
24
|
+
return /claude|gemini|gpt/i.test(id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function conservativeContextWindow(id: string): number {
|
|
28
|
+
const lower = id.toLowerCase();
|
|
29
|
+
if (lower.includes("gemini")) return CONSERVATIVE_GEMINI_CONTEXT;
|
|
30
|
+
return CONSERVATIVE_DEFAULT_CONTEXT;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function conservativeMaxTokens(id: string): number {
|
|
34
|
+
if (id.toLowerCase().includes("gpt")) return 16_384;
|
|
35
|
+
return DEFAULT_MAX_TOKENS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rawModelsFromSdk(supported: Array<ModelInfo & { id?: string; name?: string }>): PiModel[] {
|
|
39
|
+
return supported
|
|
40
|
+
.map((m) => ({ id: m.id ?? m.value, name: m.name ?? m.displayName ?? m.id ?? m.value }))
|
|
41
|
+
.filter((m) => m.id)
|
|
42
|
+
.map((m) => ({
|
|
43
|
+
id: m.id!,
|
|
44
|
+
name: m.name || m.id!,
|
|
45
|
+
reasoning: detectThinking(m.id!),
|
|
46
|
+
input: detectImages(m.id!) ? ["text", "image"] as const : ["text"] as const,
|
|
47
|
+
contextWindow: conservativeContextWindow(m.id!),
|
|
48
|
+
maxTokens: conservativeMaxTokens(m.id!),
|
|
49
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const FALLBACK_MODELS: PiModel[] = [
|
|
54
|
+
{
|
|
55
|
+
id: "hy3-preview-agent-ioa",
|
|
56
|
+
name: "Hunyuan 3 Preview",
|
|
57
|
+
reasoning: true,
|
|
58
|
+
input: ["text"],
|
|
59
|
+
contextWindow: conservativeContextWindow("hy3-preview-agent-ioa"),
|
|
60
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
61
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function buildModels(models: PiModel[]): PiModel[] {
|
|
66
|
+
return models.map((m) => ({
|
|
67
|
+
...m,
|
|
68
|
+
cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function codebuddyModelId(model: { id: string }): string {
|
|
73
|
+
return model.id;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveModel<T extends { id: string }>(models: T[], input: string): T | undefined {
|
|
77
|
+
const lower = input.toLowerCase();
|
|
78
|
+
return models.find((m) => m.id === lower || m.id.toLowerCase().includes(lower));
|
|
79
|
+
}
|
|
@@ -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 "@earendil-works/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
|
+
}
|
package/src/sdk-gate.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Serialize CodeBuddy SDK query() subprocesses — only one CLI at a time.
|
|
2
|
+
|
|
3
|
+
let chain: Promise<void> = Promise.resolve();
|
|
4
|
+
|
|
5
|
+
export function withSdkGate<T>(fn: () => Promise<T>): Promise<T> {
|
|
6
|
+
const run = chain.then(fn);
|
|
7
|
+
chain = run.then(() => undefined, () => undefined);
|
|
8
|
+
return run;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function drainQuery(q: AsyncIterable<unknown>): Promise<void> {
|
|
12
|
+
const timer = new Promise<void>((r) => setTimeout(r, 5000));
|
|
13
|
+
await Promise.race([
|
|
14
|
+
(async () => { for await (const _ of q) { /* drain */ } })(),
|
|
15
|
+
timer,
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
@@ -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,155 @@
|
|
|
1
|
+
// Skills block extraction + MCP naming constants.
|
|
2
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
3
|
+
|
|
4
|
+
import { extractAgentsAppend } from "./agents-md.js";
|
|
5
|
+
|
|
6
|
+
export const MCP_SERVER_NAME = "custom_tools";
|
|
7
|
+
export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
|
|
8
|
+
|
|
9
|
+
const SKILLS_START_MARKER = "The following skills provide specialized instructions for specific tasks.";
|
|
10
|
+
const SKILLS_END_MARKER = "</available_skills>";
|
|
11
|
+
const TOOL_GUIDANCE_MARKER = "CodeBuddy guidance:";
|
|
12
|
+
const CORE_TOOL_ORDER = ["read", "edit", "write", "bash"] as const;
|
|
13
|
+
|
|
14
|
+
const BUILTIN_TOOL_GUIDANCE: Record<string, string> = {
|
|
15
|
+
read: "Use this Pi tool to inspect file contents before answering file-specific questions or editing existing files. Prefer it over shelling out to cat/sed when reading files.",
|
|
16
|
+
write: "Use this Pi tool only when creating a new file or replacing a whole file is intended. Prefer edit for targeted changes to existing files.",
|
|
17
|
+
edit: "Use this Pi tool for targeted changes after reading the file. The oldText/old_string value must exactly match existing content; use the Pi schema field names exactly.",
|
|
18
|
+
bash: "Use this Pi tool for shell commands only when file tools are insufficient or command execution is requested. Keep commands scoped and provide a timeout for long-running work.",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface ToolBridgeInstructionOptions {
|
|
22
|
+
availableToolNames?: Iterable<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeToolNames(names: Iterable<string> | undefined): Set<string> {
|
|
26
|
+
const fallback = names ?? CORE_TOOL_ORDER;
|
|
27
|
+
return new Set([...fallback].map((name) => name.toLowerCase()));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mcpName(name: string): string {
|
|
31
|
+
return `${MCP_TOOL_PREFIX}${name}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatToolList(toolNames: Set<string>): string {
|
|
35
|
+
const ordered = [
|
|
36
|
+
...CORE_TOOL_ORDER.filter((name) => toolNames.has(name)),
|
|
37
|
+
...[...toolNames].filter((name) => !CORE_TOOL_ORDER.includes(name as typeof CORE_TOOL_ORDER[number])).sort(),
|
|
38
|
+
];
|
|
39
|
+
return ordered.map((name) => `\`${mcpName(name)}\``).join(", ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildPiToolBridgeInstruction(options: ToolBridgeInstructionOptions = {}): string {
|
|
43
|
+
const toolNames = normalizeToolNames(options.availableToolNames);
|
|
44
|
+
const has = (name: string) => toolNames.has(name);
|
|
45
|
+
const lines = [
|
|
46
|
+
"Pi Tool Bridge:",
|
|
47
|
+
`Pi executes tools; CodeBuddy sees available Pi tools through the MCP server \`${MCP_SERVER_NAME}\`.`,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (toolNames.size === 0) {
|
|
51
|
+
lines.push("No Pi tools are currently available through the bridge. Do not invoke unavailable tools.");
|
|
52
|
+
return lines.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
lines.push(`Available bridged Pi tools in this turn: ${formatToolList(toolNames)}.`);
|
|
56
|
+
lines.push("Tool selection rules:");
|
|
57
|
+
|
|
58
|
+
if (has("read")) {
|
|
59
|
+
lines.push(`- Use \`${mcpName("read")}\` to inspect existing repository files before file-specific answers or edits. Prefer it over shelling out to cat/sed for file reads.`);
|
|
60
|
+
} else {
|
|
61
|
+
lines.push("- Use available Pi file tools to inspect existing repository files before editing when such tools are present.");
|
|
62
|
+
}
|
|
63
|
+
if (has("edit")) {
|
|
64
|
+
const prefix = has("read") ? `After reading, use \`${mcpName("edit")}\`` : `Use \`${mcpName("edit")}\``;
|
|
65
|
+
lines.push(`- ${prefix} for targeted changes to existing files. The oldText/old_string value must exactly match existing content.`);
|
|
66
|
+
}
|
|
67
|
+
if (has("write")) {
|
|
68
|
+
const editFallback = has("edit") ? ` prefer \`${mcpName("edit")}\` for targeted changes to existing files.` : " avoid broad replacement of existing files.";
|
|
69
|
+
lines.push(`- Use \`${mcpName("write")}\` only for new files or deliberate full-file replacement;${editFallback}`);
|
|
70
|
+
}
|
|
71
|
+
if (has("bash")) {
|
|
72
|
+
lines.push(`- Use \`${mcpName("bash")}\` only when file tools are insufficient, for search/test/build/git information, or when command execution is requested.`);
|
|
73
|
+
}
|
|
74
|
+
lines.push("Tool arguments must match the Pi tool schema exactly. After a tool result, base the next step on that result; if it is an error, correct the call instead of assuming success.");
|
|
75
|
+
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function enhancePiToolForCodebuddy<T extends { name: string; description?: string }>(tool: T): T {
|
|
80
|
+
const guidance = BUILTIN_TOOL_GUIDANCE[tool.name.toLowerCase()];
|
|
81
|
+
if (!guidance) return tool;
|
|
82
|
+
if (tool.description?.includes(TOOL_GUIDANCE_MARKER)) return tool;
|
|
83
|
+
const description = tool.description
|
|
84
|
+
? `${tool.description}\n\n${TOOL_GUIDANCE_MARKER} ${guidance}`
|
|
85
|
+
: `${TOOL_GUIDANCE_MARKER} ${guidance}`;
|
|
86
|
+
return { ...tool, description };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract skills block from pi's system prompt for forwarding to CodeBuddy.
|
|
90
|
+
export function extractSkillsBlock(systemPrompt?: string): string | undefined {
|
|
91
|
+
if (!systemPrompt) return undefined;
|
|
92
|
+
const startMarker = SKILLS_START_MARKER;
|
|
93
|
+
const endMarker = SKILLS_END_MARKER;
|
|
94
|
+
const start = systemPrompt.indexOf(startMarker);
|
|
95
|
+
if (start === -1) return undefined;
|
|
96
|
+
const end = systemPrompt.indexOf(endMarker, start);
|
|
97
|
+
if (end === -1) return undefined;
|
|
98
|
+
return rewriteSkillsBlock(systemPrompt.slice(start, end + endMarker.length).trim());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function rewriteSkillsBlock(skillsBlock: string): string {
|
|
102
|
+
return skillsBlock.replace(
|
|
103
|
+
"Use the read tool to load a skill's file",
|
|
104
|
+
`Use the read tool (mcp__${MCP_SERVER_NAME}__read) to load a skill's file`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stripSkillsBlock(systemPrompt: string): string {
|
|
109
|
+
const start = systemPrompt.indexOf(SKILLS_START_MARKER);
|
|
110
|
+
if (start === -1) return systemPrompt;
|
|
111
|
+
const end = systemPrompt.indexOf(SKILLS_END_MARKER, start);
|
|
112
|
+
if (end === -1) return systemPrompt;
|
|
113
|
+
return (systemPrompt.slice(0, start) + systemPrompt.slice(end + SKILLS_END_MARKER.length))
|
|
114
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
115
|
+
.trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function applySkillsRewrite(systemPrompt: string): string {
|
|
119
|
+
const rewritten = extractSkillsBlock(systemPrompt);
|
|
120
|
+
if (!rewritten) return systemPrompt;
|
|
121
|
+
const start = systemPrompt.indexOf(SKILLS_START_MARKER);
|
|
122
|
+
const end = systemPrompt.indexOf(SKILLS_END_MARKER, start);
|
|
123
|
+
if (start === -1 || end === -1) return systemPrompt;
|
|
124
|
+
return systemPrompt.slice(0, start) + rewritten + systemPrompt.slice(end + SKILLS_END_MARKER.length);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pi system prompt as CodeBuddy override (replaces default "CodeBuddy Code" identity). */
|
|
128
|
+
export function buildCodebuddySystemPrompt(
|
|
129
|
+
piSystemPrompt: string | undefined,
|
|
130
|
+
options?: { includeAgents?: boolean; includeSkills?: boolean; includeToolBridge?: boolean; availableToolNames?: Iterable<string> },
|
|
131
|
+
): string | undefined {
|
|
132
|
+
const parts: string[] = [];
|
|
133
|
+
const includeToolBridge = options?.includeToolBridge !== false;
|
|
134
|
+
|
|
135
|
+
if (includeToolBridge) {
|
|
136
|
+
parts.push(buildPiToolBridgeInstruction({ availableToolNames: options?.availableToolNames }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (piSystemPrompt) {
|
|
140
|
+
let prompt = piSystemPrompt;
|
|
141
|
+
if (options?.includeSkills === false) {
|
|
142
|
+
prompt = stripSkillsBlock(prompt);
|
|
143
|
+
} else {
|
|
144
|
+
prompt = includeToolBridge ? applySkillsRewrite(prompt) : prompt;
|
|
145
|
+
}
|
|
146
|
+
if (prompt) parts.push(prompt);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (options?.includeAgents !== false) {
|
|
150
|
+
const agents = extractAgentsAppend();
|
|
151
|
+
if (agents) parts.push(agents);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
155
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
type JsonSchema = Record<string, unknown>;
|
|
15
|
+
type JsonLiteral = string | number | boolean | null;
|
|
16
|
+
|
|
17
|
+
function withDescription(schema: z.ZodTypeAny, prop: JsonSchema): z.ZodTypeAny {
|
|
18
|
+
if (typeof prop.description === "string") return schema.describe(prop.description);
|
|
19
|
+
return schema;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isJsonLiteral(value: unknown): value is JsonLiteral {
|
|
23
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function literalSchema(value: unknown): z.ZodTypeAny {
|
|
27
|
+
if (
|
|
28
|
+
isJsonLiteral(value)
|
|
29
|
+
) {
|
|
30
|
+
return z.literal(value);
|
|
31
|
+
}
|
|
32
|
+
return z.unknown();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unionSchema(schemas: z.ZodTypeAny[]): z.ZodTypeAny {
|
|
36
|
+
if (schemas.length === 0) return z.unknown();
|
|
37
|
+
if (schemas.length === 1) return schemas[0];
|
|
38
|
+
return z.union(schemas as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function intersectionSchema(schemas: z.ZodTypeAny[]): z.ZodTypeAny {
|
|
42
|
+
if (schemas.length === 0) return z.unknown();
|
|
43
|
+
if (schemas.length === 1) return schemas[0];
|
|
44
|
+
return schemas.slice(1).reduce((left, right) => z.intersection(left, right), schemas[0]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function objectSchema(prop: JsonSchema): z.ZodTypeAny {
|
|
48
|
+
const shape = jsonSchemaToZodShape(prop);
|
|
49
|
+
const additionalPropertiesSchema =
|
|
50
|
+
prop.additionalProperties && typeof prop.additionalProperties === "object"
|
|
51
|
+
? jsonSchemaPropertyToZod(prop.additionalProperties as JsonSchema)
|
|
52
|
+
: undefined;
|
|
53
|
+
if (Object.keys(shape).length > 0) {
|
|
54
|
+
const base = z.object(shape);
|
|
55
|
+
if (prop.additionalProperties === false) return base.strict();
|
|
56
|
+
if (additionalPropertiesSchema) return base.catchall(additionalPropertiesSchema);
|
|
57
|
+
return base.passthrough();
|
|
58
|
+
}
|
|
59
|
+
if (additionalPropertiesSchema) return z.record(z.string(), additionalPropertiesSchema);
|
|
60
|
+
if (prop.additionalProperties === false) return z.object({}).strict();
|
|
61
|
+
return z.record(z.string(), z.unknown());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function jsonSchemaPropertyToZod(prop: JsonSchema): z.ZodTypeAny {
|
|
65
|
+
let base: z.ZodTypeAny;
|
|
66
|
+
|
|
67
|
+
if (prop.const !== undefined) {
|
|
68
|
+
base = literalSchema(prop.const);
|
|
69
|
+
if (prop.nullable === true) base = base.nullable();
|
|
70
|
+
return withDescription(base, prop);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(prop.enum)) {
|
|
74
|
+
base = unionSchema(prop.enum.map((value) => literalSchema(value)));
|
|
75
|
+
if (prop.nullable === true) base = base.nullable();
|
|
76
|
+
return withDescription(base, prop);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(prop.oneOf)) {
|
|
80
|
+
base = unionSchema(prop.oneOf.map((item) => jsonSchemaPropertyToZod(item as JsonSchema)));
|
|
81
|
+
if (prop.nullable === true) base = base.nullable();
|
|
82
|
+
return withDescription(base, prop);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (Array.isArray(prop.anyOf)) {
|
|
86
|
+
base = unionSchema(prop.anyOf.map((item) => jsonSchemaPropertyToZod(item as JsonSchema)));
|
|
87
|
+
if (prop.nullable === true) base = base.nullable();
|
|
88
|
+
return withDescription(base, prop);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(prop.allOf)) {
|
|
92
|
+
base = intersectionSchema(prop.allOf.map((item) => jsonSchemaPropertyToZod(item as JsonSchema)));
|
|
93
|
+
if (prop.nullable === true) base = base.nullable();
|
|
94
|
+
return withDescription(base, prop);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Array.isArray(prop.type)) {
|
|
98
|
+
const allowsNull = prop.type.includes("null");
|
|
99
|
+
const schemas = prop.type
|
|
100
|
+
.filter((value): value is string => typeof value === "string" && value !== "null")
|
|
101
|
+
.map((value) => jsonSchemaPropertyToZod({ ...prop, type: value, nullable: false }));
|
|
102
|
+
base = unionSchema(schemas);
|
|
103
|
+
base = allowsNull ? base.nullable() : base;
|
|
104
|
+
return withDescription(base, prop);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const propType = typeof prop.type === "string" ? prop.type : undefined;
|
|
108
|
+
switch (propType) {
|
|
109
|
+
case "string":
|
|
110
|
+
base = z.string();
|
|
111
|
+
break;
|
|
112
|
+
case "number":
|
|
113
|
+
case "integer":
|
|
114
|
+
base = z.number();
|
|
115
|
+
break;
|
|
116
|
+
case "boolean":
|
|
117
|
+
base = z.boolean();
|
|
118
|
+
break;
|
|
119
|
+
case "array":
|
|
120
|
+
base = prop.items
|
|
121
|
+
? z.array(jsonSchemaPropertyToZod(prop.items as JsonSchema))
|
|
122
|
+
: z.array(z.unknown());
|
|
123
|
+
break;
|
|
124
|
+
case "object":
|
|
125
|
+
base = objectSchema(prop);
|
|
126
|
+
break;
|
|
127
|
+
case "null":
|
|
128
|
+
base = z.null();
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
if (prop.properties) base = objectSchema(prop);
|
|
132
|
+
else base = z.unknown();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (prop.nullable === true) base = base.nullable();
|
|
136
|
+
return withDescription(base, prop);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function jsonSchemaToZodShape(schema: unknown): Record<string, z.ZodTypeAny> {
|
|
140
|
+
const s = schema as JsonSchema;
|
|
141
|
+
const objectSchemas = [s, ...(Array.isArray(s?.allOf) ? s.allOf as JsonSchema[] : [])]
|
|
142
|
+
.filter((item) => item && typeof item === "object" && ((item as JsonSchema).type === "object" || (item as JsonSchema).properties));
|
|
143
|
+
if (objectSchemas.length === 0) return {};
|
|
144
|
+
|
|
145
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
146
|
+
for (const objectSchemaPart of objectSchemas) {
|
|
147
|
+
const props = objectSchemaPart.properties as Record<string, JsonSchema> | undefined;
|
|
148
|
+
if (!props) continue;
|
|
149
|
+
const required = new Set(Array.isArray(objectSchemaPart.required) ? objectSchemaPart.required as string[] : []);
|
|
150
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
151
|
+
const zodProp = jsonSchemaPropertyToZod(prop);
|
|
152
|
+
shape[key] = required.has(key) ? zodProp : zodProp.optional();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return shape;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function jsonSchemaToZodObject(schema: unknown): z.ZodTypeAny {
|
|
159
|
+
return jsonSchemaPropertyToZod(schema as JsonSchema);
|
|
160
|
+
}
|