Merge master into feature/upload-company-logo
This commit is contained in:
5
.changeset/add-pi-adapter-support.md
Normal file
5
.changeset/add-pi-adapter-support.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@paperclipai/shared": minor
|
||||
---
|
||||
|
||||
Add support for Pi local adapter in constants and onboarding UI.
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thanks for wanting to contribute!
|
||||
|
||||
We really appreciate both small fixes and thoughtful larger changes.
|
||||
|
||||
## Two Paths to Get Your Pull Request Accepted
|
||||
|
||||
### Path 1: Small, Focused Changes (Fastest way to get merged)
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
### Path 2: Bigger or Impactful Changes
|
||||
- **First** talk about it in Discord → #dev channel
|
||||
→ Describe what you're trying to solve
|
||||
→ Share rough ideas / approach
|
||||
- Once there's rough agreement, build it
|
||||
- In your PR include:
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## General Rules (both paths)
|
||||
- Write clear commit messages
|
||||
- Keep PR title + description meaningful
|
||||
- One PR = one logical change (unless it's a small related group)
|
||||
- Run tests locally first
|
||||
- Be kind in discussions 😄
|
||||
|
||||
Questions? Just ask in #dev — we're happy to help.
|
||||
|
||||
Happy hacking!
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Paperclip AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -38,6 +38,7 @@
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
@@ -22,6 +23,11 @@ const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||
};
|
||||
|
||||
const piLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "pi_local",
|
||||
formatStdoutEvent: printPiStreamEvent,
|
||||
};
|
||||
|
||||
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "cursor",
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
@@ -33,7 +39,7 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
50
packages/adapters/pi-local/package.json
Normal file
50
packages/adapters/pi-local/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
107
packages/adapters/pi-local/src/cli/format-event.ts
Normal file
107
packages/adapters/pi-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function printPiStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "agent_start") {
|
||||
console.log(pc.blue("Pi agent started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "agent_end") {
|
||||
console.log(pc.blue("Pi agent finished"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn_start") {
|
||||
console.log(pc.blue("Turn started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn_end") {
|
||||
const message = asRecord(parsed.message);
|
||||
if (message) {
|
||||
const content = message.content as string | Array<{ type: string; text?: string }>;
|
||||
const text = extractTextContent(content);
|
||||
if (text) {
|
||||
console.log(pc.green(`assistant: ${text}`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "message_update") {
|
||||
const assistantEvent = asRecord(parsed.assistantMessageEvent);
|
||||
if (assistantEvent) {
|
||||
const msgType = asString(assistantEvent.type);
|
||||
if (msgType === "text_delta") {
|
||||
const delta = asString(assistantEvent.delta);
|
||||
if (delta) {
|
||||
console.log(pc.green(delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_execution_start") {
|
||||
const toolName = asString(parsed.toolName);
|
||||
const args = parsed.args;
|
||||
console.log(pc.yellow(`tool_start: ${toolName}`));
|
||||
if (args !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(args, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(args)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_execution_end") {
|
||||
const result = parsed.result;
|
||||
const isError = parsed.isError === true;
|
||||
const output = typeof result === "string" ? result : JSON.stringify(result);
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
1
packages/adapters/pi-local/src/cli/index.ts
Normal file
1
packages/adapters/pi-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printPiStreamEvent } from "./format-event.js";
|
||||
40
packages/adapters/pi-local/src/index.ts
Normal file
40
packages/adapters/pi-local/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const type = "pi_local";
|
||||
export const label = "Pi (local)";
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const agentConfigurationDoc = `# pi_local agent configuration
|
||||
|
||||
Adapter: pi_local
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run Pi (the AI coding agent) locally as the agent runtime
|
||||
- You want provider/model routing in Pi format (--provider <name> --model <id>)
|
||||
- You want Pi session resume across heartbeats via --session
|
||||
- You need Pi's tool set (read, bash, edit, write, grep, find, ls)
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Pi CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file appended to system prompt via --append-system-prompt
|
||||
- promptTemplate (string, optional): user prompt template passed via -p flag
|
||||
- model (string, required): Pi model id in provider/model format (for example xai/grok-4)
|
||||
- thinking (string, optional): thinking level (off, minimal, low, medium, high, xhigh)
|
||||
- command (string, optional): defaults to "pi"
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Pi supports multiple providers and models. Use \`pi --list-models\` to list available options.
|
||||
- Paperclip requires an explicit \`model\` value for \`pi_local\` agents.
|
||||
- Sessions are stored in ~/.pi/paperclips/ and resumed with --session.
|
||||
- All tools (read, bash, edit, write, grep, find, ls) are enabled by default.
|
||||
- Agent instructions are appended to Pi's system prompt via --append-system-prompt, while the user task is sent via -p.
|
||||
`;
|
||||
478
packages/adapters/pi-local/src/server/execute.ts
Normal file
478
packages/adapters/pi-local/src/server/execute.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
||||
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelProvider(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed.includes("/")) return null;
|
||||
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||
}
|
||||
|
||||
function parseModelId(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed.includes("/")) return trimmed || null;
|
||||
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills");
|
||||
await fs.mkdir(piSkillsHome, { recursive: true });
|
||||
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(piSkillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject Pi skill "${entry.name}" into ${piSkillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSessionsDir(): Promise<string> {
|
||||
await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true });
|
||||
return PAPERCLIP_SESSIONS_DIR;
|
||||
}
|
||||
|
||||
function buildSessionPath(agentId: string, timestamp: string): string {
|
||||
const safeTimestamp = timestamp.replace(/[:.]/g, "-");
|
||||
return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`);
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "pi");
|
||||
const model = asString(config.model, "").trim();
|
||||
const thinking = asString(config.thinking, "").trim();
|
||||
|
||||
// Parse model into provider and model id
|
||||
const provider = parseModelProvider(model);
|
||||
const modelId = parseModelId(model);
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
// Ensure sessions directory exists
|
||||
await ensureSessionsDir();
|
||||
|
||||
// Inject skills
|
||||
await ensurePiSkillsInjected(onLog);
|
||||
|
||||
// Build environment
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
// Validate model is available before execution
|
||||
await ensurePiModelConfiguredAndAvailable({
|
||||
model,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
// Handle session
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString());
|
||||
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure session file exists (Pi requires this on first run)
|
||||
if (!canResumeSession) {
|
||||
try {
|
||||
await fs.writeFile(sessionPath, "", { flag: "wx" });
|
||||
} catch (err) {
|
||||
// File may already exist, that's ok
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle instructions file and build system prompt extension
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const resolvedInstructionsFilePath = instructionsFilePath
|
||||
? path.resolve(cwd, instructionsFilePath)
|
||||
: "";
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
|
||||
let systemPromptExtension = "";
|
||||
let instructionsReadFailed = false;
|
||||
if (resolvedInstructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||
systemPromptExtension =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
||||
`You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
instructionsReadFailed = true;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
// Fall back to base prompt template
|
||||
systemPromptExtension = promptTemplate;
|
||||
}
|
||||
} else {
|
||||
systemPromptExtension = promptTemplate;
|
||||
}
|
||||
|
||||
const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
|
||||
// User prompt is simple - just the rendered prompt template without instructions
|
||||
const userPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
if (instructionsReadFailed) {
|
||||
return [
|
||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
|
||||
];
|
||||
})();
|
||||
|
||||
const buildArgs = (sessionFile: string): string[] => {
|
||||
const args: string[] = [];
|
||||
|
||||
// Use RPC mode for proper lifecycle management (waits for agent completion)
|
||||
args.push("--mode", "rpc");
|
||||
|
||||
// Use --append-system-prompt to extend Pi's default system prompt
|
||||
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
||||
|
||||
if (provider) args.push("--provider", provider);
|
||||
if (modelId) args.push("--model", modelId);
|
||||
if (thinking) args.push("--thinking", thinking);
|
||||
|
||||
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
||||
args.push("--session", sessionFile);
|
||||
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const buildRpcStdin = (): string => {
|
||||
// Send the prompt as an RPC command
|
||||
const promptCommand = {
|
||||
type: "prompt",
|
||||
message: userPrompt,
|
||||
};
|
||||
return JSON.stringify(promptCommand) + "\n";
|
||||
};
|
||||
|
||||
const runAttempt = async (sessionFile: string) => {
|
||||
const args = buildArgs(sessionFile);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "pi_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt: userPrompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
// Buffer stdout by lines to handle partial JSON chunks
|
||||
let stdoutBuffer = "";
|
||||
const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
if (stream === "stderr") {
|
||||
// Pass stderr through immediately (not JSONL)
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer stdout and emit only complete lines
|
||||
stdoutBuffer += chunk;
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
// Keep the last (potentially incomplete) line in the buffer
|
||||
stdoutBuffer = lines.pop() || "";
|
||||
|
||||
// Emit complete lines
|
||||
for (const line of lines) {
|
||||
if (line) {
|
||||
await onLog(stream, line + "\n");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog: bufferedOnLog,
|
||||
stdin: buildRpcStdin(),
|
||||
});
|
||||
|
||||
// Flush any remaining buffer content
|
||||
if (stdoutBuffer) {
|
||||
await onLog("stdout", stdoutBuffer);
|
||||
}
|
||||
|
||||
return {
|
||||
proc,
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parsePiJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||
rawStderr: string;
|
||||
parsed: ReturnType<typeof parsePiJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? { sessionId: resolvedSessionId, cwd }
|
||||
: null;
|
||||
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const rawExitCode = attempt.proc.exitCode;
|
||||
const fallbackErrorMessage = stderrLine || `Pi exited with code ${rawExitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: rawExitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (rawExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
usage: {
|
||||
inputTokens: attempt.parsed.usage.inputTokens,
|
||||
outputTokens: attempt.parsed.usage.outputTokens,
|
||||
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||
},
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: provider,
|
||||
model: model,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.usage.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(),
|
||||
clearSession: Boolean(clearSessionOnMissingSession),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionPath);
|
||||
const initialFailed =
|
||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0);
|
||||
|
||||
if (
|
||||
canResumeSession &&
|
||||
initialFailed &&
|
||||
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const newSessionPath = buildSessionPath(agent.id, new Date().toISOString());
|
||||
try {
|
||||
await fs.writeFile(newSessionPath, "", { flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const retry = await runAttempt(newSessionPath);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
60
packages/adapters/pi-local/src/server/index.ts
Normal file
60
packages/adapters/pi-local/src/server/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.session);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.session);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.session)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listPiModels,
|
||||
discoverPiModels,
|
||||
discoverPiModelsCached,
|
||||
ensurePiModelConfiguredAndAvailable,
|
||||
resetPiModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
export { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";
|
||||
33
packages/adapters/pi-local/src/server/models.test.ts
Normal file
33
packages/adapters/pi-local/src/server/models.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensurePiModelConfiguredAndAvailable,
|
||||
listPiModels,
|
||||
resetPiModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
|
||||
describe("pi models", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_PI_COMMAND;
|
||||
resetPiModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
|
||||
await expect(listPiModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects when model is missing", async () => {
|
||||
await expect(
|
||||
ensurePiModelConfiguredAndAvailable({ model: "" }),
|
||||
).rejects.toThrow("Pi requires `adapterConfig.model`");
|
||||
});
|
||||
|
||||
it("rejects when discovery cannot run for configured model", async () => {
|
||||
process.env.PAPERCLIP_PI_COMMAND = "__paperclip_missing_pi_command__";
|
||||
await expect(
|
||||
ensurePiModelConfiguredAndAvailable({
|
||||
model: "xai/grok-4",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
208
packages/adapters/pi-local/src/server/models.ts
Normal file
208
packages/adapters/pi-local/src/server/models.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import { asString, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelsOutput(stdout: string): AdapterModel[] {
|
||||
const parsed: AdapterModel[] = [];
|
||||
const lines = stdout.split(/\r?\n/);
|
||||
|
||||
// Skip header line if present
|
||||
let startIndex = 0;
|
||||
if (lines.length > 0 && (lines[0].includes("provider") || lines[0].includes("model"))) {
|
||||
startIndex = 1;
|
||||
}
|
||||
|
||||
for (let i = startIndex; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Parse format: "provider model context max-out thinking images"
|
||||
// Split by 2+ spaces to handle the columnar format
|
||||
const parts = line.split(/\s{2,}/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const provider = parts[0].trim();
|
||||
const model = parts[1].trim();
|
||||
|
||||
if (!provider || !model) continue;
|
||||
if (provider === "provider" && model === "model") continue; // Skip header
|
||||
|
||||
const id = `${provider}/${model}`;
|
||||
parsed.push({ id, label: id });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortModels(models: AdapterModel[]): AdapterModel[] {
|
||||
return [...models].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePiCommand(input: unknown): string {
|
||||
const envOverride =
|
||||
typeof process.env.PAPERCLIP_PI_COMMAND === "string" &&
|
||||
process.env.PAPERCLIP_PI_COMMAND.trim().length > 0
|
||||
? process.env.PAPERCLIP_PI_COMMAND.trim()
|
||||
: "pi";
|
||||
return asString(input, envOverride);
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
|
||||
|
||||
function isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverPiModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolvePiCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const runtimeEnv = normalizeEnv({ ...process.env, ...env });
|
||||
|
||||
const result = await runChildProcess(
|
||||
`pi-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["--list-models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 20,
|
||||
graceSec: 3,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error("`pi --list-models` timed out.");
|
||||
}
|
||||
if ((result.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
|
||||
throw new Error(detail ? `\`pi --list-models\` failed: ${detail}` : "`pi --list-models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(dedupeModels(parseModelsOutput(result.stdout)));
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envInput)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function discoverPiModelsCached(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolvePiCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
const models = await discoverPiModels({ command, cwd, env });
|
||||
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function ensurePiModelConfiguredAndAvailable(input: {
|
||||
model?: unknown;
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
}): Promise<AdapterModel[]> {
|
||||
const model = asString(input.model, "").trim();
|
||||
if (!model) {
|
||||
throw new Error("Pi requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
|
||||
const models = await discoverPiModelsCached({
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error("Pi returned no models. Run `pi --list-models` and verify provider auth.");
|
||||
}
|
||||
|
||||
if (!models.some((entry) => entry.id === model)) {
|
||||
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
|
||||
throw new Error(
|
||||
`Configured Pi model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function listPiModels(): Promise<AdapterModel[]> {
|
||||
try {
|
||||
return await discoverPiModelsCached();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPiModelsCacheForTests() {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
222
packages/adapters/pi-local/src/server/parse.test.ts
Normal file
222
packages/adapters/pi-local/src/server/parse.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePiJsonl, isPiUnknownSessionError } from "./parse.js";
|
||||
|
||||
describe("parsePiJsonl", () => {
|
||||
it("parses agent lifecycle and messages", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "agent_start" }),
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello from Pi" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({ type: "agent_end", messages: [] }),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.messages).toContain("Hello from Pi");
|
||||
expect(parsed.finalMessage).toBe("Hello from Pi");
|
||||
});
|
||||
|
||||
it("parses streaming text deltas", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", delta: "Hello " },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", delta: "World" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello World",
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.messages).toContain("Hello World");
|
||||
});
|
||||
|
||||
it("parses tool execution", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "read",
|
||||
args: { path: "/tmp/test.txt" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "read",
|
||||
result: "file contents",
|
||||
isError: false,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: { role: "assistant", content: "Done" },
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "tool_1",
|
||||
content: "file contents",
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.toolCalls).toHaveLength(1);
|
||||
expect(parsed.toolCalls[0].toolName).toBe("read");
|
||||
expect(parsed.toolCalls[0].result).toBe("file contents");
|
||||
expect(parsed.toolCalls[0].isError).toBe(false);
|
||||
});
|
||||
|
||||
it("handles errors in tool execution", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "read",
|
||||
args: { path: "/missing.txt" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool_1",
|
||||
toolName: "read",
|
||||
result: "File not found",
|
||||
isError: true,
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.toolCalls).toHaveLength(1);
|
||||
expect(parsed.toolCalls[0].isError).toBe(true);
|
||||
expect(parsed.toolCalls[0].result).toBe("File not found");
|
||||
});
|
||||
|
||||
it("extracts usage and cost from turn_end events", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Response with usage",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
totalTokens: 170,
|
||||
cost: {
|
||||
input: 0.001,
|
||||
output: 0.0015,
|
||||
cacheRead: 0.0001,
|
||||
cacheWrite: 0,
|
||||
total: 0.0026,
|
||||
},
|
||||
},
|
||||
},
|
||||
toolResults: [],
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.usage.inputTokens).toBe(100);
|
||||
expect(parsed.usage.outputTokens).toBe(50);
|
||||
expect(parsed.usage.cachedInputTokens).toBe(20);
|
||||
expect(parsed.usage.costUsd).toBeCloseTo(0.0026, 4);
|
||||
});
|
||||
|
||||
it("accumulates usage from multiple turns", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "First response",
|
||||
usage: {
|
||||
input: 50,
|
||||
output: 25,
|
||||
cacheRead: 0,
|
||||
cost: { total: 0.001 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "turn_end",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Second response",
|
||||
usage: {
|
||||
input: 30,
|
||||
output: 20,
|
||||
cacheRead: 10,
|
||||
cost: { total: 0.0015 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.usage.inputTokens).toBe(80);
|
||||
expect(parsed.usage.outputTokens).toBe(45);
|
||||
expect(parsed.usage.cachedInputTokens).toBe(10);
|
||||
expect(parsed.usage.costUsd).toBeCloseTo(0.0025, 4);
|
||||
});
|
||||
|
||||
it("handles standalone usage events with Pi format", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "usage",
|
||||
usage: {
|
||||
input: 200,
|
||||
output: 100,
|
||||
cacheRead: 50,
|
||||
cost: { total: 0.005 },
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.usage.inputTokens).toBe(200);
|
||||
expect(parsed.usage.outputTokens).toBe(100);
|
||||
expect(parsed.usage.cachedInputTokens).toBe(50);
|
||||
expect(parsed.usage.costUsd).toBe(0.005);
|
||||
});
|
||||
|
||||
it("handles standalone usage events with generic format", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "usage",
|
||||
usage: {
|
||||
inputTokens: 150,
|
||||
outputTokens: 75,
|
||||
cachedInputTokens: 25,
|
||||
costUsd: 0.003,
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parsePiJsonl(stdout);
|
||||
expect(parsed.usage.inputTokens).toBe(150);
|
||||
expect(parsed.usage.outputTokens).toBe(75);
|
||||
expect(parsed.usage.cachedInputTokens).toBe(25);
|
||||
expect(parsed.usage.costUsd).toBe(0.003);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPiUnknownSessionError", () => {
|
||||
it("detects unknown session errors", () => {
|
||||
expect(isPiUnknownSessionError("session not found: s_123", "")).toBe(true);
|
||||
expect(isPiUnknownSessionError("", "unknown session id")).toBe(true);
|
||||
expect(isPiUnknownSessionError("", "no session available")).toBe(true);
|
||||
expect(isPiUnknownSessionError("all good", "")).toBe(false);
|
||||
expect(isPiUnknownSessionError("working fine", "no errors")).toBe(false);
|
||||
});
|
||||
});
|
||||
211
packages/adapters/pi-local/src/server/parse.ts
Normal file
211
packages/adapters/pi-local/src/server/parse.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
interface ParsedPiOutput {
|
||||
sessionId: string | null;
|
||||
messages: string[];
|
||||
errors: string[];
|
||||
usage: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
costUsd: number;
|
||||
};
|
||||
finalMessage: string | null;
|
||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; result: string | null; isError: boolean }>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function parsePiJsonl(stdout: string): ParsedPiOutput {
|
||||
const result: ParsedPiOutput = {
|
||||
sessionId: null,
|
||||
messages: [],
|
||||
errors: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
costUsd: 0,
|
||||
},
|
||||
finalMessage: null,
|
||||
toolCalls: [],
|
||||
};
|
||||
|
||||
let currentToolCall: { toolCallId: string; toolName: string; args: unknown } | null = null;
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const eventType = asString(event.type, "");
|
||||
|
||||
// RPC protocol messages - skip these (internal implementation detail)
|
||||
if (eventType === "response" || eventType === "extension_ui_request" || eventType === "extension_ui_response" || eventType === "extension_error") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Agent lifecycle
|
||||
if (eventType === "agent_start") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "agent_end") {
|
||||
const messages = event.messages as Array<Record<string, unknown>> | undefined;
|
||||
if (messages && messages.length > 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage?.role === "assistant") {
|
||||
const content = lastMessage.content as string | Array<{ type: string; text?: string }>;
|
||||
result.finalMessage = extractTextContent(content);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Turn lifecycle
|
||||
if (eventType === "turn_start") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "turn_end") {
|
||||
const message = asRecord(event.message);
|
||||
if (message) {
|
||||
const content = message.content as string | Array<{ type: string; text?: string }>;
|
||||
const text = extractTextContent(content);
|
||||
if (text) {
|
||||
result.finalMessage = text;
|
||||
result.messages.push(text);
|
||||
}
|
||||
|
||||
// Extract usage and cost from assistant message
|
||||
const usage = asRecord(message.usage);
|
||||
if (usage) {
|
||||
result.usage.inputTokens += asNumber(usage.input, 0);
|
||||
result.usage.outputTokens += asNumber(usage.output, 0);
|
||||
result.usage.cachedInputTokens += asNumber(usage.cacheRead, 0);
|
||||
|
||||
// Pi stores cost in usage.cost.total (and broken down in usage.cost.input, etc.)
|
||||
const cost = asRecord(usage.cost);
|
||||
if (cost) {
|
||||
result.usage.costUsd += asNumber(cost.total, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool results are in toolResults array
|
||||
const toolResults = event.toolResults as Array<Record<string, unknown>> | undefined;
|
||||
if (toolResults) {
|
||||
for (const tr of toolResults) {
|
||||
const toolCallId = asString(tr.toolCallId, "");
|
||||
const content = tr.content;
|
||||
const isError = tr.isError === true;
|
||||
|
||||
// Find matching tool call by toolCallId
|
||||
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
|
||||
if (existingCall) {
|
||||
existingCall.result = typeof content === "string" ? content : JSON.stringify(content);
|
||||
existingCall.isError = isError;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Message updates (streaming)
|
||||
if (eventType === "message_update") {
|
||||
const assistantEvent = asRecord(event.assistantMessageEvent);
|
||||
if (assistantEvent) {
|
||||
const msgType = asString(assistantEvent.type, "");
|
||||
if (msgType === "text_delta") {
|
||||
const delta = asString(assistantEvent.delta, "");
|
||||
if (delta) {
|
||||
// Append to last message or create new
|
||||
if (result.messages.length === 0) {
|
||||
result.messages.push(delta);
|
||||
} else {
|
||||
result.messages[result.messages.length - 1] += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tool execution
|
||||
if (eventType === "tool_execution_start") {
|
||||
const toolCallId = asString(event.toolCallId, "");
|
||||
const toolName = asString(event.toolName, "");
|
||||
const args = event.args;
|
||||
currentToolCall = { toolCallId, toolName, args };
|
||||
result.toolCalls.push({
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
result: null,
|
||||
isError: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventType === "tool_execution_end") {
|
||||
const toolCallId = asString(event.toolCallId, "");
|
||||
const toolName = asString(event.toolName, "");
|
||||
const toolResult = event.result;
|
||||
const isError = event.isError === true;
|
||||
|
||||
// Find the tool call by toolCallId (not toolName, to handle multiple calls to same tool)
|
||||
const existingCall = result.toolCalls.find((tc) => tc.toolCallId === toolCallId);
|
||||
if (existingCall) {
|
||||
existingCall.result = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
|
||||
existingCall.isError = isError;
|
||||
}
|
||||
currentToolCall = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Usage tracking if available in the event (fallback for standalone usage events)
|
||||
if (eventType === "usage" || event.usage) {
|
||||
const usage = asRecord(event.usage);
|
||||
if (usage) {
|
||||
// Support both Pi format (input/output/cacheRead) and generic format (inputTokens/outputTokens/cachedInputTokens)
|
||||
result.usage.inputTokens += asNumber(usage.inputTokens ?? usage.input, 0);
|
||||
result.usage.outputTokens += asNumber(usage.outputTokens ?? usage.output, 0);
|
||||
result.usage.cachedInputTokens += asNumber(usage.cachedInputTokens ?? usage.cacheRead, 0);
|
||||
|
||||
// Cost may be in usage.costUsd (direct) or usage.cost.total (Pi format)
|
||||
const cost = asRecord(usage.cost);
|
||||
if (cost) {
|
||||
result.usage.costUsd += asNumber(cost.total ?? usage.costUsd, 0);
|
||||
} else {
|
||||
result.usage.costUsd += asNumber(usage.costUsd, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isPiUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+not\s+found|session\s+.*\s+not\s+found|no\s+session/i.test(haystack);
|
||||
}
|
||||
276
packages/adapters/pi-local/src/server/test.ts
Normal file
276
packages/adapters/pi-local/src/server/test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
asStringArray,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { discoverPiModelsCached } from "./models.js";
|
||||
import { parsePiJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const PI_AUTH_REQUIRED_RE =
|
||||
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|free\s+usage\s+exceeded)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "pi");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
checks.push({
|
||||
code: "pi_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "pi_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "pi_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
checks.push({
|
||||
code: "pi_command_skipped",
|
||||
level: "warn",
|
||||
message: "Skipped command check because working directory validation failed.",
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "pi_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "pi_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable");
|
||||
|
||||
if (canRunProbe) {
|
||||
try {
|
||||
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "pi_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.length} model(s) from Pi.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "pi_models_empty",
|
||||
level: "warn",
|
||||
message: "Pi returned no models.",
|
||||
hint: "Run `pi --list-models` and verify provider authentication.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "pi_models_discovery_failed",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Pi model discovery failed.",
|
||||
hint: "Run `pi --list-models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
if (!configuredModel) {
|
||||
checks.push({
|
||||
code: "pi_model_required",
|
||||
level: "error",
|
||||
message: "Pi requires a configured model in provider/model format.",
|
||||
hint: "Set adapterConfig.model using an ID from `pi --list-models`.",
|
||||
});
|
||||
} else if (canRunProbe) {
|
||||
// Verify model is in the list
|
||||
try {
|
||||
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
|
||||
const modelExists = discovered.some((m: { id: string }) => m.id === configuredModel);
|
||||
if (modelExists) {
|
||||
checks.push({
|
||||
code: "pi_model_configured",
|
||||
level: "info",
|
||||
message: `Configured model: ${configuredModel}`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "pi_model_not_found",
|
||||
level: "warn",
|
||||
message: `Configured model "${configuredModel}" not found in available models.`,
|
||||
hint: "Run `pi --list-models` and choose a currently available provider/model ID.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If we can't verify, just note it
|
||||
checks.push({
|
||||
code: "pi_model_configured",
|
||||
level: "info",
|
||||
message: `Configured model: ${configuredModel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && configuredModel) {
|
||||
// Parse model for probe
|
||||
const provider = configuredModel.includes("/")
|
||||
? configuredModel.slice(0, configuredModel.indexOf("/"))
|
||||
: "";
|
||||
const modelId = configuredModel.includes("/")
|
||||
? configuredModel.slice(configuredModel.indexOf("/") + 1)
|
||||
: configuredModel;
|
||||
const thinking = asString(config.thinking, "").trim();
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["-p", "Respond with hello.", "--mode", "json"];
|
||||
if (provider) args.push("--provider", provider);
|
||||
if (modelId) args.push("--model", modelId);
|
||||
if (thinking) args.push("--thinking", thinking);
|
||||
args.push("--tools", "read");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parsePiJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errors[0] ?? null);
|
||||
const authEvidence = `${parsed.errors.join("\n")}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "pi_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Pi hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, run Pi manually in this working directory.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0 && parsed.errors.length === 0) {
|
||||
const summary = (parsed.finalMessage || parsed.messages.join(" ")).trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "pi_hello_probe_passed" : "pi_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Pi hello probe succeeded."
|
||||
: "Pi probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Run `pi --mode json` manually and prompt `Respond with hello` to inspect output.",
|
||||
}),
|
||||
});
|
||||
} else if (PI_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "pi_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Pi is installed, but provider authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Set provider API key environment variable (e.g., ANTHROPIC_API_KEY, XAI_API_KEY) and retry.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "pi_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Pi hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `pi --mode json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "pi_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Pi hello probe failed.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Run `pi --mode json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
71
packages/adapters/pi-local/src/ui/build-config.ts
Normal file
71
packages/adapters/pi-local/src/ui/build-config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
|
||||
|
||||
// Pi sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 20;
|
||||
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = v.extraArgs;
|
||||
if (v.args) ac.args = v.args;
|
||||
|
||||
return ac;
|
||||
}
|
||||
2
packages/adapters/pi-local/src/ui/index.ts
Normal file
2
packages/adapters/pi-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parsePiStdoutLine } from "./parse-stdout.js";
|
||||
export { buildPiLocalConfig } from "./build-config.js";
|
||||
147
packages/adapters/pi-local/src/ui/parse-stdout.ts
Normal file
147
packages/adapters/pi-local/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function parsePiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
// RPC protocol messages - filter these out (internal implementation detail)
|
||||
if (type === "response" || type === "extension_ui_request" || type === "extension_ui_response" || type === "extension_error") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Agent lifecycle
|
||||
if (type === "agent_start") {
|
||||
return [{ kind: "system", ts, text: "Pi agent started" }];
|
||||
}
|
||||
|
||||
if (type === "agent_end") {
|
||||
return [{ kind: "system", ts, text: "Pi agent finished" }];
|
||||
}
|
||||
|
||||
// Turn lifecycle
|
||||
if (type === "turn_start") {
|
||||
return [{ kind: "system", ts, text: "Turn started" }];
|
||||
}
|
||||
|
||||
if (type === "turn_end") {
|
||||
const message = asRecord(parsed.message);
|
||||
const toolResults = parsed.toolResults as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
|
||||
if (message) {
|
||||
const content = message.content as string | Array<{ type: string; text?: string }>;
|
||||
const text = extractTextContent(content);
|
||||
if (text) {
|
||||
entries.push({ kind: "assistant", ts, text });
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool results
|
||||
if (toolResults) {
|
||||
for (const tr of toolResults) {
|
||||
const content = tr.content;
|
||||
const isError = tr.isError === true;
|
||||
const contentStr = typeof content === "string" ? content : JSON.stringify(content);
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: asString(tr.toolCallId, "unknown"),
|
||||
content: contentStr,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries.length > 0 ? entries : [{ kind: "system", ts, text: "Turn ended" }];
|
||||
}
|
||||
|
||||
// Message streaming
|
||||
if (type === "message_start") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "message_update") {
|
||||
const assistantEvent = asRecord(parsed.assistantMessageEvent);
|
||||
if (assistantEvent) {
|
||||
const msgType = asString(assistantEvent.type);
|
||||
if (msgType === "text_delta") {
|
||||
const delta = asString(assistantEvent.delta);
|
||||
if (delta) {
|
||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "message_end") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Tool execution
|
||||
if (type === "tool_execution_start") {
|
||||
const toolName = asString(parsed.toolName);
|
||||
const args = parsed.args;
|
||||
if (toolName) {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input: args,
|
||||
}];
|
||||
}
|
||||
return [{ kind: "system", ts, text: `Tool started` }];
|
||||
}
|
||||
|
||||
if (type === "tool_execution_update") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "tool_execution_end") {
|
||||
const toolCallId = asString(parsed.toolCallId);
|
||||
const result = parsed.result;
|
||||
const isError = parsed.isError === true;
|
||||
const contentStr = typeof result === "string" ? result : JSON.stringify(result);
|
||||
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: toolCallId || "unknown",
|
||||
content: contentStr,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
8
packages/adapters/pi-local/tsconfig.json
Normal file
8
packages/adapters/pi-local/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/adapters/pi-local/vitest.config.ts
Normal file
7
packages/adapters/pi-local/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
] as const;
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
||||
'@paperclipai/adapter-opencode-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/opencode-local
|
||||
'@paperclipai/adapter-pi-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/pi-local
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapter-utils
|
||||
@@ -165,6 +168,22 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/adapters/pi-local:
|
||||
dependencies:
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../adapter-utils
|
||||
picocolors:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
'@paperclipai/shared':
|
||||
@@ -223,6 +242,9 @@ importers:
|
||||
'@paperclipai/adapter-opencode-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/opencode-local
|
||||
'@paperclipai/adapter-pi-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/pi-local
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapter-utils
|
||||
@@ -344,6 +366,9 @@ importers:
|
||||
'@paperclipai/adapter-opencode-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/opencode-local
|
||||
'@paperclipai/adapter-pi-local':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapters/pi-local
|
||||
'@paperclipai/adapter-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/adapter-utils
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
|
||||
@@ -37,6 +37,15 @@ import {
|
||||
} from "@paperclipai/adapter-openclaw";
|
||||
import { listCodexModels } from "./codex-models.js";
|
||||
import { listCursorModels } from "./cursor-models.js";
|
||||
import {
|
||||
execute as piExecute,
|
||||
testEnvironment as piTestEnvironment,
|
||||
sessionCodec as piSessionCodec,
|
||||
listPiModels,
|
||||
} from "@paperclipai/adapter-pi-local/server";
|
||||
import {
|
||||
agentConfigurationDoc as piAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-pi-local";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
@@ -93,8 +102,19 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const piLocalAdapter: ServerAdapterModule = {
|
||||
type: "pi_local",
|
||||
execute: piExecute,
|
||||
testEnvironment: piTestEnvironment,
|
||||
sessionCodec: piSessionCodec,
|
||||
models: [],
|
||||
listModels: listPiModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: piAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
||||
@@ -133,7 +133,7 @@ export async function createApp(
|
||||
if (uiDist) {
|
||||
app.use(express.static(uiDist));
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.sendFile(path.join(uiDist, "index.html"));
|
||||
res.sendFile("index.html", { root: uiDist });
|
||||
});
|
||||
} else {
|
||||
console.warn("[paperclip] UI dist not found; running in API-only mode");
|
||||
|
||||
@@ -45,7 +45,7 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount;
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
||||
47
ui/src/adapters/pi-local/config-fields.tsx
Normal file
47
ui/src/adapters/pi-local/config-fields.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||
|
||||
export function PiLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.instructionsFilePath ?? ""
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"instructionsFilePath",
|
||||
String(config.instructionsFilePath ?? ""),
|
||||
)
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ instructionsFilePath: v })
|
||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="/absolute/path/to/AGENTS.md"
|
||||
/>
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/pi-local/index.ts
Normal file
12
ui/src/adapters/pi-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parsePiStdoutLine } from "@paperclipai/adapter-pi-local/ui";
|
||||
import { PiLocalConfigFields } from "./config-fields";
|
||||
import { buildPiLocalConfig } from "@paperclipai/adapter-pi-local/ui";
|
||||
|
||||
export const piLocalUIAdapter: UIAdapterModule = {
|
||||
type: "pi_local",
|
||||
label: "Pi (local)",
|
||||
parseStdoutLine: parsePiStdoutLine,
|
||||
ConfigFields: PiLocalConfigFields,
|
||||
buildAdapterConfig: buildPiLocalConfig,
|
||||
};
|
||||
@@ -3,12 +3,13 @@ import { claudeLocalUIAdapter } from "./claude-local";
|
||||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { cursorLocalUIAdapter } from "./cursor";
|
||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||
import { piLocalUIAdapter } from "./pi-local";
|
||||
import { openClawUIAdapter } from "./openclaw";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
|
||||
@@ -53,6 +53,7 @@ type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "process"
|
||||
| "http"
|
||||
@@ -665,6 +666,12 @@ export function OnboardingWizard() {
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "pi_local" as const,
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
label: "OpenClaw",
|
||||
@@ -723,7 +730,7 @@ export function OnboardingWizard() {
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<span className="absolute -top-1.5 -right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
@@ -741,6 +748,7 @@ export function OnboardingWizard() {
|
||||
{(adapterType === "claude_local" ||
|
||||
adapterType === "codex_local" ||
|
||||
adapterType === "opencode_local" ||
|
||||
adapterType === "pi_local" ||
|
||||
adapterType === "cursor") && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
||||
@@ -468,7 +468,7 @@ export function AgentDetail() {
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Invoke</span>
|
||||
<span className="hidden sm:inline">Run Heartbeat</span>
|
||||
</Button>
|
||||
{agent.status === "paused" ? (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user