pi-feats 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -6
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/extensions/api-server/application-log-store.ts +35 -2
- package/extensions/api-server/application-runtime.ts +95 -23
- package/extensions/api-server/application-store.ts +26 -9
- package/extensions/api-server/profile-store.ts +79 -29
- package/extensions/api-server/server.ts +225 -51
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +54 -35
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/profile-sandbox.ts +6 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +14 -10
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +6 -1
- package/extensions/pi-console-webui/components/console.tsx +60 -10
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +14 -10
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +35 -0
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +70 -23
- package/extensions/pulse/index.ts +128 -14
- package/extensions/pulse/store.ts +110 -15
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +23 -3
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
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
|
|
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
|
-
- **
|
|
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**,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
170
|
-
|
|
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)
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
}
|