pi-feats 0.1.5 → 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 +48 -5
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-runtime.ts +65 -4
- package/extensions/api-server/application-store.ts +5 -5
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +81 -34
- package/extensions/api-server/server.ts +179 -42
- package/extensions/cli-resources.ts +125 -94
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +11 -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/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- 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 +1 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +26 -9
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
- package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
- 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 +6 -4
- package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
- package/extensions/profile-ssh.ts +2 -2
- package/extensions/profiles.ts +17 -9
- package/extensions/pulse/index.ts +35 -17
- package/extensions/pulse/store.ts +29 -7
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +19 -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/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,6 +222,12 @@ class ApiServer {
|
|
|
200
222
|
return SessionManager.listAll(sessionDir);
|
|
201
223
|
}
|
|
202
224
|
|
|
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
|
+
|
|
203
231
|
async deleteProfile(profile: string): Promise<void> {
|
|
204
232
|
if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
|
|
205
233
|
await this.profiles.readSettings(profile);
|
|
@@ -214,18 +242,26 @@ class ApiServer {
|
|
|
214
242
|
this.pulses.deleteProfile(profile);
|
|
215
243
|
}
|
|
216
244
|
|
|
217
|
-
async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
|
|
245
|
+
async listSessions(profile: string, includePulseSessions = false): Promise<Array<Record<string, unknown>>> {
|
|
218
246
|
await this.profiles.readSettings(profile);
|
|
247
|
+
const pulseExecutions = this.pulses.executionSessions(profile);
|
|
219
248
|
const sessions = await this.listProfileSessions(profile);
|
|
220
|
-
return sessions.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
});
|
|
229
265
|
}
|
|
230
266
|
|
|
231
267
|
async createSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
|
|
@@ -297,6 +333,8 @@ class ApiServer {
|
|
|
297
333
|
const info = sessions.find((session) => session.id === id);
|
|
298
334
|
if (!info) throw Object.assign(new Error("Session not found"), { status: 404 });
|
|
299
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.
|
|
300
338
|
const entries = session.getBranch().filter((entry) => entry.type !== "session_info").map((entry) => {
|
|
301
339
|
if (entry.type === "message") {
|
|
302
340
|
const message = entry.message as { role?: string; content?: unknown; toolName?: string; isError?: boolean; timestamp?: number };
|
|
@@ -310,7 +348,7 @@ class ApiServer {
|
|
|
310
348
|
}
|
|
311
349
|
if (entry.type === "compaction" || entry.type === "branch_summary") return { id: entry.id, type: entry.type, timestamp: entry.timestamp, summary: (entry as any).summary };
|
|
312
350
|
return { id: entry.id, type: entry.type, timestamp: entry.timestamp };
|
|
313
|
-
});
|
|
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)));
|
|
314
352
|
const limit = Math.max(1, Math.min(16, Math.floor(requestedLimit) || 16));
|
|
315
353
|
const end = before ? entries.findIndex((entry) => entry.id === before) : entries.length;
|
|
316
354
|
if (before && end < 0) throw Object.assign(new Error("Conversation cursor not found."), { status: 400 });
|
|
@@ -323,7 +361,8 @@ class ApiServer {
|
|
|
323
361
|
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
|
|
324
362
|
const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
|
|
325
363
|
delete environment.PI_API_WORKER;
|
|
326
|
-
const
|
|
364
|
+
const pi = piCommand();
|
|
365
|
+
const child = spawn(pi.command, [...pi.args, "profile", profile, "--no-session", "--print", ""], {
|
|
327
366
|
cwd: agentDir,
|
|
328
367
|
env: environment,
|
|
329
368
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -349,7 +388,8 @@ class ApiServer {
|
|
|
349
388
|
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir };
|
|
350
389
|
delete environment.PI_API_WORKER;
|
|
351
390
|
return new Promise<string>((resolveRun, reject) => {
|
|
352
|
-
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"] });
|
|
353
393
|
let stdout = "", stderr = "";
|
|
354
394
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); }); child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
355
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"}`)));
|
|
@@ -364,6 +404,20 @@ class ApiServer {
|
|
|
364
404
|
});
|
|
365
405
|
}
|
|
366
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
|
+
|
|
367
421
|
async discoverExtensionTools(profile: string): Promise<void> {
|
|
368
422
|
const existing = this.toolDiscoveries.get(profile);
|
|
369
423
|
if (existing) return existing;
|
|
@@ -422,29 +476,42 @@ class ApiServer {
|
|
|
422
476
|
return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
|
|
423
477
|
}
|
|
424
478
|
|
|
425
|
-
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> {
|
|
426
480
|
const key = `${profile}:${id}`;
|
|
427
481
|
if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
428
482
|
this.sandboxBusy.add(key);
|
|
429
483
|
try {
|
|
430
484
|
const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
|
|
431
485
|
if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
|
|
432
|
-
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") } : {}) } : {}) };
|
|
433
487
|
// The HTTP gateway is an API worker itself. Its children are agent
|
|
434
488
|
// runtimes, not additional HTTP servers.
|
|
435
489
|
delete workerEnv.PI_API_WORKER;
|
|
436
490
|
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolveRun, reject) => {
|
|
437
491
|
// Supply the native file path, not only its ID. The session manager
|
|
438
492
|
// otherwise scopes an ID lookup to the API worker CWD (`/` in Docker).
|
|
439
|
-
const
|
|
493
|
+
const pi = piCommand();
|
|
494
|
+
const child = spawn(pi.command, [...pi.args, "profile", profile, "--session", session.path, ...(onEvent ? ["--mode", "json"] : ["--print"]), message], {
|
|
440
495
|
// The sandboxed agent sees its profile directory as CWD, never the
|
|
441
496
|
// HTTP server's CWD or the caller's project directory.
|
|
442
|
-
cwd: this.profileDirectory(profile),
|
|
443
|
-
|
|
444
|
-
|
|
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;
|
|
445
514
|
});
|
|
446
|
-
let stdout = ""; let stderr = "";
|
|
447
|
-
child.stdout.on("data", (chunk) => { const text = String(chunk); stdout += text; onData?.(text); });
|
|
448
515
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
449
516
|
child.once("error", reject);
|
|
450
517
|
child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
|
|
@@ -462,7 +529,7 @@ class ApiServer {
|
|
|
462
529
|
} finally { this.sandboxBusy.delete(key); }
|
|
463
530
|
}
|
|
464
531
|
|
|
465
|
-
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 }> {
|
|
466
533
|
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
|
|
467
534
|
const handle = await this.getSession(profile, id);
|
|
468
535
|
if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
@@ -485,7 +552,7 @@ class ApiServer {
|
|
|
485
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" });
|
|
486
553
|
reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
487
554
|
try {
|
|
488
|
-
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`); });
|
|
489
556
|
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
490
557
|
} catch (error) {
|
|
491
558
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -513,9 +580,10 @@ class ApiServer {
|
|
|
513
580
|
});
|
|
514
581
|
response.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
515
582
|
unsubscribe = handle.session.subscribe((event) => {
|
|
516
|
-
if (
|
|
517
|
-
|
|
518
|
-
}
|
|
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`);
|
|
519
587
|
});
|
|
520
588
|
response.once("close", () => { closed = true; void handle.session.abort(); });
|
|
521
589
|
}
|
|
@@ -594,13 +662,14 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
594
662
|
};
|
|
595
663
|
const profileName = (request: FastifyRequest<{ Params: { profile: string } }>) => request.params.profile;
|
|
596
664
|
const pulseTick = async (action: "start" | "stop" | "status") => new Promise<string>((resolveRun, reject) => {
|
|
597
|
-
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"] });
|
|
598
667
|
let stdout = "", stderr = ""; child.stdout.on("data", (chunk) => stdout += String(chunk)); child.stderr.on("data", (chunk) => stderr += String(chunk));
|
|
599
668
|
child.once("error", reject); child.once("exit", (code) => code === 0 ? resolveRun(stdout.trim()) : reject(new Error(stderr.trim() || `Pulse command exited with ${code}`)));
|
|
600
669
|
});
|
|
601
670
|
const pulseTickStatus = async () => {
|
|
602
|
-
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
|
|
603
|
-
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 }; }
|
|
604
673
|
};
|
|
605
674
|
server.get("/api/pulse/tick/status", async (request, reply) => {
|
|
606
675
|
if (!guard(request, reply)) return;
|
|
@@ -618,9 +687,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
618
687
|
});
|
|
619
688
|
server.post("/api/profiles", async (request, reply) => {
|
|
620
689
|
if (!guard(request, reply)) return;
|
|
621
|
-
const
|
|
690
|
+
const body = objectBody(request.body), name = body.name, cloneFrom = body.cloneFrom;
|
|
622
691
|
if (typeof name !== "string") throw Object.assign(new Error("The 'name' field must be a string."), { status: 400 });
|
|
623
|
-
|
|
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);
|
|
624
694
|
return reply.code(201).send(profile);
|
|
625
695
|
});
|
|
626
696
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
@@ -628,6 +698,12 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
628
698
|
const settings = await api.profiles.readSettings(profileName(request));
|
|
629
699
|
return { name: profileName(request), path: api.profiles.directory(profileName(request)), hasSoul: true, hasRefine: true, policy: settings.profile ?? {} };
|
|
630
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
|
+
});
|
|
631
707
|
server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
632
708
|
if (!guard(request, reply)) return;
|
|
633
709
|
if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
|
|
@@ -670,6 +746,43 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
670
746
|
return { settings: await api.profiles.writeSettings(profileName(request), settings) };
|
|
671
747
|
});
|
|
672
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
|
+
|
|
673
786
|
const documentRoute = (name: "soul" | "refine", file: "SOUL.md" | "REFINE.md") => {
|
|
674
787
|
server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/${name}`, async (request, reply) => {
|
|
675
788
|
if (!guard(request, reply)) return;
|
|
@@ -779,9 +892,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
779
892
|
return { content };
|
|
780
893
|
});
|
|
781
894
|
|
|
782
|
-
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) => {
|
|
783
896
|
if (!guard(request, reply)) return;
|
|
784
|
-
return { sessions: await api.listSessions(profileName(request)) };
|
|
897
|
+
return { sessions: await api.listSessions(profileName(request), request.query.includePulseSessions === "true" || request.query.includePulseExecutions === "true") };
|
|
785
898
|
});
|
|
786
899
|
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/sessions", async (request, reply) => {
|
|
787
900
|
if (!guard(request, reply)) return;
|
|
@@ -823,6 +936,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
823
936
|
try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
|
|
824
937
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
825
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
|
+
});
|
|
826
946
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
|
|
827
947
|
if (!guard(request, reply)) return;
|
|
828
948
|
await api.profiles.readSettings(profileName(request));
|
|
@@ -835,7 +955,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
835
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 });
|
|
836
956
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
837
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 });
|
|
838
|
-
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
|
|
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) }) }); }
|
|
839
959
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
840
960
|
});
|
|
841
961
|
server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -845,11 +965,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
845
965
|
});
|
|
846
966
|
server.put<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
847
967
|
if (!guard(request, reply)) return;
|
|
848
|
-
const body = objectBody(request.body), fields = ["description", "schedule", "prompt"] as const;
|
|
968
|
+
const body = objectBody(request.body), fields = ["name", "description", "schedule", "prompt"] as const;
|
|
849
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 });
|
|
850
970
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
851
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 });
|
|
852
|
-
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
|
+
}
|
|
853
977
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
854
978
|
});
|
|
855
979
|
server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -865,7 +989,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
865
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) }; });
|
|
866
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); });
|
|
867
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 }); });
|
|
868
|
-
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 }); });
|
|
869
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 }; });
|
|
870
994
|
|
|
871
995
|
const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
|
|
@@ -964,7 +1088,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
964
1088
|
server.post("/api/applications", async (request, reply) => {
|
|
965
1089
|
if (!guard(request, reply)) return; const body = objectBody(request.body);
|
|
966
1090
|
if (typeof body.name !== "string" || typeof body.slug !== "string") throw Object.assign(new Error("name and slug are required."), { status: 400 });
|
|
967
|
-
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 });
|
|
968
1092
|
await loadApplication(record); return reply.code(201).send({ application: record });
|
|
969
1093
|
});
|
|
970
1094
|
server.get<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; return { application: registeredApplication(request.params.slug).record }; });
|
|
@@ -1011,9 +1135,22 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
1011
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 }; });
|
|
1012
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()); });
|
|
1013
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)); });
|
|
1014
|
-
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) => {
|
|
1015
1139
|
const application = registeredApplication(request.params.slug); if (!application.record.enabled) return apiError(reply, 404, "NOT_FOUND", "Application is disabled.");
|
|
1016
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
|
+
}
|
|
1017
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 }); }
|
|
1018
1155
|
return application.runtime.process(request.body, headers, query);
|
|
1019
1156
|
});
|
|
@@ -1023,7 +1160,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
1023
1160
|
return apiError(reply, 404, "NOT_FOUND", "Route not found.");
|
|
1024
1161
|
});
|
|
1025
1162
|
server.setErrorHandler(async (error, _request, reply) => {
|
|
1026
|
-
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.");
|
|
1027
1164
|
const typed = error as Error & { status?: number };
|
|
1028
1165
|
if (typed.status) return apiError(reply, typed.status, "BAD_REQUEST", typed.message);
|
|
1029
1166
|
console.error("[pi-api]", error);
|