pi-feats 0.1.3 → 0.1.5
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 +65 -6
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/extensions/api-server/application-log-store.ts +35 -2
- package/extensions/api-server/application-runtime.ts +95 -23
- package/extensions/api-server/application-store.ts +26 -9
- package/extensions/api-server/profile-store.ts +79 -29
- package/extensions/api-server/server.ts +225 -51
- 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 +54 -35
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/profile-sandbox.ts +6 -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/api/admin/models/route.ts +22 -0
- 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 +14 -10
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- 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 +6 -1
- package/extensions/pi-console-webui/components/console.tsx +60 -10
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +14 -10
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +35 -0
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +70 -23
- package/extensions/pulse/index.ts +128 -14
- package/extensions/pulse/store.ts +110 -15
- 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 +23 -3
- 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/profile-ssh/SKILL.md +65 -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);
|
|
@@ -196,6 +200,20 @@ class ApiServer {
|
|
|
196
200
|
return SessionManager.listAll(sessionDir);
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
async deleteProfile(profile: string): Promise<void> {
|
|
204
|
+
if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
|
|
205
|
+
await this.profiles.readSettings(profile);
|
|
206
|
+
const handles = [...this.sessions.values()].filter((handle) => handle.profile === profile);
|
|
207
|
+
if (handles.some((handle) => handle.busy) || [...this.sandboxBusy].some((key) => key.startsWith(`${profile}:`))) throw Object.assign(new Error("The profile has a session that is processing a request."), { status: 409 });
|
|
208
|
+
for (const handle of handles) { handle.session.dispose(); this.sessions.delete(`${profile}:${handle.sessionId}`); }
|
|
209
|
+
this.runtimes.delete(this.profileDirectory(profile));
|
|
210
|
+
this.toolDiscoveries.delete(profile);
|
|
211
|
+
this.profiles.clearExtensionTools(profile);
|
|
212
|
+
await this.profiles.delete(profile);
|
|
213
|
+
this.applications.deleteProfile(profile);
|
|
214
|
+
this.pulses.deleteProfile(profile);
|
|
215
|
+
}
|
|
216
|
+
|
|
199
217
|
async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
|
|
200
218
|
await this.profiles.readSettings(profile);
|
|
201
219
|
const sessions = await this.listProfileSessions(profile);
|
|
@@ -230,6 +248,21 @@ class ApiServer {
|
|
|
230
248
|
return { id: session.getSessionId(), name: session.getSessionName() ?? null, createdAt: header.timestamp, updatedAt: header.timestamp, messageCount: 0, preview: "" };
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
async createApplicationConsoleSession(profile: string, input: unknown): Promise<Record<string, unknown>> {
|
|
252
|
+
const body = objectBody(input), application = body.application, identityKey = body.identityKey;
|
|
253
|
+
if (typeof application !== "string" || !application) throw Object.assign(new Error("Application is required."), { status: 400 });
|
|
254
|
+
if (typeof identityKey !== "string" || !identityKey.trim()) throw Object.assign(new Error("Identity key is required."), { status: 400 });
|
|
255
|
+
const record = this.applications.get(application);
|
|
256
|
+
if (!record?.enabled) throw Object.assign(new Error("Application was not found or is disabled."), { status: 404 });
|
|
257
|
+
const mapping = this.applications.identityMapping(application, identityKey.trim());
|
|
258
|
+
if (!mapping) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 });
|
|
259
|
+
if (mapping.profile !== profile) throw Object.assign(new Error("This identity routes to a different profile."), { status: 422 });
|
|
260
|
+
const id = this.applications.nextConsoleSessionId(application, identityKey.trim());
|
|
261
|
+
const session = await this.createSession(profile, { id, name: `Application: ${record.name}` });
|
|
262
|
+
this.applications.createConsoleSession(id, application, profile, identityKey.trim());
|
|
263
|
+
return { ...session, application, manualApplicationSession: true };
|
|
264
|
+
}
|
|
265
|
+
|
|
233
266
|
async renameSession(profile: string, id: string, name: string): Promise<Record<string, unknown>> {
|
|
234
267
|
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
268
|
const trimmed = name.trim();
|
|
@@ -256,6 +289,7 @@ class ApiServer {
|
|
|
256
289
|
active?.session.dispose();
|
|
257
290
|
this.sessions.delete(key);
|
|
258
291
|
await rm(existing.path);
|
|
292
|
+
this.applications.deleteConsoleSession(profile, id);
|
|
259
293
|
}
|
|
260
294
|
|
|
261
295
|
async getSessionConversation(profile: string, id: string, before?: string, requestedLimit = 16): Promise<Record<string, unknown>> {
|
|
@@ -287,7 +321,7 @@ class ApiServer {
|
|
|
287
321
|
private async discoverSandboxedExtensionTools(profile: string): Promise<void> {
|
|
288
322
|
const agentDir = this.profileDirectory(profile);
|
|
289
323
|
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) => {
|
|
324
|
+
const output = await new Promise<{ stdout: string; stderr: string }>((resolveRun, reject) => {
|
|
291
325
|
delete environment.PI_API_WORKER;
|
|
292
326
|
const child = spawn(process.execPath, [process.argv[1], "profile", profile, "--no-session", "--print", ""], {
|
|
293
327
|
cwd: agentDir,
|
|
@@ -298,13 +332,15 @@ class ApiServer {
|
|
|
298
332
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
299
333
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
300
334
|
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"}`)));
|
|
335
|
+
child.once("exit", (code) => code === 0 ? resolveRun({ stdout, stderr }) : reject(new Error(stderr.trim() || `Sandboxed tool discovery exited with ${code ?? "unknown"}`)));
|
|
302
336
|
});
|
|
303
|
-
|
|
337
|
+
// Nono can relay a successful child Pi process through stderr. Its JSON
|
|
338
|
+
// protocol remains authoritative regardless of the stream it arrived on.
|
|
339
|
+
const line = `${output.stdout}\n${output.stderr}`.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
|
|
304
340
|
if (!line) throw new Error("Sandboxed tool discovery returned no tool list.");
|
|
305
341
|
const value = JSON.parse(line) as { tools?: unknown };
|
|
306
342
|
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);
|
|
343
|
+
this.profiles.registerExtensionTools(profile, value.tools);
|
|
308
344
|
}
|
|
309
345
|
|
|
310
346
|
async complete(profile: string, prompt: string): Promise<string> {
|
|
@@ -329,46 +365,71 @@ class ApiServer {
|
|
|
329
365
|
}
|
|
330
366
|
|
|
331
367
|
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
|
-
|
|
368
|
+
const existing = this.toolDiscoveries.get(profile);
|
|
369
|
+
if (existing) return existing;
|
|
370
|
+
const discovery = (async () => {
|
|
371
|
+
const agentDir = this.profileDirectory(profile);
|
|
372
|
+
await this.profiles.readSettings(profile);
|
|
373
|
+
if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
|
|
374
|
+
await this.withProfileEnvironment(agentDir, async () => {
|
|
375
|
+
const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
|
|
376
|
+
const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
|
|
377
|
+
await loader.reload();
|
|
378
|
+
let runtime = this.runtimes.get(agentDir);
|
|
379
|
+
if (!runtime) {
|
|
380
|
+
runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
|
|
381
|
+
this.runtimes.set(agentDir, runtime);
|
|
382
|
+
}
|
|
383
|
+
const { session } = await createAgentSession({
|
|
384
|
+
cwd: this.options.cwd,
|
|
385
|
+
agentDir,
|
|
386
|
+
sessionManager: SessionManager.inMemory(this.options.cwd),
|
|
387
|
+
settingsManager,
|
|
388
|
+
resourceLoader: loader,
|
|
389
|
+
modelRuntime: await runtime,
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
this.profiles.registerExtensionTools(profile, session.agent.state.tools.map((tool) => tool.name));
|
|
393
|
+
} finally {
|
|
394
|
+
session.dispose();
|
|
395
|
+
}
|
|
351
396
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
397
|
+
})();
|
|
398
|
+
this.toolDiscoveries.set(profile, discovery);
|
|
399
|
+
try {
|
|
400
|
+
await discovery;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
this.toolDiscoveries.delete(profile);
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
invalidateExtensionTools(profile?: string): void {
|
|
408
|
+
if (profile) this.toolDiscoveries.delete(profile);
|
|
409
|
+
else this.toolDiscoveries.clear();
|
|
410
|
+
this.profiles.clearExtensionTools(profile);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
setContextMemoryLogger(logger: (application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog) => void): void {
|
|
414
|
+
this.contextMemoryLogger = logger;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private recordContextMemoryExecution(application: string, profile: string, identityKey: string, sessionId: string, execution: ContextMemoryExecutionLog): void {
|
|
418
|
+
this.contextMemoryLogger?.(application, profile, identityKey, sessionId, execution);
|
|
358
419
|
}
|
|
359
420
|
|
|
360
421
|
private async sandboxed(profile: string): Promise<boolean> {
|
|
361
422
|
return isSandboxEnabled(this.profileDirectory(profile), profile === "default");
|
|
362
423
|
}
|
|
363
424
|
|
|
364
|
-
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string): Promise<string> {
|
|
425
|
+
private async runSandboxedPrompt(profile: string, id: string, message: string, onData?: (text: string) => void, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<string> {
|
|
365
426
|
const key = `${profile}:${id}`;
|
|
366
427
|
if (this.sandboxBusy.has(key)) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
367
428
|
this.sandboxBusy.add(key);
|
|
368
429
|
try {
|
|
369
430
|
const session = (await this.listProfileSessions(profile)).find((item) => item.id === id);
|
|
370
431
|
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 } : {}) };
|
|
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 } : {}) };
|
|
372
433
|
// The HTTP gateway is an API worker itself. Its children are agent
|
|
373
434
|
// runtimes, not additional HTTP servers.
|
|
374
435
|
delete workerEnv.PI_API_WORKER;
|
|
@@ -388,31 +449,43 @@ class ApiServer {
|
|
|
388
449
|
child.once("error", reject);
|
|
389
450
|
child.once("exit", (code) => resolveRun({ stdout, stderr, code: code ?? 1 }));
|
|
390
451
|
});
|
|
391
|
-
|
|
452
|
+
const stderr = result.stderr.split(/\r?\n/).filter((line) => {
|
|
453
|
+
if (!line.startsWith("PI_CONTEXT_MEMORY_LOG:")) return true;
|
|
454
|
+
if (applicationContext) try {
|
|
455
|
+
const execution = JSON.parse(line.slice("PI_CONTEXT_MEMORY_LOG:".length)) as ContextMemoryExecutionLog;
|
|
456
|
+
this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution);
|
|
457
|
+
} catch { /* Ignore malformed child telemetry. */ }
|
|
458
|
+
return false;
|
|
459
|
+
}).join("\n").trim();
|
|
460
|
+
if (result.code !== 0) throw new Error(stderr || `Sandboxed Pi exited with ${result.code}`);
|
|
392
461
|
return result.stdout.trim();
|
|
393
462
|
} finally { this.sandboxBusy.delete(key); }
|
|
394
463
|
}
|
|
395
464
|
|
|
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) };
|
|
465
|
+
async prompt(profile: string, id: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }> {
|
|
466
|
+
if (await this.sandboxed(profile)) return { profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, handoff, applicationContext) };
|
|
398
467
|
const handle = await this.getSession(profile, id);
|
|
399
468
|
if (handle.busy || !handle.session.isIdle) throw Object.assign(new Error("The session is already processing a request."), { status: 409 });
|
|
400
469
|
handle.busy = true;
|
|
401
470
|
try {
|
|
402
|
-
|
|
471
|
+
const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
|
|
472
|
+
if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
|
|
473
|
+
else await promptSession();
|
|
403
474
|
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
404
475
|
return { profile, sessionId: id, response: textFromMessage(assistant) };
|
|
405
476
|
} finally { handle.busy = false; }
|
|
406
477
|
}
|
|
407
478
|
|
|
408
479
|
private async chat(profile: string, id: string, message: string, reply: FastifyReply, stream: boolean): Promise<void> {
|
|
480
|
+
const manual = this.applications.consoleSession(profile, id);
|
|
481
|
+
const applicationContext = manual ? { application: manual.application, identityKey: manual.identityKey } : undefined;
|
|
409
482
|
if (await this.sandboxed(profile)) {
|
|
410
|
-
if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message) }); return; }
|
|
483
|
+
if (!stream) { reply.send({ profile, sessionId: id, response: await this.runSandboxedPrompt(profile, id, message, undefined, undefined, applicationContext) }); return; }
|
|
411
484
|
reply.hijack();
|
|
412
485
|
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
486
|
reply.raw.write(`event: session\ndata: ${JSON.stringify({ profile, sessionId: id })}\n\n`);
|
|
414
487
|
try {
|
|
415
|
-
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`));
|
|
488
|
+
const response = await this.runSandboxedPrompt(profile, id, message, (text) => reply.raw.write(`event: token\ndata: ${JSON.stringify({ text })}\n\n`), undefined, applicationContext);
|
|
416
489
|
reply.raw.write(`event: done\ndata: ${JSON.stringify({ response, sessionId: id, profile })}\n\n`);
|
|
417
490
|
} catch (error) {
|
|
418
491
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -446,7 +519,9 @@ class ApiServer {
|
|
|
446
519
|
});
|
|
447
520
|
response.once("close", () => { closed = true; void handle.session.abort(); });
|
|
448
521
|
}
|
|
449
|
-
|
|
522
|
+
const promptSession = () => this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
|
|
523
|
+
if (applicationContext) await applicationExecutionContext.run({ ...applicationContext, profile, sessionId: id, onContextMemoryExecution: (execution) => this.recordContextMemoryExecution(applicationContext.application, profile, applicationContext.identityKey, id, execution) }, promptSession);
|
|
524
|
+
else await promptSession();
|
|
450
525
|
const assistant = [...handle.session.messages].reverse().find((item) => item.role === "assistant");
|
|
451
526
|
const response = textFromMessage(assistant);
|
|
452
527
|
if (stream) {
|
|
@@ -556,10 +631,35 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
556
631
|
server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
557
632
|
if (!guard(request, reply)) return;
|
|
558
633
|
if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
|
|
559
|
-
await api.
|
|
634
|
+
await api.deleteProfile(profileName(request));
|
|
560
635
|
return reply.code(204).send();
|
|
561
636
|
});
|
|
562
637
|
|
|
638
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
639
|
+
if (!guard(request, reply)) return;
|
|
640
|
+
const profile = profileName(request), directory = api.profiles.directory(profile);
|
|
641
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
642
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
643
|
+
const providers = new Map<string, Array<{ id: string; name: string }>>();
|
|
644
|
+
for (const model of runtime.getModels()) {
|
|
645
|
+
const models = providers.get(model.provider) ?? [];
|
|
646
|
+
models.push({ id: model.id, name: model.name ?? model.id });
|
|
647
|
+
providers.set(model.provider, models);
|
|
648
|
+
}
|
|
649
|
+
return { settings: await api.profiles.readSettings(profile), providers: [...providers.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([id, models]) => ({ id, models: models.sort((left, right) => left.name.localeCompare(right.name)) })) };
|
|
650
|
+
});
|
|
651
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
652
|
+
if (!guard(request, reply)) return;
|
|
653
|
+
const profile = profileName(request), body = objectBody(request.body), provider = body.provider, model = body.model;
|
|
654
|
+
if (typeof provider !== "string" || !provider || typeof model !== "string" || !model) throw Object.assign(new Error("provider and model must be non-empty strings."), { status: 400 });
|
|
655
|
+
const directory = api.profiles.directory(profile);
|
|
656
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
657
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
658
|
+
if (!runtime.getModel(provider, model)) throw Object.assign(new Error("The selected provider and model are not available."), { status: 400 });
|
|
659
|
+
const settings = await api.profiles.readSettings(profile);
|
|
660
|
+
return { settings: await api.profiles.writeSettings(profile, { ...settings, defaultProvider: provider, defaultModel: model }) };
|
|
661
|
+
});
|
|
662
|
+
|
|
563
663
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
|
|
564
664
|
if (!guard(request, reply)) return;
|
|
565
665
|
return { settings: await api.profiles.readSettings(profileName(request)) };
|
|
@@ -586,6 +686,41 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
586
686
|
documentRoute("soul", "SOUL.md");
|
|
587
687
|
documentRoute("refine", "REFINE.md");
|
|
588
688
|
|
|
689
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
|
|
690
|
+
if (!guard(request, reply)) return;
|
|
691
|
+
const profile = profileName(request);
|
|
692
|
+
const settings = await api.profiles.readSettings(profile);
|
|
693
|
+
return { config: contextMemoryConfig((settings.profile as { contextMemory?: unknown } | undefined)?.contextMemory) };
|
|
694
|
+
});
|
|
695
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/context-memory", async (request, reply) => {
|
|
696
|
+
if (!guard(request, reply)) return;
|
|
697
|
+
const profile = profileName(request), body = objectBody(request.body);
|
|
698
|
+
const config = body.config === null ? undefined : contextMemoryConfig(body.config);
|
|
699
|
+
if (body.config !== null && !config) throw Object.assign(new Error("Invalid Context Memory configuration."), { status: 400 });
|
|
700
|
+
const settings = await api.profiles.readSettings(profile);
|
|
701
|
+
const policy = { ...((settings.profile as Record<string, unknown> | undefined) ?? {}) };
|
|
702
|
+
if (config) policy.contextMemory = config; else delete policy.contextMemory;
|
|
703
|
+
await api.profiles.writeSettings(profile, { ...settings, profile: policy });
|
|
704
|
+
return { config: config ?? null };
|
|
705
|
+
});
|
|
706
|
+
for (const target of ["operational", "profile"] as const) {
|
|
707
|
+
server.get<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
|
|
708
|
+
if (!guard(request, reply)) return;
|
|
709
|
+
const profile = profileName(request), directory = api.profiles.directory(profile);
|
|
710
|
+
return { content: await readMemory(memoryPath(options.agentDir, directory, target)) };
|
|
711
|
+
});
|
|
712
|
+
server.put<{ Params: { profile: string } }>(`/api/profiles/:profile/context-memory/${target}`, async (request, reply) => {
|
|
713
|
+
if (!guard(request, reply)) return;
|
|
714
|
+
const content = objectBody(request.body).content;
|
|
715
|
+
if (typeof content !== "string") throw Object.assign(new Error("Content must be a string."), { status: 400 });
|
|
716
|
+
const limit = contextMemoryLimit(target);
|
|
717
|
+
if (contextMemoryCharacters(content.trim()) > limit) throw Object.assign(new Error(`${target === "operational" ? "OPERATIONAL.md" : "PROFILE.md"} exceeds its ${limit}-character limit.`), { status: 413 });
|
|
718
|
+
const profile = profileName(request), directory = api.profiles.directory(profile), path = memoryPath(options.agentDir, directory, target);
|
|
719
|
+
await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${content.trim()}${content.trim() ? "\n" : ""}`, { mode: 0o600 });
|
|
720
|
+
return { content: content.trim() };
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
589
724
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/env", async (request, reply) => {
|
|
590
725
|
if (!guard(request, reply)) return;
|
|
591
726
|
const path = join(api.profiles.directory(profileName(request)), ".env");
|
|
@@ -652,6 +787,10 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
652
787
|
if (!guard(request, reply)) return;
|
|
653
788
|
return reply.code(201).send(await api.createSession(profileName(request), request.body));
|
|
654
789
|
});
|
|
790
|
+
server.post<{ Params: { profile: string } }>("/api/profiles/:profile/application-sessions", async (request, reply) => {
|
|
791
|
+
if (!guard(request, reply)) return;
|
|
792
|
+
return reply.code(201).send(await api.createApplicationConsoleSession(profileName(request), request.body));
|
|
793
|
+
});
|
|
655
794
|
server.get<{ Params: { profile: string; sessionId: string }; Querystring: { before?: string; limit?: string } }>("/api/profiles/:profile/sessions/:sessionId", async (request, reply) => {
|
|
656
795
|
if (!guard(request, reply)) return;
|
|
657
796
|
const limit = request.query.limit === undefined ? 16 : Number(request.query.limit);
|
|
@@ -675,6 +814,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
675
814
|
await api.profiles.readSettings(profileName(request));
|
|
676
815
|
return { pulses: api.pulses.list(profileName(request)) };
|
|
677
816
|
});
|
|
817
|
+
server.get<{ Params: { profile: string; name: string }; Querystring: { start?: string; end?: string; limit?: string } }>("/api/profiles/:profile/pulses/:name/runs", async (request, reply) => {
|
|
818
|
+
if (!guard(request, reply)) return;
|
|
819
|
+
const profile = profileName(request); await api.profiles.readSettings(profile);
|
|
820
|
+
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);
|
|
821
|
+
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 });
|
|
822
|
+
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 });
|
|
823
|
+
try { return { runs: api.pulses.runs(profile, request.params.name, start, end, limit) }; }
|
|
824
|
+
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); }
|
|
825
|
+
});
|
|
678
826
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/pulses/history", async (request, reply) => {
|
|
679
827
|
if (!guard(request, reply)) return;
|
|
680
828
|
await api.profiles.readSettings(profileName(request));
|
|
@@ -687,7 +835,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
687
835
|
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 });
|
|
688
836
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
689
837
|
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 });
|
|
690
|
-
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, profile: profileName(request) }) }); }
|
|
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 === true, profile: profileName(request) }) }); }
|
|
691
839
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
692
840
|
});
|
|
693
841
|
server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -701,7 +849,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
701
849
|
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 });
|
|
702
850
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
703
851
|
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 });
|
|
704
|
-
try { return { pulse: api.pulses.update(request.params.name, profileName(request), { description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId }) }; }
|
|
852
|
+
try { return { pulse: api.pulses.update(request.params.name, profileName(request), { description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: body.insertIntoApiSession === true }) }; }
|
|
705
853
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
706
854
|
});
|
|
707
855
|
server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -720,9 +868,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
720
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 }); });
|
|
721
869
|
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
870
|
|
|
723
|
-
const skillSource = (value: unknown): "shared" | "profile" | undefined => {
|
|
871
|
+
const skillSource = (value: unknown): "shared" | "profile" | "package" | undefined => {
|
|
724
872
|
if (value === undefined) return undefined;
|
|
725
|
-
if (value === "shared" || value === "profile") return value;
|
|
873
|
+
if (value === "shared" || value === "profile" || value === "package") return value;
|
|
726
874
|
throw Object.assign(new Error("Invalid skill source."), { status: 400 });
|
|
727
875
|
};
|
|
728
876
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/skills/sources", async (request, reply) => {
|
|
@@ -746,13 +894,15 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
746
894
|
server.patch<{ Params: { profile: string; name: string }; Body: { enabled?: unknown } }>("/api/profiles/:profile/packages/:name", async (request, reply) => {
|
|
747
895
|
if (!guard(request, reply)) return;
|
|
748
896
|
if (typeof request.body?.enabled !== "boolean") return reply.code(400).send({ error: "'enabled' must be a boolean." });
|
|
749
|
-
|
|
897
|
+
const result = await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled);
|
|
898
|
+
api.invalidateExtensionTools();
|
|
899
|
+
return { package: result };
|
|
750
900
|
});
|
|
751
901
|
|
|
752
902
|
server.get<{ Params: { profile: string; kind: string } }>("/api/profiles/:profile/resources/:kind", async (request, reply) => {
|
|
753
903
|
if (!guard(request, reply)) return;
|
|
754
904
|
const kind = resourceKind(request.params.kind);
|
|
755
|
-
if (kind === "tools") await api.discoverExtensionTools(profileName(request))
|
|
905
|
+
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
756
906
|
return { resources: await api.profiles.resources(profileName(request), kind) };
|
|
757
907
|
});
|
|
758
908
|
server.get<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
@@ -767,7 +917,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
767
917
|
if (kind === "tools") await api.discoverExtensionTools(profileName(request));
|
|
768
918
|
const enabled = objectBody(request.body).enabled;
|
|
769
919
|
if (typeof enabled !== "boolean") throw Object.assign(new Error("The 'enabled' field must be a boolean."), { status: 400 });
|
|
770
|
-
|
|
920
|
+
const result = await api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
|
|
921
|
+
if (kind === "extensions") api.invalidateExtensionTools();
|
|
922
|
+
return result;
|
|
771
923
|
});
|
|
772
924
|
|
|
773
925
|
server.delete<{ Params: { profile: string; kind: string; name: string }; Querystring: { source?: string } }>("/api/profiles/:profile/resources/:kind/:name", async (request, reply) => {
|
|
@@ -784,10 +936,13 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
784
936
|
const applicationsRoot = join(options.agentDir, "applications");
|
|
785
937
|
const applicationBySlug = new Map<string, { record: ApplicationRecord; runtime: ApplicationRuntime }>();
|
|
786
938
|
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 }); };
|
|
939
|
+
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"); };
|
|
940
|
+
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
941
|
for (const record of applicationStore.list()) await loadApplication(record);
|
|
790
942
|
const registeredApplication = (slug: string) => { const application = applicationBySlug.get(slug); if (!application) throw Object.assign(new Error("Application not found."), { status: 404 }); return application; };
|
|
943
|
+
api.setContextMemoryLogger((application, profile, identityKey, sessionId, execution) => {
|
|
944
|
+
applicationBySlug.get(application)?.runtime.logs.recordContextMemory({ profile, identityKey, sessionId, execution });
|
|
945
|
+
});
|
|
791
946
|
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
947
|
const applicationHandlerPath = (slug: string, file: unknown) => {
|
|
793
948
|
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,9 +973,9 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
818
973
|
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
974
|
await loadApplication(record); return { application: record };
|
|
820
975
|
});
|
|
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(); });
|
|
976
|
+
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
977
|
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
|
-
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
|
|
978
|
+
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, ignored = body.sessionMode === "ignore" || body.profile === null, automatic = !ignored && (identityKey === "*" || body.sessionMode === "automatic"), prefix = ignored || automatic ? null : body.sessionPrefix, profile = ignored ? null : body.profile; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || (!ignored && (typeof profile !== "string" || !profile || (!automatic && typeof prefix !== "string")))) throw Object.assign(new Error("profile and a fixed sessionPrefix are required unless this mapping ignores requests."), { status: 400 }); if (typeof prefix === "string" && !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(prefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 400 }); if (!ignored) await api.profiles.readSettings(profile as string); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) }; });
|
|
824
979
|
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 }); } });
|
|
825
980
|
server.get<{ Querystring: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } }>("/api/application-sessions", async (request, reply) => { if (!guard(request, reply)) return; const filters = request.query; return { sessions: applicationStore.activeSessions(filters) }; });
|
|
826
981
|
server.delete<{ Params: { sessionId: string }; Querystring: { application?: string; profile?: string } }>("/api/application-sessions/:sessionId", async (request, reply) => { if (!guard(request, reply)) return; const { application, profile } = request.query; if (!application || !profile) throw Object.assign(new Error("application and profile are required."), { status: 400 }); applicationStore.removeActiveSession(application, profile, request.params.sessionId); return reply.code(204).send(); });
|
|
@@ -830,7 +985,26 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
830
985
|
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
986
|
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
987
|
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) => {
|
|
988
|
+
server.get<{ Params: { slug: string }; Querystring: { summary?: string; limit?: string; start?: string; end?: string } }>("/api/applications/:slug/logs", async (request, reply) => {
|
|
989
|
+
if (!guard(request, reply)) return;
|
|
990
|
+
const logs = registeredApplication(request.params.slug).runtime.logs;
|
|
991
|
+
if (request.query.summary !== "1") return { calls: logs.list() };
|
|
992
|
+
const limit = request.query.limit === undefined ? 250 : Number(request.query.limit);
|
|
993
|
+
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 });
|
|
994
|
+
const parseDate = (value: string | undefined, name: string) => {
|
|
995
|
+
if (!value) return undefined;
|
|
996
|
+
const date = new Date(value);
|
|
997
|
+
if (Number.isNaN(date.getTime())) throw Object.assign(new Error(`The '${name}' query parameter must be a valid ISO date.`), { status: 400 });
|
|
998
|
+
return date;
|
|
999
|
+
};
|
|
1000
|
+
return { calls: logs.listSummaries(limit, parseDate(request.query.start, "start"), parseDate(request.query.end, "end")) };
|
|
1001
|
+
});
|
|
1002
|
+
server.get<{ Params: { slug: string; callId: string } }>("/api/applications/:slug/logs/calls/:callId", async (request, reply) => {
|
|
1003
|
+
if (!guard(request, reply)) return;
|
|
1004
|
+
const call = registeredApplication(request.params.slug).runtime.logs.get(request.params.callId);
|
|
1005
|
+
if (!call) throw Object.assign(new Error("Log call not found."), { status: 404 });
|
|
1006
|
+
return { call };
|
|
1007
|
+
});
|
|
834
1008
|
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
1009
|
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
1010
|
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); });
|