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
|
## 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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user