pi-feats 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +24 -1
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/assets/pi-feats-preview.png +0 -0
  4. package/extensions/api-server/application-log-store.ts +31 -0
  5. package/extensions/api-server/application-runtime.ts +80 -18
  6. package/extensions/api-server/application-store.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +47 -20
  8. package/extensions/api-server/server.ts +182 -47
  9. package/extensions/browser-harness/cdp/commands.ts +260 -0
  10. package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
  11. package/extensions/browser-harness/cdp/discovery.ts +198 -0
  12. package/extensions/browser-harness/cdp/errors.ts +23 -0
  13. package/extensions/browser-harness/cdp/events.ts +170 -0
  14. package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
  15. package/extensions/browser-harness/cdp/ownership.ts +73 -0
  16. package/extensions/browser-harness/cdp/record-store.ts +61 -0
  17. package/extensions/browser-harness/cdp/session.ts +403 -0
  18. package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
  19. package/extensions/browser-harness/cdp/target-factory.ts +153 -0
  20. package/extensions/browser-harness/cdp/types.ts +82 -0
  21. package/extensions/browser-harness/client.ts +366 -0
  22. package/extensions/browser-harness/daemon/bridge.ts +531 -0
  23. package/extensions/browser-harness/daemon/index.ts +86 -0
  24. package/extensions/browser-harness/daemon/protocol.ts +110 -0
  25. package/extensions/browser-harness/daemon/queue.ts +85 -0
  26. package/extensions/browser-harness/daemon/server.ts +170 -0
  27. package/extensions/browser-harness/daemon/spawn.ts +124 -0
  28. package/extensions/browser-harness/daemon/transport.ts +178 -0
  29. package/extensions/browser-harness/domains/ax-tree.ts +179 -0
  30. package/extensions/browser-harness/domains/box.ts +51 -0
  31. package/extensions/browser-harness/domains/cdp-call.ts +55 -0
  32. package/extensions/browser-harness/domains/click.ts +97 -0
  33. package/extensions/browser-harness/domains/console.ts +219 -0
  34. package/extensions/browser-harness/domains/dialog.ts +34 -0
  35. package/extensions/browser-harness/domains/drag.ts +67 -0
  36. package/extensions/browser-harness/domains/element-call.ts +117 -0
  37. package/extensions/browser-harness/domains/files.ts +219 -0
  38. package/extensions/browser-harness/domains/fill-engine.ts +80 -0
  39. package/extensions/browser-harness/domains/form-batch.ts +65 -0
  40. package/extensions/browser-harness/domains/form.ts +283 -0
  41. package/extensions/browser-harness/domains/headers.ts +89 -0
  42. package/extensions/browser-harness/domains/history.ts +80 -0
  43. package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
  44. package/extensions/browser-harness/domains/js.ts +241 -0
  45. package/extensions/browser-harness/domains/keyboard.ts +197 -0
  46. package/extensions/browser-harness/domains/navigate.ts +161 -0
  47. package/extensions/browser-harness/domains/network.ts +301 -0
  48. package/extensions/browser-harness/domains/page.ts +154 -0
  49. package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
  50. package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
  51. package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
  52. package/extensions/browser-harness/domains/render.ts +43 -0
  53. package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
  54. package/extensions/browser-harness/domains/screenshot.ts +76 -0
  55. package/extensions/browser-harness/domains/scroll.ts +59 -0
  56. package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
  57. package/extensions/browser-harness/domains/search/web-search.ts +144 -0
  58. package/extensions/browser-harness/domains/setup.ts +35 -0
  59. package/extensions/browser-harness/domains/snapshot.ts +256 -0
  60. package/extensions/browser-harness/domains/tabs.ts +202 -0
  61. package/extensions/browser-harness/domains/target.ts +35 -0
  62. package/extensions/browser-harness/domains/viewport.ts +42 -0
  63. package/extensions/browser-harness/index.ts +88 -0
  64. package/extensions/browser-harness/profile/bind.ts +90 -0
  65. package/extensions/browser-harness/profile/browser-process.ts +320 -0
  66. package/extensions/browser-harness/profile/command.ts +189 -0
  67. package/extensions/browser-harness/profile/launch.ts +80 -0
  68. package/extensions/browser-harness/profile/list.ts +131 -0
  69. package/extensions/browser-harness/profile/paths.ts +103 -0
  70. package/extensions/browser-harness/profile/store.ts +84 -0
  71. package/extensions/browser-harness/prompt.ts +16 -0
  72. package/extensions/browser-harness/registry.ts +80 -0
  73. package/extensions/browser-harness/schemas/common.ts +11 -0
  74. package/extensions/browser-harness/schemas/parse.ts +23 -0
  75. package/extensions/browser-harness/setup.ts +47 -0
  76. package/extensions/browser-harness/state.ts +51 -0
  77. package/extensions/browser-harness/steel.ts +215 -0
  78. package/extensions/browser-harness/util/debug.ts +8 -0
  79. package/extensions/browser-harness/util/guards.ts +30 -0
  80. package/extensions/browser-harness/util/js-template.ts +12 -0
  81. package/extensions/browser-harness/util/mutex.ts +20 -0
  82. package/extensions/browser-harness/util/paths.ts +31 -0
  83. package/extensions/browser-harness/util/result.ts +9 -0
  84. package/extensions/browser-harness/util/sharp-shim.ts +42 -0
  85. package/extensions/browser-harness/util/tool.ts +185 -0
  86. package/extensions/browser-harness/util/truncate.ts +41 -0
  87. package/extensions/cli-resources.ts +35 -16
  88. package/extensions/composio/index.ts +163 -0
  89. package/extensions/context-memory/index.ts +58 -0
  90. package/extensions/lib/application-context.ts +22 -0
  91. package/extensions/lib/application-handler-templates.ts +17 -1
  92. package/extensions/lib/context-memory.ts +165 -0
  93. package/extensions/lib/profile-sandbox.ts +3 -0
  94. package/extensions/lib/workflow-harness.ts +238 -0
  95. package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
  96. package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
  97. package/extensions/pi-console-webui/app/layout.tsx +6 -1
  98. package/extensions/pi-console-webui/app/login/page.tsx +5 -9
  99. package/extensions/pi-console-webui/app/not-found.tsx +5 -0
  100. package/extensions/pi-console-webui/app/page.tsx +6 -0
  101. package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
  102. package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
  103. package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
  104. package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
  105. package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
  106. package/extensions/pi-console-webui/components/console-page.tsx +3 -1
  107. package/extensions/pi-console-webui/components/console.tsx +53 -7
  108. package/extensions/pi-console-webui/components/login-form.tsx +14 -0
  109. package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
  110. package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
  111. package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
  112. package/extensions/profiles.ts +18 -7
  113. package/extensions/pulse/store.ts +5 -2
  114. package/extensions/sequential-workflow.ts +340 -256
  115. package/extensions/skill-sources/store.ts +24 -3
  116. package/licenses/pi-browser-harness-MIT.txt +21 -0
  117. package/package.json +25 -4
  118. package/schemas/sequential-workflow-template.schema.json +62 -0
  119. package/skills/browser-harness/SKILL.md +33 -0
  120. package/skills/composio-connections/SKILL.md +75 -0
  121. package/skills/sequential-workflow/SKILL.md +70 -0
  122. package/skills/sequential-workflow-template/SKILL.md +44 -0
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  `pi-feats` turns the lightweight, extensible Pi Coding Agent into an operational workspace without replacing Pi's native configuration model. It adds persistent profiles, a policy-controlled sandbox, HTTP and browser interfaces, application runtimes, scheduled work, Git-backed Skill discovery, and command-line administration.
6
6
 
7
+ ![Pi Feats: production features for Pi Coding Agent](assets/pi-feats-preview.png)
8
+
7
9
  ## Contents
8
10
 
9
11
  - [Glossary](#glossary)
@@ -291,9 +293,18 @@ Refreshing synchronizes the source and atomically replaces the complete installe
291
293
 
292
294
  ### Sequential Workflow
293
295
 
294
- - Create persistent workflows made of strictly ordered **Action**, **Collect**, and **Evaluate** tasks.
296
+ - Create persistent workflows made of strictly ordered **Action**, **Collect**, **Evaluate**, and subworkflow tasks.
297
+ - Run multiple workflows per profile, independently or in parent/child hierarchies, with execution ownership and persisted focus isolated by Pi session ID.
298
+ - Enforce current-task IDs, ownership, phase gates, and one tool call per model turn in the harness. External tools are allowed only during running Action tasks. Collect requires a subsequent user message. These controls enforce ordering, not the semantic truth of agent-reported results.
299
+ - Restore focus on same-session resume/reload, never automatically on new/forked sessions. `/workflow-suspend`, `/workflow-focus <id>`, and `/workflow-adopt <rootId>` provide explicit user control; adoption transfers the entire tree and revokes the former owner's access. Legacy executions remain unowned until adopted.
300
+ - Cancel any active workflow in the current profile with `sequential_workflow_cancel`, including one created by another session; cancellation does not require adoption and does not cascade to descendants. A cancelled child is returned to its parent for evaluation. `/workflow-cancel <id>` remains available for direct user control.
301
+ - Resolve `ative o sequential workflow basic` to `sequential_workflow_templates/basic.json` without inventing a new definition. Each activation creates a new execution. Missing templates fail closed.
302
+ - Refresh task context before every model request and request at most two automatic continuations per unchanged unfinished phase. Failure to progress leaves the task pending with a warning; it never auto-accepts a task.
303
+ - Let a subworkflow task wait for its linked child while unrelated workflows continue independently.
295
304
  - Prevent advancement until the current task result has been recorded and accepted.
296
305
  - Persist workflow state in SQLite for auditable retries and continuation.
306
+ - Validate reusable JSON definitions against [`schemas/sequential-workflow-template.schema.json`](schemas/sequential-workflow-template.schema.json), then create and start a workflow directly from the template.
307
+ - Include separate Skills for executing named Sequential Workflows and authoring their JSON templates. Templates created without an explicitly requested output path default to `<PI_CODING_AGENT_DIR>/sequential_workflow_templates/` (or `~/.pi/agent/sequential_workflow_templates/`); template tools resolve relative paths in that same directory.
297
308
 
298
309
  ## Command cookbook
299
310
 
@@ -410,6 +421,7 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
410
421
  | Extension | Responsibility |
411
422
  | --- | --- |
412
423
  | API Server | HTTP API, Applications, profile administration, sessions, logs, and terminal tickets. |
424
+ | Browser Harness | Steel Profile-scoped CDP browser automation for active Pi Profiles. |
413
425
  | CLI Resources | Resource, package, session, and profile commands. |
414
426
  | Guardrails | Input, tool, and output policy stages. |
415
427
  | Pi Console WebUI | Browser-based operations console and terminal client. |
@@ -418,6 +430,17 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
418
430
  | Sequential Workflow | Ordered Action, Collect, and Evaluate workflows. |
419
431
  | Skill Sources | Git-backed Skill source catalog and explicit imports. |
420
432
 
433
+ ### Steel Browser Harness
434
+
435
+ The Browser Harness maps each Pi Profile to a persisted Steel SessionContext. The Steel API creates and releases live browser sessions; the profile-local context restores cookies and storage for the next session. Configure the Steel endpoint and credential in each profile's `.env`:
436
+
437
+ ```dotenv
438
+ STEEL_API_URL=http://127.0.0.1:3000
439
+ STEEL_API_KEY=...
440
+ ```
441
+
442
+ On first browser use, the harness creates a Steel Session through the API. `browser_setup` and `/browser-setup` return Steel's interactive live viewer URL, so a person can observe and interact with the same browser and tabs while the agent works through CDP. When the session is released, the harness stores the SessionContext in `steel.json` inside that Pi Profile directory. Steel Session identifiers are never persisted. Use `/browser-viewer` to show the current viewer URL, `/browser-status` to inspect the active session, and `/browser-release` to release it.
443
+
421
444
  ## Installation
422
445
 
423
446
  Install the latest published package:
@@ -0,0 +1,12 @@
1
+ # Third-party notices
2
+
3
+ ## pi-browser-harness
4
+
5
+ `extensions/browser-harness` contains adapted source code from
6
+ [pi-browser-harness](https://github.com/amankumarsingh77/pi-browser-harness),
7
+ version 0.11.1 (commit `8d7a067`). The original project is licensed under the
8
+ MIT License. The adaptation replaces desktop-browser discovery and local
9
+ profile management with Pi Profile-scoped Steel SessionContext and Steel Session
10
+ management.
11
+
12
+ See the original project's `LICENSE` for the full license text.
Binary file
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import type { ContextMemoryExecutionLog } from "../lib/application-context.ts";
5
6
  export type CallStage = { id: string; type: string; label: string; startedAt: string; finishedAt?: string; input?: unknown; output?: unknown; payload?: unknown; stdout: string[]; stderr: string[]; error?: string };
6
7
  export type ApplicationCall = { id: string; application: string; origin: string; receivedAt: string; finishedAt?: string; status: "running" | "success" | "error"; headers: Record<string, unknown>; stages: CallStage[]; error?: string };
7
8
  export class ApplicationLogStore {
@@ -12,9 +13,39 @@ export class ApplicationLogStore {
12
13
  start(payload: unknown, headers: Record<string, unknown>) { const call: ApplicationCall = { id: randomUUID(), application: this.application, origin: String(headers["x-forwarded-for"] ?? headers.origin ?? headers.host ?? "unknown"), receivedAt: new Date().toISOString(), status: "running", headers, stages: [{ id: randomUUID(), type: "request", label: "Request", startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), payload, stdout: [], stderr: [] }] }; this.calls.set(call.id, call); void this.save(call); return call; }
13
14
  stage(call: ApplicationCall, type: string, label: string, input: unknown) { const stage: CallStage = { id: randomUUID(), type, label, startedAt: new Date().toISOString(), input, stdout: [], stderr: [] }; call.stages.push(stage); void this.save(call); return stage; }
14
15
  complete(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; stage.finishedAt = new Date().toISOString(); void this.save(call); }
16
+ annotate(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; void this.save(call); }
15
17
  fail(call: ApplicationCall, stage: CallStage | undefined, error: unknown) { const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); if (stage) { stage.error = message; stage.finishedAt = new Date().toISOString(); } call.error = message; call.status = "error"; call.finishedAt = new Date().toISOString(); void this.save(call); }
16
18
  finish(call: ApplicationCall, output: unknown) { call.status = "success"; call.finishedAt = new Date().toISOString(); const stage = this.stage(call, "final-response", "Final response", output); this.complete(call, stage, output); }
19
+ recordContextMemory(input: { profile: string; identityKey: string; sessionId: string; execution: ContextMemoryExecutionLog }) {
20
+ const { execution } = input;
21
+ const stage: CallStage = {
22
+ id: randomUUID(),
23
+ type: "context-memory",
24
+ label: `Context Memory: ${execution.handler}`,
25
+ startedAt: execution.startedAt,
26
+ finishedAt: new Date(new Date(execution.startedAt).getTime() + execution.durationMs).toISOString(),
27
+ input: { profile: input.profile, identityKey: input.identityKey, sessionId: input.sessionId, handler: execution.handler },
28
+ output: { injected: execution.status === "success" && execution.outputCharacters > 0, outputCharacters: execution.outputCharacters },
29
+ stdout: execution.stdout,
30
+ stderr: execution.stderr,
31
+ ...(execution.error ? { error: execution.error } : {}),
32
+ };
33
+ const call: ApplicationCall = {
34
+ id: randomUUID(), application: this.application, origin: "Pi Console session", receivedAt: execution.startedAt,
35
+ finishedAt: stage.finishedAt, status: execution.status === "success" ? "success" : "error", headers: {}, stages: [stage], ...(execution.error ? { error: execution.error } : {}),
36
+ };
37
+ this.calls.set(call.id, call); void this.save(call);
38
+ }
17
39
  list() { return [...this.calls.values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)); }
40
+ listSummaries(limit = 100, start?: Date, end?: Date) {
41
+ return this.list().filter((call) => {
42
+ const receivedAt = new Date(call.receivedAt);
43
+ return (!start || receivedAt >= start) && (!end || receivedAt <= end);
44
+ }).slice(0, limit).map((call) => ({
45
+ id: call.id, application: call.application, origin: call.origin, receivedAt: call.receivedAt, finishedAt: call.finishedAt, status: call.status, error: call.error,
46
+ stages: call.stages.map((stage) => ({ id: stage.id, type: stage.type, label: stage.label, startedAt: stage.startedAt, finishedAt: stage.finishedAt, error: stage.error, completed: Boolean(stage.finishedAt) })),
47
+ }));
48
+ }
18
49
  get(id: string) { return this.calls.get(id); }
19
50
  async clear() { this.calls.clear(); await rm(this.directory, { recursive: true, force: true }); await mkdir(this.directory, { recursive: true }); this.events.emit("event", { type: "logs.cleared" }); }
20
51
  on(listener: (event: unknown) => void) { this.events.on("event", listener); return () => this.events.off("event", listener); }
@@ -10,9 +10,11 @@ import { ApplicationLogStore } from "./application-log-store.ts";
10
10
  type RecordValue = Record<string, any>;
11
11
  export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
12
12
  type Handler = (payload: RecordValue, headers?: RecordValue | null, query?: RecordValue | null, context?: RecordValue | null, state?: RecordValue | null, env?: Record<string, string | undefined>) => Promise<any> | any;
13
- type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
13
+ type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
14
14
  type Handoff = { sourceSessionId: string | null; summary: string; createdAt: string; targetCreatedAt: string };
15
15
  type WorkingSession = { sessionId: string; updatedAt: string; reason: "message" | "rollover" };
16
+ type CoalescedEntry = { call: ReturnType<ApplicationLogStore["start"]>; stage: ReturnType<ApplicationLogStore["stage"]>; message: string; state: RecordValue; version: number; dispatch: (message: string, state: RecordValue) => Promise<unknown>; resolve: (output: unknown) => void; reject: (error: unknown) => void };
17
+ type CoalescingBuffer = { version: number; entries: CoalescedEntry[] };
16
18
  export type ApplicationIdentity = { id: string; name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Record<string, unknown> };
17
19
 
18
20
  const safeName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(name);
@@ -37,6 +39,8 @@ export class ApplicationRuntime {
37
39
  readonly settings: Settings;
38
40
  private readonly jiti = createJiti(process.cwd(), { moduleCache: false, fsCache: false });
39
41
  private queues = new Map<string, Promise<void>>();
42
+ private coalescingBuffers = new Map<string, CoalescingBuffer>();
43
+ private coalescingTimers = new Set<ReturnType<typeof setTimeout>>();
40
44
 
41
45
  readonly logs: ApplicationLogStore;
42
46
  constructor(readonly directory: string, settings: Settings, private readonly api: ApplicationApi, private readonly persistSettings: (settings: Settings) => Promise<void>, private readonly application: ApplicationIdentity) { this.name = application.slug; this.settings = settings; this.logs = new ApplicationLogStore(directory, this.name); }
@@ -139,11 +143,17 @@ export class ApplicationRuntime {
139
143
  temporaryPath = join(dirname(path), `.pi-handler-test-${randomUUID()}.ts`);
140
144
  await writeFile(temporaryPath, content, "utf8");
141
145
  }
142
- const loaded = this.jiti(temporaryPath ?? path) as { handle?: Handler };
143
- if (typeof loaded.handle !== "function") throw new Error("Handler must export handle().");
144
- const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: randomUUID(), headers: {}, query: {} }, test: true, log: { info: (...values: unknown[]) => stdout.push(logValues(values)), error: (...values: unknown[]) => stderr.push(logValues(values)) } };
146
+ const loaded = this.jiti(temporaryPath ?? path) as { handle?: Handler; resolveContextMemory?: (context: { application: { slug: string }; identityKey?: string; profile: string; sessionId: string }, env: Record<string, string | undefined>) => Promise<unknown> | unknown };
145
147
  installConsoleCapture();
146
- const output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle(payload, {}, {}, context, {}, await this.environmentFor(payload)));
148
+ let output: unknown;
149
+ if (typeof loaded.resolveContextMemory === "function") {
150
+ const profile = typeof payload.profile === "string" ? payload.profile : "default", identityKey = typeof payload.identityKey === "string" ? payload.identityKey : undefined, sessionId = typeof payload.sessionId === "string" ? payload.sessionId : "test-session";
151
+ output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.resolveContextMemory!({ application: { slug: this.application.slug }, identityKey, profile, sessionId }, await this.api.handlerEnvironment(profile)));
152
+ } else {
153
+ if (typeof loaded.handle !== "function") throw new Error("Handler must export handle() or resolveContextMemory().");
154
+ const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: randomUUID(), headers: {}, query: {} }, test: true, log: { info: (...values: unknown[]) => stdout.push(logValues(values)), error: (...values: unknown[]) => stderr.push(logValues(values)) } };
155
+ output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle!(payload, {}, {}, context, {}, await this.environmentFor(payload)));
156
+ }
147
157
  return { ok: true, output, logs: { stdout, stderr }, durationMs: Math.round(performance.now() - started) };
148
158
  } catch (error) {
149
159
  const message = error instanceof Error ? error.stack ?? error.message : String(error);
@@ -177,6 +187,52 @@ export class ApplicationRuntime {
177
187
  });
178
188
  }
179
189
 
190
+ private async collect(key: string, call: ReturnType<ApplicationLogStore["start"]>, message: string, state: RecordValue, dispatch: (message: string, state: RecordValue) => Promise<unknown>): Promise<unknown> {
191
+ const seconds = this.settings.messageCoalescing?.silenceDebounceSeconds ?? 0;
192
+ if (!this.settings.messageCoalescing?.enabled || seconds <= 0) return dispatch(message, state);
193
+ const buffer = this.coalescingBuffers.get(key) ?? { version: 0, entries: [] };
194
+ this.coalescingBuffers.set(key, buffer);
195
+ const version = ++buffer.version;
196
+ const stage = this.logs.stage(call, "coalescing", "Message coalescing", { key, version, silenceDebounceSeconds: seconds, messageCharacters: message.length });
197
+ this.logs.complete(call, stage, { status: "collected", version });
198
+ return new Promise((resolve, reject) => {
199
+ const entry: CoalescedEntry = { call, stage, message, state, version, dispatch, resolve, reject };
200
+ buffer.entries.push(entry);
201
+ const timer = setTimeout(() => { this.coalescingTimers.delete(timer); void this.flushCoalesced(key, version); }, seconds * 1_000);
202
+ this.coalescingTimers.add(timer);
203
+ });
204
+ }
205
+
206
+ private async flushCoalesced(key: string, version: number): Promise<void> {
207
+ const buffer = this.coalescingBuffers.get(key);
208
+ if (!buffer) return;
209
+ if (buffer.version !== version) {
210
+ for (const entry of buffer.entries.filter((item) => item.version === version)) this.logs.annotate(entry.call, entry.stage, { status: "superseded", version, latestVersion: buffer.version });
211
+ return;
212
+ }
213
+ this.coalescingBuffers.delete(key);
214
+ const entries = buffer.entries, leader = entries.at(-1)!;
215
+ const message = entries.map((entry) => entry.message).join("\n");
216
+ for (const entry of entries) this.logs.annotate(entry.call, entry.stage, { status: entry === leader ? "dispatched" : "merged", version, leaderCallId: leader.call.id, messageCount: entries.length, messageCharacters: message.length });
217
+ try {
218
+ const output = await leader.dispatch(message, leader.state);
219
+ for (const entry of entries) {
220
+ if (entry !== leader) this.logs.finish(entry.call, { coalescing: { status: "merged", leaderCallId: leader.call.id, messageCount: entries.length } });
221
+ entry.resolve(output);
222
+ }
223
+ } catch (error) {
224
+ for (const entry of entries) { if (entry.call.status !== "error") this.logs.fail(entry.call, entry === leader ? undefined : entry.stage, error); entry.reject(error); }
225
+ }
226
+ }
227
+
228
+ dispose(): void {
229
+ for (const timer of this.coalescingTimers) clearTimeout(timer);
230
+ this.coalescingTimers.clear();
231
+ const error = new Error("Message coalescing was interrupted because the Application runtime was reloaded.");
232
+ for (const buffer of this.coalescingBuffers.values()) for (const entry of buffer.entries) { this.logs.fail(entry.call, entry.stage, error); entry.reject(error); }
233
+ this.coalescingBuffers.clear();
234
+ }
235
+
180
236
  async process(payload: unknown, headers: RecordValue, query: RecordValue): Promise<unknown> {
181
237
  const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
182
238
  const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: call.id, headers, query }, log: { info: (...values: unknown[]) => active?.stdout.push(values.map(String).join(" ")), error: (...values: unknown[]) => active?.stderr.push(values.map(String).join(" ")) } };
@@ -185,19 +241,23 @@ export class ApplicationRuntime {
185
241
  for (const name of this.settings.transformHandlers ?? []) { const result = await run("transform", `Transform: ${name}`, current, async () => this.handler("transform", name)(current, headers, query, context, state, await this.environmentFor(current, payload, query))); if (result && typeof result === "object" && "payload" in result) { current = asRecord(result.payload); state = { ...state, ...asRecord(result.state) }; } else current = asRecord(result); }
186
242
  const inbound = await run("inbound-handler", `Inbound handler: ${this.settings.inboundHandler}`, current, async () => this.handler("inbound", this.settings.inboundHandler)(current, headers, query, context, state, await this.environmentFor(current, payload, query)));
187
243
  const routed = asRecord(inbound); const hasIdentityKey = Object.prototype.hasOwnProperty.call(routed, "identityKey"); const identityKey = typeof routed.identityKey === "string" ? routed.identityKey.trim() : undefined; if (hasIdentityKey && !identityKey) throw Object.assign(new Error("identityKey must be a non-empty string when provided."), { status: 422 }); let profile: string | undefined, sessionPrefix: string | null | undefined; if (identityKey) { const mapped = await this.api.getIdentityMapping(this.application.slug, identityKey); if (!mapped) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 }); profile = mapped.profile; sessionPrefix = mapped.sessionMode === "automatic" ? await this.api.automaticApplicationSessionPrefix(this.application.slug, identityKey) : mapped.sessionPrefix; if (!sessionPrefix) throw Object.assign(new Error("The Identity Key mapping has no session prefix."), { status: 422 }); } else { profile = typeof routed.profile === "string" && routed.profile.trim() ? routed.profile.trim() : undefined; sessionPrefix = typeof (routed.sessionPrefix ?? routed.session_prefix) === "string" ? String(routed.sessionPrefix ?? routed.session_prefix).trim() || undefined : undefined; if (!profile) throw Object.assign(new Error("Inbound must return profile when identityKey is not provided."), { status: 422 }); sessionPrefix ??= "default"; } if (!profile || !sessionPrefix) throw Object.assign(new Error("No Application route was resolved."), { status: 422 }); const message = asRecord(routed.payload).message; if (typeof message !== "string" || !message.trim()) throw Object.assign(new Error("The Application did not produce a text message."), { status: 422 }); state = { ...state, ...asRecord(routed.state) };
188
- const sessionId = await this.resolveSession(profile, sessionPrefix);
189
- let handoff: string | undefined;
190
- try { handoff = await run("handoff-relevance", "Handoff relevance", { profile, sessionId, message }, () => this.relevantHandoff(profile, sessionId, sessionPrefix, message)); }
191
- catch (error) { context.log.error("Handoff skipped:", error instanceof Error ? error.message : String(error)); }
192
- const result = await run("pi-agent", "Pi agent", { profile, sessionId, message, handoff: Boolean(handoff) }, () => this.api.prompt(profile, sessionId, message, handoff));
193
- await this.setWorkingSession(profile, sessionPrefix, sessionId, "message"); await this.api.touchApplicationSession(this.application.slug, profile, sessionPrefix, sessionId, identityKey);
194
- let output: unknown = result;
195
- if (this.settings.outboundHandler) {
196
- const outboundInput = { profile, sessionId, message: { content: result.response } };
197
- const handler = this.handler("outbound", this.settings.outboundHandler);
198
- output = await run("outbound-handler", `Outbound handler: ${this.settings.outboundHandler}`, outboundInput, async () => handler(outboundInput, headers, query, context, state, await this.api.handlerEnvironment(profile)));
199
- }
200
- this.logs.finish(call, output); return output;
244
+ const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
245
+ const sessionId = await this.resolveSession(profile!, sessionPrefix!);
246
+ let handoff: string | undefined;
247
+ try { handoff = await run("handoff-relevance", "Handoff relevance", { profile, sessionId, message: coalescedMessage }, () => this.relevantHandoff(profile!, sessionId, sessionPrefix, coalescedMessage)); }
248
+ catch (error) { context.log.error("Handoff skipped:", error instanceof Error ? error.message : String(error)); }
249
+ const result = await run("pi-agent", "Pi agent", { profile, sessionId, message: coalescedMessage, handoff: Boolean(handoff) }, () => this.api.prompt(profile!, sessionId, coalescedMessage, handoff, identityKey ? { application: this.application.slug, identityKey } : undefined));
250
+ await this.setWorkingSession(profile!, sessionPrefix!, sessionId, "message"); await this.api.touchApplicationSession(this.application.slug, profile!, sessionPrefix!, sessionId, identityKey);
251
+ let output: unknown = result;
252
+ if (this.settings.outboundHandler) {
253
+ const outboundInput = { profile, sessionId, message: { content: result.response } };
254
+ const handler = this.handler("outbound", this.settings.outboundHandler);
255
+ output = await run("outbound-handler", `Outbound handler: ${this.settings.outboundHandler}`, outboundInput, async () => handler(outboundInput, headers, query, context, coalescedState, await this.api.handlerEnvironment(profile!)));
256
+ }
257
+ this.logs.finish(call, output); return output;
258
+ };
259
+ const key = `${this.application.slug}:${identityKey ? `identity:${identityKey}` : `route:${profile}:${sessionPrefix}`}`;
260
+ return await this.collect(key, call, message, state, dispatch);
201
261
  } catch (error) { if (call.status !== "error") this.logs.fail(call, undefined, error); throw error; }
202
262
  }
203
263
  }
@@ -208,5 +268,7 @@ export function validateApplicationSettings(raw: unknown): asserts raw is Settin
208
268
  if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
209
269
  if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
210
270
  if (settings.transformHandlers !== undefined && (!Array.isArray(settings.transformHandlers) || settings.transformHandlers.some((name) => !safeName(name)))) throw Object.assign(new Error("Invalid transform handlers."), { status: 400 });
271
+ const coalescing = settings.messageCoalescing;
272
+ if (coalescing !== undefined && (!coalescing || typeof coalescing !== "object" || (coalescing.enabled !== undefined && typeof coalescing.enabled !== "boolean") || (coalescing.silenceDebounceSeconds !== undefined && (!Number.isInteger(coalescing.silenceDebounceSeconds) || coalescing.silenceDebounceSeconds < 1 || coalescing.silenceDebounceSeconds > 3600)))) throw Object.assign(new Error("Invalid message coalescing settings."), { status: 400 });
211
273
  }
212
274
 
@@ -6,13 +6,13 @@ export type ApplicationRecord = { id: string; name: string; slug: string; enable
6
6
  type Row = Record<string, unknown>; const now = () => new Date().toISOString();
7
7
  export class ApplicationStore {
8
8
  private db: DatabaseSync;
9
- constructor(path: string) { mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec(`CREATE TABLE IF NOT EXISTS applications (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, response_mode TEXT NOT NULL CHECK(response_mode IN ('ack','result')), default_profile TEXT, routing_policy TEXT NOT NULL CHECK(routing_policy IN ('default_as_fallback','drop')), settings_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS application_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS application_sessions (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, session_id TEXT NOT NULL UNIQUE, session_date TEXT NOT NULL, sequence INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_message_at TEXT, message_count INTEGER NOT NULL DEFAULT 0, created_from_rollover INTEGER NOT NULL DEFAULT 0, last_identity_key TEXT, UNIQUE(application_slug, profile, session_prefix, session_date, sequence)); CREATE TABLE IF NOT EXISTS application_session_state (application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, current_session_id TEXT NOT NULL, updated_at TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(application_slug, profile, session_prefix)); CREATE TABLE IF NOT EXISTS application_identity_mappings (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, profile TEXT NOT NULL, session_mode TEXT NOT NULL DEFAULT 'fixed', session_prefix TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(application_slug, identity_key)); CREATE TABLE IF NOT EXISTS application_automatic_prefixes (application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, session_prefix TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(application_slug, identity_key), UNIQUE(application_slug, session_prefix));`); this.migrate(); }
9
+ constructor(path: string) { mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec(`CREATE TABLE IF NOT EXISTS applications (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, enabled INTEGER NOT NULL DEFAULT 1, response_mode TEXT NOT NULL CHECK(response_mode IN ('ack','result')), default_profile TEXT, routing_policy TEXT NOT NULL CHECK(routing_policy IN ('default_as_fallback','drop')), settings_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS application_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS application_sessions (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, session_id TEXT NOT NULL UNIQUE, session_date TEXT NOT NULL, sequence INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_message_at TEXT, message_count INTEGER NOT NULL DEFAULT 0, created_from_rollover INTEGER NOT NULL DEFAULT 0, last_identity_key TEXT, UNIQUE(application_slug, profile, session_prefix, session_date, sequence)); CREATE TABLE IF NOT EXISTS application_session_state (application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, current_session_id TEXT NOT NULL, updated_at TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(application_slug, profile, session_prefix)); CREATE TABLE IF NOT EXISTS application_identity_mappings (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, profile TEXT NOT NULL, session_mode TEXT NOT NULL DEFAULT 'fixed', session_prefix TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(application_slug, identity_key)); CREATE TABLE IF NOT EXISTS application_automatic_prefixes (application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, session_prefix TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(application_slug, identity_key), UNIQUE(application_slug, session_prefix)); CREATE TABLE IF NOT EXISTS application_console_sessions (session_id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, profile TEXT NOT NULL, identity_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);`); this.migrate(); }
10
10
  private migrate() { const tables = new Set((this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Row[]).map((row) => String(row.name))); if (tables.has("application_from_mappings")) { if (!tables.has("application_identity_mappings")) this.db.exec("ALTER TABLE application_from_mappings RENAME TO application_identity_mappings; ALTER TABLE application_identity_mappings RENAME COLUMN from_identifier TO identity_key;"); else this.db.exec("INSERT OR IGNORE INTO application_identity_mappings (id,application_slug,identity_key,profile,session_mode,session_prefix,created_at,updated_at) SELECT id,application_slug,from_identifier,profile,'fixed',session_prefix,created_at,updated_at FROM application_from_mappings; DROP TABLE application_from_mappings;"); } const columns = (table: string) => new Set((this.db.prepare(`PRAGMA table_info(${table})`).all() as Row[]).map((row) => String(row.name))); const mapping = columns("application_identity_mappings"); if (!mapping.has("session_mode")) this.db.exec("ALTER TABLE application_identity_mappings ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'fixed';"); const sessions = columns("application_sessions"); if (sessions.has("last_from") && !sessions.has("last_identity_key")) this.db.exec("ALTER TABLE application_sessions RENAME COLUMN last_from TO last_identity_key;"); }
11
11
  private value(row: Row): ApplicationRecord { return { id: String(row.id), name: String(row.name), slug: String(row.slug), enabled: Boolean(row.enabled), responseMode: row.response_mode as "ack" | "result", defaultProfile: row.default_profile == null ? null : String(row.default_profile), routingPolicy: row.routing_policy as "default_as_fallback" | "drop", settings: JSON.parse(String(row.settings_json)), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; }
12
12
  list() { return (this.db.prepare("SELECT * FROM applications ORDER BY name").all() as Row[]).map((row) => this.value(row)); } get(slug: string) { const row = this.db.prepare("SELECT * FROM applications WHERE slug=?").get(slug) as Row | undefined; return row ? this.value(row) : undefined; }
13
13
  create(input: Pick<ApplicationRecord, "name" | "slug" | "enabled" | "responseMode" | "defaultProfile" | "routingPolicy" | "settings">) { const timestamp = now(), record = { ...input, id: randomUUID(), createdAt: timestamp, updatedAt: timestamp }; this.db.prepare("INSERT INTO applications VALUES (?,?,?,?,?,?,?,?,?,?)").run(record.id, record.name, record.slug, record.enabled ? 1 : 0, record.responseMode, record.defaultProfile, record.routingPolicy, JSON.stringify(record.settings), timestamp, timestamp); return this.get(record.slug)!; }
14
14
  update(slug: string, input: Omit<ApplicationRecord, "id" | "slug" | "createdAt" | "updatedAt">) { const result = this.db.prepare("UPDATE applications SET name=?,enabled=?,response_mode=?,default_profile=?,routing_policy=?,settings_json=?,updated_at=? WHERE slug=?").run(input.name, input.enabled ? 1 : 0, input.responseMode, input.defaultProfile, input.routingPolicy, JSON.stringify(input.settings), now(), slug); if (!result.changes) throw new Error("Application not found."); return this.get(slug)!; }
15
- delete(slug: string) { if (!this.db.prepare("DELETE FROM applications WHERE slug=?").run(slug).changes) throw new Error("Application not found."); for (const table of ["application_sessions", "application_session_state", "application_identity_mappings", "application_automatic_prefixes"]) this.db.prepare(`DELETE FROM ${table} WHERE application_slug=?`).run(slug); }
15
+ delete(slug: string) { if (!this.db.prepare("DELETE FROM applications WHERE slug=?").run(slug).changes) throw new Error("Application not found."); for (const table of ["application_sessions", "application_session_state", "application_identity_mappings", "application_automatic_prefixes", "application_console_sessions"]) this.db.prepare(`DELETE FROM ${table} WHERE application_slug=?`).run(slug); }
16
16
  identityMapping(slug: string, identityKey: string) { const row = this.db.prepare("SELECT profile, session_mode, session_prefix FROM application_identity_mappings WHERE application_slug=? AND identity_key IN (?, '*') ORDER BY CASE identity_key WHEN '*' THEN 1 ELSE 0 END LIMIT 1").get(slug, identityKey) as Row | undefined; return row ? { profile: String(row.profile), sessionMode: String(row.session_mode) as "fixed" | "automatic", sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix) } : undefined; }
17
17
  identityMappings(slug: string) { return (this.db.prepare("SELECT id, identity_key, profile, session_mode, session_prefix, created_at, updated_at FROM application_identity_mappings WHERE application_slug=? ORDER BY identity_key='*', identity_key").all(slug) as Row[]).map((row) => ({ id: String(row.id), identityKey: String(row.identity_key), profile: String(row.profile), sessionMode: String(row.session_mode), sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix), createdAt: String(row.created_at), updatedAt: String(row.updated_at) })); }
18
18
  saveIdentityMapping(slug: string, identityKey: string, profile: string, sessionMode: "fixed" | "automatic", sessionPrefix: string | null) { const timestamp = now(), current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (current) this.db.prepare("UPDATE application_identity_mappings SET profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(profile, sessionMode, sessionPrefix, timestamp, current.id); else this.db.prepare("INSERT INTO application_identity_mappings VALUES (?,?,?,?,?,?,?,?)").run(randomUUID(), slug, identityKey, profile, sessionMode, sessionPrefix, timestamp, timestamp); return this.identityMapping(slug, identityKey)!; }
@@ -26,5 +26,9 @@ export class ApplicationStore {
26
26
  touchSession(slug: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { const timestamp = now(); this.db.prepare("UPDATE application_sessions SET updated_at=?,last_message_at=?,message_count=message_count+1,last_identity_key=COALESCE(?,last_identity_key) WHERE application_slug=? AND profile=? AND session_prefix=? AND session_id=?").run(timestamp, timestamp, identityKey ?? null, slug, profile, prefix, sessionId); this.db.prepare("UPDATE application_session_state SET updated_at=?,reason='message' WHERE application_slug=? AND profile=? AND session_prefix=?").run(timestamp, slug, profile, prefix); }
27
27
  activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
28
28
  removeActiveSession(slug: string, profile: string, sessionId: string) { const session = this.db.prepare("SELECT session_prefix FROM application_sessions WHERE application_slug=? AND profile=? AND session_id=?").get(slug, profile, sessionId) as Row | undefined; if (!session) throw Object.assign(new Error("Application session not found."), { status: 404 }); if (!this.db.prepare("DELETE FROM application_session_state WHERE application_slug=? AND profile=? AND session_prefix=? AND current_session_id=?").run(slug, profile, String(session.session_prefix), sessionId).changes) throw Object.assign(new Error("Application session is no longer active."), { status: 409 }); }
29
+ nextConsoleSessionId(application: string, identityKey: string) { const date = new Date().toISOString().slice(0, 10), hash = createHash("sha256").update(identityKey).digest("hex").slice(0, 12); const base = `console__app__${application}__${hash}__${date}`; const count = Number((this.db.prepare("SELECT COUNT(*) AS count FROM application_console_sessions WHERE application_slug=? AND identity_key=? AND created_at LIKE ?").get(application, identityKey, `${date}%`) as Row).count) + 1; return `${base}-${String(count).padStart(2, "0")}`; }
30
+ createConsoleSession(sessionId: string, application: string, profile: string, identityKey: string) { const timestamp = now(); this.db.prepare("INSERT INTO application_console_sessions VALUES (?,?,?,?,?,?)").run(sessionId, application, profile, identityKey, timestamp, timestamp); return this.consoleSession(profile, sessionId)!; }
31
+ consoleSession(profile: string, sessionId: string) { const row = this.db.prepare("SELECT * FROM application_console_sessions WHERE profile=? AND session_id=?").get(profile, sessionId) as Row | undefined; return row ? { sessionId: String(row.session_id), application: String(row.application_slug), profile: String(row.profile), identityKey: String(row.identity_key), createdAt: String(row.created_at), updatedAt: String(row.updated_at) } : undefined; }
32
+ deleteConsoleSession(profile: string, sessionId: string) { this.db.prepare("DELETE FROM application_console_sessions WHERE profile=? AND session_id=?").run(profile, sessionId); }
29
33
  activeSessions(filters: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } = {}) { const clauses: string[] = [], values: string[] = []; const like = (column: string, value?: string) => { if (value?.trim()) { clauses.push(`${column} LIKE ?`); values.push(`%${value.trim()}%`); } }; like("st.profile", filters.profile); like("s.application_slug", filters.application); like("COALESCE(s.last_identity_key,'')", filters.identityKey); like("s.session_prefix", filters.prefix); like("s.session_id", filters.session); const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return (this.db.prepare(`SELECT s.*,st.reason FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id ${where} ORDER BY st.updated_at DESC`).all(...values) as Row[]).map((row) => ({ ...this.sessionValue(row), reason: String(row.reason) })); }
30
34
  }
@@ -8,7 +8,8 @@ export const RESOURCE_KINDS = ["tools", "skills", "extensions"] as const;
8
8
  export type ResourceKind = typeof RESOURCE_KINDS[number];
9
9
  type PolicyKey = "enabledTools" | "enabledSkills" | "enabledProfileSkills" | "enabledExtensions";
10
10
  type SkillSources = { shared?: boolean; profile?: boolean };
11
- type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & { skillSources?: SkillSources };
11
+ type SkillResourceSource = "shared" | "profile" | "package";
12
+ type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & { skillSources?: SkillSources; contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } };
12
13
  export type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy; sandbox?: boolean };
13
14
  export type Resource = { name: string; kind: ResourceKind; path?: string; source: "builtin" | "shared" | "profile" | "extension" | "package"; package?: string; enabled: boolean; protected?: boolean };
14
15
  export type Package = { name: string; version?: string; description?: string; enabled: boolean; installed: boolean };
@@ -22,12 +23,23 @@ const policyKey: Record<ResourceKind, PolicyKey> = {
22
23
  const protectedExtensions = new Set(["profiles", "api-server"]);
23
24
 
24
25
  export class ProfileStore {
25
- private readonly extensionTools = new Set<string>();
26
+ private readonly extensionTools = new Map<string, Set<string>>();
26
27
 
27
28
  constructor(readonly agentDir: string) {}
28
29
 
29
- registerExtensionTools(names: Iterable<string>): void {
30
- for (const name of names) if (name && !BUILTIN_TOOLS.includes(name)) this.extensionTools.add(name);
30
+ registerExtensionTools(profile: string, names: Iterable<string>): void {
31
+ const tools = this.extensionTools.get(profile) ?? new Set<string>();
32
+ for (const name of names) if (name && !BUILTIN_TOOLS.includes(name)) tools.add(name);
33
+ this.extensionTools.set(profile, tools);
34
+ }
35
+
36
+ extensionToolNames(profile: string): Iterable<string> {
37
+ return this.extensionTools.get(profile) ?? [];
38
+ }
39
+
40
+ clearExtensionTools(profile?: string): void {
41
+ if (profile) this.extensionTools.delete(profile);
42
+ else this.extensionTools.clear();
31
43
  }
32
44
 
33
45
  private profilesDir(): string { return join(this.agentDir, "profiles"); }
@@ -99,14 +111,26 @@ export class ProfileStore {
99
111
  return !path || !exclusions.includes(`!${path}`);
100
112
  }
101
113
 
102
- private async skillResources(profile: string, settings: ProfileSettings): Promise<Resource[]> {
114
+ private async packageSkillResources(settings: ProfileSettings): Promise<Resource[]> {
115
+ const resources: Resource[] = [];
116
+ for (const pkg of await this.configuredPackages(settings)) {
117
+ for (const entry of Array.isArray(pkg.manifest?.pi?.skills) ? pkg.manifest.pi.skills : []) {
118
+ if (typeof entry !== "string") continue;
119
+ for (const skill of await this.skillNames(join(pkg.base, entry))) resources.push({ name: skill.name, kind: "skills", path: skill.path, source: "package", package: pkg.name, enabled: this.skillEnabled(settings, "package", skill.name) });
120
+ }
121
+ }
122
+ return resources;
123
+ }
124
+
125
+ private async skillResources(profile: string, settings: ProfileSettings, runtimeSettings: ProfileSettings = settings): Promise<Resource[]> {
103
126
  const sources = this.skillSources(profile, settings);
104
127
  const entries = await Promise.all((["profile", "shared"] as const).filter((source) => sources[source]).map(async (source) => ({ source, entries: await this.skillNames(this.skillBase(profile, source)) })));
105
- return entries.flatMap(({ source, entries }) => entries.map(({ name, path }) => ({ name, kind: "skills" as const, path, source, enabled: this.skillEnabled(settings, source, name) })));
128
+ const local = entries.flatMap(({ source, entries }) => entries.map(({ name, path }) => ({ name, kind: "skills" as const, path, source, enabled: this.skillEnabled(settings, source, name) })));
129
+ return [...local, ...(sources.shared ? await this.packageSkillResources(runtimeSettings) : [])];
106
130
  }
107
131
 
108
- private skillEnabled(settings: ProfileSettings, source: "shared" | "profile", name: string): boolean {
109
- const values = settings.profile?.[source === "shared" ? "enabledSkills" : "enabledProfileSkills"];
132
+ private skillEnabled(settings: ProfileSettings, source: SkillResourceSource, name: string): boolean {
133
+ const values = settings.profile?.[source === "profile" ? "enabledProfileSkills" : "enabledSkills"];
110
134
  return !values || values.includes("*") || values.includes(name);
111
135
  }
112
136
 
@@ -120,12 +144,12 @@ export class ProfileStore {
120
144
  return join(this.agentDir, "git", remote);
121
145
  }
122
146
 
123
- private async configuredPackages(settings: ProfileSettings): Promise<Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown } } }>> {
147
+ private async configuredPackages(settings: ProfileSettings): Promise<Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } } }>> {
124
148
  const sources = Array.isArray(settings.packages) ? settings.packages.filter((value): value is string => typeof value === "string") : [];
125
- const packages: Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown } } }> = [];
149
+ const packages: Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } } }> = [];
126
150
  for (const source of sources) {
127
151
  const base = this.packageBase(source); if (!base) continue;
128
- try { const manifest = JSON.parse(await readFile(join(base, "package.json"), "utf8")) as { name?: string; version?: string; description?: string; pi?: { extensions?: unknown } }; packages.push({ source, base, name: manifest.name ?? source, manifest }); }
152
+ try { const manifest = JSON.parse(await readFile(join(base, "package.json"), "utf8")) as { name?: string; version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } }; packages.push({ source, base, name: manifest.name ?? source, manifest }); }
129
153
  catch { packages.push({ source, base, name: source.replace(/^npm:/, "") }); }
130
154
  }
131
155
  return packages;
@@ -168,23 +192,25 @@ export class ProfileStore {
168
192
  async resources(profile: string, kind: ResourceKind): Promise<Resource[]> {
169
193
  const settings = await this.readSettings(profile);
170
194
  const runtimeSettings = profile === "default" ? settings : await this.readSettings("default");
171
- if (kind === "skills") return this.skillResources(profile, settings);
195
+ if (kind === "skills") return this.skillResources(profile, settings, runtimeSettings);
172
196
  if (kind === "tools") {
173
- const sources = await this.packageToolSources(runtimeSettings, this.extensionTools);
174
- const names = new Set([...this.extensionTools, ...sources.keys()]);
197
+ const extensionTools = this.extensionToolNames(profile);
198
+ const sources = await this.packageToolSources(runtimeSettings, extensionTools);
199
+ const names = new Set([...extensionTools, ...sources.keys()]);
175
200
  return [...BUILTIN_TOOLS.map((name) => ({ name, kind, source: "builtin" as const, enabled: this.enabled(settings, kind, name) })), ...[...names].sort().map((name) => sources.has(name) ? ({ name, kind, source: "package" as const, package: sources.get(name), enabled: this.enabled(settings, kind, name) }) : ({ name, kind, source: "extension" as const, enabled: this.enabled(settings, kind, name) }))];
176
201
  }
177
- return [...(await this.names("extensions")).map(({ name, path }) => ({ name, kind, path, source: "shared" as const, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined })), ...(await this.packageExtensions(runtimeSettings)).map(({ name, path, package: packageName }) => ({ name, kind, path, source: "package" as const, package: packageName, enabled: true }))];
202
+ return [...(await this.names("extensions")).map(({ name, path }) => ({ name, kind, path, source: "shared" as const, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined })), ...(await this.packageExtensions(runtimeSettings)).map(({ name, path, package: packageName }) => ({ name, kind, path, source: "package" as const, package: packageName, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined }))];
178
203
  }
179
204
 
180
- async resource(profile: string, kind: ResourceKind, name: string, source?: "shared" | "profile"): Promise<Resource> {
205
+ async resource(profile: string, kind: ResourceKind, name: string, source?: SkillResourceSource): Promise<Resource> {
181
206
  const resource = (await this.resources(profile, kind)).find((item) => item.name === name && (!source || item.source === source));
182
207
  if (!resource) throw Object.assign(new Error("Resource not found"), { status: 404 });
183
208
  return resource;
184
209
  }
185
210
 
186
211
  private async skillPaths(name: string, settings: ProfileSettings): Promise<string[]> {
187
- return (await this.skillResources(name, settings)).filter((skill) => skill.enabled).map((skill) => skill.path!).filter(Boolean);
212
+ const runtimeSettings = name === "default" ? settings : await this.readSettings("default");
213
+ return (await this.skillResources(name, settings, runtimeSettings)).filter((skill) => skill.enabled).map((skill) => skill.path!).filter(Boolean);
188
214
  }
189
215
 
190
216
  private async materializeResources(name: string, settings: ProfileSettings): Promise<ProfileSettings> {
@@ -232,7 +258,7 @@ export class ProfileStore {
232
258
  return this.skillSources(profile, settings);
233
259
  }
234
260
 
235
- async readSkillDocument(profile: string, name: string, source?: "shared" | "profile"): Promise<{ name: string; frontmatter: Record<string, string>; content: string }> {
261
+ async readSkillDocument(profile: string, name: string, source?: SkillResourceSource): Promise<{ name: string; frontmatter: Record<string, string>; content: string }> {
236
262
  const skill = await this.resource(profile, "skills", name, source);
237
263
  if (!skill.path) throw Object.assign(new Error("Skill document not found"), { status: 404 });
238
264
  const document = await readFile(join(skill.path, "SKILL.md"), "utf8");
@@ -247,7 +273,7 @@ export class ProfileStore {
247
273
  return { name, frontmatter, content: match ? document.slice(match[0].length) : document };
248
274
  }
249
275
 
250
- async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?: "shared" | "profile"): Promise<Resource> {
276
+ async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?: SkillResourceSource): Promise<Resource> {
251
277
  const current = await this.resource(profile, kind, name, source);
252
278
  if (current.protected && !enabled) throw Object.assign(new Error("This extension is required and cannot be disabled."), { status: 422 });
253
279
  // Extensions are shared runtime resources, so toggling one always updates
@@ -264,8 +290,9 @@ export class ProfileStore {
264
290
  return this.resource(profile, kind, name, source);
265
291
  }
266
292
 
267
- async deleteSkill(profile: string, name: string, source?: "shared" | "profile"): Promise<void> {
293
+ async deleteSkill(profile: string, name: string, source?: SkillResourceSource): Promise<void> {
268
294
  const current = await this.resource(profile, "skills", name, source);
295
+ if (current.source === "package") throw Object.assign(new Error("Package Skills cannot be deleted here."), { status: 403 });
269
296
  if (current.source === "shared" && profile !== "default") { await this.setResource(profile, "skills", name, false, "shared"); return; }
270
297
  if (!current.path) throw Object.assign(new Error("Skill path not found."), { status: 404 });
271
298
  await rm(current.path, { recursive: true, force: false });