pi-feats 0.1.2 → 0.1.4
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 +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -18,6 +18,8 @@ import { ProfileStore, RESOURCE_KINDS, type ProfileSettings, type ResourceKind }
|
|
|
18
18
|
import { ApplicationRuntime, type Settings as ApplicationSettings } from "./application-runtime.ts";
|
|
19
19
|
import { ApplicationStore, type ApplicationRecord } from "./application-store.ts";
|
|
20
20
|
import { applicationHandlerTemplate } from "../lib/application-handler-templates.ts";
|
|
21
|
+
import { applicationExecutionContext, type ContextMemoryExecutionLog } from "../lib/application-context.ts";
|
|
22
|
+
import { contextMemoryCharacters, contextMemoryConfig, contextMemoryLimit, memoryPath, readMemory } from "../lib/context-memory.ts";
|
|
21
23
|
import { isSandboxEnabled } from "../lib/profile-sandbox.ts";
|
|
22
24
|
import { handlerEnvironment, HOST_SSH_CREDENTIAL_KEYS, parseProfileEnv, profileEnvironment } from "../lib/profile-env.ts";
|
|
23
25
|
import { PulseStore } from "../pulse/store.ts";
|
|
@@ -98,10 +100,12 @@ class ApiServer {
|
|
|
98
100
|
private readonly sessions = new Map<string, SessionHandle>();
|
|
99
101
|
private readonly sandboxBusy = new Set<string>();
|
|
100
102
|
private readonly runtimes = new Map<string, Promise<ModelRuntime>>();
|
|
103
|
+
private readonly toolDiscoveries = new Map<string, Promise<void>>();
|
|
101
104
|
readonly profiles: ProfileStore;
|
|
102
105
|
readonly pulses: PulseStore;
|
|
103
106
|
readonly applications: ApplicationStore;
|
|
104
107
|
readonly skillSources: SkillSourceStore;
|
|
108
|
+
private contextMemoryLogger?: (application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog) => void;
|
|
105
109
|
|
|
106
110
|
constructor(private readonly options: ServerOptions, private readonly config: ApiConfig) {
|
|
107
111
|
this.profiles = new ProfileStore(options.agentDir);
|
|
@@ -230,6 +234,21 @@ class ApiServer {
|
|
|
230
234
|
return { id: session.getSessionId(), name: session.getSessionName() ?? null, createdAt: header.timestamp, updatedAt: header.timestamp, messageCount: 0, preview: "" };
|
|
231
235
|
}
|
|
232
236
|
|
|
237
|
+
async createApplicationConsoleSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
|
|
238
|
+
const body = objectBody(input), application = body.application, identityKey = body.identityKey;
|
|
239
|
+
if (typeof application !== "string" || !application) throw Object.assign(new Error("Application is required."), { status: 400 });
|
|
240
|
+
if (typeof identityKey !== "string" || !identityKey.trim()) throw Object.assign(new Error("Identity key is required."), { status: 400 });
|
|
241
|
+
const record = this.applications.get(application);
|
|
242
|
+
if (!record?.enabled) throw Object.assign(new Error("Application was not found or is disabled."), { status: 404 });
|
|
243
|
+
const mapping = this.applications.identityMapping(application, identityKey.trim());
|
|
244
|
+
if (!mapping) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 });
|
|
245
|
+
if (mapping.profile !== profile) throw Object.assign(new Error("This identity routes to a different profile."), { status: 422 });
|
|
246
|
+
const id = this.applications.nextConsoleSessionId(application, identityKey.trim());
|
|
247
|
+
const session = await this.createSession(profile, { id, name: `Application: ${record.name}` });
|
|
248
|
+
this.applications.createConsoleSession(id, application, profile, identityKey.trim());
|
|
249
|
+
return { ...session, application, manualApplicationSession: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
233
252
|
async renameSession(profile: string, id: string, name: string): Promise<Record<string, unknown>> {
|
|
234
253
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) throw Object.assign(new Error("Invalid sessionId"), { status: 400 });
|
|
235
254
|
const trimmed = name.trim();
|
|
@@ -256,6 +275,7 @@ class ApiServer {
|
|
|
256
275
|
active?.session.dispose();
|
|
257
276
|
this.sessions.delete(key);
|
|
258
277
|
await rm(existing.path);
|
|
278
|
+
this.applications.deleteConsoleSession(profile, id);
|
|
259
279
|
}
|
|
260
280
|
|
|
261
281
|
async getSessionConversation(profile: string, id: string, before?: string, requestedLimit = 16): Promise<Record<string, unknown>> {
|
|
@@ -287,7 +307,7 @@ class ApiServer {
|
|
|
287
307
|
private async discoverSandboxedExtensionTools(profile: string): Promise<void> {
|
|
288
308
|
const agentDir = this.profileDirectory(profile);
|
|
289
309
|
const environment = { ...(await profileEnvironment(agentDir)), PI_PROFILE_ROOT: this.options.agentDir, PI_PROFILE_DISCOVER_TOOLS: "1" };
|
|
290
|
-
const output = await new Promise<string>((resolveRun, reject) => {
|
|
310
|
+
const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
|
|
291
311
|
delete environment.PI_API_WORKER;
|
|
292
312
|
const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", ""], {
|
|
293
313
|
cwd: agentDir,
|
|
@@ -298,13 +318,15 @@ class ApiServer {
|
|
|
298
318
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
299
319
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
300
320
|
child.once("error", reject);
|
|
301
|
-
child.once("exit", (code) => code === 0 ? resolveRun(stdout) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
|
|
321
|
+
child.once("exit", (code) => code === 0 ? resolveRun({ stdout, stderr }) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
|
|
302
322
|
});
|
|
303
|
-
|
|
323
|
+
// Nono can relay a successful child Pi process through stderr. Its JSON
|
|
324
|
+
// protocol remains authoritative regardless of the stream it arrived on.
|
|
325
|
+
const line = `${output.stdout}\n${output.stderr}`.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
|
|
304
326
|
if (!line) throw new Error("Sandboxed tool discovery returned no tool list.");
|
|
305
327
|
const value = JSON.parse(line) as { tools?: unknown };
|
|
306
328
|
if (!Array.isArray(value.tools) || value.tools.some((name) => typeof name !== "string")) throw new Error("Sandboxed tool discovery returned an invalid tool list.");
|
|
307
|
-
this.profiles.registerExtensionTools(value.tools);
|
|
329
|
+
this.profiles.registerExtensionTools(profile, value.tools);
|
|
308
330
|
}
|
|
309
331
|
|
|
310
332
|
async complete(profile: string, prompt: string): Promise<string> {
|
|
@@ -329,46 +351,71 @@ class ApiServer {
|
|
|
329
351
|
}
|
|
330
352
|
|
|
331
353
|
async discoverExtensionTools(profile: string): Promise<void> {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
await
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.runtimes.
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
354
|
+
const existing = this.toolDiscoveries.get(profile);
|
|
355
|
+
if (existing) return existing;
|
|
356
|
+
const discovery = (async () => {
|
|
357
|
+
const agentDir = this.profileDirectory(profile);
|
|
358
|
+
await this.profiles.readSettings(profile);
|
|
359
|
+
if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
|
|
360
|
+
await this.withProfileEnvironment(agentDir, async () => {
|
|
361
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
|
|
362
|
+
const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
|
|
363
|
+
await loader.reload();
|
|
364
|
+
let runtime = this.runtimes.get(agentDir);
|
|
365
|
+
if (!runtime) {
|
|
366
|
+
runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
367
|
+
this.runtimes.set(agentDir, runtime);
|
|
368
|
+
}
|
|
369
|
+
const { session } = await createAgentSession({
|
|
370
|
+
cwd: this.options.cwd,
|
|
371
|
+
agentDir,
|
|
372
|
+
sessionManager: SessionManager.inMemory(this.options.cwd),
|
|
373
|
+
settingsManager,
|
|
374
|
+
resourceLoader: loader,
|
|
375
|
+
modelRuntime: await runtime,
|
|
376
|
+
});
|
|
377
|
+
try {
|
|
378
|
+
this.profiles.registerExtensionTools(profile, session.agent.state.tools.map((tool) => tool.name));
|
|
379
|
+
} finally {
|
|
380
|
+
session.dispose();
|
|
381
|
+
}
|
|
351
382
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
383
|
+
})();
|
|
384
|
+
this.toolDiscoveries.set(profile, discovery);
|
|
385
|
+
try {
|
|
386
|
+
await discovery;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
this.toolDiscoveries.delete(profile);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
invalidateExtensionTools(profile?: string): void {
|
|
394
|
+
if (profile) this.toolDiscoveries.delete(profile);
|
|
395
|
+
else this.toolDiscoveries.clear();
|
|
396
|
+
this.profiles.clearExtensionTools(profile);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
setContextMemoryLogger(logger: (application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog) => void): void {
|
|
400
|
+
this.contextMemoryLogger = logger;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private recordContextMemoryExecution(application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog): void {
|
|
404
|
+
this.contextMemoryLogger?.(application, profile, identityKey, sessionId, execution);
|
|
358
405
|
}
|
|
359
406
|
|
|
360
407
|
private async sandboxed(profile: string): Promise<boolean> {
|
|
361
408
|
return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
|
|
362
409
|
}
|
|
363
410
|
|
|
364
|
-
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string): Promise<string> {
|
|
411
|
+
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<string> {
|
|
365
412
|
const key = `${profile}:${id}`;
|
|
366
413
|
if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
367
414
|
this.sandboxBusy.add(key);
|
|
368
415
|
try {
|
|
369
416
|
const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
|
|
370
417
|
if (!session) throw Object.assign(new Error(`No session found matching '${id}'`), { status: 404 });
|
|
371
|
-
const workerEnv = { ...(await profileEnvironment(this.profileDirectory(profile))), PI_PROFILE_ROOT: this.options.agentDir, ...(handoff ? { PI_APPLICATION_HANDOFF: handoff } : {}) };
|
|
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 } : {}) };
|
|
372
419
|
// The HTTP gateway is an API worker itself. Its children are agent
|
|
373
420
|
// runtimes, not additional HTTP servers.
|
|
374
421
|
delete workerEnv.PI_API_WORKER;
|
|
@@ -388,31 +435,43 @@ class ApiServer {
|
|
|
388
435
|
child.once("error", reject);
|
|
389
436
|
child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
|
|
390
437
|
});
|
|
391
|
-
|
|
438
|
+
const stderr = result.stderr.split(/\r?\n/).filter((line) => {
|
|
439
|
+
if (!line.startsWith("PI_CONTEXT_MEMORY_LOG:")) return true;
|
|
440
|
+
if (applicationContext) try {
|
|
441
|
+
const execution = JSON.parse(line.slice("PI_CONTEXT_MEMORY_LOG:".length)) as ContextMemoryExecutionLog;
|
|
442
|
+
this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution);
|
|
443
|
+
} catch { /* Ignore malformed child telemetry. */ }
|
|
444
|
+
return false;
|
|
445
|
+
}).join("\n").trim();
|
|
446
|
+
if (result.code !== 0) throw new Error(stderr || `Sandboxed Pi exited with ${result.code}`);
|
|
392
447
|
return result.stdout.trim();
|
|
393
448
|
} finally { this.sandboxBusy.delete(key); }
|
|
394
449
|
}
|
|
395
450
|
|
|
396
|
-
async prompt(profile: string, id: string, message: string, handoff?: string): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
397
|
-
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff) };
|
|
451
|
+
async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
452
|
+
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
|
|
398
453
|
const handle = await this.getSession(profile, id);
|
|
399
454
|
if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
400
455
|
handle.busy = true;
|
|
401
456
|
try {
|
|
402
|
-
|
|
457
|
+
const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
|
|
458
|
+
if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
|
|
459
|
+
else await promptSession();
|
|
403
460
|
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
404
461
|
return { profile, sessionId: id, response: textFromMessage(assistant) };
|
|
405
462
|
} finally { handle.busy = false; }
|
|
406
463
|
}
|
|
407
464
|
|
|
408
465
|
private async chat(profile: string, id: string, message: string, reply: FastifyReply, stream: boolean): Promise<void> {
|
|
466
|
+
const manual = this.applications.consoleSession(profile, id);
|
|
467
|
+
const applicationContext = manual ? { application: manual.application, identityKey: manual.identityKey } : undefined;
|
|
409
468
|
if (await this.sandboxed(profile)) {
|
|
410
|
-
if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message) }); return; }
|
|
469
|
+
if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, undefined, applicationContext) }); return; }
|
|
411
470
|
reply.hijack();
|
|
412
471
|
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" });
|
|
413
472
|
reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
414
473
|
try {
|
|
415
|
-
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`));
|
|
474
|
+
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext);
|
|
416
475
|
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
417
476
|
} catch (error) {
|
|
418
477
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -446,7 +505,9 @@ class ApiServer {
|
|
|
446
505
|
});
|
|
447
506
|
response.once("close", () => { closed = true; void handle.session.abort(); });
|
|
448
507
|
}
|
|
449
|
-
|
|
508
|
+
const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
|
|
509
|
+
if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
|
|
510
|
+
else await promptSession();
|
|
450
511
|
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
451
512
|
const response = textFromMessage(assistant);
|
|
452
513
|
if (stream) {
|
|
@@ -586,6 +647,41 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
586
647
|
documentRoute("soul", "SOUL.md");
|
|
587
648
|
documentRoute("refine", "REFINE.md");
|
|
588
649
|
|
|
650
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
|
|
651
|
+
if (!guard(request, reply)) return;
|
|
652
|
+
const profile = profileName(request);
|
|
653
|
+
const settings = await api.profiles.readSettings(profile);
|
|
654
|
+
return { config: contextMemoryConfig((settings.profile as { contextMemory?: unknown } | undefined)?.contextMemory) };
|
|
655
|
+
});
|
|
656
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
|
|
657
|
+
if (!guard(request, reply)) return;
|
|
658
|
+
const profile = profileName(request), body = objectBody(request.body);
|
|
659
|
+
const config = body.config === null ? undefined : contextMemoryConfig(body.config);
|
|
660
|
+
if (body.config !== null && !config) throw Object.assign(new Error("Invalid Context Memory configuration."), { status: 400 });
|
|
661
|
+
const settings = await api.profiles.readSettings(profile);
|
|
662
|
+
const policy = { ...((settings.profile as Record<string, unknown> | undefined) ?? {}) };
|
|
663
|
+
if (config) policy.contextMemory = config; else delete policy.contextMemory;
|
|
664
|
+
await api.profiles.writeSettings(profile, { ...settings, profile: policy });
|
|
665
|
+
return { config: config ?? null };
|
|
666
|
+
});
|
|
667
|
+
for (const target of ["operational", "profile"] as const) {
|
|
668
|
+
server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
|
|
669
|
+
if (!guard(request, reply)) return;
|
|
670
|
+
const profile = profileName(request), directory = api.profiles.directory(profile);
|
|
671
|
+
return { content: await readMemory(memoryPath(options.agentDir, directory, target)) };
|
|
672
|
+
});
|
|
673
|
+
server.put<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
|
|
674
|
+
if (!guard(request, reply)) return;
|
|
675
|
+
const content = objectBody(request.body).content;
|
|
676
|
+
if (typeof content !== "string") throw Object.assign(new Error("Content must be a string."), { status: 400 });
|
|
677
|
+
const limit = contextMemoryLimit(target);
|
|
678
|
+
if (contextMemoryCharacters(content.trim()) > limit) throw Object.assign(new Error(`${target === "operational" ? "OPERATIONAL.md" : "PROFILE.md"} exceeds its ${limit}-character limit.`), { status: 413 });
|
|
679
|
+
const profile = profileName(request), directory = api.profiles.directory(profile), path = memoryPath(options.agentDir, directory, target);
|
|
680
|
+
await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${content.trim()}${content.trim() ? "\n" : ""}`, { mode: 0o600 });
|
|
681
|
+
return { content: content.trim() };
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
589
685
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/env", async (request, reply) => {
|
|
590
686
|
if (!guard(request, reply)) return;
|
|
591
687
|
const path = join(api.profiles.directory(profileName(request)), ".env");
|
|
@@ -652,6 +748,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
652
748
|
if (!guard(request, reply)) return;
|
|
653
749
|
return reply.code(201).send(await api.createSession(profileName(request), request.body));
|
|
654
750
|
});
|
|
751
|
+
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/application-sessions", async (request, reply) => {
|
|
752
|
+
if (!guard(request, reply)) return;
|
|
753
|
+
return reply.code(201).send(await api.createApplicationConsoleSession(profileName(request), request.body));
|
|
754
|
+
});
|
|
655
755
|
server.get<{ Params: { profile: string; sessionId: string }; Querystring: { before?: string; limit?: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
|
|
656
756
|
if (!guard(request, reply)) return;
|
|
657
757
|
const limit = request.query.limit === undefined ? 16 : Number(request.query.limit);
|
|
@@ -675,6 +775,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
675
775
|
await api.profiles.readSettings(profileName(request));
|
|
676
776
|
return { pulses: api.pulses.list(profileName(request)) };
|
|
677
777
|
});
|
|
778
|
+
server.get<{ Params: { profile: string; name: string }; Querystring: { start?: string; end?: string; limit?: string } }>("/api/profiles/:profile/pulses/:name/runs", async (request, reply) => {
|
|
779
|
+
if (!guard(request, reply)) return;
|
|
780
|
+
const profile = profileName(request); await api.profiles.readSettings(profile);
|
|
781
|
+
const end = request.query.end ?? new Date().toISOString(), start = request.query.start ?? new Date(Date.now() - 24 * 60 * 60 * 1_000).toISOString(), limit = request.query.limit === undefined ? 100 : Number(request.query.limit);
|
|
782
|
+
if (Number.isNaN(Date.parse(start)) || Number.isNaN(Date.parse(end)) || Date.parse(start) > Date.parse(end)) throw Object.assign(new Error("'start' and 'end' must be a valid chronological ISO-8601 range."), { status: 400 });
|
|
783
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw Object.assign(new Error("The 'limit' query parameter must be an integer from 1 to 100."), { status: 400 });
|
|
784
|
+
try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
|
|
785
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
786
|
+
});
|
|
678
787
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
|
|
679
788
|
if (!guard(request, reply)) return;
|
|
680
789
|
await api.profiles.readSettings(profileName(request));
|
|
@@ -720,9 +829,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
720
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 }); });
|
|
721
830
|
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 }; });
|
|
722
831
|
|
|
723
|
-
const skillSource = (value: unknown): "shared" | "profile" | undefined => {
|
|
832
|
+
const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
|
|
724
833
|
if (value === undefined) return undefined;
|
|
725
|
-
if (value === "shared" || value === "profile") return value;
|
|
834
|
+
if (value === "shared" || value === "profile" || value === "package") return value;
|
|
726
835
|
throw Object.assign(new Error("Invalid skill source."), { status: 400 });
|
|
727
836
|
};
|
|
728
837
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/skills/sources", async (request, reply) => {
|
|
@@ -746,13 +855,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
746
855
|
server.patch<{ Params: { profile: string; name: string }; Body: { enabled?: unknown } }>("/api/profiles/:profile/packages/:name", async (request, reply) => {
|
|
747
856
|
if (!guard(request, reply)) return;
|
|
748
857
|
if (typeof request.body?.enabled !== "boolean") return reply.code(400).send({ error: "'enabled' must be a boolean." });
|
|
749
|
-
|
|
858
|
+
const result = await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled);
|
|
859
|
+
api.invalidateExtensionTools();
|
|
860
|
+
return { package: result };
|
|
750
861
|
});
|
|
751
862
|
|
|
752
863
|
server.get<{ Params: { profile: string; kind: string } }>("/api/profiles/:profile/resources/:kind", async (request, reply) => {
|
|
753
864
|
if (!guard(request, reply)) return;
|
|
754
865
|
const kind = resourceKind(request.params.kind);
|
|
755
|
-
if (kind === "tools") await api.discoverExtensionTools(profileName(request))
|
|
866
|
+
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
756
867
|
return { resources: await api.profiles.resources(profileName(request), kind) };
|
|
757
868
|
});
|
|
758
869
|
server.get<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
@@ -767,7 +878,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
767
878
|
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
768
879
|
const enabled = objectBody(request.body).enabled;
|
|
769
880
|
if (typeof enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
|
|
770
|
-
|
|
881
|
+
const result = await api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
|
|
882
|
+
if (kind === "extensions") api.invalidateExtensionTools();
|
|
883
|
+
return result;
|
|
771
884
|
});
|
|
772
885
|
|
|
773
886
|
server.delete<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
@@ -784,10 +897,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
784
897
|
const applicationsRoot = join(options.agentDir, "applications");
|
|
785
898
|
const applicationBySlug = new Map<string, { record: ApplicationRecord; runtime: ApplicationRuntime }>();
|
|
786
899
|
const applicationSettings = (record: ApplicationRecord): ApplicationSettings => ({ ...record.settings, responseMode: record.responseMode, defaultProfile: record.defaultProfile }) as ApplicationSettings;
|
|
787
|
-
const ensureApplicationFiles = async (slug: string) => { const handlers = join(applicationsRoot, slug, "handlers"); await mkdir(join(handlers, "transforms"), { recursive: true }); const inbound = join(handlers, "inbound.ts"); if (!existsSync(inbound)) await writeFile(inbound, applicationHandlerTemplate("inbound"), "utf8"); };
|
|
788
|
-
const loadApplication = async (record: ApplicationRecord) => { const directory = join(applicationsRoot, record.slug); await ensureApplicationFiles(record.slug); const runtime = await ApplicationRuntime.load(directory, api, applicationSettings(record), async (settings) => { const saved = applicationStore.update(record.slug, { settings, responseMode: settings.responseMode, defaultProfile: settings.defaultProfile ?? null }); applicationBySlug.set(saved.slug, { record: saved, runtime }); }, record); applicationBySlug.set(record.slug, { record, runtime }); };
|
|
900
|
+
const ensureApplicationFiles = async (slug: string) => { const handlers = join(applicationsRoot, slug, "handlers"); await mkdir(join(handlers, "transforms"), { recursive: true }); await mkdir(join(handlers, "context-memory"), { recursive: true }); const inbound = join(handlers, "inbound.ts"); if (!existsSync(inbound)) await writeFile(inbound, applicationHandlerTemplate("inbound"), "utf8"); };
|
|
901
|
+
const loadApplication = async (record: ApplicationRecord) => { applicationBySlug.get(record.slug)?.runtime.dispose(); const directory = join(applicationsRoot, record.slug); await ensureApplicationFiles(record.slug); const runtime = await ApplicationRuntime.load(directory, api, applicationSettings(record), async (settings) => { const saved = applicationStore.update(record.slug, { settings, responseMode: settings.responseMode, defaultProfile: settings.defaultProfile ?? null }); applicationBySlug.set(saved.slug, { record: saved, runtime }); }, record); applicationBySlug.set(record.slug, { record, runtime }); };
|
|
789
902
|
for (const record of applicationStore.list()) await loadApplication(record);
|
|
790
903
|
const registeredApplication = (slug: string) => { const application = applicationBySlug.get(slug); if (!application) throw Object.assign(new Error("Application not found."), { status: 404 }); return application; };
|
|
904
|
+
api.setContextMemoryLogger((application, profile, identityKey, sessionId, execution) => {
|
|
905
|
+
applicationBySlug.get(application)?.runtime.logs.recordContextMemory({ profile, identityKey, sessionId, execution });
|
|
906
|
+
});
|
|
791
907
|
const handlerFiles = async (directory: string, prefix = ""): Promise<string[]> => { const entries = await readdir(directory, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const path = join(directory, entry.name), name = `${prefix}${entry.name}`; if (entry.isDirectory()) files.push(...await handlerFiles(path, `${name}/`)); else if (entry.isFile() && entry.name.endsWith(".ts")) files.push(name); } return files; };
|
|
792
908
|
const applicationHandlerPath = (slug: string, file: unknown) => {
|
|
793
909
|
if (typeof file !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_./-]*\.ts$/.test(file) || file.includes("..")) throw Object.assign(new Error("Invalid handler path."), { status: 400 });
|
|
@@ -818,7 +934,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
818
934
|
const record = applicationStore.update(current.slug, { name: typeof body.name === "string" ? body.name : current.name, enabled: typeof body.enabled === "boolean" ? body.enabled : current.enabled, responseMode: body.responseMode === "result" || body.responseMode === "ack" ? body.responseMode : current.responseMode, defaultProfile: typeof body.defaultProfile === "string" ? body.defaultProfile || null : current.defaultProfile, routingPolicy: body.routingPolicy === "drop" || body.routingPolicy === "default_as_fallback" ? body.routingPolicy : current.routingPolicy, settings: body.settings && typeof body.settings === "object" && !Array.isArray(body.settings) ? body.settings as Record<string, unknown> : current.settings });
|
|
819
935
|
await loadApplication(record); return { application: record };
|
|
820
936
|
});
|
|
821
|
-
server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; 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(); });
|
|
937
|
+
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(); });
|
|
822
938
|
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) }; });
|
|
823
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 body.profile !== "string" || !body.profile || (!automatic && typeof prefix !== "string")) throw Object.assign(new Error("profile and a fixed sessionPrefix are required."), { 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 }); await api.profiles.readSettings(body.profile); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) }; });
|
|
824
940
|
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 }); } });
|
|
@@ -830,7 +946,26 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
830
946
|
server.patch<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), oldPath = applicationHandlerPath(request.params.slug, request.query.path), newFile = objectBody(request.body).newPath; const newPath = applicationHandlerPath(request.params.slug, newFile); if (dirname(oldPath) !== dirname(newPath)) throw Object.assign(new Error("Handlers must remain in their current folder."), { status: 400 }); if (existsSync(newPath)) throw Object.assign(new Error("A handler with this name already exists."), { status: 409 }); await rename(oldPath, newPath); const oldFile = String(request.query.path), oldName = oldFile.replace(/^transforms\//, "").replace(/\.ts$/, ""), newName = String(newFile).replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>, testPayloads = handlerTestPayloads(settings); if (oldFile in testPayloads) { testPayloads[String(newFile)] = testPayloads[oldFile]; delete testPayloads[oldFile]; } settings.handlerTestPayloads = testPayloads; if (settings.inboundHandler === oldName) settings.inboundHandler = newName; if (settings.outboundHandler === oldName) settings.outboundHandler = newName; settings.transformHandlers = settings.transformHandlers?.map((value) => value === oldName ? newName : value); const record = updateApplicationSettings(application, settings); await loadApplication(record); return { ok: true, path: newFile, settingsUpdated: true }; });
|
|
831
947
|
server.delete<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), path = applicationHandlerPath(request.params.slug, request.query.path), file = String(request.query.path), handlerName = file.replace(/^transforms\//, "").replace(/\.ts$/, ""), settings = structuredClone(application.record.settings) as ApplicationSettings & Record<string, unknown>; if (settings.inboundHandler === handlerName || settings.outboundHandler === handlerName || settings.transformHandlers?.includes(handlerName)) throw Object.assign(new Error("This handler is active in Application Settings."), { status: 409 }); await rm(path); const testPayloads = handlerTestPayloads(settings); if (file in testPayloads) { delete testPayloads[file]; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); } return reply.code(204).send(); });
|
|
832
948
|
server.post<{ Params: { slug: string }; Querystring: { path?: string } }>("/api/applications/:slug/handler/test", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug); const body = objectBody(request.body); if (!body.payload || typeof body.payload !== "object" || Array.isArray(body.payload)) throw Object.assign(new Error("The 'payload' field must be a JSON object."), { status: 400 }); applicationHandlerPath(request.params.slug, request.query.path); const settings = structuredClone(application.record.settings) as Record<string, unknown>, testPayloads = handlerTestPayloads(settings); testPayloads[String(request.query.path)] = body.payload; settings.handlerTestPayloads = testPayloads; const record = updateApplicationSettings(application, settings); applicationBySlug.set(record.slug, { record, runtime: application.runtime }); return application.runtime.testHandler(applicationHandlerPath(request.params.slug, request.query.path), body.payload as Record<string, unknown>, typeof body.content === "string" ? body.content : undefined); });
|
|
833
|
-
server.get<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => {
|
|
949
|
+
server.get<{ Params: { slug: string }; Querystring: { summary?: string; limit?: string; start?: string; end?: string } }>("/api/applications/:slug/logs", async (request, reply) => {
|
|
950
|
+
if (!guard(request, reply)) return;
|
|
951
|
+
const logs = registeredApplication(request.params.slug).runtime.logs;
|
|
952
|
+
if (request.query.summary !== "1") return { calls: logs.list() };
|
|
953
|
+
const limit = request.query.limit === undefined ? 250 : Number(request.query.limit);
|
|
954
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 250) throw Object.assign(new Error("The 'limit' query parameter must be an integer from 1 to 250."), { status: 400 });
|
|
955
|
+
const parseDate = (value: string | undefined, name: string) => {
|
|
956
|
+
if (!value) return undefined;
|
|
957
|
+
const date = new Date(value);
|
|
958
|
+
if (Number.isNaN(date.getTime())) throw Object.assign(new Error(`The '${name}' query parameter must be a valid ISO date.`), { status: 400 });
|
|
959
|
+
return date;
|
|
960
|
+
};
|
|
961
|
+
return { calls: logs.listSummaries(limit, parseDate(request.query.start, "start"), parseDate(request.query.end, "end")) };
|
|
962
|
+
});
|
|
963
|
+
server.get<{ Params: { slug: string; callId: string } }>("/api/applications/:slug/logs/calls/:callId", async (request, reply) => {
|
|
964
|
+
if (!guard(request, reply)) return;
|
|
965
|
+
const call = registeredApplication(request.params.slug).runtime.logs.get(request.params.callId);
|
|
966
|
+
if (!call) throw Object.assign(new Error("Log call not found."), { status: 404 });
|
|
967
|
+
return { call };
|
|
968
|
+
});
|
|
834
969
|
server.delete<{ Params: { slug: string } }>("/api/applications/:slug/logs", async (request, reply) => { if (!guard(request, reply)) return; await registeredApplication(request.params.slug).runtime.logs.clear(); return reply.code(204).send(); });
|
|
835
970
|
server.post<{ Params: { slug: string } }>("/api/applications/:slug/logs/ticket", async (request, reply) => { if (!guard(request, reply)) return; const application = registeredApplication(request.params.slug), ticket = randomUUID(); applicationLogTickets.set(ticket, { slug: application.record.slug, expiresAt: Date.now() + 60_000 }); const base = new URL(config.publicBaseUrl ?? `http://127.0.0.1:${config.port}`); base.protocol = base.protocol === "https:" ? "wss:" : "ws:"; base.pathname = `/api/applications/${encodeURIComponent(application.record.slug)}/logs/stream`; base.search = `ticket=${encodeURIComponent(ticket)}`; return { ticket, url: base.toString() }; });
|
|
836
971
|
server.get("/api/applications/:slug/logs/stream", { websocket: true }, (socket, request) => { const ticket = (request.query as { ticket?: string }).ticket; const value = ticket && applicationLogTickets.get(ticket); if (!value || value.slug !== (request.params as { slug: string }).slug || value.expiresAt < Date.now()) return socket.close(1008, "Invalid ticket"); applicationLogTickets.delete(ticket!); const unsubscribe = registeredApplication(value.slug).runtime.logs.on((event) => socket.send(JSON.stringify(event))); socket.on("close", unsubscribe); });
|