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.
Files changed (133) hide show
  1. package/README.md +65 -6
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/extensions/api-server/application-log-store.ts +35 -2
  4. package/extensions/api-server/application-runtime.ts +95 -23
  5. package/extensions/api-server/application-store.ts +26 -9
  6. package/extensions/api-server/profile-store.ts +79 -29
  7. package/extensions/api-server/server.ts +225 -51
  8. package/extensions/browser-harness/cdp/commands.ts +260 -0
  9. package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
  10. package/extensions/browser-harness/cdp/discovery.ts +198 -0
  11. package/extensions/browser-harness/cdp/errors.ts +23 -0
  12. package/extensions/browser-harness/cdp/events.ts +170 -0
  13. package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
  14. package/extensions/browser-harness/cdp/ownership.ts +73 -0
  15. package/extensions/browser-harness/cdp/record-store.ts +61 -0
  16. package/extensions/browser-harness/cdp/session.ts +403 -0
  17. package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
  18. package/extensions/browser-harness/cdp/target-factory.ts +153 -0
  19. package/extensions/browser-harness/cdp/types.ts +82 -0
  20. package/extensions/browser-harness/client.ts +366 -0
  21. package/extensions/browser-harness/daemon/bridge.ts +531 -0
  22. package/extensions/browser-harness/daemon/index.ts +86 -0
  23. package/extensions/browser-harness/daemon/protocol.ts +110 -0
  24. package/extensions/browser-harness/daemon/queue.ts +85 -0
  25. package/extensions/browser-harness/daemon/server.ts +170 -0
  26. package/extensions/browser-harness/daemon/spawn.ts +124 -0
  27. package/extensions/browser-harness/daemon/transport.ts +178 -0
  28. package/extensions/browser-harness/domains/ax-tree.ts +179 -0
  29. package/extensions/browser-harness/domains/box.ts +51 -0
  30. package/extensions/browser-harness/domains/cdp-call.ts +55 -0
  31. package/extensions/browser-harness/domains/click.ts +97 -0
  32. package/extensions/browser-harness/domains/console.ts +219 -0
  33. package/extensions/browser-harness/domains/dialog.ts +34 -0
  34. package/extensions/browser-harness/domains/drag.ts +67 -0
  35. package/extensions/browser-harness/domains/element-call.ts +117 -0
  36. package/extensions/browser-harness/domains/files.ts +219 -0
  37. package/extensions/browser-harness/domains/fill-engine.ts +80 -0
  38. package/extensions/browser-harness/domains/form-batch.ts +65 -0
  39. package/extensions/browser-harness/domains/form.ts +283 -0
  40. package/extensions/browser-harness/domains/headers.ts +89 -0
  41. package/extensions/browser-harness/domains/history.ts +80 -0
  42. package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
  43. package/extensions/browser-harness/domains/js.ts +241 -0
  44. package/extensions/browser-harness/domains/keyboard.ts +197 -0
  45. package/extensions/browser-harness/domains/navigate.ts +161 -0
  46. package/extensions/browser-harness/domains/network.ts +301 -0
  47. package/extensions/browser-harness/domains/page.ts +154 -0
  48. package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
  49. package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
  50. package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
  51. package/extensions/browser-harness/domains/render.ts +43 -0
  52. package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
  53. package/extensions/browser-harness/domains/screenshot.ts +76 -0
  54. package/extensions/browser-harness/domains/scroll.ts +59 -0
  55. package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
  56. package/extensions/browser-harness/domains/search/web-search.ts +144 -0
  57. package/extensions/browser-harness/domains/setup.ts +35 -0
  58. package/extensions/browser-harness/domains/snapshot.ts +256 -0
  59. package/extensions/browser-harness/domains/tabs.ts +202 -0
  60. package/extensions/browser-harness/domains/target.ts +35 -0
  61. package/extensions/browser-harness/domains/viewport.ts +42 -0
  62. package/extensions/browser-harness/index.ts +88 -0
  63. package/extensions/browser-harness/profile/bind.ts +90 -0
  64. package/extensions/browser-harness/profile/browser-process.ts +320 -0
  65. package/extensions/browser-harness/profile/command.ts +189 -0
  66. package/extensions/browser-harness/profile/launch.ts +80 -0
  67. package/extensions/browser-harness/profile/list.ts +131 -0
  68. package/extensions/browser-harness/profile/paths.ts +103 -0
  69. package/extensions/browser-harness/profile/store.ts +84 -0
  70. package/extensions/browser-harness/prompt.ts +16 -0
  71. package/extensions/browser-harness/registry.ts +80 -0
  72. package/extensions/browser-harness/schemas/common.ts +11 -0
  73. package/extensions/browser-harness/schemas/parse.ts +23 -0
  74. package/extensions/browser-harness/setup.ts +47 -0
  75. package/extensions/browser-harness/state.ts +51 -0
  76. package/extensions/browser-harness/steel.ts +215 -0
  77. package/extensions/browser-harness/util/debug.ts +8 -0
  78. package/extensions/browser-harness/util/guards.ts +30 -0
  79. package/extensions/browser-harness/util/js-template.ts +12 -0
  80. package/extensions/browser-harness/util/mutex.ts +20 -0
  81. package/extensions/browser-harness/util/paths.ts +31 -0
  82. package/extensions/browser-harness/util/result.ts +9 -0
  83. package/extensions/browser-harness/util/sharp-shim.ts +42 -0
  84. package/extensions/browser-harness/util/tool.ts +185 -0
  85. package/extensions/browser-harness/util/truncate.ts +41 -0
  86. package/extensions/cli-resources.ts +54 -35
  87. package/extensions/composio/index.ts +163 -0
  88. package/extensions/context-memory/index.ts +58 -0
  89. package/extensions/conversation-search/index.ts +42 -0
  90. package/extensions/lib/application-context.ts +22 -0
  91. package/extensions/lib/application-handler-templates.ts +17 -1
  92. package/extensions/lib/builtin-tools.ts +24 -0
  93. package/extensions/lib/context-memory.ts +165 -0
  94. package/extensions/lib/conversation-search.ts +33 -0
  95. package/extensions/lib/profile-sandbox.ts +6 -0
  96. package/extensions/lib/workflow-harness.ts +238 -0
  97. package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
  98. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  99. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
  100. package/extensions/pi-console-webui/app/layout.tsx +6 -1
  101. package/extensions/pi-console-webui/app/login/page.tsx +5 -9
  102. package/extensions/pi-console-webui/app/not-found.tsx +5 -0
  103. package/extensions/pi-console-webui/app/page.tsx +6 -0
  104. package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
  105. package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
  106. package/extensions/pi-console-webui/components/application-logs.tsx +14 -10
  107. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  108. package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
  109. package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
  110. package/extensions/pi-console-webui/components/console-page.tsx +6 -1
  111. package/extensions/pi-console-webui/components/console.tsx +60 -10
  112. package/extensions/pi-console-webui/components/login-form.tsx +14 -0
  113. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  114. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  115. package/extensions/pi-console-webui/components/pulse-resources.tsx +14 -10
  116. package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
  117. package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
  118. package/extensions/pi-console-webui/lib/page-metadata.ts +35 -0
  119. package/extensions/pi-console-webui/next.config.js +1 -0
  120. package/extensions/profile-ssh.ts +274 -0
  121. package/extensions/profiles.ts +70 -23
  122. package/extensions/pulse/index.ts +128 -14
  123. package/extensions/pulse/store.ts +110 -15
  124. package/extensions/sequential-workflow.ts +340 -256
  125. package/extensions/skill-sources/store.ts +24 -3
  126. package/licenses/pi-browser-harness-MIT.txt +21 -0
  127. package/package.json +23 -3
  128. package/schemas/sequential-workflow-template.schema.json +62 -0
  129. package/skills/browser-harness/SKILL.md +33 -0
  130. package/skills/composio-connections/SKILL.md +75 -0
  131. package/skills/profile-ssh/SKILL.md +65 -0
  132. package/skills/sequential-workflow/SKILL.md +70 -0
  133. 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
- const line = output.split(/\r?\n/).map((value) => value.trim()).find((value) => value.startsWith("{\"tools\":"));
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 agentDir = this.profileDirectory(profile);
333
- await this.profiles.readSettings(profile);
334
- if (await this.sandboxed(profile)) return this.discoverSandboxedExtensionTools(profile);
335
- await this.withProfileEnvironment(agentDir, async () => {
336
- const settingsManager = SettingsManager.create(this.options.cwd, agentDir);
337
- const loader = new DefaultResourceLoader({ cwd: this.options.cwd, agentDir, settingsManager });
338
- await loader.reload();
339
- let runtime = this.runtimes.get(agentDir);
340
- if (!runtime) {
341
- runtime = ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
342
- this.runtimes.set(agentDir, runtime);
343
- }
344
- const { session } = await createAgentSession({
345
- cwd: this.options.cwd,
346
- agentDir,
347
- sessionManager: SessionManager.inMemory(this.options.cwd),
348
- settingsManager,
349
- resourceLoader: loader,
350
- modelRuntime: await runtime,
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
- try {
353
- this.profiles.registerExtensionTools(session.agent.state.tools.map((tool) => tool.name));
354
- } finally {
355
- session.dispose();
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
- if (result.code !== 0) throw new Error(result.stderr.trim() || `Sandboxed Pi exited with ${result.code}`);
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
- await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }), handoff ? { PI_APPLICATION_HANDOFF: handoff } : {});
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
- await this.withProfileEnvironment(this.profileDirectory(profile), () => handle.session.prompt(message, { source: "rpc" }));
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.profiles.delete(profileName(request));
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
- return { package: await api.profiles.setPackage(profileName(request), decodeURIComponent(request.params.name), request.body.enabled) };
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)).catch(() => {});
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
- return api.profiles.setResource(profileName(request), kind, request.params.name, enabled, kind === "skills" ? skillSource(request.query.source) : undefined);
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 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) }; });
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) => { if (!guard(request, reply)) return; return { calls: registeredApplication(request.params.slug).runtime.logs.list() }; });
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); });