Download src/pi-cli-runtime.ts from CosmosMind/RSI-Harness: direct link, hf CLI and curl.
- Browser
- Download file 36.2 kB
-
https://huggingface.co/CosmosMind/RSI-Harness/resolve/main/src/pi-cli-runtime.ts
- Command line
-
hf download hf://CosmosMind/RSI-Harness/src/pi-cli-runtime.ts
-
curl -L -o pi-cli-runtime.ts https://huggingface.co/CosmosMind/RSI-Harness/resolve/main/src/pi-cli-runtime.ts
36.2 kB
| import { | |
| existsSync, | |
| mkdirSync, | |
| readFileSync, | |
| readdirSync, | |
| statSync, | |
| writeFileSync, | |
| } from "node:fs"; | |
| import { homedir } from "node:os"; | |
| import { dirname, isAbsolute, join, resolve, sep } from "node:path"; | |
| import { | |
| createAssistantMessageEventStream, | |
| Type, | |
| } from "@earendil-works/pi-ai"; | |
| import type { | |
| AssistantMessage, | |
| Context, | |
| Model, | |
| SimpleStreamOptions, | |
| Usage, | |
| } from "@earendil-works/pi-ai"; | |
| import { | |
| getAgentDir as getPiAgentDir, | |
| main as runPiMain, | |
| } from "@earendil-works/pi-coding-agent"; | |
| import type { | |
| ExtensionAPI, | |
| ExtensionFactory, | |
| } from "@earendil-works/pi-coding-agent"; | |
| import { loadEnvFile, loadProviderProfiles, resolveProviderProfile } from "./model/env.ts"; | |
| import { ModelRuntime } from "./model/model-runtime.ts"; | |
| import { createGeneratedTools } from "./harness/builtin-tools.ts"; | |
| import { | |
| createRsiEditor, | |
| createRsiHeader, | |
| } from "./tui/rsih-tui.ts"; | |
| import { | |
| renderHarnessSystemPrompt, | |
| } from "./harness/genome.ts"; | |
| import { | |
| resolveHarnessGenome, | |
| genomeDisplayName, | |
| } from "./harness/genome-loader.ts"; | |
| import { validateJsonSchema } from "./core/schema.ts"; | |
| import { createMcpTools } from "./harness/mcp.ts"; | |
| import { | |
| applyModelOptionsToPayload, | |
| genomeResourceIsolationArgs, | |
| projectGenomeResources, | |
| projectGenomeSettings, | |
| } from "./harness/pi-projection.ts"; | |
| import { applyManagedConfiguration } from "./harness/settings-layer.ts"; | |
| import { runGenomeCommand } from "./cli/genome-command.ts"; | |
| /** Kept in sync with package.json; shown in the header and settings stamp. */ | |
| export const RSIH_VERSION = "0.1.0"; | |
| const EMPTY_USAGE: Usage = { | |
| input: 0, | |
| output: 0, | |
| cacheRead: 0, | |
| cacheWrite: 0, | |
| totalTokens: 0, | |
| cost: { | |
| input: 0, | |
| output: 0, | |
| cacheRead: 0, | |
| cacheWrite: 0, | |
| total: 0, | |
| }, | |
| }; | |
| const CUSTOM_OPTIONS = new Set([ | |
| "--genome", | |
| "--profile", | |
| "--config", | |
| "--env", | |
| "--cwd", | |
| "--max-turns", | |
| "--run-id", | |
| ]); | |
| const RSIH_FLAG_DESCRIPTIONS = Object.freeze([ | |
| ["profile", "Provider profile from --config (also sets Pi's --provider)."], | |
| ["config", "Provider profile JSON with custom model endpoints."], | |
| ["env", "Env file to load before starting (default: ./.env)."], | |
| ["cwd", "Working directory to run in."], | |
| ["max-turns", "Abort the agent after this many turns."], | |
| ["run-id", "Session id to create or reuse (Pi's --session-id)."], | |
| ]); | |
| /** | |
| * Settings RSIH itself needs regardless of Genome. RSIH draws its own header, | |
| * so Pi's startup resource listing would be printed twice; `quietStartup` | |
| * suppresses the listing without disabling any resource. | |
| */ | |
| const RSIH_BASE_SETTINGS = Object.freeze({ quietStartup: true }); | |
| function requireValue(argv, index, option) { | |
| const value = argv[index + 1]; | |
| if (value === undefined || value.startsWith("-")) { | |
| throw new Error(`${option} requires a value.`); | |
| } | |
| return value; | |
| } | |
| export function parseCliArgs(argv) { | |
| const result = { | |
| cwd: ".", | |
| maxTurns: undefined, | |
| json: false, | |
| newSession: false, | |
| piArgs: [], | |
| }; | |
| for (let index = 0; index < argv.length; index += 1) { | |
| const argument = argv[index]; | |
| // Pi accepts both `--option value` and `--option=value`. RSIH consumes its | |
| // own options before Pi parses argv, so it has to understand both | |
| // spellings — otherwise `--genome=paperlab` falls through to Pi untouched and | |
| // silently starts the default Genome. | |
| const equals = argument.indexOf("="); | |
| const option = equals === -1 ? argument : argument.slice(0, equals); | |
| if (CUSTOM_OPTIONS.has(option)) { | |
| let value; | |
| if (equals === -1) { | |
| value = requireValue(argv, index, option); | |
| index += 1; | |
| } else { | |
| value = argument.slice(equals + 1); | |
| if (value === "") throw new Error(`${option} requires a value.`); | |
| } | |
| switch (option) { | |
| case "--genome": | |
| result.genome = value; | |
| break; | |
| case "--profile": | |
| result.profile = value; | |
| result.piArgs.push("--provider", value); | |
| break; | |
| case "--config": | |
| result.config = value; | |
| break; | |
| case "--env": | |
| result.env = value; | |
| break; | |
| case "--cwd": | |
| result.cwd = value; | |
| break; | |
| case "--max-turns": { | |
| const maxTurns = Number(value); | |
| if (!Number.isInteger(maxTurns) || maxTurns < 1) { | |
| throw new Error("--max-turns must be a positive integer."); | |
| } | |
| result.maxTurns = maxTurns; | |
| break; | |
| } | |
| case "--run-id": | |
| result.piArgs.push("--session-id", value); | |
| break; | |
| } | |
| continue; | |
| } | |
| if (argument === "--json") { | |
| result.json = true; | |
| result.piArgs.push("--mode", "json"); | |
| continue; | |
| } | |
| if (argument === "--new") { | |
| result.newSession = true; | |
| continue; | |
| } | |
| result.piArgs.push(argument); | |
| } | |
| return result; | |
| } | |
| function optionValue(args, name) { | |
| const equalsPrefix = `${name}=`; | |
| for (let index = args.length - 1; index >= 0; index -= 1) { | |
| if (args[index].startsWith(equalsPrefix)) { | |
| return args[index].slice(equalsPrefix.length); | |
| } | |
| if (args[index] === name) return args[index + 1]; | |
| } | |
| return undefined; | |
| } | |
| function hasOption(args, ...names) { | |
| return args.some((argument) => | |
| names.some((name) => argument === name || argument.startsWith(`${name}=`)), | |
| ); | |
| } | |
| /** | |
| * Theme selection and the theme-discovery switch are CLI-level; the theme | |
| * *paths* a Genome ships are supplied through `resources_discover` instead. | |
| */ | |
| export function genomeAppearanceArgs(genome, piArgs = []) { | |
| const args = []; | |
| if ( | |
| genome.appearance?.theme && | |
| !hasOption(piArgs, "--use-theme") | |
| ) { | |
| args.push("--use-theme", genome.appearance.theme); | |
| } | |
| if (genome.appearance?.no_themes && !hasOption(piArgs, "--no-themes")) { | |
| args.push("--no-themes"); | |
| } | |
| return args; | |
| } | |
| function sourceEntry(entry) { | |
| if (typeof entry === "string") return { source: entry, enabled: true }; | |
| return { ...entry, enabled: entry.enabled !== false }; | |
| } | |
| function resolveGenomeSources(entries, baseDirectory) { | |
| return (entries ?? []) | |
| .map(sourceEntry) | |
| .filter( | |
| (entry) => | |
| entry.enabled && | |
| typeof entry.source === "string" && | |
| entry.source.trim() !== "", | |
| ) | |
| .map((entry) => resolve(baseDirectory, entry.source)); | |
| } | |
| function sessionFiles(sessionDirectory) { | |
| try { | |
| return readdirSync(sessionDirectory) | |
| .filter((name) => name.endsWith(".jsonl")) | |
| .map((name) => join(sessionDirectory, name)); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function readSessionEntries(path) { | |
| try { | |
| return readFileSync(path, "utf8") | |
| .split(/\r?\n/) | |
| .filter(Boolean) | |
| .map((line) => JSON.parse(line)); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| function sessionId(path) { | |
| return readSessionEntries(path).find((entry) => entry.type === "session")?.id; | |
| } | |
| function findSessionFile(args, sessionDirectory, cwd) { | |
| if (hasOption(args, "--no-session", "--resume", "-r")) return undefined; | |
| const requested = optionValue(args, "--session"); | |
| if (requested) { | |
| if ( | |
| isAbsolute(requested) || | |
| requested.includes("/") || | |
| requested.includes("\\") || | |
| requested.endsWith(".jsonl") | |
| ) { | |
| return resolve(cwd, requested); | |
| } | |
| return sessionFiles(sessionDirectory).find((path) => { | |
| const id = sessionId(path); | |
| return id === requested || id?.startsWith(requested); | |
| }); | |
| } | |
| const exactId = optionValue(args, "--session-id"); | |
| if (exactId) { | |
| return sessionFiles(sessionDirectory).find( | |
| (path) => sessionId(path) === exactId, | |
| ); | |
| } | |
| if (hasOption(args, "--continue", "-c")) { | |
| return sessionFiles(sessionDirectory) | |
| .map((path) => ({ path, modified: statSync(path).mtimeMs })) | |
| .sort((left, right) => right.modified - left.modified)[0]?.path; | |
| } | |
| return undefined; | |
| } | |
| function storedGenomeReference(sessionFile) { | |
| if (!sessionFile) return undefined; | |
| const entry = readSessionEntries(sessionFile) | |
| .filter( | |
| (candidate) => | |
| candidate.type === "custom" && | |
| candidate.customType === "rsih.genome", | |
| ) | |
| .at(-1); | |
| if (!entry?.data?.genome) return undefined; | |
| return { | |
| genome: entry.data.genome, | |
| reference: entry.data.reference ?? entry.data.genome.genome_id, | |
| path: undefined, | |
| baseDirectory: entry.data.baseDirectory, | |
| }; | |
| } | |
| function mockResponsesFromProfiles(profiles) { | |
| return Object.fromEntries( | |
| Object.entries(profiles) | |
| .filter(([, profile]) => profile.kind === "mock") | |
| .map(([id, profile]) => [ | |
| id, | |
| profile.mock_responses ?? profile.responses ?? [], | |
| ]), | |
| ); | |
| } | |
| function builtinProviderProfiles() { | |
| return { | |
| gpt: resolveProviderProfile("gpt", { | |
| kind: "http", | |
| api: "openai-responses", | |
| provider: "openai", | |
| model_env: "GPT_MODEL", | |
| base_url_env: "GPT_API_BASE_URL", | |
| api_key_env: "GPT_API_KEY", | |
| max_tokens: 16384, | |
| capabilities: { | |
| tools: true, | |
| structured_output: true, | |
| streaming: false, | |
| reasoning: true, | |
| images: false, | |
| usage: true, | |
| }, | |
| }), | |
| local: resolveProviderProfile("local", { | |
| kind: "http", | |
| api: "openai-completions", | |
| provider: "local", | |
| model_env: "LOCAL_MODEL_ID", | |
| base_url_env: "LOCAL_MODEL_BASE_URL", | |
| api_key_env: "LOCAL_MODEL_API_KEY", | |
| allow_unauthenticated: true, | |
| reasoning: true, | |
| max_tokens: 24576, | |
| timeout_ms: 3600000, | |
| chat_template_kwargs: { enable_thinking: true }, | |
| compat: { supportsStrictMode: false }, | |
| capabilities: { | |
| tools: true, | |
| structured_output: false, | |
| streaming: false, | |
| reasoning: true, | |
| images: false, | |
| usage: true, | |
| }, | |
| }), | |
| }; | |
| } | |
| function messageText(content) { | |
| if (typeof content === "string") return content; | |
| return (content ?? []) | |
| .filter((item) => item.type === "text") | |
| .map((item) => item.text) | |
| .join(""); | |
| } | |
| function fromPiContext(context: Context) { | |
| return context.messages.map((message) => { | |
| if (message.role === "user") { | |
| return { role: "user", content: messageText(message.content) }; | |
| } | |
| if (message.role === "toolResult") { | |
| return { | |
| role: "tool", | |
| tool_call_id: message.toolCallId, | |
| name: message.toolName, | |
| content: messageText(message.content), | |
| is_error: message.isError, | |
| }; | |
| } | |
| return { | |
| role: "assistant", | |
| content: message.content | |
| .filter((item) => item.type === "text") | |
| .map((item) => item.text) | |
| .join(""), | |
| tool_calls: message.content | |
| .filter((item) => item.type === "toolCall") | |
| .map((item) => ({ | |
| id: item.id, | |
| name: item.name, | |
| arguments: item.arguments, | |
| })), | |
| stop_reason: | |
| message.stopReason === "toolUse" ? "tool_use" : message.stopReason, | |
| }; | |
| }); | |
| } | |
| function toUsage(usage = {}): Usage { | |
| return { | |
| ...EMPTY_USAGE, | |
| input: usage.input ?? 0, | |
| output: usage.output ?? 0, | |
| cacheRead: usage.cacheRead ?? usage.cache_read ?? 0, | |
| cacheWrite: usage.cacheWrite ?? usage.cache_write ?? 0, | |
| totalTokens: usage.totalTokens ?? usage.total_tokens ?? 0, | |
| cost: { ...EMPTY_USAGE.cost, ...(usage.cost ?? {}) }, | |
| }; | |
| } | |
| function streamProfile( | |
| runtime, | |
| profileId, | |
| genome, | |
| model: Model<string>, | |
| context: Context, | |
| options?: SimpleStreamOptions, | |
| ) { | |
| const stream = createAssistantMessageEventStream(); | |
| void runtime | |
| .generate(profileId, { | |
| system: context.systemPrompt, | |
| messages: fromPiContext(context), | |
| tools: context.tools ?? [], | |
| maxTokens: options?.maxTokens ?? genome.model_options?.max_tokens, | |
| temperature: options?.temperature ?? genome.model_options?.temperature, | |
| chatTemplateKwargs: genome.model_options?.chat_template_kwargs, | |
| timeoutMs: options?.timeoutMs, | |
| maxRetries: options?.maxRetries, | |
| signal: options?.signal, | |
| }) | |
| .then((response) => { | |
| const message: AssistantMessage = { | |
| role: "assistant", | |
| content: [ | |
| ...(response.reasoning | |
| ? [{ type: "thinking", thinking: response.reasoning }] | |
| : []), | |
| ...(response.text ? [{ type: "text", text: response.text }] : []), | |
| ...(response.tool_calls ?? []).map((call) => ({ | |
| type: "toolCall", | |
| id: call.id, | |
| name: call.name, | |
| arguments: call.arguments ?? {}, | |
| })), | |
| ], | |
| api: model.api, | |
| provider: profileId, | |
| model: response.model ?? model.id, | |
| responseId: response.response_id ?? undefined, | |
| usage: toUsage(response.usage), | |
| stopReason: | |
| response.stop_reason === "tool_use" | |
| ? "toolUse" | |
| : response.stop_reason === "length" | |
| ? "length" | |
| : "stop", | |
| timestamp: Date.now(), | |
| }; | |
| stream.push({ type: "start", partial: { ...message, content: [] } }); | |
| stream.push({ type: "done", reason: message.stopReason, message }); | |
| }) | |
| .catch((error) => { | |
| const message: AssistantMessage = { | |
| role: "assistant", | |
| content: [], | |
| api: model.api, | |
| provider: profileId, | |
| model: model.id, | |
| usage: EMPTY_USAGE, | |
| stopReason: options?.signal?.aborted ? "aborted" : "error", | |
| errorMessage: error instanceof Error ? error.message : String(error), | |
| timestamp: Date.now(), | |
| }; | |
| stream.push({ | |
| type: "error", | |
| reason: message.stopReason, | |
| error: message, | |
| }); | |
| }); | |
| return stream; | |
| } | |
| function registerProfiles(pi: ExtensionAPI, profiles, genome) { | |
| const runtime = new ModelRuntime({ | |
| profiles, | |
| mockResponses: mockResponsesFromProfiles(profiles), | |
| }); | |
| for (const [profileId, profile] of Object.entries(profiles)) { | |
| if (!profile.model) continue; | |
| const api = `rsih-${profileId}`; | |
| pi.registerProvider(profileId, { | |
| name: profileId, | |
| baseUrl: profile.base_url ?? "http://127.0.0.1", | |
| apiKey: profile.api_key ?? "rsih-runtime", | |
| api, | |
| authHeader: false, | |
| models: [ | |
| { | |
| id: profile.model, | |
| name: profile.model, | |
| api, | |
| reasoning: | |
| profile.reasoning === true || | |
| profile.capabilities?.reasoning === true, | |
| input: profile.capabilities?.images ? ["text", "image"] : ["text"], | |
| cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, | |
| contextWindow: profile.context_window ?? 128000, | |
| maxTokens: profile.max_tokens ?? 16384, | |
| }, | |
| ], | |
| streamSimple: (model, context, options) => | |
| streamProfile(runtime, profileId, genome, model, context, options), | |
| }); | |
| } | |
| } | |
| function supplementalSystemPrompt(genome) { | |
| return renderHarnessSystemPrompt({ | |
| ...genome, | |
| system_prompt: "", | |
| append_system_prompt: "", | |
| }); | |
| } | |
| function registerInlineResources(pi: ExtensionAPI, genome) { | |
| const inlineSkills = (genome.skills ?? []).filter( | |
| (skill) => typeof skill === "object" && skill.source === undefined, | |
| ); | |
| if (inlineSkills.length > 0) { | |
| const byName = new Map(inlineSkills.map((skill) => [skill.name, skill])); | |
| pi.registerTool({ | |
| name: "load_skill", | |
| label: "Load skill", | |
| description: "Load the complete instructions for an inline Genome skill.", | |
| parameters: Type.Object({ name: Type.String() }), | |
| async execute(_toolCallId, { name }) { | |
| const skill = byName.get(name); | |
| if (!skill) { | |
| throw new Error(`Unknown Genome skill "${name}".`); | |
| } | |
| return { | |
| content: [ | |
| { | |
| type: "text", | |
| text: [ | |
| `Skill: ${skill.name}`, | |
| `Description: ${skill.description}`, | |
| "Instructions:", | |
| skill.content, | |
| ].join("\n"), | |
| }, | |
| ], | |
| details: {}, | |
| }; | |
| }, | |
| }); | |
| for (const skill of inlineSkills) { | |
| pi.registerCommand(`skill:${skill.name}`, { | |
| description: skill.description, | |
| async handler(args, ctx) { | |
| pi.sendUserMessage( | |
| [`Use the "${skill.name}" skill.`, skill.content, args] | |
| .filter(Boolean) | |
| .join("\n\n"), | |
| ); | |
| }, | |
| }); | |
| } | |
| } | |
| // File-backed templates are discovered by Pi through `resources_discover`; | |
| // only inline ones become commands here. | |
| const inlineTemplates = (genome.prompt_templates ?? []).filter( | |
| (template) => | |
| template && typeof template === "object" && template.source === undefined, | |
| ); | |
| for (const template of inlineTemplates) { | |
| pi.registerCommand(template.name, { | |
| description: template.description ?? "", | |
| async handler(args) { | |
| pi.sendUserMessage( | |
| [template.content, args].filter(Boolean).join("\n\n"), | |
| ); | |
| }, | |
| }); | |
| } | |
| } | |
| function registerScratchpad(pi: ExtensionAPI, genome) { | |
| if (!genome.policies?.scratchpad?.enabled) return; | |
| const values = new Map(); | |
| const policy = genome.policies.scratchpad; | |
| pi.registerTool({ | |
| name: "scratchpad", | |
| label: "Scratchpad", | |
| description: "Read or update session-only Genome state.", | |
| parameters: Type.Object({ | |
| action: Type.Union([ | |
| Type.Literal("list"), | |
| Type.Literal("get"), | |
| Type.Literal("set"), | |
| Type.Literal("append"), | |
| Type.Literal("delete"), | |
| ]), | |
| key: Type.Optional(Type.String()), | |
| value: Type.Optional(Type.String()), | |
| }), | |
| async execute(_toolCallId, { action, key, value }) { | |
| if (action === "list") { | |
| return { | |
| content: [{ type: "text", text: JSON.stringify(Object.fromEntries(values)) }], | |
| details: {}, | |
| }; | |
| } | |
| if (!key) throw new Error(`Scratchpad action "${action}" requires key.`); | |
| if (action === "get") { | |
| return { | |
| content: [{ type: "text", text: values.get(key) ?? "" }], | |
| details: {}, | |
| }; | |
| } | |
| if (action === "delete") { | |
| values.delete(key); | |
| return { | |
| content: [{ type: "text", text: `Deleted ${key}` }], | |
| details: {}, | |
| }; | |
| } | |
| if (typeof value !== "string") { | |
| throw new Error(`Scratchpad action "${action}" requires value.`); | |
| } | |
| if (!values.has(key) && values.size >= Number(policy.max_entries ?? 32)) { | |
| throw new Error(`Scratchpad is limited to ${policy.max_entries ?? 32} entries.`); | |
| } | |
| const updated = action === "append" ? `${values.get(key) ?? ""}${value}` : value; | |
| if (updated.length > Number(policy.max_value_chars ?? 4000)) { | |
| throw new Error( | |
| `Scratchpad values are limited to ${policy.max_value_chars ?? 4000} characters.`, | |
| ); | |
| } | |
| values.set(key, updated); | |
| return { | |
| content: [{ type: "text", text: `Stored ${key}` }], | |
| details: {}, | |
| }; | |
| }, | |
| }); | |
| } | |
| /** | |
| * Tools whose implementation belongs to Pi. A Genome can enable, disable, and | |
| * narrow their parameters, but it cannot restate their description: doing so | |
| * would mean re-registering — and therefore reimplementing — Pi's tool. | |
| */ | |
| function genomeNarrowedSchemas(genome) { | |
| const narrowed = new Map(); | |
| for (const tool of genome.tools ?? []) { | |
| if (tool.parameters) narrowed.set(toolName(tool.name), tool.parameters); | |
| } | |
| return narrowed; | |
| } | |
| /** Pi calls its directory-listing tool `ls`; older Genomes wrote `list`. */ | |
| function toolName(name) { | |
| return name === "list" ? "ls" : name; | |
| } | |
| /** | |
| * Genome `tools` entries are a patch on whatever Pi already activated, not a | |
| * whitelist. A Genome that never mentions tools keeps Pi's full tool set, and a | |
| * Genome that disables one tool keeps the rest. | |
| */ | |
| export function genomeActiveToolNames(activeToolNames, allToolNames, genome) { | |
| const entries = genome.tools ?? []; | |
| if (entries.length === 0) return undefined; | |
| const known = new Set(allToolNames); | |
| const active = new Set(activeToolNames); | |
| for (const tool of entries) { | |
| const name = toolName(tool.name); | |
| if (tool.enabled === false) { | |
| active.delete(name); | |
| } else if (known.has(name)) { | |
| active.add(name); | |
| } | |
| } | |
| return allToolNames.filter((name) => active.has(name)); | |
| } | |
| /** | |
| * What to tell the user about the relationship between their installed Genome | |
| * and the one this RSIH ships. A stale unmodified copy is refreshed silently by | |
| * the loader, but the user still needs to know their Genome changed underneath | |
| * them; a modified copy is never touched, so the only honest move is to say a | |
| * newer version exists and leave the decision to them. | |
| */ | |
| export function genomeSeedNotice(resolvedGenome, label) { | |
| if (resolvedGenome.seeded) { | |
| return { | |
| message: `Installed Genome "${label}" to ${dirname(resolvedGenome.path)}`, | |
| level: "info", | |
| }; | |
| } | |
| if (resolvedGenome.refreshed) { | |
| return { | |
| message: `Updated Genome "${label}" to the version shipped with this RSIH`, | |
| level: "info", | |
| }; | |
| } | |
| if (resolvedGenome.outdated) { | |
| return { | |
| message: `Genome "${label}" differs from the version shipped with this RSIH. Run \`rsih genome install ${label}\` to replace your copy.`, | |
| level: "warning", | |
| }; | |
| } | |
| return undefined; | |
| } | |
| export function genomeFooterStatus(label) { | |
| return `\u001b[35mGenome: ${label}\u001b[39m`; | |
| } | |
| function genomeResourceLabels(entries): string[] { | |
| return (entries ?? []) | |
| .map((entry) => { | |
| if (typeof entry === "string") { | |
| return entry.split(/[\\/]/).filter(Boolean).at(-1) ?? entry; | |
| } | |
| if (!entry || typeof entry !== "object") return ""; | |
| return ( | |
| entry.name ?? | |
| entry.source?.split(/[\\/]/).filter(Boolean).at(-1) ?? | |
| "" | |
| ); | |
| }) | |
| .filter( | |
| (label): label is string => | |
| typeof label === "string" && label.length > 0, | |
| ); | |
| } | |
| function resourcePathLabel(path: string): string { | |
| if (path.startsWith("<") && path.endsWith(">")) return path; | |
| return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path; | |
| } | |
| function runtimeResourceLabels(pi, genome) { | |
| const skills = new Set(genomeResourceLabels(genome.skills)); | |
| const extensions = new Set(genomeResourceLabels(genome.extensions)); | |
| for (const command of pi.getCommands()) { | |
| if (command.source === "skill") { | |
| skills.add(command.name.replace(/^skill:/, "")); | |
| } else if (command.source === "extension") { | |
| extensions.add(resourcePathLabel(command.sourceInfo.path)); | |
| } | |
| } | |
| for (const tool of pi.getAllTools()) { | |
| const sourcePath = tool.sourceInfo?.path; | |
| if (sourcePath && !sourcePath.startsWith("<builtin")) { | |
| extensions.add(resourcePathLabel(sourcePath)); | |
| } | |
| } | |
| return { | |
| skills: [...skills].sort(), | |
| extensions: [...extensions].sort(), | |
| }; | |
| } | |
| async function createGenomeExtension({ | |
| genome, | |
| genomeLabel, | |
| modelLabel, | |
| resources, | |
| showGenomeStatus, | |
| reference, | |
| seedNotice, | |
| baseDirectory, | |
| profiles, | |
| maxTurns, | |
| cliOverridesTools, | |
| cliOverridesThinking, | |
| preserveSessionDefaults, | |
| cwd, | |
| }): Promise<ExtensionFactory> { | |
| return async (pi) => { | |
| const activeResources = { | |
| skills: [...(resources?.skills ?? [])], | |
| extensions: [...(resources?.extensions ?? [])], | |
| }; | |
| const narrowedSchemas = genomeNarrowedSchemas(genome); | |
| // RSIH consumes these before Pi parses argv; registering them is what puts | |
| // them in `--help` alongside Pi's own options. | |
| pi.registerFlag("genome", { | |
| type: "string", | |
| description: | |
| "Genome name or JSON path (./.rsih/genomes, then ~/.rsih/genomes). Shorthand: rsih +name, :name or ::name.", | |
| default: reference, | |
| }); | |
| for (const [name, description] of RSIH_FLAG_DESCRIPTIONS) { | |
| pi.registerFlag(name, { type: "string", description }); | |
| } | |
| pi.registerFlag("json", { | |
| type: "boolean", | |
| description: "Shorthand for --mode json.", | |
| }); | |
| pi.registerFlag("new", { | |
| type: "boolean", | |
| description: "Start a fresh session instead of resuming.", | |
| }); | |
| registerProfiles(pi, profiles, genome); | |
| registerInlineResources(pi, genome); | |
| registerScratchpad(pi, genome); | |
| for (const tool of createGeneratedTools({ cwd, genome })) { | |
| pi.registerTool({ | |
| name: tool.name, | |
| label: tool.name, | |
| description: tool.description, | |
| parameters: tool.parameters, | |
| executionMode: genome.runtime?.tool_execution, | |
| async execute(_toolCallId, params, signal) { | |
| const text = await tool.execute(params, { signal }); | |
| return { | |
| content: [{ type: "text", text: String(text ?? "") }], | |
| details: {}, | |
| }; | |
| }, | |
| }); | |
| } | |
| const mcp = await createMcpTools({ cwd, genome }); | |
| for (const tool of mcp.tools) { | |
| pi.registerTool({ | |
| name: tool.name, | |
| label: tool.name, | |
| description: tool.description, | |
| parameters: tool.parameters, | |
| executionMode: genome.runtime?.tool_execution, | |
| async execute(_toolCallId, params, signal) { | |
| const text = await tool.execute(params, { signal }); | |
| return { | |
| content: [{ type: "text", text: String(text ?? "") }], | |
| details: {}, | |
| }; | |
| }, | |
| }); | |
| } | |
| pi.on("resources_discover", () => | |
| projectGenomeResources(genome, (path) => resolve(baseDirectory, path)), | |
| ); | |
| pi.on("before_provider_request", (event) => | |
| applyModelOptionsToPayload(event.payload, genome.model_options), | |
| ); | |
| pi.on("session_start", (_event, ctx) => { | |
| if (ctx.mode === "tui") { | |
| const loadedResources = runtimeResourceLabels(pi, genome); | |
| activeResources.skills = loadedResources.skills; | |
| activeResources.extensions = loadedResources.extensions; | |
| ctx.ui.setHeader((tui, theme) => | |
| createRsiHeader(theme, { | |
| cwd, | |
| model: ctx.model?.id ?? modelLabel, | |
| genome: genomeLabel, | |
| version: RSIH_VERSION, | |
| resources: activeResources, | |
| }), | |
| ); | |
| ctx.ui.setEditorComponent( | |
| (tui, theme, keybindings) => createRsiEditor(tui, theme, keybindings), | |
| ); | |
| } | |
| if (showGenomeStatus && ctx.mode === "tui") { | |
| ctx.ui.setStatus("rsih-genome", genomeFooterStatus(genomeLabel)); | |
| } | |
| if (seedNotice && ctx.mode === "tui") { | |
| ctx.ui.notify(seedNotice.message, seedNotice.level); | |
| } | |
| const prior = ctx.sessionManager | |
| .getEntries() | |
| .filter( | |
| (entry) => | |
| entry.type === "custom" && entry.customType === "rsih.genome", | |
| ) | |
| .at(-1); | |
| if ( | |
| prior?.data?.genome_id !== genome.genome_id || | |
| prior?.data?.reference !== reference | |
| ) { | |
| pi.appendEntry("rsih.genome", { | |
| reference, | |
| baseDirectory, | |
| genome_id: genome.genome_id, | |
| genome, | |
| }); | |
| } | |
| if (!cliOverridesTools) { | |
| const activeTools = genomeActiveToolNames( | |
| pi.getActiveTools(), | |
| pi.getAllTools().map((tool) => tool.name), | |
| genome, | |
| ); | |
| if (activeTools) pi.setActiveTools(activeTools); | |
| } | |
| if ( | |
| !cliOverridesThinking && | |
| !preserveSessionDefaults && | |
| genome.runtime?.thinking_level | |
| ) { | |
| pi.setThinkingLevel(genome.runtime.thinking_level); | |
| } | |
| }); | |
| pi.on("turn_start", (event, ctx) => { | |
| // Pi has no turn cap of its own, so only enforce one that was asked for. | |
| if (maxTurns !== undefined && event.turnIndex >= maxTurns) ctx.abort(); | |
| }); | |
| pi.on("tool_call", (event) => { | |
| if (genome.policies?.tool?.blocked_tools?.includes(event.toolName)) { | |
| return { | |
| block: true, | |
| reason: `Tool "${event.toolName}" is blocked by Genome policy.`, | |
| }; | |
| } | |
| // Pi owns its tool implementations, so a Genome narrows a tool by | |
| // rejecting out-of-contract calls rather than re-registering the tool. | |
| const schema = narrowedSchemas.get(event.toolName); | |
| if (schema) { | |
| const errors = validateJsonSchema(event.input, schema); | |
| if (errors.length > 0) { | |
| return { | |
| block: true, | |
| reason: `Tool "${event.toolName}" was called outside its Genome parameter contract:\n- ${errors.join("\n- ")}`, | |
| }; | |
| } | |
| } | |
| return undefined; | |
| }); | |
| pi.on("tool_result", (event) => { | |
| const maxChars = genome.policies?.tool?.max_result_chars; | |
| if (maxChars === undefined) return undefined; | |
| let remaining = Number(maxChars); | |
| let truncated = false; | |
| const content = event.content.map((item) => { | |
| if (item.type !== "text") return item; | |
| if (item.text.length <= remaining) { | |
| remaining -= item.text.length; | |
| return item; | |
| } | |
| truncated = true; | |
| const text = `${item.text.slice(0, Math.max(0, remaining))}\n[truncated]`; | |
| remaining = 0; | |
| return { ...item, text }; | |
| }); | |
| return truncated ? { content } : undefined; | |
| }); | |
| pi.on("session_shutdown", async () => { | |
| await mcp.close(); | |
| }); | |
| }; | |
| } | |
| /** | |
| * Mirror of Pi's default session directory (`<agentDir>/sessions/--<cwd>--`). | |
| * RSIH only needs it to find the Genome snapshot stored in a resumed session; | |
| * Pi still owns session creation and layout. | |
| * | |
| * This runs before the Genome's settings are compiled, so it reads the settings | |
| * file as it stands — which is where the run being resumed put its sessions. | |
| */ | |
| function piSessionDirectory(cwd, piArgs) { | |
| const agentDirectory = getPiAgentDir(); | |
| const settingsPath = join(agentDirectory, "settings.json"); | |
| let settingsSessionDir; | |
| if (existsSync(settingsPath)) { | |
| try { | |
| settingsSessionDir = JSON.parse(readFileSync(settingsPath, "utf8")) | |
| ?.sessionDir; | |
| } catch { | |
| // Pi reports malformed settings; fall back to its default layout. | |
| } | |
| } | |
| const explicit = | |
| optionValue(piArgs, "--session-dir") ?? | |
| process.env.RSIH_CODING_AGENT_SESSION_DIR ?? | |
| settingsSessionDir; | |
| if (explicit) return resolve(cwd, explicit); | |
| const encoded = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; | |
| return join(agentDirectory, "sessions", encoded); | |
| } | |
| export async function runPiCli(argv) { | |
| // `rsih genome ...` is RSIH's own subcommand; everything else, including Pi's | |
| // install/update/list/config/auth subcommands, falls through to Pi. | |
| if (runGenomeCommand(argv)) return; | |
| const parsed = parseCliArgs(argv); | |
| if ( | |
| parsed.newSession && | |
| hasOption(parsed.piArgs, "--continue", "-c", "--resume", "-r", "--session") | |
| ) { | |
| throw new Error("--new cannot be combined with session resume options."); | |
| } | |
| process.chdir(resolve(parsed.cwd)); | |
| // Read the directory back so RSIH and Pi agree on the canonical cwd; on | |
| // macOS `/tmp` and `/var` resolve through symlinks and the session directory | |
| // Pi derives would not match. | |
| const cwd = process.cwd(); | |
| if (parsed.env) { | |
| loadEnvFile(resolve(cwd, parsed.env)); | |
| } else { | |
| try { | |
| loadEnvFile(join(cwd, ".env")); | |
| } catch (error) { | |
| if (error?.code !== "ENOENT") throw error; | |
| } | |
| } | |
| // Session storage stays wherever Pi puts it, so `rsih` and `pi` resume the | |
| // same way. A Genome that wants project-local runs sets its own session dir. | |
| const sessionDirectory = piSessionDirectory(cwd, parsed.piArgs); | |
| const existingSession = findSessionFile(parsed.piArgs, sessionDirectory, cwd); | |
| const storedGenome = | |
| parsed.genome === undefined | |
| ? storedGenomeReference(existingSession) | |
| : undefined; | |
| const resolvedGenome = | |
| storedGenome ?? | |
| resolveHarnessGenome(parsed.genome, { | |
| cwd, | |
| homeDirectory: homedir(), | |
| }); | |
| const { genome } = resolvedGenome; | |
| const genomeLabel = genomeDisplayName(resolvedGenome); | |
| const baseDirectory = resolve(resolvedGenome.baseDirectory ?? cwd); | |
| // Pi exposes no API for settings or keybindings, so the Genome's projection | |
| // is compiled into the files Pi reads. Only Genome-declared keys are touched. | |
| const projection = projectGenomeSettings(genome); | |
| applyManagedConfiguration({ | |
| agentDirectory: getPiAgentDir(), | |
| settings: { ...RSIH_BASE_SETTINGS, ...projection.settings }, | |
| keybindings: projection.keybindings, | |
| stamp: { | |
| genome: resolvedGenome.reference, | |
| genomeId: genome.genome_id, | |
| rsih: RSIH_VERSION, | |
| }, | |
| }); | |
| const profiles = parsed.config | |
| ? loadProviderProfiles(resolve(cwd, parsed.config)) | |
| : builtinProviderProfiles(); | |
| const selectedProfile = parsed.profile ?? genome.model?.profile; | |
| const selectedModel = | |
| optionValue(parsed.piArgs, "--model") ?? | |
| genome.model?.id ?? | |
| (selectedProfile ? profiles[selectedProfile]?.model : undefined); | |
| const preserveSessionDefaults = existingSession !== undefined && parsed.genome === undefined; | |
| const genomeArgs = []; | |
| if ( | |
| !preserveSessionDefaults && | |
| !hasOption(parsed.piArgs, "--provider") && | |
| selectedProfile | |
| ) { | |
| genomeArgs.push("--provider", selectedProfile); | |
| } | |
| if ( | |
| !preserveSessionDefaults && | |
| !hasOption(parsed.piArgs, "--model") && | |
| selectedModel | |
| ) { | |
| genomeArgs.push("--model", selectedModel); | |
| } | |
| // Only replace Pi's own system prompt when the Genome actually declares one. | |
| if ( | |
| genome.system_prompt && | |
| !hasOption(parsed.piArgs, "--system-prompt") | |
| ) { | |
| genomeArgs.push("--system-prompt", genome.system_prompt); | |
| } | |
| const appendedPrompt = [ | |
| genome.append_system_prompt, | |
| supplementalSystemPrompt(genome), | |
| ] | |
| .filter(Boolean) | |
| .join("\n\n"); | |
| if (appendedPrompt && !hasOption(parsed.piArgs, "--append-system-prompt")) { | |
| genomeArgs.push("--append-system-prompt", appendedPrompt); | |
| } | |
| // Skills, prompt templates, and themes reach Pi through the | |
| // `resources_discover` hook. Extensions have no such hook, so they stay on | |
| // argv; explicit `-e` paths keep working even under resource isolation. | |
| for (const path of resolveGenomeSources(genome.extensions, baseDirectory)) { | |
| genomeArgs.push("--extension", path); | |
| } | |
| genomeArgs.push(...genomeAppearanceArgs(genome, parsed.piArgs)); | |
| genomeArgs.push(...genomeResourceIsolationArgs(genome, parsed.piArgs)); | |
| if (parsed.genome !== undefined) { | |
| genomeArgs.push("--genome", parsed.genome); | |
| } | |
| const customProfiles = | |
| parsed.config || (selectedProfile && ["gpt", "local"].includes(selectedProfile)) | |
| ? profiles | |
| : {}; | |
| if (selectedProfile && parsed.config && !profiles[selectedProfile]) { | |
| throw new Error( | |
| `Provider profile "${selectedProfile}" was not found in ${resolve(cwd, parsed.config)}.`, | |
| ); | |
| } | |
| if ( | |
| selectedProfile && | |
| Object.hasOwn(customProfiles, selectedProfile) && | |
| !customProfiles[selectedProfile].model | |
| ) { | |
| throw new Error( | |
| `Provider profile "${selectedProfile}" has no resolved model.`, | |
| ); | |
| } | |
| const extensionFactory = await createGenomeExtension({ | |
| genome, | |
| genomeLabel, | |
| modelLabel: selectedModel ?? "", | |
| resources: { | |
| skills: genomeResourceLabels(genome.skills), | |
| extensions: genomeResourceLabels(genome.extensions), | |
| }, | |
| showGenomeStatus: resolvedGenome.reference !== "default", | |
| reference: resolvedGenome.reference, | |
| // A built-in Genome is copied into ~/.rsih/genomes on first use, so that the | |
| // Genome the agent runs -- and reads its skills from -- is always the one in | |
| // the user's own directory. Every write, and every refusal to write, is | |
| // announced rather than done silently. | |
| seedNotice: genomeSeedNotice(resolvedGenome, genomeLabel), | |
| baseDirectory, | |
| profiles: customProfiles, | |
| maxTurns: parsed.maxTurns ?? genome.runtime?.max_turns, | |
| cliOverridesTools: hasOption( | |
| parsed.piArgs, | |
| "--tools", | |
| "-t", | |
| "--exclude-tools", | |
| "-xt", | |
| "--no-tools", | |
| "-nt", | |
| "--no-builtin-tools", | |
| "-nbt", | |
| ), | |
| cliOverridesThinking: hasOption(parsed.piArgs, "--thinking"), | |
| preserveSessionDefaults, | |
| cwd, | |
| }); | |
| await runPiMain([...genomeArgs, ...parsed.piArgs], { | |
| extensionFactories: [extensionFactory], | |
| }); | |
| } | |