feat(pi): port the managed stack to Pi 0.87.1 and Pi Web 0.9.3

This commit is contained in:
Shujakuinkuraudo
2026-09-28 17:27:05 -04:00
committed by shujakuin
parent 624065c4f1
commit 9e64132d91
8 changed files with 268 additions and 148 deletions
+6 -2
View File
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
## Requirements ## Requirements
- Pi Coding Agent 0.80+ - Pi Coding Agent 0.86+ (managed fork validated against 0.87.1)
- A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`) - A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`)
- Node 18+ - Node 22.19+
The CLI binary is resolved in this order: The CLI binary is resolved in this order:
@@ -81,3 +81,7 @@ This is a standard Pi package (`keywords: ["pi-package"]` + `pi.extensions`). Af
## License ## License
MIT. Unofficial. Not affiliated with Cognition. MIT. Unofficial. Not affiliated with Cognition.
## Managed fork
Based on npm pi-devin 0.2.1. Retains native signature/response replay, corrected wire fields, bounded frames, context clamping, multi-window catalog variants and abort cleanup. Uses the upstream six-hour XDG catalog cache. Tests and typecheck are maintained in the parent app tests directory.
+62 -76
View File
@@ -1,10 +1,14 @@
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai"; import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js"; import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js";
import { whichDevin, devinVersion } from "../src/cli.js"; import { whichDevin, devinVersion } from "../src/cli.js";
import {
type CachedDevinCatalog,
isUsableCatalog,
isCatalogCacheFresh,
readCatalogCache,
writeCatalogCache,
} from "../src/catalog-cache.js";
import { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js"; import { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js";
import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js"; import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js";
import { streamDevin } from "../src/stream.js"; import { streamDevin } from "../src/stream.js";
@@ -12,36 +16,38 @@ import { streamDevin } from "../src/stream.js";
const PROVIDER_ID = "devin"; const PROVIDER_ID = "devin";
const PLACEHOLDER_BASE_URL = "https://server.codeium.com"; const PLACEHOLDER_BASE_URL = "https://server.codeium.com";
const CACHE_PATH = join(homedir(), ".pi", "agent", "devin-catalog-cache.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
interface CachedCatalog {
catalog: unknown;
timestamp: number;
}
function loadCachedCatalog(): unknown | null {
try {
if (!existsSync(CACHE_PATH)) return null;
const raw = readFileSync(CACHE_PATH, "utf-8");
const cached = JSON.parse(raw) as CachedCatalog;
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null;
return cached.catalog;
} catch {
return null;
}
}
function saveCachedCatalog(catalog: unknown): void {
try {
mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
writeFileSync(CACHE_PATH, JSON.stringify({ catalog, timestamp: Date.now() }));
} catch {
// ignore cache write failures
}
}
let _pi: ExtensionAPI | null = null; let _pi: ExtensionAPI | null = null;
let catalogRequest: Promise<ProviderModelConfig[]> | null = null;
function isOffline(): boolean {
const value = process.env.PI_OFFLINE?.toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
function refreshCatalog(pi: ExtensionAPI): Promise<ProviderModelConfig[]> {
if (!catalogRequest) {
const pending = loadCliCatalog().then((catalog) => {
if (!isUsableCatalog(catalog)) {
throw new Error("Devin CLI returned no usable model families");
}
const models = modelsFromCatalog(catalog);
try {
writeCatalogCache(catalog);
} catch (error) {
console.warn(`Devin: failed to cache model catalog: ${error instanceof Error ? error.message : String(error)}`);
}
return models;
});
catalogRequest = pending;
void pending.finally(() => {
if (catalogRequest === pending) catalogRequest = null;
}).catch(() => {});
}
return catalogRequest.then((models) => {
if (_pi === pi) registerDevinProvider(pi, models);
return models;
});
}
function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void { function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
pi.registerProvider(PROVIDER_ID, { pi.registerProvider(PROVIDER_ID, {
@@ -55,9 +61,7 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
const creds = await loginWithCli(); const creds = await loginWithCli();
if (_pi) { if (_pi) {
try { try {
const catalog = await loadCliCatalog(); await refreshCatalog(_pi);
saveCachedCatalog(catalog);
registerDevinProvider(_pi, modelsFromCatalog(catalog));
} catch { } catch {
// keep current models // keep current models
} }
@@ -90,47 +94,32 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
export default async function (pi: ExtensionAPI): Promise<void> { export default async function (pi: ExtensionAPI): Promise<void> {
_pi = pi; _pi = pi;
let cached: CachedDevinCatalog | null | undefined;
// Synchronously load cached catalog so subagent processes have the full let initialModels = FALLBACK_MODELS;
// model list immediately (they may not live long enough for the async try {
// refresh to complete). cached = readCatalogCache();
const cached = loadCachedCatalog(); if (cached) initialModels = modelsFromCatalog(cached.catalog);
const initialModels = cached } catch (error) {
? modelsFromCatalog(cached as Parameters<typeof modelsFromCatalog>[0]) cached = null;
: FALLBACK_MODELS; console.warn(`Devin: failed to read model catalog cache: ${error instanceof Error ? error.message : String(error)}`);
}
registerDevinProvider(pi, initialModels); registerDevinProvider(pi, initialModels);
if (!isOffline()) {
try { try {
if (readCredentials()) { if (readCredentials()) {
// Refresh in background without blocking startup if (!cached) {
loadCliCatalog().then((catalog) => { await refreshCatalog(pi);
if (catalog) { } else if (!isCatalogCacheFresh(cached)) {
saveCachedCatalog(catalog); void refreshCatalog(pi).catch((error) => {
if (_pi) registerDevinProvider(_pi, modelsFromCatalog(catalog)); console.warn(`Devin: background model catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
}
}).catch(() => {});
}
} catch {
// fallback models already registered
}
pi.on("session_start", async () => {
try {
if (!_pi || !readCredentials()) return;
const cached = loadCachedCatalog();
if (cached) {
registerDevinProvider(_pi, modelsFromCatalog(cached as Parameters<typeof modelsFromCatalog>[0]));
}
loadCliCatalog().then((catalog) => {
if (catalog) {
saveCachedCatalog(catalog);
if (_pi) registerDevinProvider(_pi, modelsFromCatalog(catalog));
}
}).catch(() => {});
} catch {
// keep current models
}
}); });
}
}
} catch {
// Cached or fallback models stay registered when credential discovery or a cold refresh fails.
}
}
pi.registerCommand("devin-status", { pi.registerCommand("devin-status", {
description: "Show Devin CLI auth + binary status", description: "Show Devin CLI auth + binary status",
@@ -154,10 +143,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
description: "Refresh Devin Local model catalog from `devin models list`", description: "Refresh Devin Local model catalog from `devin models list`",
handler: async (_args, ctx) => { handler: async (_args, ctx) => {
try { try {
const catalog = await loadCliCatalog(); const models = await refreshCatalog(pi);
saveCachedCatalog(catalog);
const models = modelsFromCatalog(catalog);
registerDevinProvider(pi, models);
ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info"); ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info");
} catch (error) { } catch (error) {
ctx.ui.notify( ctx.ui.notify(
@@ -169,6 +155,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
}); });
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
_pi = null; if (_pi === pi) _pi = null;
}); });
} }
+5 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "pi-devin", "name": "pi-devin",
"version": "0.1.2", "version": "0.2.1",
"description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.", "description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.",
"type": "module", "type": "module",
"keywords": [ "keywords": [
@@ -28,18 +28,16 @@
"AGENTS.md", "AGENTS.md",
"LICENSE" "LICENSE"
], ],
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": ">=0.86.0",
"@earendil-works/pi-coding-agent": "*" "@earendil-works/pi-coding-agent": ">=0.86.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"tsx": "^4.23.15",
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=22.19.0"
} }
} }
+104
View File
@@ -0,0 +1,104 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { DevinCatalog } from "./models.js";
const CACHE_VERSION = 1;
export const CATALOG_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
export interface CachedDevinCatalog {
catalog: DevinCatalog;
fetchedAt: number;
}
interface CatalogCacheFile extends CachedDevinCatalog {
version: typeof CACHE_VERSION;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
}
function optionalTokenLimit(value: unknown): boolean {
return value === undefined || (typeof value === "number" && Number.isSafeInteger(value) && value > 0);
}
export function isUsableCatalog(value: unknown): value is DevinCatalog {
if (!isRecord(value) || !Array.isArray(value.families) || value.families.length === 0) return false;
return value.families.some((family) => isRecord(family) && Array.isArray(family.variants) && family.variants.length > 0)
&& value.families.every((family) =>
isRecord(family)
&& typeof family.family_label === "string"
&& typeof family.family_uid === "string"
&& typeof family.slug === "string"
&& (family.aliases === undefined || (Array.isArray(family.aliases) && family.aliases.every((alias) => typeof alias === "string")))
&& Array.isArray(family.variants)
&& family.variants.every((variant) =>
isRecord(variant)
&& typeof variant.model_uid === "string"
&& variant.model_uid.trim().length > 0
&& typeof variant.label === "string"
&& optionalString(variant.cost_summary)
&& optionalString(variant.cost_tier)
&& optionalTokenLimit(variant.max_context_tokens)
&& optionalTokenLimit(variant.max_output_tokens)
&& (variant.is_new === undefined || typeof variant.is_new === "boolean")
&& (variant.is_beta === undefined || typeof variant.is_beta === "boolean")
)
);
}
export function catalogCachePath(
env: NodeJS.ProcessEnv = process.env,
home: string = homedir(),
): string {
const cacheRoot = env.XDG_CACHE_HOME || join(home, ".cache");
return join(cacheRoot, "pi-devin", "models.json");
}
export function readCatalogCache(path: string = catalogCachePath()): CachedDevinCatalog | null {
if (!existsSync(path)) return null;
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (
!isRecord(parsed)
|| parsed.version !== CACHE_VERSION
|| typeof parsed.fetchedAt !== "number"
|| !Number.isFinite(parsed.fetchedAt)
|| !isUsableCatalog(parsed.catalog)
) {
throw new Error(`Invalid Devin model catalog cache: ${path}`);
}
return { catalog: parsed.catalog, fetchedAt: parsed.fetchedAt };
}
export function writeCatalogCache(
catalog: DevinCatalog,
path: string = catalogCachePath(),
fetchedAt: number = Date.now(),
): void {
if (!isUsableCatalog(catalog)) throw new Error("Refusing to cache an invalid Devin model catalog");
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const temporaryPath = `${path}.${randomUUID()}.tmp`;
const body: CatalogCacheFile = { version: CACHE_VERSION, fetchedAt, catalog };
try {
writeFileSync(temporaryPath, `${JSON.stringify(body)}\n`, { mode: 0o600 });
renameSync(temporaryPath, path);
} finally {
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
}
}
export function isCatalogCacheFresh(
cached: CachedDevinCatalog,
now: number = Date.now(),
maxAgeMs: number = CATALOG_CACHE_MAX_AGE_MS,
): boolean {
const age = now - cached.fetchedAt;
return age >= 0 && age <= maxAgeMs;
}
+57 -9
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs"; import { statSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
@@ -6,21 +6,50 @@ import { promisify } from "node:util";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const KNOWN_BINS = [ function candidateBins(): string[] {
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local");
return [
// Explicit override always wins. Read lazily so a value set after module
// load (e.g. by the host process) is still honoured.
process.env.DEVIN_CLI, process.env.DEVIN_CLI,
...(process.platform === "win32" ? [
// Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe)
join(localAppData, "devin/cli/bin/devin.exe"),
// Devin Desktop (Windsurf-based) bundled CLI, per-user install
join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
// Devin Desktop bundled CLI, machine-wide install
process.env.ProgramFiles &&
join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"),
// Set by the official Windows installer when it completes normally.
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"),
// Manual fallback for installs that could not finish while Devin was running.
join(homedir(), ".devin-cli/devin.exe"),
] : [
join(homedir(), ".local/bin/devin"), join(homedir(), ".local/bin/devin"),
join(homedir(), ".devin/bin/devin"), join(homedir(), ".devin/bin/devin"),
"/opt/homebrew/bin/devin", "/opt/homebrew/bin/devin",
"/usr/local/bin/devin", "/usr/local/bin/devin",
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
]),
].filter((p): p is string => Boolean(p)); ].filter((p): p is string => Boolean(p));
}
let cachedBin: string | null | undefined; let cachedBin: string | null | undefined;
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
export function findDevinBin(): string | null { export function findDevinBin(): string | null {
// An explicit override must take effect even after a successful discovery.
if (process.env.DEVIN_CLI && isFile(process.env.DEVIN_CLI)) return process.env.DEVIN_CLI;
if (cachedBin !== undefined) return cachedBin; if (cachedBin !== undefined) return cachedBin;
for (const bin of KNOWN_BINS) { for (const bin of candidateBins()) {
if (existsSync(bin)) { if (isFile(bin)) {
cachedBin = bin; cachedBin = bin;
return bin; return bin;
} }
@@ -36,16 +65,25 @@ export function clearDevinBinCache(): void {
export async function whichDevin(): Promise<string | null> { export async function whichDevin(): Promise<string | null> {
const known = findDevinBin(); const known = findDevinBin();
if (known) return known; if (known) return known;
// Windows has no `which`; use `where` and probe the returned paths directly.
const isWindows = process.platform === "win32";
const locator = isWindows ? "where.exe" : "/usr/bin/which";
const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"];
for (const candidate of candidates) {
try { try {
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
const path = stdout.trim(); // `where` may return several lines; take the first existing one.
if (path && existsSync(path)) { for (const line of stdout.split(/\r?\n/)) {
const path = line.trim();
if (path && isFile(path)) {
cachedBin = path; cachedBin = path;
return path; return path;
} }
}
} catch { } catch {
// not on PATH // not on PATH
} }
}
return null; return null;
} }
@@ -60,9 +98,18 @@ export async function runDevin(
); );
} }
// Batch wrappers need cmd.exe. Quote paths with spaces, and reject shell
// expansion characters rather than interpreting them as part of a command.
const wrapper = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
if (wrapper && [bin, ...args].some((part) => /["%!\r\n]/.test(part))) {
throw new Error("Unsupported shell characters in Devin wrapper path or arguments. Set DEVIN_CLI to devin.exe.");
}
const executable = wrapper ? process.env.ComSpec || "cmd.exe" : bin;
const commandArgs = wrapper ? ["/d", "/s", "/c", `"${[bin, ...args].map((part) => `"${part}"`).join(" ")}"`] : args;
if (opts.inheritStdio) { if (opts.inheritStdio) {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: "inherit" }); const child = spawn(executable, commandArgs, { stdio: "inherit", windowsVerbatimArguments: wrapper });
child.on("error", reject); child.on("error", reject);
child.on("close", (code) => { child.on("close", (code) => {
resolve({ stdout: "", stderr: "", code: code ?? 1 }); resolve({ stdout: "", stderr: "", code: code ?? 1 });
@@ -71,9 +118,10 @@ export async function runDevin(
} }
try { try {
const { stdout, stderr } = await execFileAsync(bin, args, { const { stdout, stderr } = await execFileAsync(executable, commandArgs, {
timeout: opts.timeoutMs ?? 30_000, timeout: opts.timeoutMs ?? 30_000,
maxBuffer: 8 * 1024 * 1024, maxBuffer: 8 * 1024 * 1024,
windowsVerbatimArguments: wrapper,
}); });
return { stdout, stderr, code: 0 }; return { stdout, stderr, code: 0 };
} catch (error) { } catch (error) {
+10 -29
View File
@@ -1,7 +1,7 @@
import type { Api, Context, Message, Model, Usage } from "@earendil-works/pi-ai"; import { getSystemMessageText, type Api, type TranscriptContext, type Message, type Model, type Usage } from "@earendil-works/pi-ai";
/** /**
* Local port of pi-ai's `utils/estimate.js` + `clampMaxTokensToContext`. * Local port of pi-ai 0.87.1's `utils/estimate.js` + `clampMaxTokensToContext`.
* Extensions can only resolve the pi-ai compat entry, so the upstream helpers * Extensions can only resolve the pi-ai compat entry, so the upstream helpers
* are reproduced here to keep the maxTokens context clamp identical. * are reproduced here to keep the maxTokens context clamp identical.
*/ */
@@ -38,6 +38,10 @@ function estimateTextAndImageContentTokens(content: Message["content"]): number
} }
function estimateMessageTokens(message: Message): number { function estimateMessageTokens(message: Message): number {
if (message.role === "system") {
return estimateTextTokens(getSystemMessageText(message)) +
estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved);
}
if (message.role === "user" || message.role === "toolResult") { if (message.role === "user" || message.role === "toolResult") {
return estimateTextAndImageContentTokens(message.content); return estimateTextAndImageContentTokens(message.content);
} }
@@ -98,39 +102,16 @@ function estimateMessages(messages: Message[]): ContextEstimate {
return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null }; return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
} }
function estimateToolsTokens(tools: Context["tools"]): number { function estimateToolsTokens(tools: readonly unknown[] | undefined): number {
if (!tools || tools.length === 0) return 0; if (!tools || tools.length === 0) return 0;
return estimateTextTokens(safeJsonStringify(tools)); return estimateTextTokens(safeJsonStringify(tools));
} }
export function estimateContextTokens(context: Context): ContextEstimate { export function estimateContextTokens(context: TranscriptContext): ContextEstimate {
const estimate = estimateMessages(context.messages); return estimateMessages(context.messages);
if (estimate.lastUsageIndex !== null) {
const addedNames = new Set(
context.messages
.slice(estimate.lastUsageIndex + 1)
.filter((message) => message.role === "toolResult")
.flatMap((message) => (message.role === "toolResult" ? (message.addedToolNames ?? []) : [])),
);
const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name)));
return {
tokens: estimate.tokens + addedToolTokens,
usageTokens: estimate.usageTokens,
trailingTokens: estimate.trailingTokens + addedToolTokens,
lastUsageIndex: estimate.lastUsageIndex,
};
}
const prefixTokens =
(context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools);
return {
tokens: estimate.tokens + prefixTokens,
usageTokens: estimate.usageTokens,
trailingTokens: estimate.trailingTokens + prefixTokens,
lastUsageIndex: estimate.lastUsageIndex,
};
} }
export function clampMaxTokensToContext(model: Model<Api>, context: Context, maxTokens: number): number { export function clampMaxTokensToContext(model: Model<Api>, context: TranscriptContext, maxTokens: number): number {
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens); if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS; const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available)); return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
+9 -7
View File
@@ -1,4 +1,7 @@
import type { Api, Context, Message, Model, Tool } from "@earendil-works/pi-ai"; import {
collapseSystemMessages, getCurrentSystemPrompt, getCurrentTools, withoutInitialSystemMessage,
type Api, type TranscriptContext, type Message, type Model, type Tool,
} from "@earendil-works/pi-ai";
export interface ContentPart { export interface ContentPart {
type: "text" | "image"; type: "text" | "image";
@@ -45,10 +48,11 @@ function contentParts(content: Message["content"] | string): ContentPart[] {
return parts; return parts;
} }
export function mapContextToChat(context: Context, model: Model<Api>): MappedChat { export function mapContextToChat(context: TranscriptContext, model: Model<Api>): MappedChat {
const transcript = collapseSystemMessages(context);
const messages: ChatHistoryItem[] = []; const messages: ChatHistoryItem[] = [];
for (const message of context.messages) { for (const message of withoutInitialSystemMessage(transcript.messages)) {
if (message.role === "user") { if (message.role === "user") {
const content = contentParts(message.content); const content = contentParts(message.content);
if (content.length === 0) continue; if (content.length === 0) continue;
@@ -106,15 +110,13 @@ export function mapContextToChat(context: Context, model: Model<Api>): MappedCha
} }
} }
const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({ const tools: ToolDef[] = getCurrentTools(transcript.messages).map((tool: Tool) => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: tool.parameters, parameters: tool.parameters,
})); }));
const systemPrompt = Array.isArray(context.systemPrompt) const systemPrompt = getCurrentSystemPrompt(transcript.messages) || undefined;
? context.systemPrompt.join("\n\n")
: context.systemPrompt;
return { systemPrompt, messages, tools }; return { systemPrompt, messages, tools };
} }
+4 -7
View File
@@ -4,7 +4,7 @@ import {
type Api, type Api,
type AssistantMessage, type AssistantMessage,
type AssistantMessageEventStream, type AssistantMessageEventStream,
type Context, type TranscriptContext,
type Model, type Model,
type SimpleStreamOptions, type SimpleStreamOptions,
calculateCost, calculateCost,
@@ -400,11 +400,8 @@ async function* streamChatEvents(args: {
// ignore // ignore
} }
try { try {
// cancel() on an already-errored stream rejects with the stored // Await teardown so a rejection from an errored stream is observed.
// error (e.g. undici TypeError("terminated") after the server drops await resp.body?.cancel();
// the socket). try/catch cannot observe that rejection, so swallow
// it explicitly or it escapes as an uncaughtException and kills pi.
void resp.body?.cancel()?.catch(() => {});
} catch { } catch {
// ignore // ignore
} }
@@ -432,7 +429,7 @@ async function* streamChatEvents(args: {
export function streamDevin( export function streamDevin(
model: Model<Api>, model: Model<Api>,
context: Context, context: TranscriptContext,
options?: SimpleStreamOptions, options?: SimpleStreamOptions,
): AssistantMessageEventStream { ): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream(); const stream = createAssistantMessageEventStream();