feat(pi): port the managed stack to Pi 0.87.1 and Pi Web 0.9.3
This commit is contained in:
@@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C
|
||||
|
||||
## 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`)
|
||||
- Node 18+
|
||||
- Node 22.19+
|
||||
|
||||
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
|
||||
|
||||
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
@@ -1,10 +1,14 @@
|
||||
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
||||
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 { 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 { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js";
|
||||
import { streamDevin } from "../src/stream.js";
|
||||
@@ -12,36 +16,38 @@ import { streamDevin } from "../src/stream.js";
|
||||
const PROVIDER_ID = "devin";
|
||||
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 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 {
|
||||
pi.registerProvider(PROVIDER_ID, {
|
||||
@@ -55,9 +61,7 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
|
||||
const creds = await loginWithCli();
|
||||
if (_pi) {
|
||||
try {
|
||||
const catalog = await loadCliCatalog();
|
||||
saveCachedCatalog(catalog);
|
||||
registerDevinProvider(_pi, modelsFromCatalog(catalog));
|
||||
await refreshCatalog(_pi);
|
||||
} catch {
|
||||
// keep current models
|
||||
}
|
||||
@@ -90,47 +94,32 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]):
|
||||
|
||||
export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
_pi = pi;
|
||||
|
||||
// Synchronously load cached catalog so subagent processes have the full
|
||||
// model list immediately (they may not live long enough for the async
|
||||
// refresh to complete).
|
||||
const cached = loadCachedCatalog();
|
||||
const initialModels = cached
|
||||
? modelsFromCatalog(cached as Parameters<typeof modelsFromCatalog>[0])
|
||||
: FALLBACK_MODELS;
|
||||
let cached: CachedDevinCatalog | null | undefined;
|
||||
let initialModels = FALLBACK_MODELS;
|
||||
try {
|
||||
cached = readCatalogCache();
|
||||
if (cached) initialModels = modelsFromCatalog(cached.catalog);
|
||||
} catch (error) {
|
||||
cached = null;
|
||||
console.warn(`Devin: failed to read model catalog cache: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
registerDevinProvider(pi, initialModels);
|
||||
|
||||
if (!isOffline()) {
|
||||
try {
|
||||
if (readCredentials()) {
|
||||
// Refresh in background without blocking startup
|
||||
loadCliCatalog().then((catalog) => {
|
||||
if (catalog) {
|
||||
saveCachedCatalog(catalog);
|
||||
if (_pi) registerDevinProvider(_pi, modelsFromCatalog(catalog));
|
||||
}
|
||||
}).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
|
||||
}
|
||||
if (!cached) {
|
||||
await refreshCatalog(pi);
|
||||
} else if (!isCatalogCacheFresh(cached)) {
|
||||
void refreshCatalog(pi).catch((error) => {
|
||||
console.warn(`Devin: background model catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cached or fallback models stay registered when credential discovery or a cold refresh fails.
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerCommand("devin-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`",
|
||||
handler: async (_args, ctx) => {
|
||||
try {
|
||||
const catalog = await loadCliCatalog();
|
||||
saveCachedCatalog(catalog);
|
||||
const models = modelsFromCatalog(catalog);
|
||||
registerDevinProvider(pi, models);
|
||||
const models = await refreshCatalog(pi);
|
||||
ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info");
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
@@ -169,6 +155,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
_pi = null;
|
||||
if (_pi === pi) _pi = null;
|
||||
});
|
||||
}
|
||||
|
||||
+5
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
@@ -28,18 +28,16 @@
|
||||
"AGENTS.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*"
|
||||
"@earendil-works/pi-ai": ">=0.86.0",
|
||||
"@earendil-works/pi-coding-agent": ">=0.86.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
@@ -6,21 +6,50 @@ import { promisify } from "node:util";
|
||||
|
||||
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.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(), ".devin/bin/devin"),
|
||||
"/opt/homebrew/bin/devin",
|
||||
"/usr/local/bin/devin",
|
||||
"/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin",
|
||||
]),
|
||||
].filter((p): p is string => Boolean(p));
|
||||
}
|
||||
|
||||
let cachedBin: string | null | undefined;
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
for (const bin of KNOWN_BINS) {
|
||||
if (existsSync(bin)) {
|
||||
for (const bin of candidateBins()) {
|
||||
if (isFile(bin)) {
|
||||
cachedBin = bin;
|
||||
return bin;
|
||||
}
|
||||
@@ -36,16 +65,25 @@ export function clearDevinBinCache(): void {
|
||||
export async function whichDevin(): Promise<string | null> {
|
||||
const known = findDevinBin();
|
||||
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 {
|
||||
const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 });
|
||||
const path = stdout.trim();
|
||||
if (path && existsSync(path)) {
|
||||
const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 });
|
||||
// `where` may return several lines; take the first existing one.
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const path = line.trim();
|
||||
if (path && isFile(path)) {
|
||||
cachedBin = path;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not on PATH
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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("close", (code) => {
|
||||
resolve({ stdout: "", stderr: "", code: code ?? 1 });
|
||||
@@ -71,9 +118,10 @@ export async function runDevin(
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(bin, args, {
|
||||
const { stdout, stderr } = await execFileAsync(executable, commandArgs, {
|
||||
timeout: opts.timeoutMs ?? 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
windowsVerbatimArguments: wrapper,
|
||||
});
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (error) {
|
||||
|
||||
+10
-29
@@ -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
|
||||
* 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 {
|
||||
if (message.role === "system") {
|
||||
return estimateTextTokens(getSystemMessageText(message)) +
|
||||
estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved);
|
||||
}
|
||||
if (message.role === "user" || message.role === "toolResult") {
|
||||
return estimateTextAndImageContentTokens(message.content);
|
||||
}
|
||||
@@ -98,39 +102,16 @@ function estimateMessages(messages: Message[]): ContextEstimate {
|
||||
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;
|
||||
return estimateTextTokens(safeJsonStringify(tools));
|
||||
}
|
||||
|
||||
export function estimateContextTokens(context: Context): ContextEstimate {
|
||||
const estimate = 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 estimateContextTokens(context: TranscriptContext): ContextEstimate {
|
||||
return estimateMessages(context.messages);
|
||||
}
|
||||
|
||||
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);
|
||||
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
|
||||
|
||||
+9
-7
@@ -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 {
|
||||
type: "text" | "image";
|
||||
@@ -45,10 +48,11 @@ function contentParts(content: Message["content"] | string): ContentPart[] {
|
||||
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[] = [];
|
||||
|
||||
for (const message of context.messages) {
|
||||
for (const message of withoutInitialSystemMessage(transcript.messages)) {
|
||||
if (message.role === "user") {
|
||||
const content = contentParts(message.content);
|
||||
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,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}));
|
||||
|
||||
const systemPrompt = Array.isArray(context.systemPrompt)
|
||||
? context.systemPrompt.join("\n\n")
|
||||
: context.systemPrompt;
|
||||
const systemPrompt = getCurrentSystemPrompt(transcript.messages) || undefined;
|
||||
|
||||
return { systemPrompt, messages, tools };
|
||||
}
|
||||
|
||||
+4
-7
@@ -4,7 +4,7 @@ import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Context,
|
||||
type TranscriptContext,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
calculateCost,
|
||||
@@ -400,11 +400,8 @@ async function* streamChatEvents(args: {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
// cancel() on an already-errored stream rejects with the stored
|
||||
// error (e.g. undici TypeError("terminated") after the server drops
|
||||
// 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(() => {});
|
||||
// Await teardown so a rejection from an errored stream is observed.
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -432,7 +429,7 @@ async function* streamChatEvents(args: {
|
||||
|
||||
export function streamDevin(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
context: TranscriptContext,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
|
||||
Reference in New Issue
Block a user