Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/site.yml'
- "site/**"
- ".github/workflows/site.yml"
workflow_dispatch:

permissions:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> [!TIP]
> **📖 See the docs & live demos → [galaxyproject.github.io/loom](https://galaxyproject.github.io/loom/)**
> — *Agentic Science with Galaxy*: an overview of Orbit, Loom, and Galaxy MCP, an animated walkthrough of real analyses, and getting-started guides.
> — _Agentic Science with Galaxy_: an overview of Orbit, Loom, and Galaxy MCP, an animated walkthrough of real analyses, and getting-started guides.

An AI research harness for [Galaxy](https://galaxyproject.org) bioinformatics, built on [Pi.dev](https://pi.dev).

Expand Down
1,390 changes: 628 additions & 762 deletions app/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@earendil-works/pi-ai": "^0.78.0",
"@earendil-works/pi-ai": "^0.83.0",
"@types/dompurify": "^3.0.5",
"@xyflow/react": "^12.10.2",
"dompurify": "^3.4.1",
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/galaxy-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
import { validateGalaxyUrl } from "./galaxy-url.js";

export type GalaxyUserStatus =
| { ok: true; username?: string; email?: string }
| { ok: false; authFailed: boolean };
{ ok: true; username?: string; email?: string } | { ok: false; authFailed: boolean };

/** A non-empty string, or undefined -- Galaxy returns "" for unset fields. */
function nonEmptyString(v: unknown): string | undefined {
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
} from "./galaxy-status.js";
import { fetchGalaxyCurrentUser, type GalaxyUserStatus } from "./galaxy-user.js";
import { normalizeGalaxyUrl, validateGalaxyUrl } from "./galaxy-url.js";
import { getProviders, getModels } from "@earendil-works/pi-ai";
// pi 0.80 moved pi-ai's global API off the package root to /compat. pi's
// extension loader aliases the root back to compat, but this is Orbit's main
// process -- not an extension -- so it gets no alias and must import /compat
// directly or these are undefined at runtime.
import { getProviders, getModels } from "@earendil-works/pi-ai/compat";
import { isDeprecatedModelId } from "./model-catalog.js";
import { checkLatestVersion } from "./version-check.js";
import { resolveReleasePageUrl } from "./release-page.js";
Expand Down
36 changes: 25 additions & 11 deletions app/src/main/oauth-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { shell } from "electron";
import { loginOpenAICodex } from "@earendil-works/pi-ai/oauth";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";

/**
* OAuth provider integration for the brain's auth.json. The brain (pi-coding-agent)
Expand Down Expand Up @@ -58,8 +58,7 @@ export interface OAuthStatus {
export function getOAuthStatus(provider: string): OAuthStatus {
const data = readAuthFile();
const cred = data[provider] as
| { type?: string; expires?: number; accountId?: string }
| undefined;
{ type?: string; expires?: number; accountId?: string } | undefined;
if (!cred || cred.type !== "oauth") return { signedIn: false };
const expiresInSeconds =
typeof cred.expires === "number" ? Math.floor((cred.expires - Date.now()) / 1000) : undefined;
Expand All @@ -75,27 +74,42 @@ export function signOutOAuth(provider: string): void {

/**
* Drive the OpenAI Codex OAuth flow. Opens the auth URL in the user's default
* browser; pi-ai's loginOpenAICodex spins up a local callback server on
* 127.0.0.1:1455 and returns once the browser hands back the code.
* browser; the flow spins up a local callback server on 127.0.0.1:1455 and
* returns once the browser hands back the code.
*
* pi 0.81 folded the per-service `loginOpenAICodex()` helper into the provider
* itself: auth now hangs off `provider.auth.oauth` as a login/refresh/toAuth
* triple, and driving login is the app's job. So we ask a ModelRuntime for the
* provider rather than importing a service-specific function.
*
* Throws if the flow fails (port conflict, user cancellation, network error).
*/
export async function signInOpenAICodex(): Promise<OAuthStatus> {
const creds = await loginOpenAICodex({
onAuth: ({ url }) => {
void shell.openExternal(url);
const runtime = await ModelRuntime.create({ authPath: getAuthPath() });
const oauth = runtime.getProvider("openai-codex")?.auth?.oauth;
if (!oauth) {
throw new Error("This build of pi does not expose an OpenAI Codex OAuth provider.");
}

const creds = await oauth.login({
notify: (event) => {
if (event.type === "auth_url") {
void shell.openExternal(event.url);
return;
}
if (event.type === "progress" || event.type === "info") {
console.log("[oauth]", event.message);
}
},
// Fallback paste path -- only triggered if the local callback server fails
// to start (port already in use). Orbit doesn't surface a paste UI today,
// so we reject with a guidance message instead of hanging.
onPrompt: async () => {
prompt: async () => {
throw new Error(
"OAuth callback server could not bind to 127.0.0.1:1455. " +
"Free the port (e.g. quit Codex CLI) and try again.",
);
},
onProgress: (msg) => console.log("[oauth]", msg),
originator: "loom",
});

const data = readAuthFile();
Expand Down
37 changes: 23 additions & 14 deletions app/src/renderer/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ let streaming = false;
let PRICING: Record<string, { in: number; out: number; cacheRead?: number; cacheWrite?: number }> =
{
// Anthropic
"claude-fable-5": { in: 10, out: 50, cacheRead: 1, cacheWrite: 12.5 },
"claude-opus-5": { in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
// Sonnet 5 is at introductory pricing; the registry is the source of truth
// if that lapses.
"claude-sonnet-5": { in: 2, out: 10, cacheRead: 0.2, cacheWrite: 2.5 },
"claude-opus-4-8": { in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-7": { in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-6": { in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25 },
Expand Down Expand Up @@ -167,6 +172,9 @@ let PRICING: Record<string, { in: number; out: number; cacheRead?: number; cache
// under-report on those paths. Documented; not special-cased.
let CONTEXT_WINDOWS: Record<string, Record<string, number>> = {
anthropic: {
"claude-fable-5": 1_000_000,
"claude-opus-5": 1_000_000,
"claude-sonnet-5": 1_000_000,
"claude-opus-4-8": 1_000_000,
"claude-opus-4-7": 1_000_000,
"claude-opus-4-6": 1_000_000,
Expand Down Expand Up @@ -404,7 +412,7 @@ function shortModelLabel(model: string): string {
// Strip date suffix (claude-opus-4-6-20250514 → claude-opus-4-6)
const id = model.replace(/-\d{8}$/, "");
// Anthropic
const cm = id.match(/^claude-(opus|sonnet|haiku)-(\d+(?:-\d+)?)/);
const cm = id.match(/^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)?)/);
if (cm) {
const family = cm[1].charAt(0).toUpperCase() + cm[1].slice(1);
const ver = cm[2].replace(/-/g, ".");
Expand Down Expand Up @@ -1576,7 +1584,7 @@ function flushNextQueuedMessage(): void {
* Handle slash commands. Returns true if handled (no need to send to agent).
*
* Supported:
* /model <name> — switch LLM model (e.g. /model sonnet, /model claude-opus-4-6)
* /model <name> — switch LLM model (e.g. /model sonnet, /model claude-opus-5)
* /help — list slash commands
*/
function formatArgsPreview(args: Record<string, unknown> | undefined): string | undefined {
Expand Down Expand Up @@ -1667,7 +1675,7 @@ function handleSlashCommand(text: string): boolean {
chat.addUserMessage(text);
chat.addErrorMessage(
"Usage: /model <name>. Examples: /model sonnet, /model haiku, /model opus, " +
"or /model claude-sonnet-4-6 for an exact id.",
"or /model claude-opus-5 for an exact id.",
);
return true;
}
Expand Down Expand Up @@ -2975,28 +2983,29 @@ interface ModelChoice {
}
let MODELS_BY_PROVIDER: Record<string, ModelChoice[]> = {
anthropic: [
{ id: "claude-opus-4-8", label: "Opus 4.8 — $5/$25 (most capable)" },
{ id: "claude-sonnet-4-6", label: "Sonnet 4.6 — $3/$15 (recommended)" },
{ id: "claude-opus-5", label: "Opus 5 — $5/$25 (recommended)" },
{ id: "claude-sonnet-5", label: "Sonnet 5 — $2/$10" },
{ id: "claude-haiku-4-5", label: "Haiku 4.5 — $1/$5 (cheapest)" },
{ id: "claude-fable-5", label: "Fable 5 — $10/$50 (most capable)" },
{ id: "claude-opus-4-8", label: "Opus 4.8 — $5/$25" },
{ id: "claude-sonnet-4-6", label: "Sonnet 4.6 — $3/$15" },
{ id: "claude-opus-4-7", label: "Opus 4.7 — $5/$25" },
{ id: "claude-opus-4-6", label: "Opus 4.6 — $5/$25" },
{ id: "claude-sonnet-4-5", label: "Sonnet 4.5 — $3/$15" },
{ id: "claude-opus-4-5", label: "Opus 4.5 — $5/$25" },
],
openai: [
{ id: "gpt-4o-mini", label: "GPT-4o mini — $0.15/$0.60 (cheapest)" },
{ id: "gpt-4o", label: "GPT-4o — $2.50/$10" },
{ id: "gpt-4-turbo", label: "GPT-4 Turbo — $10/$30" },
{ id: "o1-mini", label: "o1-mini — $3/$12" },
{ id: "o1", label: "o1 — $15/$60" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 mini — $0.75/$4.50 (cheapest)" },
{ id: "gpt-5.4", label: "GPT-5.4 — $2.50/$15 (recommended)" },
{ id: "gpt-5.2", label: "GPT-5.2 — $1.75/$14" },
{ id: "gpt-5.5", label: "GPT-5.5 — $5/$30" },
],
"openai-codex": [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 mini" },
],
google: [
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash — $0.15/$0.60 (cheapest)" },
{ id: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite — $0.25/$1.50 (cheapest)" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro — $2/$12 (recommended)" },
{ id: "gemini-3.5-flash", label: "Gemini 3.5 Flash — $1.50/$9" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro — $1.25/$10" },
],
mistral: [
Expand Down
14 changes: 7 additions & 7 deletions bin/loom.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ const piPackageDir = dirname(dirname(piEntryPointPath));
const piArgsModulePath = join(piPackageDir, "dist/cli/args.js");
const piListModelsModulePath = join(piPackageDir, "dist/cli/list-models.js");
const piConfigModulePath = join(piPackageDir, "dist/config.js");
const piAuthStorageModulePath = join(piPackageDir, "dist/core/auth-storage.js");
const piModelRegistryModulePath = join(piPackageDir, "dist/core/model-registry.js");
const piModelRuntimeModulePath = join(piPackageDir, "dist/core/model-runtime.js");
const userArgs = process.argv.slice(2);

// Local-execution safety flags. Translate to env so the exec-guard (brain side)
Expand Down Expand Up @@ -134,11 +133,12 @@ async function handleInformationalCommand() {
if (hasArg("--list-models")) {
const { listModels } = await import(pathToFileURL(piListModelsModulePath).href);
const { getModelsPath } = await import(pathToFileURL(piConfigModulePath).href);
const { AuthStorage } = await import(pathToFileURL(piAuthStorageModulePath).href);
const { ModelRegistry } = await import(pathToFileURL(piModelRegistryModulePath).href);
const authStorage = AuthStorage.inMemory();
const modelRegistry = new ModelRegistry(authStorage, getModelsPath());
await listModels(modelRegistry, getListModelsSearchPattern());
// pi 0.83 reshaped this: listModels now takes a ModelRuntime (built via an
// async factory that reads credentials from authPath itself) rather than a
// ModelRegistry wrapped around an AuthStorage.
const { ModelRuntime } = await import(pathToFileURL(piModelRuntimeModulePath).href);
const modelRuntime = await ModelRuntime.create({ modelsPath: getModelsPath() });
await listModels(modelRuntime, getListModelsSearchPattern());
return true;
}

Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/galaxy-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,7 @@ export function registerGalaxyUploadTool(pi: ExtensionAPI): void {
},
renderResult: (result) => {
const d = result.details as
| { error?: boolean; datasetId?: string; state?: string }
| undefined;
{ error?: boolean; datasetId?: string; state?: string } | undefined;
if (d?.error) return new Text("❌ Galaxy upload failed");
if (d?.datasetId)
return new Text(`⬆️ Uploaded to Galaxy (dataset ${d.datasetId}, ${d.state ?? "queued"})`);
Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/session-index/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ export function openIndexDb(filePath: string): Db {
function isSchemaCurrent(db: Db): boolean {
try {
const row = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
| { value: string }
| undefined;
{ value: string } | undefined;
return row?.value === String(SCHEMA_VERSION);
} catch {
// meta table doesn't exist yet
Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/session-index/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ export function scanSessions(db: Db, sessionsDir: string = defaultSessionsDir())

const indexOne = db.transaction((filePath: string) => {
const prior = selState.get(filePath) as
| { session_id: string; last_indexed_offset: number }
| undefined;
{ session_id: string; last_indexed_offset: number } | undefined;
const startOffset = prior?.last_indexed_offset ?? 0;
const fileSize = fs.statSync(filePath).size;
if (prior && startOffset >= fileSize) return;
Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/session-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,7 @@ function syncSessionJsonlSymlink(ctx: ExtensionContext): void {
}

type GreetingAction =
| { kind: "model"; message: string }
| { kind: "notify"; text: string; level: "info" | "warning" };
{ kind: "model"; message: string } | { kind: "notify"; text: string; level: "info" | "warning" };

/**
* Decide the startup greeting from the active Galaxy credential status. Pure so
Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/skills-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,7 @@ export function skillsCacheDir(repo: ConfiguredSkillRepo): string {
}

export type FetchSkillResult =
| { ok: true; text: string; cached: boolean }
| { ok: false; status?: number; error: string };
{ ok: true; text: string; cached: boolean } | { ok: false; status?: number; error: string };

/**
* Fetch one file from a skills repo, reading/writing the same on-disk cache the
Expand Down
7 changes: 5 additions & 2 deletions extensions/loom/teams/tool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text } from "@earendil-works/pi-tui";
import { completeSimple } from "@earendil-works/pi-ai";
import type { Model } from "@earendil-works/pi-ai";
// pi 0.80 moved the global API to /compat. The extension loader aliases the
// root at runtime, but the typecheck resolves the published types, so import
// the real path.
import { completeSimple } from "@earendil-works/pi-ai/compat";
import type { Model } from "@earendil-works/pi-ai/compat";
import { runTeamDispatch } from "./dispatcher";
import { validateTeamSpec } from "./validate";
import type { DispatchDeps, RoleTurnResult, TeamSpec, RoleSpec } from "./types";
Expand Down
3 changes: 1 addition & 2 deletions extensions/loom/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,8 +487,7 @@ Writes a fenced \`loom-invocation\` YAML block at the end of the notebook. Polli
},
renderResult: (result) => {
const d = result.details as
| { invocationId?: string; notebookAnchor?: string; error?: boolean }
| undefined;
{ invocationId?: string; notebookAnchor?: string; error?: boolean } | undefined;
if (d?.error) return new Text("❌ Failed to record invocation");
return new Text(`🔗 Invocation ${d?.invocationId} → ${d?.notebookAnchor}`);
},
Expand Down
Loading
Loading