mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
d201760ffe
57 changed files with 719 additions and 198 deletions
80
package-lock.json
generated
80
package-lock.json
generated
|
|
@ -5280,12 +5280,12 @@
|
|||
},
|
||||
"packages/agent": {
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.8",
|
||||
"typebox": "1.3.27",
|
||||
|
|
@ -5310,12 +5310,12 @@
|
|||
},
|
||||
"packages/ai": {
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.124.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
@ -5361,7 +5361,7 @@
|
|||
},
|
||||
"packages/chord": {
|
||||
"name": "@earendil-works/chord",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
|
|
@ -5376,11 +5376,11 @@
|
|||
},
|
||||
"packages/client": {
|
||||
"name": "@earendil-works/pi-client",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
@ -5392,13 +5392,13 @@
|
|||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-tui": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-tui": "^0.87.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "6.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
|
@ -5419,9 +5419,9 @@
|
|||
"pi": "dist/bundle/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-client": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1",
|
||||
"@earendil-works/pi-server": "^0.86.1",
|
||||
"@earendil-works/pi-client": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0",
|
||||
"@earendil-works/pi-server": "^0.87.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/hosted-git-info": "3.0.5",
|
||||
"@types/node": "22.19.19",
|
||||
|
|
@ -5438,32 +5438,32 @@
|
|||
},
|
||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.52.0"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"version": "0.86.1"
|
||||
"version": "0.87.0"
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/gondolin": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/sandbox": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/with-deps": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"ms": "2.1.3"
|
||||
},
|
||||
|
|
@ -5517,11 +5517,11 @@
|
|||
},
|
||||
"packages/durable": {
|
||||
"name": "@earendil-works/pi-durable",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
@ -5533,10 +5533,10 @@
|
|||
},
|
||||
"packages/evals": {
|
||||
"name": "@earendil-works/pi-evals",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-coding-agent": "^0.87.0",
|
||||
"@types/node": "22.19.19",
|
||||
"@vitest-evals/core": "0.15.0",
|
||||
"autoevals": "0.3.0",
|
||||
|
|
@ -5548,10 +5548,10 @@
|
|||
},
|
||||
"packages/protocol": {
|
||||
"name": "@earendil-works/pi-protocol",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -5564,12 +5564,12 @@
|
|||
},
|
||||
"packages/server": {
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
@ -5581,11 +5581,11 @@
|
|||
},
|
||||
"packages/session-backends/sqlite-node": {
|
||||
"name": "@earendil-works/pi-session-backend-sqlite-node",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1"
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
|
|
@ -5597,7 +5597,7 @@
|
|||
},
|
||||
"packages/telemetry": {
|
||||
"name": "@earendil-works/pi-telemetry",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
|
|
@ -5609,7 +5609,7 @@
|
|||
},
|
||||
"packages/tui": {
|
||||
"name": "@earendil-works/pi-tui",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `AgentOptions.shouldStopAfterTurn` and `AgentLoopConfig.shouldStopAfterTurn`. Use `finishTurn` and return `{ action: "end" }` to stop after the completed turn:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -59,9 +59,9 @@
|
|||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.8",
|
||||
"typebox": "1.3.27",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Added Grok 4.7 to the built-in xAI model catalog with long-context pricing metadata.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed image-only user messages being rejected by some OpenAI-compatible providers because they included an empty text part ([#9797](https://github.com/earendil-works/pi/issues/9797))
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added model image-input limit and cache-safe resize metadata to the generated catalog ([#9631](https://github.com/earendil-works/pi/issues/9631)).
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -833,7 +833,7 @@ Many models support thinking/reasoning capabilities where they can show their in
|
|||
const model = models.getModel('anthropic', 'claude-sonnet-4-5')!;
|
||||
// or models.getModel('openai', 'gpt-5-mini');
|
||||
// or models.getModel('google', 'gemini-2.5-flash');
|
||||
// or models.getModel('xai', 'grok-4.6');
|
||||
// or models.getModel('xai', 'grok-4.7');
|
||||
|
||||
// Check if model supports reasoning
|
||||
if (model.reasoning) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.124.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
|
|||
|
|
@ -2030,12 +2030,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
compat: { ...XAI_RESPONSES_COMPAT },
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1257,21 +1257,23 @@ export function convertMessages(
|
|||
content: sanitizeSurrogates(msg.content),
|
||||
});
|
||||
} else {
|
||||
const content: ChatCompletionContentPart[] = msg.content.map((item): ChatCompletionContentPart => {
|
||||
if (item.type === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(item.text),
|
||||
} satisfies ChatCompletionContentPartText;
|
||||
} else {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${item.mimeType};base64,${item.data}`,
|
||||
},
|
||||
} satisfies ChatCompletionContentPartImage;
|
||||
}
|
||||
});
|
||||
const content: ChatCompletionContentPart[] = msg.content
|
||||
.filter((item) => item.type !== "text" || item.text.length > 0)
|
||||
.map((item): ChatCompletionContentPart => {
|
||||
if (item.type === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(item.text),
|
||||
} satisfies ChatCompletionContentPartText;
|
||||
} else {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${item.mimeType};base64,${item.data}`,
|
||||
},
|
||||
} satisfies ChatCompletionContentPartImage;
|
||||
}
|
||||
});
|
||||
if (content.length === 0) continue;
|
||||
params.push({
|
||||
role: "user",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ it("derives model API, ID, and provider literals from grouped model data", () =>
|
|||
expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">();
|
||||
expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">();
|
||||
expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">();
|
||||
expectTypeOf(XAI_MODELS["grok-4.7"].api).toEqualTypeOf<"openai-responses">();
|
||||
expectTypeOf(XAI_MODELS["grok-4.7"].id).toEqualTypeOf<"grok-4.7">();
|
||||
expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,35 @@ function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResult
|
|||
}
|
||||
|
||||
describe("openai-completions convertMessages", () => {
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/9797
|
||||
it("omits empty text parts from user messages with images", () => {
|
||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
||||
const model: Model<"openai-completions"> = {
|
||||
...baseModel,
|
||||
api: "openai-completions",
|
||||
input: ["text", "image"],
|
||||
};
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(convertMessages(model, context, compat)).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "data:image/png;base64,ZmFrZQ==" } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("batches tool-result images after consecutive tool results", () => {
|
||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
||||
const model: Model<"openai-completions"> = {
|
||||
|
|
|
|||
|
|
@ -540,8 +540,8 @@ describe("Generate E2E Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => {
|
||||
const llm = getModel("xai", "grok-4.3");
|
||||
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.7 via OpenAI Responses)", () => {
|
||||
const llm = getModel("xai", "grok-4.7");
|
||||
|
||||
it("should complete basic text generation", { retry: 3 }, async () => {
|
||||
await basicTextGeneration(llm);
|
||||
|
|
|
|||
|
|
@ -132,9 +132,35 @@ describe("xAI Responses provider", () => {
|
|||
}
|
||||
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]);
|
||||
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]);
|
||||
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.7"])).toEqual(["low", "medium", "high", "xhigh"]);
|
||||
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it("includes Grok 4.7 capabilities and long-context pricing", () => {
|
||||
expect(XAI_MODELS["grok-4.7"]).toMatchObject({
|
||||
api: "openai-responses",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 500000,
|
||||
maxTokens: 500000,
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 6,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 0,
|
||||
tiers: [
|
||||
{
|
||||
inputTokensAbove: 200000,
|
||||
input: 4,
|
||||
output: 12,
|
||||
cacheRead: 1,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses /responses with bearer auth and xAI-compatible request fields", async () => {
|
||||
const captured = await captureRequest(
|
||||
XAI_MODELS["grok-4.5"],
|
||||
|
|
@ -188,9 +214,9 @@ describe("xAI Responses provider", () => {
|
|||
expect(captured.body).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => {
|
||||
it("uses /responses for Grok 4.7 with xhigh effort and encrypted reasoning", async () => {
|
||||
const captured = await captureRequest(
|
||||
XAI_MODELS["grok-4.6"],
|
||||
XAI_MODELS["grok-4.7"],
|
||||
{
|
||||
systemPrompt: "You are a careful coding assistant.",
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
|
|
@ -203,7 +229,7 @@ describe("xAI Responses provider", () => {
|
|||
|
||||
expect(captured.url).toBe("https://api.x.ai/v1/responses");
|
||||
expect(captured.body).toMatchObject({
|
||||
model: "grok-4.6",
|
||||
model: "grok-4.7",
|
||||
store: false,
|
||||
stream: true,
|
||||
reasoning: { effort: "xhigh" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/chord",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Application composition runtime for services, replicated state, RPC, and plugins",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-client",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Transport-neutral client for remote pi sessions over framed CBOR bytes",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -47,8 +47,8 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,20 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the default xAI model to Grok 4.7.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed missing or invalid `--mode` values being silently ignored instead of reporting an error and exiting with a nonzero status ([#9045](https://github.com/earendil-works/pi/issues/9045)).
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
### New Features
|
||||
|
||||
- **Canonical session context and extension boundaries** — Edit model context without rewriting history and add actionable lifecycle hooks. See [ContextEditEntry](docs/session-format.md#contexteditentry) and [extension events](docs/extensions.md#extension-events).
|
||||
- **Full-transcript context extensions** — Use `context_with_system` for per-request system-message transformations. See [`context_with_system`](docs/extensions.md#context_with_system).
|
||||
- **Per-model image input limits** — Configure cache-safe image resizing per model for attachments, `read`, and tool-result images. See [Image Input Limits](docs/models.md#image-input-limits).
|
||||
|
||||
### Breaking Changes
|
||||
|
|
@ -19,6 +31,7 @@
|
|||
- Added append-only model-context edits. For example, `sessionManager.appendContextEdit(entryId, null)` omits one message from future provider context without changing raw history, usage, or UI history.
|
||||
- Added actionable `turn_end` and `agent_before_settle` extension boundaries. Return `{ entries: [...event.entries, draft], continue: true }` to persist structural entries in order and ensure one next provider request without changing steering or follow-up scheduling.
|
||||
- Added retain-none compaction input: `sessionManager.appendCompaction(summary, null, tokensBefore)` stores the compaction's own ID as its kept boundary.
|
||||
- Added the `context_with_system` extension event, which runs after `context` handlers on the full transcript including system messages and sends its result verbatim. See [`context_with_system`](docs/extensions.md#context_with_system).
|
||||
- Added per-model image resize profiles through `inputLimits.images.resize` in `models.json`, applied to file attachments, image reads, and tool-result images ([#9631](https://github.com/earendil-works/pi/issues/9631)).
|
||||
|
||||
### Fixed
|
||||
|
|
@ -27,11 +40,12 @@
|
|||
- Fixed context-invisible boundary metadata and replacement edits causing newly appended or replaced input to be summarized before its first provider request.
|
||||
- Fixed edited-context accounting both discarding valid assistant usage captured after the latest context edit and reusing that usage after a later compaction made it stale.
|
||||
- Fixed selected error retries and final length/overflow recovery retaining abandoned model attempts in future provider context; post-run recovery omissions are now persisted without hiding raw transcript history or changing queue scheduling.
|
||||
- Fixed `/bug` allowing uploads in offline mode while preserving local zip exports.
|
||||
- Fixed `context` handlers that filter or slice messages dropping the prompt and tool declarations, which after extension-driven compaction left requests without built-in tools or made Codex emit raw tool-call text. Handlers no longer see system messages; Pi restores the prompt and tool state after they run. See [`context`](docs/extensions.md#context) ([#9789](https://github.com/earendil-works/pi/issues/9789), [#9822](https://github.com/earendil-works/pi/issues/9822)).
|
||||
- Fixed `/bug` allowing uploads in offline mode while preserving local zip exports ([#9841](https://github.com/earendil-works/pi/pull/9841) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed idle prompt-cache warming rebuilding expired caches when its timer or an extension decision is delayed.
|
||||
- Improved crash diagnostics with hints identifying loaded extensions that appear in the stack trace.
|
||||
- Fixed text files beginning with `GIF` being misclassified as images and omitted from `read` and CLI `@file` input ([#9755](https://github.com/earendil-works/pi/issues/9755)).
|
||||
- Fixed malformed prompt template frontmatter being silently ignored instead of reported as a resource warning ([#9830](https://github.com/earendil-works/pi/pull/9830)).
|
||||
- Fixed malformed prompt template frontmatter being silently ignored instead of reported as a resource warning ([#9830](https://github.com/earendil-works/pi/pull/9830) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed inherited unknown OpenAI-compatible Chat Completions endpoints receiving strict tool schemas unless they explicitly advertise support ([#9816](https://github.com/earendil-works/pi/issues/9816)).
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
|
|
|||
|
|
@ -294,7 +294,8 @@ user sends prompt ────────────────────
|
|||
│ ┌─── turn (repeats while LLM calls tools) ───┐ │
|
||||
│ │ │ │
|
||||
│ ├─► turn_start │ │
|
||||
│ ├─► context (can modify messages) │ │
|
||||
│ ├─► context (can modify conversation messages) │
|
||||
│ ├─► context_with_system (can modify the full transcript)
|
||||
│ ├─► before_provider_headers (can mutate headers) |
|
||||
│ ├─► before_provider_request (can inspect or replace payload)
|
||||
│ ├─► after_provider_response (status + headers, before stream consume)
|
||||
|
|
@ -725,12 +726,31 @@ Fired before each LLM call. Modify messages non-destructively. See [Session Form
|
|||
|
||||
```typescript
|
||||
pi.on("context", async (event, ctx) => {
|
||||
// event.messages - deep copy, safe to modify
|
||||
// event.messages - deep copy without system messages, safe to modify
|
||||
const filtered = event.messages.filter(m => !shouldPrune(m));
|
||||
return { messages: filtered };
|
||||
});
|
||||
```
|
||||
|
||||
`event.messages` holds the conversation without system messages. The prompt and tool declarations belong to Pi and are not part of this hook: when the handler returns a changed list, Pi replays the current prompt sections and tool declarations into one leading system message ahead of the returned messages. Filtering, windowing, or slicing from a compaction summary therefore cannot drop the prompt or the tools. An unchanged list keeps mid-conversation system messages in place, so models that accept them retain their cached prefix. System messages a handler adds are kept after Pi's head. To change the prompt or the tool set durably, use [`before_agent_start`](#before_agent_start) or `pi.setActiveTools()`; to edit system messages for one request, use [`context_with_system`](#context_with_system).
|
||||
|
||||
#### context_with_system
|
||||
|
||||
Fired before each LLM call, after every `context` handler has run and Pi has restored the prompt and tool state. `event.messages` is the full transcript, including the leading system message and any mid-conversation prompt or tool patches (see [Session Format](session-format.md#sessionmessageentry)). The returned messages are sent as they are: this hook owns the prompt and tool declarations for the request.
|
||||
|
||||
```typescript
|
||||
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
pi.on("context_with_system", async (event, ctx) => {
|
||||
const cut = findCutIndex(event.messages);
|
||||
// Fold the dropped prefix so its prompt and tool state survives as the new head.
|
||||
const head = getCurrentSystemMessage(event.messages.slice(0, cut));
|
||||
return { messages: head ? [head, ...event.messages.slice(cut)] : event.messages.slice(cut) };
|
||||
});
|
||||
```
|
||||
|
||||
Rules: keep a system message at index 0 (providers read the prompt and initial tool declarations there; Pi reports an error if a handler drops it). Removing a system message removes the tool declarations and section patches it carries. Check your output with `getCurrentSystemPrompt()` and `getCurrentTools()` from `@earendil-works/pi-ai`. Handlers run in extension load order; a `systemPrompt` forced from `before_agent_start` is still projected onto the request afterwards.
|
||||
|
||||
#### before_provider_headers
|
||||
|
||||
Fired after the outgoing HTTP headers are assembled. Use it to add, override, or remove request headers.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
|
|
|||
46
packages/coding-agent/install-lock/package-lock.json
generated
46
packages/coding-agent/install-lock/package-lock.json
generated
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.86.1"
|
||||
"@earendil-works/pi-coding-agent": "0.87.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
|
|
@ -386,8 +386,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/chord": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
|
|
@ -397,13 +397,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.8",
|
||||
"typebox": "1.3.27",
|
||||
|
|
@ -414,13 +414,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.124.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
@ -460,14 +460,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-tui": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-tui": "^0.87.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "6.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
|
@ -492,16 +492,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-telemetry": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"private": true,
|
||||
"description": "Lockfile root used by the Pi installer and updater.",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.86.1"
|
||||
"@earendil-works/pi-coding-agent": "0.87.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "7.6.6"
|
||||
|
|
|
|||
40
packages/coding-agent/npm-shrinkwrap.json
generated
40
packages/coding-agent/npm-shrinkwrap.json
generated
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-tui": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-tui": "^0.87.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "6.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
|
@ -408,8 +408,8 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/chord": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
|
|
@ -419,13 +419,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.8",
|
||||
"typebox": "1.3.27",
|
||||
|
|
@ -436,13 +436,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.124.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1127.0",
|
||||
"@earendil-works/pi-telemetry": "^0.86.1",
|
||||
"@earendil-works/pi-telemetry": "^0.87.0",
|
||||
"@google/genai": "2.21.0",
|
||||
"@smithy/node-http-handler": "4.12.1",
|
||||
"http-proxy-agent": "9.1.0",
|
||||
|
|
@ -482,16 +482,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-telemetry": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.86.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.86.1.tgz",
|
||||
"version": "0.87.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.87.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
|
|
@ -49,10 +49,10 @@
|
|||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-tui": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-tui": "^0.87.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "6.0.0",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
|
@ -73,9 +73,9 @@
|
|||
"protobufjs": "7.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-client": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1",
|
||||
"@earendil-works/pi-server": "^0.86.1",
|
||||
"@earendil-works/pi-client": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0",
|
||||
"@earendil-works/pi-server": "^0.87.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@types/hosted-git-info": "3.0.5",
|
||||
"@types/node": "22.19.19",
|
||||
|
|
|
|||
|
|
@ -92,11 +92,21 @@ export function parseArgs(args: string[]): Args {
|
|||
result.help = true;
|
||||
} else if (arg === "--version" || arg === "-v") {
|
||||
result.version = true;
|
||||
} else if (arg === "--mode" && i + 1 < args.length) {
|
||||
const mode = args[++i];
|
||||
if (mode === "text" || mode === "json" || mode === "rpc") {
|
||||
result.mode = mode;
|
||||
} else if (arg === "--mode") {
|
||||
const mode = args[i + 1];
|
||||
if (mode === undefined || mode.startsWith("-")) {
|
||||
result.diagnostics.push({ type: "error", message: "--mode requires text, json, or rpc" });
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (mode !== "text" && mode !== "json" && mode !== "rpc") {
|
||||
result.diagnostics.push({
|
||||
type: "error",
|
||||
message: `Invalid mode "${mode}". Valid values: text, json, rpc`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
result.mode = mode;
|
||||
} else if (arg === "--continue" || arg === "-c") {
|
||||
result.continue = true;
|
||||
} else if (arg === "--resume" || arg === "-r") {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export type {
|
|||
// Event Results
|
||||
ContextEventResult,
|
||||
ContextUsage,
|
||||
ContextWithSystemEvent,
|
||||
CustomEntryDraft,
|
||||
CustomMessageEntryDraft,
|
||||
CustomToolCallEvent,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@
|
|||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ImageContent, Model, Provider, ProviderHeaders } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
getCurrentSystemMessage,
|
||||
type ImageContent,
|
||||
type Model,
|
||||
type Provider,
|
||||
type ProviderHeaders,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { KeyId } from "@earendil-works/pi-tui";
|
||||
import { type Theme, theme } from "../../modes/interactive/theme/theme.ts";
|
||||
import type { CacheWarmingAction } from "../cache-warmer.ts";
|
||||
|
|
@ -32,6 +38,7 @@ import type {
|
|||
ContextEvent,
|
||||
ContextEventResult,
|
||||
ContextUsage,
|
||||
ContextWithSystemEvent,
|
||||
EntryRenderer,
|
||||
Extension,
|
||||
ExtensionActions,
|
||||
|
|
@ -169,6 +176,7 @@ type RunnerEmitEvent = Exclude<
|
|||
| ToolResultEvent
|
||||
| UserBashEvent
|
||||
| ContextEvent
|
||||
| ContextWithSystemEvent
|
||||
| CacheWarmingDecisionEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| BeforeProviderHeadersEvent
|
||||
|
|
@ -258,6 +266,28 @@ function snapshotEventHandlers(extensions: Extension[], event: ExtensionEvent["t
|
|||
return extensions.map((ext) => ({ ext, handlers: ext.handlers.get(event)?.slice() ?? [] }));
|
||||
}
|
||||
|
||||
function sameMessages(left: AgentMessage[], right: AgentMessage[]): boolean {
|
||||
return left.length === right.length && left.every((message, index) => message === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the prompt and tool state after a `context` handler. Handlers only see the
|
||||
* conversation; the system messages belong to Pi. An unchanged conversation keeps every
|
||||
* system message in place, so models with mid-conversation support keep their cached
|
||||
* prefix. A changed one gets the replayed prompt sections and tool declarations as one
|
||||
* leading system message, so pruning, windowing, or slicing from a compaction summary
|
||||
* cannot drop them.
|
||||
*/
|
||||
function restoreSystemMessages(
|
||||
current: AgentMessage[],
|
||||
visible: AgentMessage[],
|
||||
returned: AgentMessage[],
|
||||
): AgentMessage[] {
|
||||
if (sameMessages(returned, visible)) return current;
|
||||
const head = getCurrentSystemMessage(current);
|
||||
return head ? [head, ...returned] : returned;
|
||||
}
|
||||
|
||||
export async function emitProjectTrustEvent(
|
||||
extensionsResult: LoadExtensionsResult,
|
||||
event: ProjectTrustEvent,
|
||||
|
|
@ -1152,6 +1182,11 @@ export class ExtensionRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the request-time transforms in two phases. `context` handlers see the conversation
|
||||
* only and Pi restores the prompt and tool state after each; `context_with_system`
|
||||
* handlers then see the full transcript and their output is used as returned.
|
||||
*/
|
||||
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
||||
const ctx = this.createContext();
|
||||
let currentMessages = structuredClone(messages);
|
||||
|
|
@ -1159,12 +1194,17 @@ export class ExtensionRunner {
|
|||
for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context")) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const event: ContextEvent = { type: "context", messages: currentMessages };
|
||||
const handlerResult = await handler(event, ctx);
|
||||
const visibleMessages = currentMessages.filter((message) => message.role !== "system");
|
||||
const visibleSnapshot = visibleMessages.slice();
|
||||
const event: ContextEvent = { type: "context", messages: visibleMessages };
|
||||
const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined;
|
||||
|
||||
if (handlerResult && (handlerResult as ContextEventResult).messages) {
|
||||
currentMessages = (handlerResult as ContextEventResult).messages!;
|
||||
}
|
||||
// Handlers may return a new list or edit event.messages in place.
|
||||
const returned =
|
||||
handlerResult?.messages ??
|
||||
(sameMessages(visibleMessages, visibleSnapshot) ? undefined : visibleMessages);
|
||||
if (!returned) continue;
|
||||
currentMessages = restoreSystemMessages(currentMessages, visibleSnapshot, returned);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
|
|
@ -1178,6 +1218,35 @@ export class ExtensionRunner {
|
|||
}
|
||||
}
|
||||
|
||||
for (const { ext, handlers } of snapshotEventHandlers(this.extensions, "context_with_system")) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const hadLeadingSystemMessage = currentMessages[0]?.role === "system";
|
||||
const event: ContextWithSystemEvent = { type: "context_with_system", messages: currentMessages };
|
||||
const handlerResult = (await handler(event, ctx)) as ContextEventResult | undefined;
|
||||
currentMessages = handlerResult?.messages ?? currentMessages;
|
||||
// Providers read the prompt and initial tools from the leading system message.
|
||||
// Losing it is never intended; report it but honor the handler's output.
|
||||
if (hadLeadingSystemMessage && currentMessages[0]?.role !== "system") {
|
||||
this.emitError({
|
||||
extensionPath: ext.path,
|
||||
event: "context_with_system",
|
||||
error: "Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage().",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
this.emitError({
|
||||
extensionPath: ext.path,
|
||||
event: "context_with_system",
|
||||
error: message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentMessages;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -688,12 +688,28 @@ export type SessionEvent =
|
|||
// Agent Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired before each LLM call. Can modify messages. */
|
||||
/**
|
||||
* Fired before each LLM call. Can modify messages.
|
||||
*
|
||||
* `messages` holds the conversation without system messages. The prompt and tool state
|
||||
* belong to Pi: it restores them after the handler returns, so a handler cannot drop
|
||||
* them and does not need to preserve them.
|
||||
*/
|
||||
export interface ContextEvent {
|
||||
type: "context";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired before each LLM call, after every `context` handler has run and Pi has restored
|
||||
* the prompt and tool state. `messages` is the full transcript including system messages,
|
||||
* and the result is sent as returned: the handler owns the prompt and tool declarations.
|
||||
*/
|
||||
export interface ContextWithSystemEvent {
|
||||
type: "context_with_system";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Fired before a provider request is sent. Can replace the payload. */
|
||||
export interface BeforeProviderRequestEvent {
|
||||
type: "before_provider_request";
|
||||
|
|
@ -1156,6 +1172,7 @@ export type ExtensionEvent =
|
|||
| ResourcesDiscoverEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| ContextWithSystemEvent
|
||||
| CacheWarmingDecisionEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| BeforeProviderHeadersEvent
|
||||
|
|
@ -1362,6 +1379,7 @@ export interface ExtensionAPI {
|
|||
): () => void;
|
||||
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): () => void;
|
||||
on(event: "context", handler: ExtensionHandler<ContextEvent, ContextEventResult>): () => void;
|
||||
on(event: "context_with_system", handler: ExtensionHandler<ContextWithSystemEvent, ContextEventResult>): () => void;
|
||||
on(
|
||||
event: "cache_warming_decision",
|
||||
handler: ExtensionHandler<CacheWarmingDecisionEvent, CacheWarmingDecisionEventResult>,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export {
|
|||
type CompactionEntryDraft,
|
||||
type ContextEditEntryDraft,
|
||||
type ContextEvent,
|
||||
type ContextWithSystemEvent,
|
||||
type CustomEntryDraft,
|
||||
type CustomMessageEntryDraft,
|
||||
defineTool,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|||
"github-copilot": "gpt-5.4",
|
||||
openrouter: "moonshotai/kimi-k2.6",
|
||||
"vercel-ai-gateway": "zai/glm-5.1",
|
||||
xai: "grok-4.6",
|
||||
xai: "grok-4.7",
|
||||
groq: "openai/gpt-oss-120b",
|
||||
cerebras: "gpt-oss-120b",
|
||||
zai: "glm-5.3",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export type {
|
|||
ContextEvent,
|
||||
ContextEventResult,
|
||||
ContextUsage,
|
||||
ContextWithSystemEvent,
|
||||
CustomEntryDraft,
|
||||
CustomMessageEntryDraft,
|
||||
CustomToolCallEvent,
|
||||
|
|
|
|||
|
|
@ -632,6 +632,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"system",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,16 +115,6 @@ describe("parseArgs", () => {
|
|||
expect(result.appendSystemPrompt).toEqual(["Context A", "Context B"]);
|
||||
});
|
||||
|
||||
test("parses --mode", () => {
|
||||
const result = parseArgs(["--mode", "json"]);
|
||||
expect(result.mode).toBe("json");
|
||||
});
|
||||
|
||||
test("parses --mode rpc", () => {
|
||||
const result = parseArgs(["--mode", "rpc"]);
|
||||
expect(result.mode).toBe("rpc");
|
||||
});
|
||||
|
||||
test("parses --session", () => {
|
||||
const result = parseArgs(["--session", "/path/to/session.jsonl"]);
|
||||
expect(result.session).toBe("/path/to/session.jsonl");
|
||||
|
|
@ -157,6 +147,48 @@ describe("parseArgs", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Issue #9045
|
||||
describe("--mode flag", () => {
|
||||
test.each(["text", "json", "rpc"] as const)("parses --mode %s", (mode) => {
|
||||
const result = parseArgs(["--mode", mode]);
|
||||
expect(result.mode).toBe(mode);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test.each(["yaml", ""])("rejects invalid --mode value %j", (mode) => {
|
||||
const result = parseArgs(["--mode", mode, "--version"]);
|
||||
expect(result.mode).toBeUndefined();
|
||||
expect(result.version).toBe(true);
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.unknownFlags.size).toBe(0);
|
||||
expect(result.diagnostics).toEqual([
|
||||
{ type: "error", message: `Invalid mode "${mode}". Valid values: text, json, rpc` },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports a missing --mode value", () => {
|
||||
const result = parseArgs(["--mode"]);
|
||||
expect(result.mode).toBeUndefined();
|
||||
expect(result.unknownFlags.size).toBe(0);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]);
|
||||
});
|
||||
|
||||
test("does not consume another option as a --mode value", () => {
|
||||
const result = parseArgs(["--mode", "--version"]);
|
||||
expect(result.mode).toBeUndefined();
|
||||
expect(result.version).toBe(true);
|
||||
expect(result.unknownFlags.size).toBe(0);
|
||||
expect(result.diagnostics).toEqual([{ type: "error", message: "--mode requires text, json, or rpc" }]);
|
||||
});
|
||||
|
||||
test("reports an invalid --mode value after a valid one", () => {
|
||||
const result = parseArgs(["--mode", "json", "--mode", "yaml"]);
|
||||
expect(result.diagnostics).toEqual([
|
||||
{ type: "error", message: 'Invalid mode "yaml". Valid values: text, json, rpc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--name flag", () => {
|
||||
test("parses --name flag with value", () => {
|
||||
const result = parseArgs(["--name", "my-session"]);
|
||||
|
|
|
|||
|
|
@ -704,7 +704,7 @@ describe("ModelRegistry", () => {
|
|||
expect(sonnet?.name).toBe("Custom Sonnet Name");
|
||||
|
||||
// Other models should be unchanged
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
|
||||
expect(opus?.name).not.toBe("Custom Sonnet Name");
|
||||
});
|
||||
|
||||
|
|
@ -783,7 +783,7 @@ describe("ModelRegistry", () => {
|
|||
expect(sonnet?.samplingParams).toEqual({ top_p: 0.9 });
|
||||
|
||||
// Models without sampling config keep it unset.
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
|
||||
expect(opus?.samplingParams).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -810,7 +810,7 @@ describe("ModelRegistry", () => {
|
|||
expect(registry.getError()).toBeUndefined();
|
||||
expect(openrouter.find((m) => m.id === "custom/cached-model")?.promptCache).toEqual({ short: 120 });
|
||||
expect(openrouter.find((m) => m.id === "anthropic/claude-sonnet-4")?.promptCache).toEqual({ short: 300 });
|
||||
expect(openrouter.find((m) => m.id === "anthropic/claude-opus-4")?.promptCache).toBeUndefined();
|
||||
expect(openrouter.find((m) => m.id === "anthropic/claude-opus-4.1")?.promptCache).toBeUndefined();
|
||||
// Overrides merge per tier with the built-in catalog.
|
||||
expect(registry.find("anthropic", "claude-sonnet-4-6")?.promptCache).toEqual({ short: 300, long: 1800 });
|
||||
});
|
||||
|
|
@ -898,7 +898,7 @@ describe("ModelRegistry", () => {
|
|||
const registry = await createModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnet = models.find((model) => model.id === "anthropic/claude-sonnet-4");
|
||||
const opus = models.find((model) => model.id === "anthropic/claude-opus-4");
|
||||
const opus = models.find((model) => model.id === "anthropic/claude-opus-4.1");
|
||||
|
||||
expect((sonnet?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(false);
|
||||
expect((opus?.compat as OpenAICompletionsCompat | undefined)?.supportsFinishReason).toBe(true);
|
||||
|
|
@ -933,7 +933,7 @@ describe("ModelRegistry", () => {
|
|||
"anthropic/claude-sonnet-4": {
|
||||
compat: { openRouterRouting: { only: ["amazon-bedrock"] } },
|
||||
},
|
||||
"anthropic/claude-opus-4": {
|
||||
"anthropic/claude-opus-4.1": {
|
||||
compat: { openRouterRouting: { only: ["anthropic"] } },
|
||||
},
|
||||
},
|
||||
|
|
@ -944,7 +944,7 @@ describe("ModelRegistry", () => {
|
|||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
|
||||
|
||||
const sonnetCompat = sonnet?.compat as OpenAICompletionsCompat | undefined;
|
||||
const opusCompat = opus?.compat as OpenAICompletionsCompat | undefined;
|
||||
|
|
@ -973,7 +973,7 @@ describe("ModelRegistry", () => {
|
|||
expect(sonnet?.name).toBe("Proxied Sonnet");
|
||||
|
||||
// Other models should have the baseUrl but not the name override
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
|
||||
expect(opus?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
expect(opus?.name).not.toBe("Proxied Sonnet");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -735,7 +735,7 @@ describe("default model selection", () => {
|
|||
});
|
||||
|
||||
test("xai default tracks current model", () => {
|
||||
expect(defaultModelPerProvider.xai).toBe("grok-4.6");
|
||||
expect(defaultModelPerProvider.xai).toBe("grok-4.7");
|
||||
});
|
||||
|
||||
test("qwen token plan individual default tracks current model", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
fauxAssistantMessage,
|
||||
getCurrentSystemPrompt,
|
||||
getCurrentTools,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionFactory } from "../../../src/index.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
/** Compaction supplied by an extension hook, as in the reported sessions. */
|
||||
const compactViaHook: ExtensionFactory = (pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "extension summary",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: { source: "test" },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
function captureRequest(harness: Harness, text: string): () => TranscriptContext {
|
||||
let request: TranscriptContext | undefined;
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
request = context;
|
||||
return fauxAssistantMessage(text);
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
if (!request) throw new Error("expected a provider request");
|
||||
return request;
|
||||
};
|
||||
}
|
||||
|
||||
function toolNames(context: TranscriptContext): string[] {
|
||||
return getCurrentTools(context.messages).map((tool) => tool.name);
|
||||
}
|
||||
|
||||
async function compactSession(harness: Harness): Promise<void> {
|
||||
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
|
||||
await harness.session.prompt("first");
|
||||
await harness.session.prompt("second");
|
||||
await harness.session.compact();
|
||||
expect(harness.session.messages.map((message) => message.role).slice(0, 2)).toEqual(["system", "compactionSummary"]);
|
||||
}
|
||||
|
||||
describe("context handlers and system messages", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
||||
});
|
||||
|
||||
// Regression #9789, #9822: pruning from the compaction summary dropped the prompt and tool checkpoint.
|
||||
it("keeps the prompt and tools when a handler slices from the compaction summary", async () => {
|
||||
const seen: AgentMessage[][] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
compactViaHook,
|
||||
(pi) => {
|
||||
pi.on("context", async (event) => {
|
||||
seen.push(event.messages);
|
||||
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
|
||||
return { messages: event.messages.slice(summary) };
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
await compactSession(harness);
|
||||
const getRequest = captureRequest(harness, "after compaction");
|
||||
|
||||
await harness.session.prompt("third");
|
||||
|
||||
const request = getRequest();
|
||||
expect(seen.at(-1)?.some((message) => message.role === "system")).toBe(false);
|
||||
expect(request.messages[0]?.role).toBe("system");
|
||||
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
|
||||
expect(getCurrentSystemPrompt(request.messages)).toBe(harness.session.systemPrompt);
|
||||
expect(request.messages.filter((message) => message.role === "system")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps mid-conversation system messages in place when a handler leaves the conversation unchanged", async () => {
|
||||
let turn = 0;
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", (event) => {
|
||||
if (++turn === 2) event.systemPromptOptions.sections.plan_mode = "Plan only.";
|
||||
});
|
||||
pi.on("context", async (event) => ({ messages: event.messages }));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("one")]);
|
||||
await harness.session.prompt("first");
|
||||
const getRequest = captureRequest(harness, "two");
|
||||
|
||||
await harness.session.prompt("second");
|
||||
|
||||
const systemMessages = getRequest().messages.filter((message) => message.role === "system");
|
||||
expect(systemMessages).toHaveLength(2);
|
||||
expect(systemMessages[1]?.sections).toEqual({ plan_mode: "<plan_mode>\nPlan only.\n</plan_mode>" });
|
||||
});
|
||||
|
||||
it("applies in-place edits to event.messages without a return value", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("context", async (event) => {
|
||||
event.messages.splice(0, 0, {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "injected" }],
|
||||
timestamp: 0,
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const getRequest = captureRequest(harness, "done");
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
const request = getRequest();
|
||||
expect(request.messages.map((message) => message.role)).toEqual(["system", "user", "user"]);
|
||||
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
|
||||
});
|
||||
|
||||
it("keeps system messages a handler adds after the replayed head", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("context", async (event) => ({
|
||||
messages: [{ role: "system", content: "ephemeral reminder", timestamp: 0 }, ...event.messages],
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const getRequest = captureRequest(harness, "done");
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
const request = getRequest();
|
||||
expect(request.messages.map((message) => message.role)).toEqual(["system", "system", "user"]);
|
||||
expect(toolNames(request)).toEqual(harness.session.getActiveToolNames());
|
||||
expect(getCurrentSystemPrompt(request.messages)).toContain(harness.session.systemPrompt);
|
||||
expect(getCurrentSystemPrompt(request.messages)).toContain("ephemeral reminder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("context_with_system handlers", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
||||
});
|
||||
|
||||
it("runs after context handlers on the restored transcript and sends its output verbatim", async () => {
|
||||
const seen: AgentMessage[][] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
compactViaHook,
|
||||
(pi) => {
|
||||
pi.on("context_with_system", async (event) => {
|
||||
seen.push(event.messages);
|
||||
return {
|
||||
messages: event.messages.map((message) =>
|
||||
message.role === "system" && message.toolsAdded
|
||||
? { ...message, toolsAdded: message.toolsAdded.filter((tool) => tool.name !== "bash") }
|
||||
: message,
|
||||
),
|
||||
};
|
||||
});
|
||||
// Registered after, but runs first: context handlers precede context_with_system.
|
||||
pi.on("context", async (event) => {
|
||||
const summary = event.messages.findIndex((message) => message.role === "compactionSummary");
|
||||
return { messages: event.messages.slice(summary) };
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
await compactSession(harness);
|
||||
const getRequest = captureRequest(harness, "after compaction");
|
||||
|
||||
await harness.session.prompt("third");
|
||||
|
||||
const input = seen.at(-1);
|
||||
expect(input?.[0]?.role).toBe("system");
|
||||
expect(input?.[1]?.role).toBe("compactionSummary");
|
||||
expect(harness.session.getActiveToolNames()).toContain("bash");
|
||||
expect(toolNames(getRequest())).toEqual(harness.session.getActiveToolNames().filter((name) => name !== "bash"));
|
||||
});
|
||||
|
||||
it("reports a handler that drops the leading system message but honors its output", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("context_with_system", async (event) => ({
|
||||
messages: event.messages.filter((message) => message.role !== "system"),
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const errors: string[] = [];
|
||||
harness.session.extensionRunner.onError((error) => {
|
||||
errors.push(`${error.event}: ${error.error}`);
|
||||
});
|
||||
const getRequest = captureRequest(harness, "done");
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
expect(getRequest().messages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect(errors).toEqual([
|
||||
expect.stringMatching(/^context_with_system: Handler removed the leading system message/),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-durable",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Durable conversation, task, and document runtime for Pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -42,8 +42,8 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-ai": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-evals",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -12,8 +12,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"autoevals": "0.3.0",
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.86.1",
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-coding-agent": "^0.87.0",
|
||||
"@types/node": "22.19.19",
|
||||
"@vitest-evals/core": "0.15.0",
|
||||
"shx": "0.4.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-protocol",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Transport-neutral CBOR protocol for remote pi sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"typebox": "1.3.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "experimental server package for pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -47,9 +47,9 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/chord": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1",
|
||||
"@earendil-works/pi-protocol": "^0.86.1"
|
||||
"@earendil-works/chord": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0",
|
||||
"@earendil-works/pi-protocol": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-session-backend-sqlite-node",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Node sqlite session backend for @earendil-works/pi-agent-core sessions",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
@ -35,8 +35,8 @@
|
|||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.86.1",
|
||||
"@earendil-works/pi-agent-core": "^0.86.1"
|
||||
"@earendil-works/pi-ai": "^0.87.0",
|
||||
"@earendil-works/pi-agent-core": "^0.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-telemetry",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Vendor-neutral telemetry contracts and typed schema utilities for pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.87.0] - 2026-09-21
|
||||
|
||||
## [0.86.1] - 2026-09-20
|
||||
|
||||
## [0.86.0] - 2026-09-19
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@earendil-works/pi-tui",
|
||||
"version": "0.86.1",
|
||||
"version": "0.87.0",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -1630,11 +1630,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
const row = clip.y + clip.height - 1;
|
||||
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
|
||||
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
|
||||
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
|
||||
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
|
||||
const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
|
||||
const labelWidth = visibleWidth(label);
|
||||
const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
|
||||
const rightEdge = scrollbarColumn ?? clip.x + clip.width;
|
||||
const availableWidth = Math.max(0, rightEdge - column);
|
||||
const text = truncateToWidth(label, availableWidth, "");
|
||||
const textWidth = visibleWidth(text);
|
||||
if (textWidth === 0) return screen;
|
||||
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
|
||||
const result = [...screen];
|
||||
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
|
||||
this.scrollToEndIndicatorRect = { row, column, width: textWidth };
|
||||
|
|
|
|||
|
|
@ -138,7 +138,51 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => {
|
||||
it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => {
|
||||
// Regression test for #9136: auto scrollbar visibility must not move the indicator.
|
||||
const terminal = new VirtualTerminal(80, 6);
|
||||
const label = " ↓ Jump to latest message · End ";
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
scrollToEndIndicator: () => label,
|
||||
});
|
||||
const transcript = new ScrollView(
|
||||
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
|
||||
{ follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 },
|
||||
);
|
||||
tui.setLayoutRoot(transcript);
|
||||
tui.start();
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Scrolling over the track keeps the scrollbar visible until the pointer leaves.
|
||||
terminal.sendInput("\x1b[<64;80;1M");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(transcript.isScrollbarVisible, true);
|
||||
assert.strictEqual(transcript.isFollowingEnd, false);
|
||||
const scrollTop = transcript.scrollTop;
|
||||
const visibleColumn = terminal.getViewport()[5].indexOf(label);
|
||||
|
||||
// Leaving the track lets the auto-hide timer expire without changing the content.
|
||||
terminal.sendInput("\x1b[<35;79;1M");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(transcript.isScrollbarVisible, false);
|
||||
assert.strictEqual(transcript.scrollTop, scrollTop);
|
||||
const hiddenColumn = terminal.getViewport()[5].indexOf(label);
|
||||
|
||||
terminal.sendInput("\x1b[<35;80;1M");
|
||||
await terminal.waitForRender();
|
||||
assert.strictEqual(transcript.isScrollbarVisible, true);
|
||||
assert.strictEqual(transcript.scrollTop, scrollTop);
|
||||
const revealedColumn = terminal.getViewport()[5].indexOf(label);
|
||||
|
||||
assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => {
|
||||
// Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar.
|
||||
const terminal = new VirtualTerminal(30, 6);
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
scrollToEndIndicator: () => "↓".repeat(30),
|
||||
|
|
@ -161,6 +205,7 @@ describe("TuiAltScreen", () => {
|
|||
assert.strictEqual(transcript.isFollowingEnd, false);
|
||||
|
||||
// The indicator must not intercept a press on the scrollbar's last column.
|
||||
assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`);
|
||||
terminal.sendInput("\x1b[<0;30;4M");
|
||||
terminal.sendInput("\x1b[<0;30;4m");
|
||||
await terminal.waitForRender();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue