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
package/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  - [Glossary](#glossary)
12
12
  - [Features](#features)
13
+ - [Context Memory](#context-memory)
13
14
  - [Command cookbook](#command-cookbook)
14
15
  - [Included extensions](#included-extensions)
15
16
  - [Installation](#installation)
@@ -28,6 +29,7 @@
28
29
  | **Identity Key mapping** | A rule that maps an external identity to an Application profile and session strategy. A single `*` mapping can be an explicit fallback. |
29
30
  | **Handoff** | Private context retained when an Application rolls a conversation into a replacement session. |
30
31
  | **Guardrail** | An ordered policy instruction executed at an input, tool, or output lifecycle stage. |
32
+ | **Context Memory** | Bounded persistent operational and personal context loaded when a new session starts. |
31
33
  | **Shared Skill** | A Skill stored in the main Pi agent directory and selectively exposed to profiles. |
32
34
  | **Profile Skill** | A Skill stored in one profile directory and unavailable to other profiles unless explicitly copied or imported. |
33
35
  | **Skill Source** | A Git-backed staging catalog. Synchronizing it discovers Skills but does not activate them. |
@@ -56,6 +58,21 @@ Pi Coding Agent intentionally keeps its core lightweight and extensible. Pi Feat
56
58
  - Limit profile tools, shared Skills, and profile Skills through profile policy.
57
59
  - Keep extensions and packages in the default runtime; named profiles load those shared resources read-only and never clone or install them.
58
60
  - Load a profile-specific `SOUL.md` into every agent turn.
61
+ - Select the default provider and model for each profile from the Console. After changing it, administrators can roll over active Application sessions so subsequent conversations use the new model.
62
+
63
+ ### Profile SSH
64
+
65
+ Profile SSH is an optional, isolated host-registration mechanism. It complements normal SSH rather than replacing a user's global `~/.ssh/config`, agent, or keys.
66
+
67
+ - Register, list, and delete SSH hosts for the active profile with `pi ssh add|list|delete <alias>`, or target a named profile with `pi profile <profile> ssh ...`.
68
+ - Store aliases, keys, and known-host entries under `<PI_CODING_AGENT_DIR>/.ssh/`, keeping profile credentials separate from the host user's SSH configuration.
69
+ - Require a hostname and IP address, display the scanned server fingerprint for explicit confirmation, and keep strict host-key verification enabled.
70
+ - Generate Ed25519 keys for modern servers and RSA-4096 keys with legacy compatibility settings for older OpenSSH servers. The setup validates key-based access before saving; when needed, it invokes `ssh-copy-id` interactively once with a profile-isolated home directory.
71
+ - Connect to a registered host explicitly through its profile configuration:
72
+
73
+ ```bash
74
+ ssh -F "$PI_CODING_AGENT_DIR/.ssh/config" <alias>
75
+ ```
59
76
 
60
77
  ### Remote Hosts
61
78
 
@@ -109,7 +126,7 @@ Applications are the integration boundary between Pi and external systems. Use o
109
126
  ```
110
127
 
111
128
  - Normalize provider-specific payloads in an inbound handler, enrich or redact data through transforms, and shape integration responses in an outbound handler.
112
- - Route each external identity to a profile and session strategy through explicit Identity Key mappings. Mappings may use automatic sessions or fixed prefixes; one `*` wildcard can serve as a deliberate fallback.
129
+ - Route each external identity to a profile and session strategy through explicit Identity Key mappings. Mappings may use automatic sessions or fixed prefixes, or intentionally ignore an identity (`None` in the Console) after inbound handling without calling Pi. Exact mappings take precedence, so an ignore mapping can exclude an identity even when a single `*` wildcard fallback exists.
113
130
  - Choose acknowledgement mode for asynchronous webhook-style work or result mode when an integration needs the completed response.
114
131
  - Inspect active sessions, roll over a conversation, preserve private handoff context, test handlers with a JSON payload, and stream or clear application logs from the Console or API.
115
132
  - Include an Evolution API adapter implementation while keeping Applications structurally independent from adapters.
@@ -163,7 +180,7 @@ curl \
163
180
  What the API exposes:
164
181
 
165
182
  - Persistent chat and streaming chat sessions, including profile-specific sessions.
166
- - Profile lifecycle, settings, `SOUL.md`, environment variables, Guardrails, tools, Skills, and sessions.
183
+ - Profile lifecycle, settings, default model selection, `SOUL.md`, environment variables, Guardrails, Context Memory, tools, Skills, and sessions.
167
184
  - Runtime-wide extension and package inventory and configuration.
168
185
  - Pulse schedules and execution history.
169
186
  - Git-backed Skill Sources, document preview, synchronization, and explicit profile imports.
@@ -189,8 +206,9 @@ pi console stop
189
206
 
190
207
  The console provides dedicated workspaces for:
191
208
 
192
- - **Profiles:** settings, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
193
- - **Applications:** configuration, identity mappings, active sessions, handler editor, transform testing, rollover, handoff, and live logs.
209
+ - **Profiles:** settings, default model selection, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
210
+ - **Context Memory:** personal-context source selection, persistent Markdown memory editing, and Application handler creation and testing.
211
+ - **Applications:** configuration, identity mappings (including `None`/ignore rules), active sessions, handler editor, transform testing, rollover, handoff, and live logs.
194
212
  - **Skill Sources:** source registration, Git synchronization, Skill preview, and deliberate import into a profile.
195
213
  - **Pulse:** schedules, enablement, run history, and profile/session association.
196
214
  - **Operations:** API Server and Console configuration, service controls, and resource visibility.
@@ -239,7 +257,7 @@ Skills remain explicit resources rather than hidden package behavior. A profile
239
257
  ~/.pi/agent/profiles/<profile>/skills/<skill>/SKILL.md
240
258
  ```
241
259
 
242
- - **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile.
260
+ - **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile. Skills declared by installed shared-runtime packages are also discovered for named profiles and follow the same shared-Skill policy and allowlist.
243
261
  - **Profile Skills** live under `~/.pi/agent/profiles/<profile>/skills/` and remain isolated from other profiles.
244
262
  - Profile policy controls whether shared and profile-local sources are available and can allow all Skills or a named allowlist.
245
263
  - List and enable or disable Skills from the CLI and Console without editing settings by hand.
@@ -275,6 +293,7 @@ Refreshing synchronizes the source and atomically replaces the complete installe
275
293
 
276
294
  - Create and manage persistent scheduled prompts, reminders, cron jobs, one-time jobs, and heartbeat jobs.
277
295
  - Bind scheduled work to the active profile and conversation session.
296
+ - Set `insertIntoApiSession: true` when a Pulse result should also be recorded in every active Application session for the same profile. This adds context only: it does not invoke an outbound handler or send a message through an Application channel.
278
297
  - Persist schedules and execution history in SQLite with WAL support.
279
298
  - Start and inspect the scheduler:
280
299
 
@@ -282,8 +301,21 @@ Refreshing synchronizes the source and atomically replaces the complete installe
282
301
  pi pulse start
283
302
  pi pulse status
284
303
  pi pulse list
304
+ # After confirming an interrupted worker is no longer running:
305
+ pi pulse recover
285
306
  ```
286
307
 
308
+ ### Context Memory
309
+
310
+ Context Memory keeps compact, durable facts separate from a session transcript and injects them when a new session starts. It is intended for stable operational context and personal context, not an unbounded conversation archive.
311
+
312
+ - Each profile can maintain shared operational context in `context-memory/OPERATIONAL.md` (maximum 2,750 characters).
313
+ - Personal context is configurable as a profile-wide `context-memory/PROFILE.md`, an Application-identity-specific `applications/<application>/context-memory/identities/<identity>/USER.md`, or a TypeScript Application handler that resolves it dynamically. Profile and identity memory are limited to 1,375 characters.
314
+ - Identity memory is available only in an Application session and is isolated by Application and Identity Key.
315
+ - The `context_memory` tool supports `read`, `insert`, `update`, `remove`, and `replace` actions against `operational` or `personal` memory without exposing file paths to the agent. Personal memory writes to the configured profile or identity target; storing sensitive values requires explicit confirmation.
316
+ - Dynamic handlers must return Markdown, run only for Application sessions, have a three-second timeout, and can be created, edited, and tested from the Console. Handler execution is recorded in Application logs.
317
+ - Stored memory is presented to the model as data rather than instructions. Changes apply to newly started sessions.
318
+
287
319
  ### CLI resource management
288
320
 
289
321
  - Inspect tools, Skills, extensions, sessions, packages, and profile resources from the terminal.
@@ -293,9 +325,18 @@ Refreshing synchronizes the source and atomically replaces the complete installe
293
325
 
294
326
  ### Sequential Workflow
295
327
 
296
- - Create persistent workflows made of strictly ordered **Action**, **Collect**, and **Evaluate** tasks.
328
+ - Create persistent workflows made of strictly ordered **Action**, **Collect**, **Evaluate**, and subworkflow tasks.
329
+ - Run multiple workflows per profile, independently or in parent/child hierarchies, with execution ownership and persisted focus isolated by Pi session ID.
330
+ - 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.
331
+ - 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.
332
+ - 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.
333
+ - 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.
334
+ - 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.
335
+ - Let a subworkflow task wait for its linked child while unrelated workflows continue independently.
297
336
  - Prevent advancement until the current task result has been recorded and accepted.
298
337
  - Persist workflow state in SQLite for auditable retries and continuation.
338
+ - 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.
339
+ - 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.
299
340
 
300
341
  ## Command cookbook
301
342
 
@@ -321,6 +362,11 @@ pi profile support extensions list
321
362
  pi profile support sessions list
322
363
  pi profile support packages list
323
364
 
365
+ # Register, list, or delete hosts scoped to this profile
366
+ pi profile support ssh add production-db
367
+ pi profile support ssh list
368
+ pi profile support ssh delete production-db
369
+
324
370
  # Resume the latest session or open a specific one
325
371
  pi profile resume support
326
372
  pi profile open support <session-id>
@@ -412,7 +458,9 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
412
458
  | Extension | Responsibility |
413
459
  | --- | --- |
414
460
  | API Server | HTTP API, Applications, profile administration, sessions, logs, and terminal tickets. |
461
+ | Browser Harness | Steel Profile-scoped CDP browser automation for active Pi Profiles. |
415
462
  | CLI Resources | Resource, package, session, and profile commands. |
463
+ | Context Memory | Persistent operational and personal context for new sessions. |
416
464
  | Guardrails | Input, tool, and output policy stages. |
417
465
  | Pi Console WebUI | Browser-based operations console and terminal client. |
418
466
  | Profiles | Profile lifecycle, policy, sandbox, and remote routing. |
@@ -420,6 +468,17 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
420
468
  | Sequential Workflow | Ordered Action, Collect, and Evaluate workflows. |
421
469
  | Skill Sources | Git-backed Skill source catalog and explicit imports. |
422
470
 
471
+ ### Steel Browser Harness
472
+
473
+ 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`:
474
+
475
+ ```dotenv
476
+ STEEL_API_URL=http://127.0.0.1:3000
477
+ STEEL_API_KEY=...
478
+ ```
479
+
480
+ 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.
481
+
423
482
  ## Installation
424
483
 
425
484
  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.
@@ -2,19 +2,52 @@ 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
- 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
+ export type ApplicationCall = { id: string; application: string; origin: string; receivedAt: string; finishedAt?: string; status: "running" | "success" | "ignored" | "error"; outcome?: "ignored"; headers: Record<string, unknown>; stages: CallStage[]; error?: string };
7
8
  export class ApplicationLogStore {
8
9
  private calls = new Map<string, ApplicationCall>(); private events = new EventEmitter(); private directory: string;
9
- constructor(applicationDirectory: string, readonly application: string) { this.directory = join(applicationDirectory, "logs", "calls"); }
10
+ readonly application: string;
11
+ constructor(applicationDirectory: string, application: string) { this.application = application; this.directory = join(applicationDirectory, "logs", "calls"); }
10
12
  async initialize() { await mkdir(this.directory, { recursive: true }); const cutoff = Date.now() - 7 * 86400000; for (const entry of await readdir(this.directory)) { const path = join(this.directory, entry); try { const call = JSON.parse(await readFile(path, "utf8")) as ApplicationCall; if (new Date(call.receivedAt).getTime() < cutoff) await rm(path); else this.calls.set(call.id, call); } catch {} } }
11
13
  private async save(call: ApplicationCall) { await mkdir(this.directory, { recursive: true }); await writeFile(join(this.directory, `${call.id}.json`), JSON.stringify(call), "utf8"); this.events.emit("event", { type: "call.updated", call }); }
12
14
  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
15
  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
16
  complete(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; stage.finishedAt = new Date().toISOString(); void this.save(call); }
17
+ annotate(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; void this.save(call); }
15
18
  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
19
  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); }
20
+ ignore(call: ApplicationCall, identityKey: string, reason: "identity_miss" | "identity_mapping_none" = "identity_miss") { const mappingIgnore = reason === "identity_mapping_none", output = { status: "ignored", reason, identityKey }; call.status = "success"; call.outcome = "ignored"; call.finishedAt = new Date().toISOString(); const stage = this.stage(call, mappingIgnore ? "identity-ignored" : "identity-miss", mappingIgnore ? "Ignored by identity mapping" : "Identity miss", { identityKey, reason }); this.complete(call, stage, output); return output; }
21
+ recordContextMemory(input: { profile: string; identityKey: string; sessionId: string; execution: ContextMemoryExecutionLog }) {
22
+ const { execution } = input;
23
+ const stage: CallStage = {
24
+ id: randomUUID(),
25
+ type: "context-memory",
26
+ label: `Context Memory: ${execution.handler}`,
27
+ startedAt: execution.startedAt,
28
+ finishedAt: new Date(new Date(execution.startedAt).getTime() + execution.durationMs).toISOString(),
29
+ input: { profile: input.profile, identityKey: input.identityKey, sessionId: input.sessionId, handler: execution.handler },
30
+ output: { injected: execution.status === "success" && execution.outputCharacters > 0, outputCharacters: execution.outputCharacters },
31
+ stdout: execution.stdout,
32
+ stderr: execution.stderr,
33
+ ...(execution.error ? { error: execution.error } : {}),
34
+ };
35
+ const call: ApplicationCall = {
36
+ id: randomUUID(), application: this.application, origin: "Pi Console session", receivedAt: execution.startedAt,
37
+ finishedAt: stage.finishedAt, status: execution.status === "success" ? "success" : "error", headers: {}, stages: [stage], ...(execution.error ? { error: execution.error } : {}),
38
+ };
39
+ this.calls.set(call.id, call); void this.save(call);
40
+ }
17
41
  list() { return [...this.calls.values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)); }
42
+ listSummaries(limit = 100, start?: Date, end?: Date) {
43
+ return this.list().filter((call) => {
44
+ const receivedAt = new Date(call.receivedAt);
45
+ return (!start || receivedAt >= start) && (!end || receivedAt <= end);
46
+ }).slice(0, limit).map((call) => ({
47
+ id: call.id, application: call.application, origin: call.origin, receivedAt: call.receivedAt, finishedAt: call.finishedAt, status: call.status, error: call.error,
48
+ 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) })),
49
+ }));
50
+ }
18
51
  get(id: string) { return this.calls.get(id); }
19
52
  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
53
  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 | null; sessionMode: "fixed" | "automatic" | "ignore"; 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); }
@@ -91,12 +95,22 @@ export class ApplicationRuntime {
91
95
  try { return await operation(); } finally { release(); if (this.queues.get(key) === current) this.queues.delete(key); }
92
96
  }
93
97
 
98
+ private async createApplicationSession(profile: string, sessionPrefix: string, rollover: boolean): Promise<string> {
99
+ const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
100
+ // A process can stop after Pi has persisted the session file but before the
101
+ // Application row is written. Reclaim that deterministic orphan instead of
102
+ // attempting to create it again and permanently failing with a 409.
103
+ const exists = (await this.api.listSessions(profile)).some((session) => session.id === id);
104
+ if (!exists) await this.api.createSession(profile, { id });
105
+ await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, rollover);
106
+ return id;
107
+ }
108
+
94
109
  private async resolveSession(profile: string, sessionPrefix: string): Promise<string> {
95
110
  if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 422 });
96
111
  return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
97
112
  const current = await this.api.getApplicationSession(this.application.slug, profile, sessionPrefix); if (current) return current.sessionId;
98
- const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
99
- await this.api.createSession(profile, { id }); await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, false); return id;
113
+ return this.createApplicationSession(profile, sessionPrefix, false);
100
114
  });
101
115
  }
102
116
 
@@ -139,11 +153,17 @@ export class ApplicationRuntime {
139
153
  temporaryPath = join(dirname(path), `.pi-handler-test-${randomUUID()}.ts`);
140
154
  await writeFile(temporaryPath, content, "utf8");
141
155
  }
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)) } };
156
+ 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
157
  installConsoleCapture();
146
- const output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle(payload, {}, {}, context, {}, await this.environmentFor(payload)));
158
+ let output: unknown;
159
+ if (typeof loaded.resolveContextMemory === "function") {
160
+ 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";
161
+ output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.resolveContextMemory!({ application: { slug: this.application.slug }, identityKey, profile, sessionId }, await this.api.handlerEnvironment(profile)));
162
+ } else {
163
+ if (typeof loaded.handle !== "function") throw new Error("Handler must export handle() or resolveContextMemory().");
164
+ 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)) } };
165
+ output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle!(payload, {}, {}, context, {}, await this.environmentFor(payload)));
166
+ }
147
167
  return { ok: true, output, logs: { stdout, stderr }, durationMs: Math.round(performance.now() - started) };
148
168
  } catch (error) {
149
169
  const message = error instanceof Error ? error.stack ?? error.message : String(error);
@@ -166,8 +186,8 @@ export class ApplicationRuntime {
166
186
  if (typeof sessionPrefix !== "string" || !sessionPrefix) throw Object.assign(new Error("The 'sessionPrefix' field is required."), { status: 400 });
167
187
  if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix"), { status: 400 });
168
188
  return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
169
- const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
170
- await this.api.createSession(profile, { id: sessionId }); await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, sessionId, true); if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
189
+ const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.createApplicationSession(profile, sessionPrefix, true);
190
+ if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
171
191
  try {
172
192
  const handoff = await this.handoff(profile, sessionId, sessionPrefix, true);
173
193
  return { profile, sessionId, messageCount: 0, handoff: { status: handoff?.summary ? "ready" : "none", sourceSessionId: handoff?.sourceSessionId ?? null } };
@@ -177,6 +197,52 @@ export class ApplicationRuntime {
177
197
  });
178
198
  }
179
199
 
200
+ private async collect(key: string, call: ReturnType<ApplicationLogStore["start"]>, message: string, state: RecordValue, dispatch: (message: string, state: RecordValue) => Promise<unknown>): Promise<unknown> {
201
+ const seconds = this.settings.messageCoalescing?.silenceDebounceSeconds ?? 0;
202
+ if (!this.settings.messageCoalescing?.enabled || seconds <= 0) return dispatch(message, state);
203
+ const buffer = this.coalescingBuffers.get(key) ?? { version: 0, entries: [] };
204
+ this.coalescingBuffers.set(key, buffer);
205
+ const version = ++buffer.version;
206
+ const stage = this.logs.stage(call, "coalescing", "Message coalescing", { key, version, silenceDebounceSeconds: seconds, messageCharacters: message.length });
207
+ this.logs.complete(call, stage, { status: "collected", version });
208
+ return new Promise((resolve, reject) => {
209
+ const entry: CoalescedEntry = { call, stage, message, state, version, dispatch, resolve, reject };
210
+ buffer.entries.push(entry);
211
+ const timer = setTimeout(() => { this.coalescingTimers.delete(timer); void this.flushCoalesced(key, version); }, seconds * 1_000);
212
+ this.coalescingTimers.add(timer);
213
+ });
214
+ }
215
+
216
+ private async flushCoalesced(key: string, version: number): Promise<void> {
217
+ const buffer = this.coalescingBuffers.get(key);
218
+ if (!buffer) return;
219
+ if (buffer.version !== version) {
220
+ for (const entry of buffer.entries.filter((item) => item.version === version)) this.logs.annotate(entry.call, entry.stage, { status: "superseded", version, latestVersion: buffer.version });
221
+ return;
222
+ }
223
+ this.coalescingBuffers.delete(key);
224
+ const entries = buffer.entries, leader = entries.at(-1)!;
225
+ const message = entries.map((entry) => entry.message).join("\n");
226
+ 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 });
227
+ try {
228
+ const output = await leader.dispatch(message, leader.state);
229
+ for (const entry of entries) {
230
+ if (entry !== leader) this.logs.finish(entry.call, { coalescing: { status: "merged", leaderCallId: leader.call.id, messageCount: entries.length } });
231
+ entry.resolve(output);
232
+ }
233
+ } catch (error) {
234
+ for (const entry of entries) { if (entry.call.status !== "error") this.logs.fail(entry.call, entry === leader ? undefined : entry.stage, error); entry.reject(error); }
235
+ }
236
+ }
237
+
238
+ dispose(): void {
239
+ for (const timer of this.coalescingTimers) clearTimeout(timer);
240
+ this.coalescingTimers.clear();
241
+ const error = new Error("Message coalescing was interrupted because the Application runtime was reloaded.");
242
+ for (const buffer of this.coalescingBuffers.values()) for (const entry of buffer.entries) { this.logs.fail(entry.call, entry.stage, error); entry.reject(error); }
243
+ this.coalescingBuffers.clear();
244
+ }
245
+
180
246
  async process(payload: unknown, headers: RecordValue, query: RecordValue): Promise<unknown> {
181
247
  const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
182
248
  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(" ")) } };
@@ -184,20 +250,24 @@ export class ApplicationRuntime {
184
250
  try {
185
251
  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
252
  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
- 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;
253
+ 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) return this.logs.ignore(call, identityKey, "identity_miss"); if (mapped.sessionMode === "ignore") return this.logs.ignore(call, identityKey, "identity_mapping_none"); profile = mapped.profile ?? undefined; 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) };
254
+ const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
255
+ const sessionId = await this.resolveSession(profile!, sessionPrefix!);
256
+ let handoff: string | undefined;
257
+ try { handoff = await run("handoff-relevance", "Handoff relevance", { profile, sessionId, message: coalescedMessage }, () => this.relevantHandoff(profile!, sessionId, sessionPrefix, coalescedMessage)); }
258
+ catch (error) { context.log.error("Handoff skipped:", error instanceof Error ? error.message : String(error)); }
259
+ 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));
260
+ await this.setWorkingSession(profile!, sessionPrefix!, sessionId, "message"); await this.api.touchApplicationSession(this.application.slug, profile!, sessionPrefix!, sessionId, identityKey);
261
+ let output: unknown = result;
262
+ if (this.settings.outboundHandler) {
263
+ const outboundInput = { profile, sessionId, message: { content: result.response } };
264
+ const handler = this.handler("outbound", this.settings.outboundHandler);
265
+ output = await run("outbound-handler", `Outbound handler: ${this.settings.outboundHandler}`, outboundInput, async () => handler(outboundInput, headers, query, context, coalescedState, await this.api.handlerEnvironment(profile!)));
266
+ }
267
+ this.logs.finish(call, output); return output;
268
+ };
269
+ const key = `${this.application.slug}:${identityKey ? `identity:${identityKey}` : `route:${profile}:${sessionPrefix}`}`;
270
+ return await this.collect(key, call, message, state, dispatch);
201
271
  } catch (error) { if (call.status !== "error") this.logs.fail(call, undefined, error); throw error; }
202
272
  }
203
273
  }
@@ -208,5 +278,7 @@ export function validateApplicationSettings(raw: unknown): asserts raw is Settin
208
278
  if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
209
279
  if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
210
280
  if (settings.transformHandlers !== undefined && (!Array.isArray(settings.transformHandlers) || settings.transformHandlers.some((name) => !safeName(name)))) throw Object.assign(new Error("Invalid transform handlers."), { status: 400 });
281
+ const coalescing = settings.messageCoalescing;
282
+ 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
283
  }
212
284
 
@@ -6,25 +6,42 @@ 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(); }
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;"); }
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, 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
+ 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';"); if (mapping.has("profile") && (this.db.prepare("PRAGMA table_info(application_identity_mappings)").all() as Row[]).find((row) => String(row.name) === "profile")?.notnull) this.db.exec("BEGIN; CREATE TABLE application_identity_mappings_next (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, profile TEXT, 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)); INSERT INTO application_identity_mappings_next SELECT id,application_slug,identity_key,profile,session_mode,session_prefix,created_at,updated_at FROM application_identity_mappings; DROP TABLE application_identity_mappings; ALTER TABLE application_identity_mappings_next RENAME TO application_identity_mappings; COMMIT;"); 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); }
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
- 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
- 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)!; }
19
- renameIdentityMapping(slug: string, previousIdentityKey: string, identityKey: string, profile: string, sessionMode: "fixed" | "automatic", sessionPrefix: string | null) { if (previousIdentityKey === identityKey) return this.saveIdentityMapping(slug, identityKey, profile, sessionMode, sessionPrefix); const current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, previousIdentityKey) as Row | undefined; if (!current) throw Object.assign(new Error("Identity mapping not found."), { status: 404 }); if (this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey)) throw Object.assign(new Error("An Identity Key mapping with this key already exists."), { status: 409 }); this.db.prepare("UPDATE application_identity_mappings SET identity_key=?,profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(identityKey, profile, sessionMode, sessionPrefix, now(), current.id); return this.identityMapping(slug, identityKey)!; }
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
+ deleteProfile(profile: string) {
17
+ this.db.exec("BEGIN IMMEDIATE");
18
+ try {
19
+ this.db.prepare("DELETE FROM application_automatic_prefixes WHERE EXISTS (SELECT 1 FROM application_identity_mappings m WHERE m.profile=? AND m.application_slug=application_automatic_prefixes.application_slug AND m.identity_key=application_automatic_prefixes.identity_key)").run(profile);
20
+ this.db.prepare("DELETE FROM application_console_sessions WHERE profile=?").run(profile);
21
+ this.db.prepare("DELETE FROM application_session_state WHERE profile=?").run(profile);
22
+ this.db.prepare("DELETE FROM application_sessions WHERE profile=?").run(profile);
23
+ this.db.prepare("DELETE FROM application_identity_mappings WHERE profile=?").run(profile);
24
+ this.db.exec("COMMIT");
25
+ } catch (error) { this.db.exec("ROLLBACK"); throw error; }
26
+ }
27
+ 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: row.profile == null ? null : String(row.profile), sessionMode: String(row.session_mode) as "fixed" | "automatic" | "ignore", sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix) } : undefined; }
28
+ 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: row.profile == null ? null : 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) })); }
29
+ saveIdentityMapping(slug: string, identityKey: string, profile: string | null, sessionMode: "fixed" | "automatic" | "ignore", 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)!; }
30
+ renameIdentityMapping(slug: string, previousIdentityKey: string, identityKey: string, profile: string | null, sessionMode: "fixed" | "automatic" | "ignore", sessionPrefix: string | null) { if (previousIdentityKey === identityKey) return this.saveIdentityMapping(slug, identityKey, profile, sessionMode, sessionPrefix); const current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, previousIdentityKey) as Row | undefined; if (!current) throw Object.assign(new Error("Identity mapping not found."), { status: 404 }); if (this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey)) throw Object.assign(new Error("An Identity Key mapping with this key already exists."), { status: 409 }); this.db.prepare("UPDATE application_identity_mappings SET identity_key=?,profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(identityKey, profile, sessionMode, sessionPrefix, now(), current.id); return this.identityMapping(slug, identityKey)!; }
20
31
  deleteIdentityMapping(slug: string, identityKey: string) { if (!this.db.prepare("DELETE FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").run(slug, identityKey).changes) throw new Error("Identity mapping not found."); }
21
32
  automaticPrefix(slug: string, identityKey: string) { const existing = this.db.prepare("SELECT session_prefix FROM application_automatic_prefixes WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (existing) return String(existing.session_prefix); const normalized = identityKey.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^[_-]+|[_-]+$/g, ""); const base = (normalized && /^[a-z]/.test(normalized) ? normalized : `id_${normalized || "identity"}`).slice(0, 63); let prefix = base; const collision = () => this.db.prepare("SELECT identity_key FROM application_automatic_prefixes WHERE application_slug=? AND session_prefix=?").get(slug, prefix) as Row | undefined; if (collision()) { const suffix = `--${createHash("sha256").update(identityKey).digest("hex").slice(0, 8)}`; prefix = `${base.slice(0, 63 - suffix.length)}${suffix}`; } this.db.prepare("INSERT INTO application_automatic_prefixes VALUES (?,?,?,?)").run(slug, identityKey, prefix, now()); return prefix; }
22
33
  session(slug: string, profile: string, prefix: string) { const row = this.db.prepare("SELECT s.* FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id WHERE st.application_slug=? AND st.profile=? AND st.session_prefix=?").get(slug, profile, prefix) as Row | undefined; return row ? this.sessionValue(row) : undefined; }
23
34
  private sessionValue(row: Row) { return { application: String(row.application_slug), profile: String(row.profile), sessionPrefix: String(row.session_prefix), sessionId: String(row.session_id), sessionDate: String(row.session_date), sequence: Number(row.sequence), createdAt: String(row.created_at), updatedAt: String(row.updated_at), lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), messageCount: Number(row.message_count), createdFromRollover: Boolean(row.created_from_rollover), lastIdentityKey: row.last_identity_key == null ? null : String(row.last_identity_key) }; }
24
- nextSessionId(slug: string, profile: string, prefix: string) { const date = new Date().toISOString().slice(0, 10), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND profile=? AND session_prefix=? AND session_date=?").get(slug, profile, prefix, date) as Row).sequence); return `${slug}_${prefix}_${date}-${String(sequence).padStart(2, "0")}`; }
25
- createSession(slug: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { const date = new Date().toISOString().slice(0, 10), timestamp = now(), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND profile=? AND session_prefix=? AND session_date=?").get(slug, profile, prefix, date) as Row).sequence); this.db.prepare("INSERT INTO application_sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(randomUUID(), slug, profile, prefix, sessionId, date, sequence, timestamp, timestamp, null, 0, rollover ? 1 : 0, null); this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix)!; }
35
+ // Session IDs omit the profile name and are globally unique in SQLite, so
36
+ // their sequence must be shared by profiles that use the same Application/prefix.
37
+ nextSessionId(slug: string, profile: string, prefix: string) { void profile; const date = new Date().toISOString().slice(0, 10), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); return `${slug}_${prefix}_${date}-${String(sequence).padStart(2, "0")}`; }
38
+ createSession(slug: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { const date = new Date().toISOString().slice(0, 10), timestamp = now(), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); this.db.prepare("INSERT INTO application_sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(randomUUID(), slug, profile, prefix, sessionId, date, sequence, timestamp, timestamp, null, 0, rollover ? 1 : 0, null); this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix)!; }
26
39
  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
40
  activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
28
41
  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 }); }
42
+ 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")}`; }
43
+ 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)!; }
44
+ 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; }
45
+ deleteConsoleSession(profile: string, sessionId: string) { this.db.prepare("DELETE FROM application_console_sessions WHERE profile=? AND session_id=?").run(profile, sessionId); }
29
46
  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
47
  }