pi-feats 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -22,6 +22,7 @@ import { applicationExecutionContext, type ContextMemoryExecutionLog } from "../
|
|
|
22
22
|
import { contextMemoryCharacters, contextMemoryConfig, contextMemoryLimit, memoryPath, readMemory } from "../lib/context-memory.ts";
|
|
23
23
|
import { isSandboxEnabled } from "../lib/profile-sandbox.ts";
|
|
24
24
|
import { handlerEnvironment, HOST_SSH_CREDENTIAL_KEYS, parseProfileEnv, profileEnvironment } from "../lib/profile-env.ts";
|
|
25
|
+
import { piCommand } from "../lib/pi-command.ts";
|
|
25
26
|
import { PulseStore } from "../pulse/store.ts";
|
|
26
27
|
import { SkillSourceStore } from "../skill-sources/store.ts";
|
|
27
28
|
|
|
@@ -38,6 +39,7 @@ type ChatBody = { message: string };
|
|
|
38
39
|
type ChatParams = { profile?: string; sessionId: string };
|
|
39
40
|
|
|
40
41
|
const MAX_BODY_BYTES = 1_048_576;
|
|
42
|
+
const MAX_APPLICATION_BODY_BYTES = 200 * 1024 * 1024;
|
|
41
43
|
let serverPromise: Promise<FastifyInstance> | undefined;
|
|
42
44
|
// Some third-party extensions read PI_CODING_AGENT_DIR while loading.
|
|
43
45
|
// Only bootstrap is serialized so each runtime captures its own profile.
|
|
@@ -79,12 +81,32 @@ function objectBody(input: unknown): Record<string, unknown> {
|
|
|
79
81
|
return input as Record<string, unknown>;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
async function hasOpenRouterCredential(directory: string, environment: NodeJS.ProcessEnv): Promise<boolean> {
|
|
85
|
+
if (environment.OPENROUTER_API_KEY?.trim()) return true;
|
|
86
|
+
try {
|
|
87
|
+
const auth: unknown = JSON.parse(await readFile(join(directory, "auth.json"), "utf8"));
|
|
88
|
+
return !!auth && typeof auth === "object" && !Array.isArray(auth) && typeof (auth as { openrouter?: { key?: unknown } }).openrouter?.key === "string" && !!(auth as { openrouter: { key: string } }).openrouter.key.trim();
|
|
89
|
+
} catch { return false; }
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
function resourceKind(value: string): ResourceKind {
|
|
83
93
|
if (!(RESOURCE_KINDS as readonly string[]).includes(value)) throw Object.assign(new Error("Unknown resource type."), { status: 404 });
|
|
84
94
|
return value as ResourceKind;
|
|
85
95
|
}
|
|
86
96
|
function skillSourceIdentifier(value: string): string { return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^[^a-z]+/, "").replace(/^-+|-+$/g, "").slice(0, 64).replace(/-+$/g, ""); }
|
|
87
97
|
|
|
98
|
+
function activityFromAgentEvent(event: Record<string, any>): Record<string, unknown> | undefined {
|
|
99
|
+
if (event.type === "tool_execution_start") return { id: String(event.toolCallId), kind: "tool", status: "started", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: `Running ${String(event.toolName)}…` };
|
|
100
|
+
if (event.type === "tool_execution_update") return { id: String(event.toolCallId), kind: "tool", status: "running", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: `Running ${String(event.toolName)}…` };
|
|
101
|
+
if (event.type === "tool_execution_end") return { id: String(event.toolCallId), kind: "tool", status: event.isError ? "failed" : "completed", name: String(event.toolName), parentToolCallId: event.parentToolCallId, summary: event.isError ? `${String(event.toolName)} failed.` : `${String(event.toolName)} completed.` };
|
|
102
|
+
if (event.type === "agent_start") return { id: "agent", kind: "agent", status: "running", name: "Pi", summary: "Pi is working…" };
|
|
103
|
+
if (event.type === "agent_settled") return { id: "agent", kind: "agent", status: "completed", name: "Pi", summary: "Pi completed its work." };
|
|
104
|
+
const update = event.type === "message_update" ? event.assistantMessageEvent as Record<string, unknown> | undefined : undefined;
|
|
105
|
+
if (update?.type === "thinking_start" || update?.type === "thinking_delta") return { id: `thinking-${String(update.contentIndex ?? 0)}`, kind: "thinking", status: "running", name: "Thinking", summary: "Pi is thinking…" };
|
|
106
|
+
if (update?.type === "thinking_end") return { id: `thinking-${String(update.contentIndex ?? 0)}`, kind: "thinking", status: "completed", name: "Thinking", summary: "Pi finished thinking." };
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
function textFromMessage(message: unknown): string {
|
|
89
111
|
const content = (message as { content?: unknown })?.content;
|
|
90
112
|
if (typeof content === "string") return content;
|
|
@@ -150,10 +172,10 @@ class ApiServer {
|
|
|
150
172
|
|
|
151
173
|
async getIdentityMapping(application: string, identityKey: string) { return this.applications.identityMapping(application, identityKey); }
|
|
152
174
|
async automaticApplicationSessionPrefix(application: string, identityKey: string) { return this.applications.automaticPrefix(application, identityKey); }
|
|
153
|
-
async getApplicationSession(application: string, profile: string, prefix: string) { return this.applications.session(application, profile, prefix); }
|
|
175
|
+
async getApplicationSession(application: string, profile: string, prefix: string, sessionType: "stateful" | "stateless" = "stateful") { return this.applications.session(application, profile, prefix, sessionType); }
|
|
154
176
|
async getActiveApplicationSession(application: string, profile: string, prefix: string) { return this.applications.activeSession(application, profile, prefix); }
|
|
155
177
|
async nextApplicationSessionId(application: string, profile: string, prefix: string) { return this.applications.nextSessionId(application, profile, prefix); }
|
|
156
|
-
async recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { this.applications.createSession(application, profile, prefix, sessionId, rollover); }
|
|
178
|
+
async recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean, sessionType: "stateful" | "stateless" = "stateful") { this.applications.createSession(application, profile, prefix, sessionId, rollover, sessionType); }
|
|
157
179
|
async touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { this.applications.touchSession(application, profile, prefix, sessionId, identityKey); }
|
|
158
180
|
async getApplicationWorkingSessions(profile: string, application: string) { return this.profiles.readApplicationWorkingSessions(profile, application); }
|
|
159
181
|
async setApplicationWorkingSession(profile: string, application: string, key: string, session: { sessionId: string; updatedAt: string; reason: "message" | "rollover" }): Promise<void> { await this.profiles.setApplicationWorkingSession(profile, application, key, session); }
|
|
@@ -200,18 +222,46 @@ class ApiServer {
|
|
|
200
222
|
return SessionManager.listAll(sessionDir);
|
|
201
223
|
}
|
|
202
224
|
|
|
203
|
-
|
|
225
|
+
private isPulseOnlySession(profile: string, session: { path: string; cwd: string }): boolean {
|
|
226
|
+
const entries = SessionManager.open(session.path, this.sessionDirectory(profile), session.cwd).getBranch() as Array<{ type?: string }>;
|
|
227
|
+
const relevant = entries.filter((entry) => entry.type !== "session_info");
|
|
228
|
+
return relevant.length > 0 && relevant.some((entry) => entry.type === "pulse") && relevant.every((entry) => entry.type === "pulse");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async deleteProfile(profile: string): Promise<void> {
|
|
232
|
+
if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
|
|
204
233
|
await this.profiles.readSettings(profile);
|
|
234
|
+
const handles = [...this.sessions.values()].filter((handle) => handle.profile === profile);
|
|
235
|
+
if (handles.some((handle) => handle.busy) || [...this.sandboxBusy].some((key) => key.startsWith(`${profile}:`))) throw Object.assign(new Error("The profile has a session that is processing a request."), { status: 409 });
|
|
236
|
+
for (const handle of handles) { handle.session.dispose(); this.sessions.delete(`${profile}:${handle.sessionId}`); }
|
|
237
|
+
this.runtimes.delete(this.profileDirectory(profile));
|
|
238
|
+
this.toolDiscoveries.delete(profile);
|
|
239
|
+
this.profiles.clearExtensionTools(profile);
|
|
240
|
+
await this.profiles.delete(profile);
|
|
241
|
+
this.applications.deleteProfile(profile);
|
|
242
|
+
this.pulses.deleteProfile(profile);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async listSessions(profile: string, includePulseSessions = false): Promise<Array<Record<string, unknown>>> {
|
|
246
|
+
await this.profiles.readSettings(profile);
|
|
247
|
+
const pulseExecutions = this.pulses.executionSessions(profile);
|
|
205
248
|
const sessions = await this.listProfileSessions(profile);
|
|
206
|
-
return sessions.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
249
|
+
return sessions.flatMap((session) => {
|
|
250
|
+
const execution = pulseExecutions.get(session.id), pulseOnly = !execution && this.isPulseOnlySession(profile, session);
|
|
251
|
+
if (!includePulseSessions && (execution || pulseOnly)) return [];
|
|
252
|
+
const pulse = execution ? { id: execution.pulseId, name: execution.pulseName, type: execution.pulseType, runId: execution.runId, status: execution.status, startedAt: execution.startedAt, finishedAt: execution.finishedAt, source: "execution" } : pulseOnly ? { source: "injected-result" } : undefined;
|
|
253
|
+
return [{
|
|
254
|
+
id: session.id,
|
|
255
|
+
name: session.name,
|
|
256
|
+
cwd: session.cwd,
|
|
257
|
+
createdAt: session.created.toISOString(),
|
|
258
|
+
updatedAt: session.modified.toISOString(),
|
|
259
|
+
messageCount: session.messageCount,
|
|
260
|
+
preview: session.firstMessage,
|
|
261
|
+
kind: execution ? "pulse-execution" : pulseOnly ? "pulse-only" : "conversation",
|
|
262
|
+
...(pulse ? { pulse } : {}),
|
|
263
|
+
}];
|
|
264
|
+
});
|
|
215
265
|
}
|
|
216
266
|
|
|
217
267
|
async createSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
|
|
@@ -283,6 +333,8 @@ class ApiServer {
|
|
|
283
333
|
const info = sessions.find((session) => session.id === id);
|
|
284
334
|
if (!info) throw Object.assign(new Error("Session not found"), { status: 404 });
|
|
285
335
|
const session = SessionManager.open(info.path, this.sessionDirectory(profile), this.options.cwd);
|
|
336
|
+
// System prompt entries and empty assistant messages are session internals,
|
|
337
|
+
// not conversation content. Neither must become an empty chat bubble.
|
|
286
338
|
const entries = session.getBranch().filter((entry) => entry.type !== "session_info").map((entry) => {
|
|
287
339
|
if (entry.type === "message") {
|
|
288
340
|
const message = entry.message as { role?: string; content?: unknown; toolName?: string; isError?: boolean; timestamp?: number };
|
|
@@ -296,7 +348,7 @@ class ApiServer {
|
|
|
296
348
|
}
|
|
297
349
|
if (entry.type === "compaction" || entry.type === "branch_summary") return { id: entry.id, type: entry.type, timestamp: entry.timestamp, summary: (entry as any).summary };
|
|
298
350
|
return { id: entry.id, type: entry.type, timestamp: entry.timestamp };
|
|
299
|
-
});
|
|
351
|
+
}).filter((entry: any) => entry.type !== "message" || (entry.role !== "system" && (entry.role !== "assistant" || entry.content.some((block: { text?: unknown }) => typeof block.text === "string" && block.text.trim().length > 0) || entry.tools.length > 0 || !!entry.toolName)));
|
|
300
352
|
const limit = Math.max(1, Math.min(16, Math.floor(requestedLimit) || 16));
|
|
301
353
|
const end = before ? entries.findIndex((entry) => entry.id === before) : entries.length;
|
|
302
354
|
if (before && end < 0) throw Object.assign(new Error("Conversation cursor not found."), { status: 400 });
|
|
@@ -309,7 +361,8 @@ class ApiServer {
|
|
|
309
361
|
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
|
|
310
362
|
const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
|
|
311
363
|
delete environment.PI_API_WORKER;
|
|
312
|
-
const
|
|
364
|
+
const pi = piCommand();
|
|
365
|
+
const child = spawn(pi.command, [...pi.args, "profile", profile, "--no-session", "--print", ""], {
|
|
313
366
|
cwd: agentDir,
|
|
314
367
|
env: environment,
|
|
315
368
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -335,7 +388,8 @@ class ApiServer {
|
|
|
335
388
|
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir };
|
|
336
389
|
delete environment.PI_API_WORKER;
|
|
337
390
|
return new Promise<string>((resolveRun, reject) => {
|
|
338
|
-
const
|
|
391
|
+
const pi = piCommand();
|
|
392
|
+
const child = spawn(pi.command, [...pi.args, "profile", profile, "--no-session", "--print", prompt], { cwd: agentDir, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
339
393
|
let stdout = "", stderr = "";
|
|
340
394
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
341
395
|
child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Handoff completion exited with ${code ?? "unknown"}`)));
|
|
@@ -350,6 +404,20 @@ class ApiServer {
|
|
|
350
404
|
});
|
|
351
405
|
}
|
|
352
406
|
|
|
407
|
+
async promptStateless(profile: string, id: string, history: Array<{ role: "user" | "assistant"; content: string }>, message: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
408
|
+
if (await this.sandboxed(profile)) throw Object.assign(new Error("Stateless sessions are not yet available for sandboxed profiles."), { status: 501 });
|
|
409
|
+
const agentDir = this.profileDirectory(profile); await this.profiles.readSettings(profile);
|
|
410
|
+
return this.withProfileEnvironment(agentDir, async () => {
|
|
411
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir); const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager }); await loader.reload();
|
|
412
|
+
let runtime = this.runtimes.get(agentDir); if (!runtime) { runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") }); this.runtimes.set(agentDir, runtime); }
|
|
413
|
+
const manager = SessionManager.inMemory(this.options.cwd);
|
|
414
|
+
for (const item of history) { const timestamp = Date.now(); if (item.role === "user") manager.appendMessage({ role: "user", content: item.content, timestamp }); else manager.appendMessage({ role: "assistant", content: [{ type: "text", text: item.content }], api: "openai-completions", provider: "external-history", model: "external-history", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp } as any); }
|
|
415
|
+
const { session } = await createAgentSession({ cwd: this.options.cwd, agentDir, sessionManager: manager, settingsManager, resourceLoader: loader, modelRuntime: await runtime });
|
|
416
|
+
let response = ""; const unsubscribe = session.subscribe((event) => { if (event.type === "message_end" && event.message.role === "assistant") response = textFromMessage(event.message); });
|
|
417
|
+
try { const run = () => session.prompt(message, { source: "rpc" }); if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, run); else await run(); response ||= session.getLastAssistantText() ?? ""; if (!response) throw Object.assign(new Error("Pi completed without an assistant response."), { status: 502, code: "STATELESS_RESPONSE_MISSING", stage: "response_extraction" }); return { profile, sessionId: id, response }; } finally { unsubscribe(); session.dispose(); }
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
353
421
|
async discoverExtensionTools(profile: string): Promise<void> {
|
|
354
422
|
const existing = this.toolDiscoveries.get(profile);
|
|
355
423
|
if (existing) return existing;
|
|
@@ -408,29 +476,42 @@ class ApiServer {
|
|
|
408
476
|
return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
|
|
409
477
|
}
|
|
410
478
|
|
|
411
|
-
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<string> {
|
|
479
|
+
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }, onEvent?: (event: Record<string, unknown>) => void): Promise<string> {
|
|
412
480
|
const key = `${profile}:${id}`;
|
|
413
481
|
if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
414
482
|
this.sandboxBusy.add(key);
|
|
415
483
|
try {
|
|
416
484
|
const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
|
|
417
485
|
if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
|
|
418
|
-
const workerEnv: NodeJS.ProcessEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}), ...(applicationContext ? { PI_APPLICATION_IDENTITY_KEY: applicationContext.identityKey, PI_APPLICATION_SLUG: applicationContext.application, PI_APPLICATION_SESSION_ID: id } : {}) };
|
|
486
|
+
const workerEnv: NodeJS.ProcessEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}), ...(applicationContext ? { PI_APPLICATION_IDENTITY_KEY: applicationContext.identityKey, PI_APPLICATION_SLUG: applicationContext.application, PI_APPLICATION_SESSION_ID: id, ...(applicationContext.directContextMemory !== undefined ? { PI_APPLICATION_DIRECT_CONTEXT_MEMORY: Buffer.from(applicationContext.directContextMemory, "utf8").toString("base64url") } : {}) } : {}) };
|
|
419
487
|
// The HTTP gateway is an API worker itself. Its children are agent
|
|
420
488
|
// runtimes, not additional HTTP servers.
|
|
421
489
|
delete workerEnv.PI_API_WORKER;
|
|
422
490
|
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolveRun, reject) => {
|
|
423
491
|
// Supply the native file path, not only its ID. The session manager
|
|
424
492
|
// otherwise scopes an ID lookup to the API worker CWD (`/` in Docker).
|
|
425
|
-
const
|
|
493
|
+
const pi = piCommand();
|
|
494
|
+
const child = spawn(pi.command, [...pi.args, "profile", profile, "--session", session.path, ...(onEvent ? ["--mode", "json"] : ["--print"]), message], {
|
|
426
495
|
// The sandboxed agent sees its profile directory as CWD, never the
|
|
427
496
|
// HTTP server's CWD or the caller's project directory.
|
|
428
|
-
cwd: this.profileDirectory(profile),
|
|
429
|
-
|
|
430
|
-
|
|
497
|
+
cwd: this.profileDirectory(profile), env: workerEnv, stdio: ["ignore", "pipe", "pipe"],
|
|
498
|
+
});
|
|
499
|
+
let stdout = ""; let stderr = ""; let jsonBuffer = ""; let finalResponse = "";
|
|
500
|
+
child.stdout.on("data", (chunk) => {
|
|
501
|
+
const text = String(chunk);
|
|
502
|
+
if (!onEvent) { stdout += text; onData?.(text); return; }
|
|
503
|
+
jsonBuffer += text;
|
|
504
|
+
const lines = jsonBuffer.split("\n"); jsonBuffer = lines.pop() ?? "";
|
|
505
|
+
for (const line of lines) {
|
|
506
|
+
if (!line.trim()) continue;
|
|
507
|
+
try {
|
|
508
|
+
const event = JSON.parse(line) as Record<string, unknown>; onEvent(event);
|
|
509
|
+
if (event.type === "message_update") { const update = event.assistantMessageEvent as Record<string, unknown> | undefined; if (update?.type === "text_delta" && typeof update.delta === "string") onData?.(update.delta); }
|
|
510
|
+
if (event.type === "message_end" && (event.message as Record<string, unknown> | undefined)?.role === "assistant") finalResponse = textFromMessage(event.message);
|
|
511
|
+
} catch { stderr += `Invalid JSON event from sandboxed Pi: ${line}\n`; }
|
|
512
|
+
}
|
|
513
|
+
stdout = finalResponse;
|
|
431
514
|
});
|
|
432
|
-
let stdout = ""; let stderr = "";
|
|
433
|
-
child.stdout.on("data", (chunk) => { const text = String(chunk); stdout += text; onData?.(text); });
|
|
434
515
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
435
516
|
child.once("error", reject);
|
|
436
517
|
child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
|
|
@@ -448,7 +529,7 @@ class ApiServer {
|
|
|
448
529
|
} finally { this.sandboxBusy.delete(key); }
|
|
449
530
|
}
|
|
450
531
|
|
|
451
|
-
async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
532
|
+
async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: string }): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
452
533
|
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
|
|
453
534
|
const handle = await this.getSession(profile, id);
|
|
454
535
|
if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
@@ -471,7 +552,7 @@ class ApiServer {
|
|
|
471
552
|
reply.raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" });
|
|
472
553
|
reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
473
554
|
try {
|
|
474
|
-
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext);
|
|
555
|
+
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext, (event) => { const activity = activityFromAgentEvent(event); if (activity) reply.raw.write(`event: activity\ndata: ${JSON.stringify(activity)}\n\n`); });
|
|
475
556
|
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
476
557
|
} catch (error) {
|
|
477
558
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -499,9 +580,10 @@ class ApiServer {
|
|
|
499
580
|
});
|
|
500
581
|
response.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
501
582
|
unsubscribe = handle.session.subscribe((event) => {
|
|
502
|
-
if (
|
|
503
|
-
|
|
504
|
-
}
|
|
583
|
+
if (closed) return;
|
|
584
|
+
const activity = activityFromAgentEvent(event as unknown as Record<string, any>);
|
|
585
|
+
if (activity) response.write(`event: activity\ndata: ${JSON.stringify(activity)}\n\n`);
|
|
586
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") response.write(`event: token\ndata: ${JSON.stringify({ text: event.assistantMessageEvent.delta })}\n\n`);
|
|
505
587
|
});
|
|
506
588
|
response.once("close", () => { closed = true; void handle.session.abort(); });
|
|
507
589
|
}
|
|
@@ -580,13 +662,14 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
580
662
|
};
|
|
581
663
|
const profileName = (request: FastifyRequest<{ Params: { profile: string } }>) => request.params.profile;
|
|
582
664
|
const pulseTick = async (action: "start" | "stop" | "status") => new Promise<string>((resolveRun, reject) => {
|
|
583
|
-
const
|
|
665
|
+
const pi = piCommand();
|
|
666
|
+
const child = spawn(pi.command, [...pi.args, "pulse", action], { cwd: options.cwd, env: { ...process.env, PI_API_WORKER: "" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
584
667
|
let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
|
|
585
668
|
child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Pulse command exited with ${code}`)));
|
|
586
669
|
});
|
|
587
670
|
const pulseTickStatus = async () => {
|
|
588
|
-
try { const state = JSON.parse(await readFile(join(options.agentDir, "pulse-tick.state.json"), "utf8")) as { pid?: unknown; startedAt?: unknown }; const pid = typeof state.pid === "number" ? state.pid : 0; let running = false; try { process.kill(pid, 0); running = pid > 0; } catch {} return { running, status: running
|
|
589
|
-
catch { return { running: false, status: "Pulse tick stopped." }; }
|
|
671
|
+
try { const state = JSON.parse(await readFile(join(options.agentDir, "pulse-tick.state.json"), "utf8")) as { pid?: unknown; startedAt?: unknown; lastHeartbeatAt?: unknown }; const pid = typeof state.pid === "number" ? state.pid : 0, heartbeat = typeof state.lastHeartbeatAt === "string" ? Date.parse(state.lastHeartbeatAt) : NaN; let running = false; try { process.kill(pid, 0); running = pid > 0; } catch {} if (!running) return { running: false, status: `Pulse tick abandoned (PID ${pid} is not running).`, state: "abandoned", lastHeartbeatAt: state.lastHeartbeatAt ?? null }; if (!Number.isNaN(heartbeat) && Date.now() - heartbeat > 2 * 60_000) return { running: false, status: `Pulse tick stale (last heartbeat ${state.lastHeartbeatAt}).`, state: "stale", lastHeartbeatAt: state.lastHeartbeatAt }; return { running: true, status: `Pulse tick running (PID ${pid}).`, state: "healthy", lastHeartbeatAt: state.lastHeartbeatAt ?? null }; }
|
|
672
|
+
catch { return { running: false, status: "Pulse tick stopped.", state: "stopped", lastHeartbeatAt: null }; }
|
|
590
673
|
};
|
|
591
674
|
server.get("/api/pulse/tick/status", async (request, reply) => {
|
|
592
675
|
if (!guard(request, reply)) return;
|
|
@@ -604,9 +687,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
604
687
|
});
|
|
605
688
|
server.post("/api/profiles", async (request, reply) => {
|
|
606
689
|
if (!guard(request, reply)) return;
|
|
607
|
-
const
|
|
690
|
+
const body = objectBody(request.body), name = body.name, cloneFrom = body.cloneFrom;
|
|
608
691
|
if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
|
|
609
|
-
|
|
692
|
+
if (cloneFrom !== undefined && typeof cloneFrom !== "string") throw Object.assign(new Error("The 'cloneFrom' field must be a string."), { status: 400 });
|
|
693
|
+
const profile = await api.profiles.create(name, { description: body.description, tags: body.tags }, cloneFrom);
|
|
610
694
|
return reply.code(201).send(profile);
|
|
611
695
|
});
|
|
612
696
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
@@ -614,13 +698,44 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
614
698
|
const settings = await api.profiles.readSettings(profileName(request));
|
|
615
699
|
return { name: profileName(request), path: api.profiles.directory(profileName(request)), hasSoul: true, hasRefine: true, policy: settings.profile ?? {} };
|
|
616
700
|
});
|
|
701
|
+
server.patch<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
702
|
+
if (!guard(request, reply)) return;
|
|
703
|
+
const body = objectBody(request.body);
|
|
704
|
+
if (body.description === undefined || body.tags === undefined) throw Object.assign(new Error("description and tags are required."), { status: 400 });
|
|
705
|
+
return { profile: await api.profiles.updateMetadata(profileName(request), { description: body.description, tags: body.tags }) };
|
|
706
|
+
});
|
|
617
707
|
server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
618
708
|
if (!guard(request, reply)) return;
|
|
619
709
|
if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
|
|
620
|
-
await api.
|
|
710
|
+
await api.deleteProfile(profileName(request));
|
|
621
711
|
return reply.code(204).send();
|
|
622
712
|
});
|
|
623
713
|
|
|
714
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
715
|
+
if (!guard(request, reply)) return;
|
|
716
|
+
const profile = profileName(request), directory = api.profiles.directory(profile);
|
|
717
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
718
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
719
|
+
const providers = new Map<string, Array<{ id: string; name: string }>>();
|
|
720
|
+
for (const model of runtime.getModels()) {
|
|
721
|
+
const models = providers.get(model.provider) ?? [];
|
|
722
|
+
models.push({ id: model.id, name: model.name ?? model.id });
|
|
723
|
+
providers.set(model.provider, models);
|
|
724
|
+
}
|
|
725
|
+
return { settings: await api.profiles.readSettings(profile), providers: [...providers.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([id, models]) => ({ id, models: models.sort((left, right) => left.name.localeCompare(right.name)) })) };
|
|
726
|
+
});
|
|
727
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
728
|
+
if (!guard(request, reply)) return;
|
|
729
|
+
const profile = profileName(request), body = objectBody(request.body), provider = body.provider, model = body.model;
|
|
730
|
+
if (typeof provider !== "string" || !provider || typeof model !== "string" || !model) throw Object.assign(new Error("provider and model must be non-empty strings."), { status: 400 });
|
|
731
|
+
const directory = api.profiles.directory(profile);
|
|
732
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
733
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
734
|
+
if (!runtime.getModel(provider, model)) throw Object.assign(new Error("The selected provider and model are not available."), { status: 400 });
|
|
735
|
+
const settings = await api.profiles.readSettings(profile);
|
|
736
|
+
return { settings: await api.profiles.writeSettings(profile, { ...settings, defaultProvider: provider, defaultModel: model }) };
|
|
737
|
+
});
|
|
738
|
+
|
|
624
739
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
|
|
625
740
|
if (!guard(request, reply)) return;
|
|
626
741
|
return { settings: await api.profiles.readSettings(profileName(request)) };
|
|
@@ -631,6 +746,43 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
631
746
|
return { settings: await api.profiles.writeSettings(profileName(request), settings) };
|
|
632
747
|
});
|
|
633
748
|
|
|
749
|
+
const workflowEvaluation = async (profile: string, value: unknown) => {
|
|
750
|
+
const input = objectBody(value), modelType = input.modelType;
|
|
751
|
+
if (modelType === "llm") return { modelType: "llm" as const };
|
|
752
|
+
const systemOne = objectBody(input.systemOne), provider = typeof systemOne.provider === "string" ? systemOne.provider.trim() : "", model = typeof systemOne.model === "string" ? systemOne.model.trim() : "", endpoint = systemOne.endpoint && typeof systemOne.endpoint === "object" && !Array.isArray(systemOne.endpoint) ? systemOne.endpoint as Record<string, unknown> : {}, baseUrl = typeof endpoint.baseUrl === "string" && endpoint.baseUrl.trim() ? endpoint.baseUrl.trim() : "https://openrouter.ai/api/", path = typeof endpoint.path === "string" && endpoint.path.trim() ? endpoint.path.trim().replace(/^\/+/, "") : "alpha/decisions";
|
|
753
|
+
try { const url = new URL(baseUrl); if (!/^https?:$/.test(url.protocol) || /^(?:https?:)?\/\//i.test(path)) throw new Error(); }
|
|
754
|
+
catch { throw Object.assign(new Error("Invalid System One endpoint configuration."), { status: 400 }); }
|
|
755
|
+
if (modelType !== "system_one" || !provider || !model || (systemOne.onUncertain !== undefined && systemOne.onUncertain !== "block")) throw Object.assign(new Error("Invalid Sequential Workflow evaluation configuration."), { status: 400 });
|
|
756
|
+
const directory = api.profiles.directory(profile), runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
757
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
758
|
+
if (!runtime.getModels().some((available) => available.provider === provider)) throw Object.assign(new Error("The selected provider is not available for this profile."), { status: 400 });
|
|
759
|
+
return { modelType: "system_one" as const, systemOne: { provider, model, endpoint: { baseUrl, path }, onUncertain: "block" as const } };
|
|
760
|
+
};
|
|
761
|
+
const configuredWorkflowEvaluation = async (profile: string, settings: ProfileSettings) => {
|
|
762
|
+
const value = (settings as Record<string, unknown>).sequentialWorkflow;
|
|
763
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { modelType: "llm" as const };
|
|
764
|
+
const evaluation = (value as Record<string, unknown>).evaluation;
|
|
765
|
+
return !evaluation || typeof evaluation !== "object" || Array.isArray(evaluation) ? { modelType: "llm" as const } : workflowEvaluation(profile, evaluation);
|
|
766
|
+
};
|
|
767
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/config", async (request, reply) => {
|
|
768
|
+
if (!guard(request, reply)) return;
|
|
769
|
+
const profile = profileName(request); return { evaluation: await configuredWorkflowEvaluation(profile, await api.profiles.readSettings(profile)) };
|
|
770
|
+
});
|
|
771
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/config", async (request, reply) => {
|
|
772
|
+
if (!guard(request, reply)) return;
|
|
773
|
+
const profile = profileName(request), evaluation = await workflowEvaluation(profile, objectBody(request.body).evaluation), settings = await api.profiles.readSettings(profile);
|
|
774
|
+
const sequentialWorkflow = { ...(((settings as Record<string, unknown>).sequentialWorkflow as Record<string, unknown> | undefined) ?? {}), evaluation };
|
|
775
|
+
await api.profiles.writeSettings(profile, { ...settings, sequentialWorkflow } as ProfileSettings);
|
|
776
|
+
return { evaluation };
|
|
777
|
+
});
|
|
778
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sequential-workflows/status", async (request, reply) => {
|
|
779
|
+
if (!guard(request, reply)) return;
|
|
780
|
+
const profile = profileName(request), evaluation = await configuredWorkflowEvaluation(profile, await api.profiles.readSettings(profile));
|
|
781
|
+
const directory = api.profiles.directory(profile), environment = await api.handlerEnvironment(profile);
|
|
782
|
+
const credentialConfigured = evaluation.modelType !== "system_one" || await hasOpenRouterCredential(directory, environment);
|
|
783
|
+
return { evaluation, credentialConfigured };
|
|
784
|
+
});
|
|
785
|
+
|
|
634
786
|
const documentRoute = (name: "soul" | "refine", file: "SOUL.md" | "REFINE.md") => {
|
|
635
787
|
server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
|
|
636
788
|
if (!guard(request, reply)) return;
|
|
@@ -740,9 +892,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
740
892
|
return { content };
|
|
741
893
|
});
|
|
742
894
|
|
|
743
|
-
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
895
|
+
server.get<{ Params: { profile: string }; Querystring: { includePulseSessions?: string; includePulseExecutions?: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
744
896
|
if (!guard(request, reply)) return;
|
|
745
|
-
return { sessions: await api.listSessions(profileName(request)) };
|
|
897
|
+
return { sessions: await api.listSessions(profileName(request), request.query.includePulseSessions === "true" || request.query.includePulseExecutions === "true") };
|
|
746
898
|
});
|
|
747
899
|
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
748
900
|
if (!guard(request, reply)) return;
|
|
@@ -784,6 +936,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
784
936
|
try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
|
|
785
937
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
786
938
|
});
|
|
939
|
+
server.post<{ Params: { profile: string; name: string; runId: string } }>("/api/profiles/:profile/pulses/:name/runs/:runId/finalize", async (request, reply) => {
|
|
940
|
+
if (!guard(request, reply)) return;
|
|
941
|
+
const status = objectBody(request.body).status;
|
|
942
|
+
if (status !== "success" && status !== "error") throw Object.assign(new Error("The 'status' field must be 'success' or 'error'."), { status: 400 });
|
|
943
|
+
try { return { run: api.pulses.finalizeRunning(request.params.name, profileName(request), request.params.runId, status) }; }
|
|
944
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 409 }); }
|
|
945
|
+
});
|
|
787
946
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
|
|
788
947
|
if (!guard(request, reply)) return;
|
|
789
948
|
await api.profiles.readSettings(profileName(request));
|
|
@@ -796,7 +955,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
796
955
|
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
797
956
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
798
957
|
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
799
|
-
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, profile: profileName(request) }) }); }
|
|
958
|
+
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: body.insertIntoApiSession !== false, profile: profileName(request) }) }); }
|
|
800
959
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
801
960
|
});
|
|
802
961
|
server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -806,11 +965,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
806
965
|
});
|
|
807
966
|
server.put<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
808
967
|
if (!guard(request, reply)) return;
|
|
809
|
-
const body = objectBody(request.body), fields = ["description", "schedule", "prompt"] as const;
|
|
968
|
+
const body = objectBody(request.body), fields = ["name", "description", "schedule", "prompt"] as const;
|
|
810
969
|
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
811
970
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
812
971
|
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
813
|
-
try {
|
|
972
|
+
try {
|
|
973
|
+
const profile = profileName(request), current = api.pulses.get(request.params.name, profile);
|
|
974
|
+
if (!current) throw new Error("Pulse not found.");
|
|
975
|
+
return { pulse: api.pulses.update(request.params.name, profile, { name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: typeof body.insertIntoApiSession === "boolean" ? body.insertIntoApiSession : current.insertIntoApiSession }) };
|
|
976
|
+
}
|
|
814
977
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
815
978
|
});
|
|
816
979
|
server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -826,7 +989,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
826
989
|
server.get<{ Params: { identifier: string } }>("/api/skill-sources/:identifier/skills", async (request, reply) => { if (!guard(request, reply)) return; return { skills:api.skillSources.skills(request.params.identifier) }; });
|
|
827
990
|
server.get<{ Params: { identifier: string; name: string } }>("/api/skill-sources/:identifier/skills/:name/document", async (request, reply) => { if (!guard(request, reply)) return; return api.skillSources.document(request.params.identifier, request.params.name); });
|
|
828
991
|
server.post<{ Params: { profile: string; identifier: string; name: string } }>("/api/profiles/:profile/skills/from-source/:identifier/:name", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request); await api.profiles.readSettings(profile); const destination=api.profiles.skillImportDirectory(profile); const skill=await api.skillSources.install(request.params.identifier,request.params.name,destination,profile); await api.profiles.refreshSkills(profile); return reply.code(201).send({ skill, destination }); });
|
|
829
|
-
server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/publish", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), body=objectBody(request.body), source=skillSource(request.query.source); if (typeof body.identifier !== "string" || !body.identifier) throw Object.assign(new Error("A Skill Source is required."), { status: 400 }); const skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); if (source === "shared" && profile !== "default") throw Object.assign(new Error("Shared Skills can only be published by the default profile."), { status: 403 }); const publication=await api.skillSources.publish(body.identifier, profile, request.params.name, skill.path); await api.profiles.refreshSkills(profile); return reply.code(publication.action === "created" ? 201 : 200).send({ publication }); });
|
|
992
|
+
server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/publish", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), body=objectBody(request.body), source=skillSource(request.query.source); if (typeof body.identifier !== "string" || !body.identifier) throw Object.assign(new Error("A Skill Source is required."), { status: 400 }); const skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); if (source === "shared" && profile !== "default") throw Object.assign(new Error("Shared Skills can only be published by the default profile."), { status: 403 }); const publication=await api.skillSources.publish(body.identifier, profile, request.params.name, skill.path, body.confirmOverwrite === true); if (publication.action !== "confirmation_required") await api.profiles.refreshSkills(profile); return reply.code(publication.action === "created" ? 201 : 200).send({ publication }); });
|
|
830
993
|
server.post<{ Params: { profile: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/skills/:name/sync", async (request, reply) => { if (!guard(request, reply)) return; const profile=profileName(request), source=skillSource(request.query.source), skill=await api.profiles.resource(profile, "skills", request.params.name, source); if (!skill.path) throw Object.assign(new Error("Skill path is not available."), { status: 404 }); const synchronization=await api.skillSources.syncInstallation(profile, request.params.name, skill.path); await api.profiles.refreshSkills(profile); return { synchronization }; });
|
|
831
994
|
|
|
832
995
|
const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
|
|
@@ -925,7 +1088,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
925
1088
|
server.post("/api/applications", async (request, reply) => {
|
|
926
1089
|
if (!guard(request, reply)) return; const body = objectBody(request.body);
|
|
927
1090
|
if (typeof body.name !== "string" || typeof body.slug !== "string") throw Object.assign(new Error("name and slug are required."), { status: 400 });
|
|
928
|
-
const responseMode = body.responseMode === "result" ? "result" : "ack"; const defaults: ApplicationSettings = { responseMode, inboundHandler: "inbound", transformHandlers: [] }; const record = applicationStore.create({ name: body.name, slug: body.slug, enabled: body.enabled !== false, responseMode, defaultProfile: typeof body.defaultProfile === "string" && body.defaultProfile ? body.defaultProfile : null, routingPolicy: body.routingPolicy === "drop" ? "drop" : "default_as_fallback", settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : defaults });
|
|
1091
|
+
const responseMode = body.responseMode === "result" ? "result" : "ack"; const defaults: ApplicationSettings = { responseMode, inboundHandler: "inbound", transformHandlers: [], directMode: { enabled: true } }; const record = applicationStore.create({ name: body.name, slug: body.slug, enabled: body.enabled !== false, responseMode, defaultProfile: typeof body.defaultProfile === "string" && body.defaultProfile ? body.defaultProfile : null, routingPolicy: body.routingPolicy === "drop" ? "drop" : "default_as_fallback", settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : defaults });
|
|
929
1092
|
await loadApplication(record); return reply.code(201).send({ application: record });
|
|
930
1093
|
});
|
|
931
1094
|
server.get<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; return { application: registeredApplication(request.params.slug).record }; });
|
|
@@ -936,7 +1099,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
936
1099
|
});
|
|
937
1100
|
server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; applicationBySlug.get(request.params.slug)?.runtime.dispose(); applicationStore.delete(request.params.slug); applicationBySlug.delete(request.params.slug); await rm(join(applicationsRoot, request.params.slug), { recursive: true, force: true }); return reply.code(204).send(); });
|
|
938
1101
|
server.get<{ Params: { slug: string } }>("/api/applications/:slug/identity-mappings", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); return { mappings: applicationStore.identityMappings(request.params.slug) }; });
|
|
939
|
-
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, automatic = identityKey === "*" || body.sessionMode === "automatic", prefix = automatic ? null : body.sessionPrefix; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || typeof
|
|
1102
|
+
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, ignored = body.sessionMode === "ignore" || body.profile === null, automatic = !ignored && (identityKey === "*" || body.sessionMode === "automatic"), prefix = ignored || automatic ? null : body.sessionPrefix, profile = ignored ? null : body.profile; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || (!ignored && (typeof profile !== "string" || !profile || (!automatic && typeof prefix !== "string")))) throw Object.assign(new Error("profile and a fixed sessionPrefix are required unless this mapping ignores requests."), { status: 400 }); if (typeof prefix === "string" && !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(prefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 400 }); if (!ignored) await api.profiles.readSettings(profile as string); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) }; });
|
|
940
1103
|
server.delete<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; try { applicationStore.deleteIdentityMapping(request.params.slug, request.params.identityKey); return reply.code(204).send(); } catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); } });
|
|
941
1104
|
server.get<{ Querystring: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } }>("/api/application-sessions", async (request, reply) => { if (!guard(request, reply)) return; const filters = request.query; return { sessions: applicationStore.activeSessions(filters) }; });
|
|
942
1105
|
server.delete<{ Params: { sessionId: string }; Querystring: { application?: string; profile?: string } }>("/api/application-sessions/:sessionId", async (request, reply) => { if (!guard(request, reply)) return; const { application, profile } = request.query; if (!application || !profile) throw Object.assign(new Error("application and profile are required."), { status: 400 }); applicationStore.removeActiveSession(application, profile, request.params.sessionId); return reply.code(204).send(); });
|
|
@@ -972,9 +1135,22 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
972
1135
|
server.post("/api/terminal/ticket", async (request, reply) => { if (!guard(request, reply)) return; if (!terminalEnabled) throw Object.assign(new Error("Console terminal is disabled."), { status: 403 }); const ticket = randomUUID(); terminalTickets.set(ticket, { expiresAt: Date.now() + 30_000 }); return { url: terminalUrl(ticket), cwd: terminalCwd }; });
|
|
973
1136
|
server.get("/api/terminal/stream", { websocket: true }, (socket, request) => { const ticket = (request.query as { ticket?: string }).ticket; const value = ticket && terminalTickets.get(ticket); if (!terminalEnabled || !value || value.expiresAt < Date.now()) return socket.close(1008, "Invalid ticket"); terminalTickets.delete(ticket!); let child: pty.IPty; try { child = pty.spawn(terminalShell, ["-l"], { name: "xterm-256color", cols: 80, rows: 24, cwd: terminalCwd, env: process.env }); } catch (error) { socket.send(JSON.stringify({ type: "error", data: (error as Error).message })); return socket.close(1011); } const send = (type: string, data: string) => { if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ type, data })); }; child.onData((data) => send("output", data)); child.onExit(() => socket.close()); socket.on("message", (raw) => { try { const message = JSON.parse(raw.toString()) as { type?: string; data?: string; cols?: number; rows?: number }; if (message.type === "input" && typeof message.data === "string") child.write(message.data); if (message.type === "resize" && Number.isInteger(message.cols) && Number.isInteger(message.rows) && message.cols! > 0 && message.rows! > 0) child.resize(message.cols!, message.rows!); } catch { /* ignore malformed terminal messages */ } }); socket.on("close", () => child.kill()); });
|
|
974
1137
|
server.post<{ Params: { slug: string } }>("/api/applications/:slug/session-rollover", async (request, reply) => { if (!guard(request, reply)) return; return reply.code(201).send(await registeredApplication(request.params.slug).runtime.rollover(request.body)); });
|
|
975
|
-
server.post<{ Params: { slug: string } }>("/api/message/app/:slug", async (request, reply) => {
|
|
1138
|
+
server.post<{ Params: { slug: string } }>("/api/message/app/:slug", { bodyLimit: MAX_APPLICATION_BODY_BYTES }, async (request, reply) => {
|
|
976
1139
|
const application = registeredApplication(request.params.slug); if (!application.record.enabled) return apiError(reply, 404, "NOT_FOUND", "Application is disabled.");
|
|
977
1140
|
const headers = Object.fromEntries(Object.entries(request.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])); const query = request.query && typeof request.query === "object" ? request.query as Record<string, unknown> : {};
|
|
1141
|
+
if (application.runtime.isDirectRequest(request.body)) {
|
|
1142
|
+
const direct = await application.runtime.prepareDirectRequest(request.body);
|
|
1143
|
+
if (application.record.responseMode === "ack") {
|
|
1144
|
+
void application.runtime.process(request.body, headers, query, direct).then(async (value) => {
|
|
1145
|
+
const result = value as { profile: string; sessionId: string; response: string };
|
|
1146
|
+
const response = await fetch(direct.responseWebhookUrl!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ application: application.record.slug, identityKey: direct.identityKey, profile: result.profile, sessionId: result.sessionId, response: result.response }), signal: AbortSignal.timeout(15_000) });
|
|
1147
|
+
if (!response.ok) throw new Error(`Response webhook returned HTTP ${response.status}.`);
|
|
1148
|
+
}).catch((error) => console.error(`[application:${application.record.slug}] Direct Mode response webhook failed:`, error));
|
|
1149
|
+
return reply.code(202).send({ ok: true, accepted: true });
|
|
1150
|
+
}
|
|
1151
|
+
// In wait mode responseWebhookUrl is intentionally ignored.
|
|
1152
|
+
return application.runtime.process(request.body, headers, query, direct);
|
|
1153
|
+
}
|
|
978
1154
|
if (application.record.responseMode === "ack") { void application.runtime.process(request.body, headers, query).catch((error) => console.error(`[application:${application.record.slug}]`, error)); return reply.code(202).send({ ok: true, accepted: true }); }
|
|
979
1155
|
return application.runtime.process(request.body, headers, query);
|
|
980
1156
|
});
|
|
@@ -984,7 +1160,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
984
1160
|
return apiError(reply, 404, "NOT_FOUND", "Route not found.");
|
|
985
1161
|
});
|
|
986
1162
|
server.setErrorHandler(async (error, _request, reply) => {
|
|
987
|
-
if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return apiError(reply, 413, "BAD_REQUEST", "payload exceeds
|
|
1163
|
+
if (error.code === "FST_ERR_CTP_BODY_TOO_LARGE") return apiError(reply, 413, "BAD_REQUEST", "payload exceeds the allowed request size limit.");
|
|
988
1164
|
const typed = error as Error & { status?: number };
|
|
989
1165
|
if (typed.status) return apiError(reply, typed.status, "BAD_REQUEST", typed.message);
|
|
990
1166
|
console.error("[pi-api]", error);
|