pi-feats 0.1.4 → 0.1.7
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 +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- 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 +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -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,8 +126,15 @@ 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.
|
|
131
|
+
- Use **Direct Mode** (enabled by default) for integrations that already provide an Identity Key and message. Direct requests bypass transform, inbound, and outbound handlers and route exclusively through the configured Identity Key mapping.
|
|
132
|
+
- When a Direct Mode request omits `sessionId`, Pi reuses the active Application session for the resolved profile and session prefix; if none exists, it creates one. This is scoped to the Application route, profile, and identity-derived prefix—not the Application's globally most recent session.
|
|
133
|
+
- Provide `sessionId` in a Direct Mode request to explicitly create or adopt that session for the resolved route. A session cannot be associated with another Application route.
|
|
134
|
+
- Pass transient `contextMemory` in a Direct Mode request to supply personal context only when initializing that request's session. It is not persisted in profile or identity memory.
|
|
135
|
+
- In `ack` response mode, Direct Mode requires `responseWebhookUrl`; Pi immediately acknowledges the request and POSTs the completed response to that URL. In `result` mode, Pi returns the completed response synchronously and ignores `responseWebhookUrl`.
|
|
136
|
+
- Direct Mode defaults to `sessionType: "stateful"`. Use `sessionType: "stateless"` with an optional client-supplied `history` array to keep the Application session ID and routing while rebuilding the Pi context in memory for each request; no Pi session transcript is written or reused. The client owns the full conversational history in stateless mode. Stateless sessions are currently unavailable for sandboxed profiles.
|
|
137
|
+
- `POST /api/message/app/:slug` accepts request bodies up to 200 MiB. This limit applies to the complete HTTP body, so base64-encoded file content is larger than the original file.
|
|
114
138
|
- 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
139
|
- Include an Evolution API adapter implementation while keeping Applications structurally independent from adapters.
|
|
116
140
|
|
|
@@ -128,11 +152,35 @@ curl -X POST \
|
|
|
128
152
|
curl -H "Authorization: Bearer $PI_API_TOKEN" \
|
|
129
153
|
http://127.0.0.1:8767/api/applications/support-inbox/handlers
|
|
130
154
|
|
|
131
|
-
# Deliver
|
|
155
|
+
# Deliver a conventional integration payload to an enabled Application
|
|
132
156
|
curl -X POST \
|
|
133
157
|
-H "Content-Type: application/json" \
|
|
134
158
|
-d '{"message":"A customer needs help with an order."}' \
|
|
135
159
|
http://127.0.0.1:8767/api/message/app/support-inbox
|
|
160
|
+
|
|
161
|
+
# Direct Mode: route through the Identity Key mapping and return the result.
|
|
162
|
+
# Omit sessionId to use/create the active session for that identity route.
|
|
163
|
+
curl -X POST \
|
|
164
|
+
-H "Content-Type: application/json" \
|
|
165
|
+
-d '{"identityKey":"customer@example.com","message":"Where is my order?"}' \
|
|
166
|
+
http://127.0.0.1:8767/api/message/app/support-inbox
|
|
167
|
+
|
|
168
|
+
# Stateless Direct Mode: history belongs entirely to the caller. Omit history for an empty context.
|
|
169
|
+
curl -X POST -H "Content-Type: application/json" \
|
|
170
|
+
-d '{"identityKey":"customer@example.com","sessionType":"stateless","history":[{"role":"user","content":"Hello"},{"role":"assistant","content":"How can I help?"}],"message":"Continue."}' \
|
|
171
|
+
http://127.0.0.1:8767/api/message/app/support-inbox
|
|
172
|
+
|
|
173
|
+
# A stateless request may use a stable logical sessionId for routing and logs,
|
|
174
|
+
# but it never creates or reuses a Pi JSONL transcript.
|
|
175
|
+
curl -X POST -H "Content-Type: application/json" \
|
|
176
|
+
-d '{"identityKey":"customer@example.com","sessionId":"integration-thread-42","sessionType":"stateless","message":"Start without prior context."}' \
|
|
177
|
+
http://127.0.0.1:8767/api/message/app/support-inbox
|
|
178
|
+
|
|
179
|
+
# In ack mode, provide a webhook for the completed response.
|
|
180
|
+
curl -X POST \
|
|
181
|
+
-H "Content-Type: application/json" \
|
|
182
|
+
-d '{"identityKey":"customer@example.com","message":"Where is my order?","responseWebhookUrl":"https://integration.example.com/pi-response"}' \
|
|
183
|
+
http://127.0.0.1:8767/api/message/app/support-inbox
|
|
136
184
|
```
|
|
137
185
|
|
|
138
186
|
### API Server
|
|
@@ -162,8 +210,8 @@ curl \
|
|
|
162
210
|
|
|
163
211
|
What the API exposes:
|
|
164
212
|
|
|
165
|
-
- Persistent chat and streaming chat sessions, including profile-specific sessions.
|
|
166
|
-
- Profile lifecycle, settings, `SOUL.md`, environment variables, Guardrails, tools, Skills, and sessions.
|
|
213
|
+
- Persistent chat and streaming chat sessions, including profile-specific sessions. Streaming chat reports real-time agent activity such as tool execution, thinking state, completion, and failures alongside response tokens.
|
|
214
|
+
- Profile lifecycle, settings, default model selection, `SOUL.md`, environment variables, Guardrails, Context Memory, tools, Skills, and sessions.
|
|
167
215
|
- Runtime-wide extension and package inventory and configuration.
|
|
168
216
|
- Pulse schedules and execution history.
|
|
169
217
|
- Git-backed Skill Sources, document preview, synchronization, and explicit profile imports.
|
|
@@ -174,7 +222,7 @@ The service generates and stores its bearer token in `api-server.json`, supports
|
|
|
174
222
|
|
|
175
223
|
### Pi Console WebUI and terminal
|
|
176
224
|
|
|
177
|
-
The Pi Console WebUI is an operations cockpit, not merely a settings page. It gives administrators a visual way to operate the same runtime exposed by the CLI and API: inspect activity, configure profiles, edit application handlers, manage schedules, examine logs, and open a real terminal when direct command-line access is needed.
|
|
225
|
+
The Pi Console WebUI is an operations cockpit, not merely a settings page. It gives administrators a visual way to operate the same runtime exposed by the CLI and API: inspect activity, configure profiles, edit application handlers, manage schedules, examine logs, and open a real terminal when direct command-line access is needed. During a chat response, it renders the agent's live tool and work activity before the final answer is complete.
|
|
178
226
|
|
|
179
227
|
```bash
|
|
180
228
|
# Start, inspect, restart, and stop the WebUI
|
|
@@ -189,8 +237,9 @@ pi console stop
|
|
|
189
237
|
|
|
190
238
|
The console provides dedicated workspaces for:
|
|
191
239
|
|
|
192
|
-
- **Profiles:** settings, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
|
|
193
|
-
- **
|
|
240
|
+
- **Profiles:** settings, default model selection, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
|
|
241
|
+
- **Context Memory:** personal-context source selection, persistent Markdown memory editing, and Application handler creation and testing.
|
|
242
|
+
- **Applications:** configuration, identity mappings (including `None`/ignore rules), active sessions, handler editor, transform testing, rollover, handoff, and live logs.
|
|
194
243
|
- **Skill Sources:** source registration, Git synchronization, Skill preview, and deliberate import into a profile.
|
|
195
244
|
- **Pulse:** schedules, enablement, run history, and profile/session association.
|
|
196
245
|
- **Operations:** API Server and Console configuration, service controls, and resource visibility.
|
|
@@ -239,7 +288,7 @@ Skills remain explicit resources rather than hidden package behavior. A profile
|
|
|
239
288
|
~/.pi/agent/profiles/<profile>/skills/<skill>/SKILL.md
|
|
240
289
|
```
|
|
241
290
|
|
|
242
|
-
- **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile.
|
|
291
|
+
- **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
292
|
- **Profile Skills** live under `~/.pi/agent/profiles/<profile>/skills/` and remain isolated from other profiles.
|
|
244
293
|
- Profile policy controls whether shared and profile-local sources are available and can allow all Skills or a named allowlist.
|
|
245
294
|
- List and enable or disable Skills from the CLI and Console without editing settings by hand.
|
|
@@ -275,6 +324,7 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
275
324
|
|
|
276
325
|
- Create and manage persistent scheduled prompts, reminders, cron jobs, one-time jobs, and heartbeat jobs.
|
|
277
326
|
- Bind scheduled work to the active profile and conversation session.
|
|
327
|
+
- Pulse results are recorded in every active Application session for the same profile by default. Set `insertIntoApiSession: false` only when that recording is not wanted. This adds context only: it does not invoke an outbound handler or send a message through an Application channel.
|
|
278
328
|
- Persist schedules and execution history in SQLite with WAL support.
|
|
279
329
|
- Start and inspect the scheduler:
|
|
280
330
|
|
|
@@ -282,8 +332,21 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
282
332
|
pi pulse start
|
|
283
333
|
pi pulse status
|
|
284
334
|
pi pulse list
|
|
335
|
+
# After confirming an interrupted worker is no longer running:
|
|
336
|
+
pi pulse recover
|
|
285
337
|
```
|
|
286
338
|
|
|
339
|
+
### Context Memory
|
|
340
|
+
|
|
341
|
+
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.
|
|
342
|
+
|
|
343
|
+
- Each profile can maintain shared operational context in `context-memory/OPERATIONAL.md` (maximum 2,750 characters).
|
|
344
|
+
- 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.
|
|
345
|
+
- Identity memory is available only in an Application session and is isolated by Application and Identity Key.
|
|
346
|
+
- 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.
|
|
347
|
+
- 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.
|
|
348
|
+
- Stored memory is presented to the model as data rather than instructions. Changes apply to newly started sessions.
|
|
349
|
+
|
|
287
350
|
### CLI resource management
|
|
288
351
|
|
|
289
352
|
- Inspect tools, Skills, extensions, sessions, packages, and profile resources from the terminal.
|
|
@@ -310,6 +373,8 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
310
373
|
|
|
311
374
|
The following commands are the operational entry points added by `pi-feats`. Commands can target the default profile, a named local profile, or a remote host.
|
|
312
375
|
|
|
376
|
+
Finite `pi-feats` CLI commands run with Pi's regular TUI mode so their output remains in the terminal scrollback. This does not change profile settings or affect interactive launches; pass `--tui-mode fullscreen` explicitly to override it for one command.
|
|
377
|
+
|
|
313
378
|
### Profiles
|
|
314
379
|
|
|
315
380
|
```bash
|
|
@@ -330,6 +395,11 @@ pi profile support extensions list
|
|
|
330
395
|
pi profile support sessions list
|
|
331
396
|
pi profile support packages list
|
|
332
397
|
|
|
398
|
+
# Register, list, or delete hosts scoped to this profile
|
|
399
|
+
pi profile support ssh add production-db
|
|
400
|
+
pi profile support ssh list
|
|
401
|
+
pi profile support ssh delete production-db
|
|
402
|
+
|
|
333
403
|
# Resume the latest session or open a specific one
|
|
334
404
|
pi profile resume support
|
|
335
405
|
pi profile open support <session-id>
|
|
@@ -355,6 +425,10 @@ pi skills disable my-skill
|
|
|
355
425
|
pi skills enable my-skill
|
|
356
426
|
pi extensions disable my-extension
|
|
357
427
|
pi extensions enable my-extension
|
|
428
|
+
# A package with a sole index extension uses its package name. These commands
|
|
429
|
+
# also update an explicit runtime extension allowlist.
|
|
430
|
+
pi extensions disable pi-mcp-adapter
|
|
431
|
+
pi extensions enable pi-mcp-adapter
|
|
358
432
|
|
|
359
433
|
# Enable or disable an installed package for the selected profile
|
|
360
434
|
pi packages disable npm:some-package
|
|
@@ -423,6 +497,7 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
|
|
|
423
497
|
| API Server | HTTP API, Applications, profile administration, sessions, logs, and terminal tickets. |
|
|
424
498
|
| Browser Harness | Steel Profile-scoped CDP browser automation for active Pi Profiles. |
|
|
425
499
|
| CLI Resources | Resource, package, session, and profile commands. |
|
|
500
|
+
| Context Memory | Persistent operational and personal context for new sessions. |
|
|
426
501
|
| Guardrails | Input, tool, and output policy stages. |
|
|
427
502
|
| Pi Console WebUI | Browser-based operations console and terminal client. |
|
|
428
503
|
| Profiles | Profile lifecycle, policy, sandbox, and remote routing. |
|
|
@@ -498,7 +573,11 @@ npm install
|
|
|
498
573
|
npm run build:web
|
|
499
574
|
```
|
|
500
575
|
|
|
501
|
-
The root `package.json` is the package manifest. Its `pi.extensions`
|
|
576
|
+
The root `package.json` is the package manifest. Its `pi.extensions` and `pi.themes` fields explicitly export every extension and theme; the package does not rely on directory auto-discovery.
|
|
577
|
+
|
|
578
|
+
### Themes
|
|
579
|
+
|
|
580
|
+
The package includes `legacy-dark`, which preserves Pi's pre-`bf8e4b95` dark palette. After updating or installing `pi-feats`, select it in `/settings` → **Theme**, or set `"theme": "legacy-dark"` in `<agent-dir>/settings.json` and run `/reload`.
|
|
502
581
|
|
|
503
582
|
## Security
|
|
504
583
|
|
|
@@ -523,6 +602,8 @@ pi-feats/
|
|
|
523
602
|
│ ├── cli-resources.ts
|
|
524
603
|
│ ├── profiles.ts
|
|
525
604
|
│ └── sequential-workflow.ts
|
|
605
|
+
├── themes/
|
|
606
|
+
│ └── legacy-dark.json # Pre-bf8e4b95 Pi dark palette
|
|
526
607
|
└── README.md
|
|
527
608
|
```
|
|
528
609
|
|
|
@@ -62,6 +62,26 @@ Request body:
|
|
|
62
62
|
|
|
63
63
|
`/chat/stream` uses Server-Sent Events: `session`, `token`, and `done`.
|
|
64
64
|
|
|
65
|
+
## Application Direct Mode
|
|
66
|
+
|
|
67
|
+
`POST /api/message/app/:slug` supports Direct Mode for integrations that supply an Identity Key and a message. It resolves the Application's Identity Key mapping and bypasses application handlers.
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"identityKey": "customer@example.com",
|
|
72
|
+
"sessionType": "stateless",
|
|
73
|
+
"history": [
|
|
74
|
+
{ "role": "user", "content": "My name is Ada." },
|
|
75
|
+
{ "role": "assistant", "content": "Hello, Ada." }
|
|
76
|
+
],
|
|
77
|
+
"message": "What is my name?"
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`sessionType` defaults to `stateful`, which creates or resumes a persistent Pi JSONL session. With `sessionType: "stateless"`, the server creates an in-memory Pi session for the request only and disposes it after responding. The optional `history` is supplied and owned by the caller; omitting it starts with no conversational context. A stateless `sessionId` is a logical identifier for Application routing, logs, and metrics only, not a transcript. Stateless mode is unavailable for sandboxed profiles.
|
|
82
|
+
|
|
83
|
+
The Application message endpoint accepts complete HTTP request bodies up to 200 MiB. Account for base64 expansion when embedding file data in JSON.
|
|
84
|
+
|
|
65
85
|
## Sessions and profiles
|
|
66
86
|
|
|
67
87
|
Sessions are created in the same persistent location used by the CLI. For example, a session created through the API can be resumed with:
|
|
@@ -81,7 +101,7 @@ All routes below require Bearer authentication. Resource responses expose only m
|
|
|
81
101
|
|
|
82
102
|
```text
|
|
83
103
|
GET /api/profiles
|
|
84
|
-
POST /api/profiles { "name": "work" }
|
|
104
|
+
POST /api/profiles { "name": "work", "cloneFrom": "existing-profile" }
|
|
85
105
|
GET /api/profiles/:profile
|
|
86
106
|
DELETE /api/profiles/:profile { "force": true }
|
|
87
107
|
|
|
@@ -4,10 +4,11 @@ import { join } from "node:path";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import type { ContextMemoryExecutionLog } from "../lib/application-context.ts";
|
|
6
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 };
|
|
7
|
-
export type ApplicationCall = { id: string; application: string; origin: string; receivedAt: string; finishedAt?: string; status: "running" | "success" | "error"; headers: Record<string, unknown>; stages: CallStage[]; error?: string };
|
|
7
|
+
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 };
|
|
8
8
|
export class ApplicationLogStore {
|
|
9
9
|
private calls = new Map<string, ApplicationCall>(); private events = new EventEmitter(); private directory: string;
|
|
10
|
-
|
|
10
|
+
readonly application: string;
|
|
11
|
+
constructor(applicationDirectory: string, application: string) { this.application = application; this.directory = join(applicationDirectory, "logs", "calls"); }
|
|
11
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 {} } }
|
|
12
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 }); }
|
|
13
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; }
|
|
@@ -16,6 +17,7 @@ export class ApplicationLogStore {
|
|
|
16
17
|
annotate(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; void this.save(call); }
|
|
17
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); }
|
|
18
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; }
|
|
19
21
|
recordContextMemory(input: { profile: string; identityKey: string; sessionId: string; execution: ContextMemoryExecutionLog }) {
|
|
20
22
|
const { execution } = input;
|
|
21
23
|
const stage: CallStage = {
|
|
@@ -8,9 +8,10 @@ import { inspect } from "node:util";
|
|
|
8
8
|
import { ApplicationLogStore } from "./application-log-store.ts";
|
|
9
9
|
|
|
10
10
|
type RecordValue = Record<string, any>;
|
|
11
|
-
export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
11
|
+
export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; directMode?: { enabled?: boolean }; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
12
|
+
export type DirectRequest = { identityKey: string; message: string; sessionId?: string; contextMemory?: string; responseWebhookUrl?: string; profile: string; sessionPrefix: string; sessionType: "stateful" | "stateless"; history?: Array<{ role: "user" | "assistant"; content: string }> };
|
|
12
13
|
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, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
|
|
14
|
+
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; directContextMemory?: string }): Promise<{ profile: string; sessionId: string; response: string }>; promptStateless(profile: string, sessionId: string, history: Array<{ role: "user" | "assistant"; content: string }>, message: string, applicationContext?: { application: string; identityKey: string; directContextMemory?: 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, sessionType?: "stateful" | "stateless"): Promise<{ sessionId: string; sessionType?: "stateful" | "stateless" } | 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, sessionType?: "stateful" | "stateless"): 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
15
|
type Handoff = { sourceSessionId: string | null; summary: string; createdAt: string; targetCreatedAt: string };
|
|
15
16
|
type WorkingSession = { sessionId: string; updatedAt: string; reason: "message" | "rollover" };
|
|
16
17
|
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 };
|
|
@@ -95,12 +96,22 @@ export class ApplicationRuntime {
|
|
|
95
96
|
try { return await operation(); } finally { release(); if (this.queues.get(key) === current) this.queues.delete(key); }
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
private async createApplicationSession(profile: string, sessionPrefix: string, rollover: boolean): Promise<string> {
|
|
100
|
+
const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
|
|
101
|
+
// A process can stop after Pi has persisted the session file but before the
|
|
102
|
+
// Application row is written. Reclaim that deterministic orphan instead of
|
|
103
|
+
// attempting to create it again and permanently failing with a 409.
|
|
104
|
+
const exists = (await this.api.listSessions(profile)).some((session) => session.id === id);
|
|
105
|
+
if (!exists) await this.api.createSession(profile, { id });
|
|
106
|
+
await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, rollover);
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
private async resolveSession(profile: string, sessionPrefix: string): Promise<string> {
|
|
99
111
|
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 422 });
|
|
100
112
|
return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
|
|
101
113
|
const current = await this.api.getApplicationSession(this.application.slug, profile, sessionPrefix); if (current) return current.sessionId;
|
|
102
|
-
|
|
103
|
-
await this.api.createSession(profile, { id }); await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, false); return id;
|
|
114
|
+
return this.createApplicationSession(profile, sessionPrefix, false);
|
|
104
115
|
});
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -176,8 +187,8 @@ export class ApplicationRuntime {
|
|
|
176
187
|
if (typeof sessionPrefix !== "string" || !sessionPrefix) throw Object.assign(new Error("The 'sessionPrefix' field is required."), { status: 400 });
|
|
177
188
|
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix"), { status: 400 });
|
|
178
189
|
return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
|
|
179
|
-
const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.
|
|
180
|
-
|
|
190
|
+
const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.createApplicationSession(profile, sessionPrefix, true);
|
|
191
|
+
if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
|
|
181
192
|
try {
|
|
182
193
|
const handoff = await this.handoff(profile, sessionId, sessionPrefix, true);
|
|
183
194
|
return { profile, sessionId, messageCount: 0, handoff: { status: handoff?.summary ? "ready" : "none", sourceSessionId: handoff?.sourceSessionId ?? null } };
|
|
@@ -233,14 +244,72 @@ export class ApplicationRuntime {
|
|
|
233
244
|
this.coalescingBuffers.clear();
|
|
234
245
|
}
|
|
235
246
|
|
|
236
|
-
|
|
247
|
+
isDirectRequest(payload: unknown): boolean {
|
|
248
|
+
return this.settings.directMode?.enabled !== false && Object.prototype.hasOwnProperty.call(asRecord(payload), "identityKey");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async prepareDirectRequest(payload: unknown): Promise<DirectRequest> {
|
|
252
|
+
const body = asRecord(payload);
|
|
253
|
+
const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
|
|
254
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
255
|
+
if (!identityKey) throw Object.assign(new Error("identityKey must be a non-empty string for a Direct Mode request."), { status: 400 });
|
|
256
|
+
if (!message) throw Object.assign(new Error("message must be a non-empty string for a Direct Mode request."), { status: 400 });
|
|
257
|
+
const sessionType = body.sessionType === undefined ? "stateful" : body.sessionType; if (sessionType !== "stateful" && sessionType !== "stateless") throw Object.assign(new Error("sessionType must be 'stateful' or 'stateless'."), { status: 400 }); const history = body.history; if (history !== undefined && (!Array.isArray(history) || history.some((item) => !item || typeof item !== "object" || !["user", "assistant"].includes((item as RecordValue).role) || typeof (item as RecordValue).content !== "string"))) throw Object.assign(new Error("history must contain text user or assistant messages."), { status: 400 }); if (history !== undefined && (history.length > 100 || history.reduce((total, item) => total + (item as RecordValue).content.length, 0) > 100_000)) throw Object.assign(new Error("history exceeds the 100-message or 100,000-character limit."), { status: 413 }); if (sessionType === "stateful" && history !== undefined) throw Object.assign(new Error("history is only supported for stateless sessions."), { status: 400 });
|
|
258
|
+
const sessionId = body.sessionId;
|
|
259
|
+
if (sessionId !== undefined && (typeof sessionId !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(sessionId))) throw Object.assign(new Error("Invalid sessionId."), { status: 400 });
|
|
260
|
+
const contextMemory = body.contextMemory;
|
|
261
|
+
if (contextMemory !== undefined && typeof contextMemory !== "string") throw Object.assign(new Error("contextMemory must be a string."), { status: 400 });
|
|
262
|
+
if (typeof contextMemory === "string" && Array.from(contextMemory.trim()).length > 1375) throw Object.assign(new Error("contextMemory exceeds its 1375-character limit."), { status: 413 });
|
|
263
|
+
const responseWebhookUrl = body.responseWebhookUrl;
|
|
264
|
+
if (this.settings.responseMode === "ack") {
|
|
265
|
+
if (typeof responseWebhookUrl !== "string" || !responseWebhookUrl.trim()) throw Object.assign(new Error("responseWebhookUrl is required for Direct Mode requests when Response Mode is ack."), { status: 400 });
|
|
266
|
+
try { const url = new URL(responseWebhookUrl); if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(); } catch { throw Object.assign(new Error("responseWebhookUrl must be a valid HTTP or HTTPS URL."), { status: 400 }); }
|
|
267
|
+
}
|
|
268
|
+
const mapped = await this.api.getIdentityMapping(this.application.slug, identityKey);
|
|
269
|
+
if (!mapped) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 });
|
|
270
|
+
if (mapped.sessionMode === "ignore") throw Object.assign(new Error("The Identity Key mapping is configured to ignore requests."), { status: 422 });
|
|
271
|
+
const profile = mapped.profile ?? undefined;
|
|
272
|
+
const sessionPrefix = mapped.sessionMode === "automatic" ? await this.api.automaticApplicationSessionPrefix(this.application.slug, identityKey) : mapped.sessionPrefix;
|
|
273
|
+
if (!profile || !sessionPrefix) throw Object.assign(new Error("The Identity Key mapping has no Application route."), { status: 422 });
|
|
274
|
+
return { identityKey, message, ...(typeof sessionId === "string" ? { sessionId } : {}), ...(typeof contextMemory === "string" ? { contextMemory } : {}), ...(this.settings.responseMode === "ack" && typeof responseWebhookUrl === "string" ? { responseWebhookUrl: responseWebhookUrl.trim() } : {}), profile, sessionPrefix, sessionType, ...(history !== undefined ? { history: history as Array<{ role: "user" | "assistant"; content: string }> } : {}) };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async resolveDirectSession(request: DirectRequest): Promise<string> {
|
|
278
|
+
if (!request.sessionId && request.sessionType === "stateful") return this.resolveSession(request.profile, request.sessionPrefix); if (!request.sessionId) return this.withQueue(`${this.application.slug}:${request.profile}:${request.sessionPrefix}:stateless`, async () => { const current = await this.api.getApplicationSession(this.application.slug, request.profile, request.sessionPrefix, request.sessionType) as any; if (current) { if (current.sessionType !== "stateless") throw Object.assign(new Error("The active Application session is stateful."), { status: 409 }); return current.sessionId; } const id = await this.api.nextApplicationSessionId(this.application.slug, request.profile, request.sessionPrefix); await this.api.recordApplicationSession(this.application.slug, request.profile, request.sessionPrefix, id, false, "stateless"); return id; });
|
|
279
|
+
return this.withQueue(`${this.application.slug}:${request.profile}:${request.sessionPrefix}:direct:${request.sessionId}`, async () => {
|
|
280
|
+
const current = await this.api.getApplicationSession(this.application.slug, request.profile, request.sessionPrefix, request.sessionType) as any;
|
|
281
|
+
if (current?.sessionType && current.sessionType !== request.sessionType) throw Object.assign(new Error("sessionId belongs to a different sessionType."), { status: 409 });
|
|
282
|
+
if (request.sessionType === "stateful") { const exists = (await this.api.listSessions(request.profile)).some((session) => session.id === request.sessionId); if (!exists) await this.api.createSession(request.profile, { id: request.sessionId }); }
|
|
283
|
+
await this.api.recordApplicationSession(this.application.slug, request.profile, request.sessionPrefix, request.sessionId, false, request.sessionType);
|
|
284
|
+
return request.sessionId;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async processDirect(payload: unknown, headers: RecordValue, query: RecordValue, prepared?: DirectRequest): Promise<unknown> {
|
|
289
|
+
const request = prepared ?? await this.prepareDirectRequest(payload);
|
|
290
|
+
const call = this.logs.start(payload, headers); let active: any;
|
|
291
|
+
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(" ")) } };
|
|
292
|
+
const run = async (type: string, label: string, input: unknown, operation: () => Promise<any>) => { active = this.logs.stage(call, type, label, input); try { const output = await operation(); this.logs.complete(call, active, output); return output; } catch (error) { this.logs.fail(call, active, error); throw error; } finally { active = undefined; } };
|
|
293
|
+
try {
|
|
294
|
+
const sessionId = await this.resolveDirectSession(request);
|
|
295
|
+
const result = await run("pi-agent", "Pi agent (Direct Mode)", { profile: request.profile, sessionId, message: request.message }, () => request.sessionType === "stateless" ? this.api.promptStateless(request.profile, sessionId, request.history ?? [], request.message, { application: this.application.slug, identityKey: request.identityKey, ...(request.contextMemory !== undefined ? { directContextMemory: request.contextMemory } : {}) }) : this.api.prompt(request.profile, sessionId, request.message, undefined, { application: this.application.slug, identityKey: request.identityKey, ...(request.contextMemory !== undefined ? { directContextMemory: request.contextMemory } : {}) }));
|
|
296
|
+
await this.setWorkingSession(request.profile, request.sessionPrefix, sessionId, "message");
|
|
297
|
+
await this.api.touchApplicationSession(this.application.slug, request.profile, request.sessionPrefix, sessionId, request.identityKey);
|
|
298
|
+
const output = { ...result, direct: true };
|
|
299
|
+
this.logs.finish(call, output);
|
|
300
|
+
return output;
|
|
301
|
+
} catch (error) { if (call.status !== "error") this.logs.fail(call, undefined, error); throw error; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async process(payload: unknown, headers: RecordValue, query: RecordValue, preparedDirect?: DirectRequest): Promise<unknown> {
|
|
305
|
+
if (preparedDirect || this.isDirectRequest(payload)) return this.processDirect(payload, headers, query, preparedDirect);
|
|
237
306
|
const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
|
|
238
307
|
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(" ")) } };
|
|
239
308
|
const run = async (type: string, label: string, input: unknown, operation: () => Promise<any>) => { active = this.logs.stage(call, type, label, input); try { const output = await operation(); this.logs.complete(call, active, output); return output; } catch (error) { this.logs.fail(call, active, error); throw error; } finally { active = undefined; } };
|
|
240
309
|
try {
|
|
241
310
|
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); }
|
|
242
311
|
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)));
|
|
243
|
-
const routed = asRecord(inbound); const
|
|
312
|
+
const routed = asRecord(inbound); const rawIdentityKey = routed.identityKey; const identityKey = typeof rawIdentityKey === "string" ? rawIdentityKey.trim() || undefined : undefined; if (rawIdentityKey !== undefined && rawIdentityKey !== null && typeof rawIdentityKey !== "string") throw Object.assign(new Error("identityKey must be a 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() : this.application.defaultProfile ?? 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 and the Application has no default profile."), { 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) };
|
|
244
313
|
const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
|
|
245
314
|
const sessionId = await this.resolveSession(profile!, sessionPrefix!);
|
|
246
315
|
let handoff: string | undefined;
|
|
@@ -268,6 +337,8 @@ export function validateApplicationSettings(raw: unknown): asserts raw is Settin
|
|
|
268
337
|
if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
|
|
269
338
|
if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
|
|
270
339
|
if (settings.transformHandlers !== undefined && (!Array.isArray(settings.transformHandlers) || settings.transformHandlers.some((name) => !safeName(name)))) throw Object.assign(new Error("Invalid transform handlers."), { status: 400 });
|
|
340
|
+
const directMode = settings.directMode;
|
|
341
|
+
if (directMode !== undefined && (!directMode || typeof directMode !== "object" || (directMode.enabled !== undefined && typeof directMode.enabled !== "boolean"))) throw Object.assign(new Error("Invalid Direct Mode settings."), { status: 400 });
|
|
271
342
|
const coalescing = settings.messageCoalescing;
|
|
272
343
|
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 });
|
|
273
344
|
}
|
|
@@ -6,23 +6,36 @@ 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, session_type TEXT NOT NULL DEFAULT 'stateful' CHECK(session_type IN ('stateful','stateless')), 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, session_type TEXT NOT NULL DEFAULT 'stateful' CHECK(session_type IN ('stateful','stateless')), current_session_id TEXT NOT NULL, updated_at TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(application_slug, profile, session_prefix, session_type)); 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;"); if (!columns("application_sessions").has("session_type")) this.db.exec("ALTER TABLE application_sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'stateful' CHECK(session_type IN ('stateful','stateless'));"); const state = columns("application_session_state"); if (!state.has("session_type")) this.db.exec("BEGIN; CREATE TABLE application_session_state_next (application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, session_type TEXT NOT NULL DEFAULT 'stateful' CHECK(session_type IN ('stateful','stateless')), current_session_id TEXT NOT NULL, updated_at TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(application_slug,profile,session_prefix,session_type)); INSERT INTO application_session_state_next (application_slug,profile,session_prefix,session_type,current_session_id,updated_at,reason) SELECT application_slug,profile,session_prefix,'stateful',current_session_id,updated_at,reason FROM application_session_state; DROP TABLE application_session_state; ALTER TABLE application_session_state_next RENAME TO application_session_state; COMMIT;"); }
|
|
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
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
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
|
-
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
|
-
|
|
33
|
+
session(slug: string, profile: string, prefix: string, sessionType: "stateful" | "stateless" = "stateful") { 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=? AND st.session_type=?").get(slug, profile, prefix, sessionType) as Row | undefined; return row ? this.sessionValue(row) : undefined; }
|
|
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), sessionType: String(row.session_type ?? "stateful") as "stateful" | "stateless" }; }
|
|
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, sessionType: "stateful" | "stateless" = "stateful") { const date = new Date().toISOString().slice(0, 10), timestamp = now(); const existing = this.db.prepare("SELECT application_slug,profile,session_prefix,session_type FROM application_sessions WHERE session_id=?").get(sessionId) as Row | undefined; if (existing && (String(existing.application_slug) !== slug || String(existing.profile) !== profile || String(existing.session_prefix) !== prefix || String(existing.session_type ?? "stateful") !== sessionType)) throw Object.assign(new Error("sessionId is already associated with another Application route."), { status: 409 }); if (!existing) { const 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, sessionType); } this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix,session_type) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionType, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix, sessionType)!; }
|
|
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 }); }
|
|
@@ -5,6 +5,8 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
+
import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
|
|
9
|
+
import { piCommand } from "../lib/pi-command.ts";
|
|
8
10
|
|
|
9
11
|
type ApiSettings = { host?: string; port?: number; apiToken?: string; cors?: { origin?: string } };
|
|
10
12
|
type LegacySettings = Record<string, unknown> & { api?: ApiSettings };
|
|
@@ -14,6 +16,7 @@ const configPath = () => join(agentDir(), "api-server.json");
|
|
|
14
16
|
const legacySettingsPath = () => join(agentDir(), "settings.json");
|
|
15
17
|
const statePath = () => join(agentDir(), "api-server.state.json");
|
|
16
18
|
const logPath = () => join(agentDir(), "api-server.log");
|
|
19
|
+
const persistentCommand = "dir=$(mktemp -d) || exit 1; fifo=$dir/stdin; mkfifo \"$fifo\" || { rmdir \"$dir\"; exit 1; }; cleanup() { kill \"$tail_pid\" 2>/dev/null || true; wait \"$tail_pid\" 2>/dev/null || true; rm -f \"$fifo\"; rmdir \"$dir\"; }; trap cleanup EXIT HUP INT TERM; tail -f /dev/null >\"$fifo\" & tail_pid=$!; \"$@\" <\"$fifo\"; exit $?";
|
|
17
20
|
|
|
18
21
|
async function readJson<T>(path: string): Promise<T | undefined> { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new Error(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`); } }
|
|
19
22
|
async function writeJson(path: string, value: unknown) { await mkdir(agentDir(), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); }
|
|
@@ -42,11 +45,12 @@ async function start() {
|
|
|
42
45
|
const api = await getApiSettings(); await clearStaleState(); const current = await readState();
|
|
43
46
|
if (current && isAlive(current.pid)) return console.log(`API is already running (PID ${current.pid}) at http://${current.host}:${current.port}`);
|
|
44
47
|
const fd = openSync(logPath(), "a");
|
|
45
|
-
const
|
|
48
|
+
const pi = piCommand();
|
|
49
|
+
const child = spawn("sh", ["-c", persistentCommand, "pi-api-worker", pi.command, ...pi.args, "--mode", "rpc", "--no-session"], { cwd: process.cwd(), detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, PI_API_WORKER: "1" } });
|
|
46
50
|
child.unref(); await writeJson(statePath(), { pid: child.pid!, host: api.host, port: api.port, startedAt: new Date().toISOString() });
|
|
47
51
|
console.log(`API started at http://${api.host}:${api.port} (PID ${child.pid}).`); console.log(`The token is stored in ${configPath()}.`);
|
|
48
52
|
}
|
|
49
53
|
async function stop() { const state = await readState(); if (!state || !isAlive(state.pid)) { await unlink(statePath()).catch(() => {}); return console.log("API is not running."); } try { process.kill(-state.pid, "SIGTERM"); } catch { process.kill(state.pid, "SIGTERM"); } for (let i = 0; i < 30 && isAlive(state.pid); i++) await new Promise((resolve) => setTimeout(resolve, 100)); if (isAlive(state.pid)) { try { process.kill(-state.pid, "SIGKILL"); } catch { process.kill(state.pid, "SIGKILL"); } } await unlink(statePath()).catch(() => {}); console.log("API stopped."); }
|
|
50
54
|
async function status() { await clearStaleState(); const state = await readState(); console.log(!state || !isAlive(state.pid) ? "API: stopped" : `API: running (PID ${state.pid}) at http://${state.host}:${state.port}`); }
|
|
51
|
-
async function handleCliCommand(): Promise<boolean> { const args = process.argv.slice(2); if (args[0] !== "api" || process.env.PI_API_WORKER === "1") return false; switch (args[1]) { case "start": await start(); break; case "stop": await stop(); break; case "status": await status(); break; case "restart": await stop(); await start(); break; default: console.error("Usage: pi api start | stop | restart | status"); process.exitCode = 1; } return true; }
|
|
55
|
+
async function handleCliCommand(): Promise<boolean> { const args = withoutTuiModeArgs(process.argv.slice(2)); if (args[0] !== "api" || process.env.PI_API_WORKER === "1") return false; switch (args[1]) { case "start": await start(); break; case "stop": await stop(); break; case "status": await status(); break; case "restart": await stop(); await start(); break; default: console.error("Usage: pi api start | stop | restart | status"); process.exitCode = 1; } return true; }
|
|
52
56
|
export default async function (pi: ExtensionAPI) { if (await handleCliCommand()) process.exit(); if (process.env.PI_API_WORKER !== "1") return; pi.on("session_start", async () => { const { startApiServer } = await import("./server.ts"); await startApiServer({ agentDir: agentDir(), cwd: process.cwd() }); }); }
|