pi-feats 0.1.5 → 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 +48 -5
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-runtime.ts +65 -4
- package/extensions/api-server/application-store.ts +5 -5
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +81 -34
- package/extensions/api-server/server.ts +179 -42
- package/extensions/cli-resources.ts +125 -94
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +11 -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/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- 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 +1 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +26 -9
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
- package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
- 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 +6 -4
- package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
- package/extensions/profile-ssh.ts +2 -2
- package/extensions/profiles.ts +17 -9
- package/extensions/pulse/index.ts +35 -17
- package/extensions/pulse/store.ts +29 -7
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +19 -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/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
package/README.md
CHANGED
|
@@ -128,6 +128,13 @@ Applications are the integration boundary between Pi and external systems. Use o
|
|
|
128
128
|
- Normalize provider-specific payloads in an inbound handler, enrich or redact data through transforms, and shape integration responses in an outbound handler.
|
|
129
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.
|
|
130
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.
|
|
131
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.
|
|
132
139
|
- Include an Evolution API adapter implementation while keeping Applications structurally independent from adapters.
|
|
133
140
|
|
|
@@ -145,11 +152,35 @@ curl -X POST \
|
|
|
145
152
|
curl -H "Authorization: Bearer $PI_API_TOKEN" \
|
|
146
153
|
http://127.0.0.1:8767/api/applications/support-inbox/handlers
|
|
147
154
|
|
|
148
|
-
# Deliver
|
|
155
|
+
# Deliver a conventional integration payload to an enabled Application
|
|
149
156
|
curl -X POST \
|
|
150
157
|
-H "Content-Type: application/json" \
|
|
151
158
|
-d '{"message":"A customer needs help with an order."}' \
|
|
152
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
|
|
153
184
|
```
|
|
154
185
|
|
|
155
186
|
### API Server
|
|
@@ -179,7 +210,7 @@ curl \
|
|
|
179
210
|
|
|
180
211
|
What the API exposes:
|
|
181
212
|
|
|
182
|
-
- Persistent chat and streaming chat sessions, including profile-specific 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.
|
|
183
214
|
- Profile lifecycle, settings, default model selection, `SOUL.md`, environment variables, Guardrails, Context Memory, tools, Skills, and sessions.
|
|
184
215
|
- Runtime-wide extension and package inventory and configuration.
|
|
185
216
|
- Pulse schedules and execution history.
|
|
@@ -191,7 +222,7 @@ The service generates and stores its bearer token in `api-server.json`, supports
|
|
|
191
222
|
|
|
192
223
|
### Pi Console WebUI and terminal
|
|
193
224
|
|
|
194
|
-
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.
|
|
195
226
|
|
|
196
227
|
```bash
|
|
197
228
|
# Start, inspect, restart, and stop the WebUI
|
|
@@ -293,7 +324,7 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
293
324
|
|
|
294
325
|
- Create and manage persistent scheduled prompts, reminders, cron jobs, one-time jobs, and heartbeat jobs.
|
|
295
326
|
- Bind scheduled work to the active profile and conversation session.
|
|
296
|
-
-
|
|
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.
|
|
297
328
|
- Persist schedules and execution history in SQLite with WAL support.
|
|
298
329
|
- Start and inspect the scheduler:
|
|
299
330
|
|
|
@@ -342,6 +373,8 @@ Context Memory keeps compact, durable facts separate from a session transcript a
|
|
|
342
373
|
|
|
343
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.
|
|
344
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
|
+
|
|
345
378
|
### Profiles
|
|
346
379
|
|
|
347
380
|
```bash
|
|
@@ -392,6 +425,10 @@ pi skills disable my-skill
|
|
|
392
425
|
pi skills enable my-skill
|
|
393
426
|
pi extensions disable my-extension
|
|
394
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
|
|
395
432
|
|
|
396
433
|
# Enable or disable an installed package for the selected profile
|
|
397
434
|
pi packages disable npm:some-package
|
|
@@ -536,7 +573,11 @@ npm install
|
|
|
536
573
|
npm run build:web
|
|
537
574
|
```
|
|
538
575
|
|
|
539
|
-
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`.
|
|
540
581
|
|
|
541
582
|
## Security
|
|
542
583
|
|
|
@@ -561,6 +602,8 @@ pi-feats/
|
|
|
561
602
|
│ ├── cli-resources.ts
|
|
562
603
|
│ ├── profiles.ts
|
|
563
604
|
│ └── sequential-workflow.ts
|
|
605
|
+
├── themes/
|
|
606
|
+
│ └── legacy-dark.json # Pre-bf8e4b95 Pi dark palette
|
|
564
607
|
└── README.md
|
|
565
608
|
```
|
|
566
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
|
|
|
@@ -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 | 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
|
+
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 };
|
|
@@ -243,14 +244,72 @@ export class ApplicationRuntime {
|
|
|
243
244
|
this.coalescingBuffers.clear();
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
|
|
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);
|
|
247
306
|
const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
|
|
248
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(" ")) } };
|
|
249
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; } };
|
|
250
309
|
try {
|
|
251
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); }
|
|
252
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)));
|
|
253
|
-
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) };
|
|
254
313
|
const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
|
|
255
314
|
const sessionId = await this.resolveSession(profile!, sessionPrefix!);
|
|
256
315
|
let handoff: string | undefined;
|
|
@@ -278,6 +337,8 @@ export function validateApplicationSettings(raw: unknown): asserts raw is Settin
|
|
|
278
337
|
if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
|
|
279
338
|
if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
|
|
280
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 });
|
|
281
342
|
const coalescing = settings.messageCoalescing;
|
|
282
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 });
|
|
283
344
|
}
|
|
@@ -6,8 +6,8 @@ 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, 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;"); }
|
|
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)!; }
|
|
@@ -30,12 +30,12 @@ export class ApplicationStore {
|
|
|
30
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)!; }
|
|
31
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."); }
|
|
32
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; }
|
|
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; }
|
|
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) }; }
|
|
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
35
|
// Session IDs omit the profile name and are globally unique in SQLite, so
|
|
36
36
|
// their sequence must be shared by profiles that use the same Application/prefix.
|
|
37
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 (
|
|
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)!; }
|
|
39
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); }
|
|
40
40
|
activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
|
|
41
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() }); }); }
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { copyFile, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { copyFile, cp, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import { ensureProfileSandbox } from "../lib/profile-sandbox.ts";
|
|
5
5
|
import { BUILTIN_TOOLS, activeBuiltinTools, updatedBuiltinTools } from "../lib/builtin-tools.ts";
|
|
6
|
+
import { packageExtensions } from "../lib/package-resources.ts";
|
|
6
7
|
|
|
7
8
|
export { BUILTIN_TOOLS } from "../lib/builtin-tools.ts";
|
|
8
9
|
export const RESOURCE_KINDS = ["tools", "skills", "extensions"] as const;
|
|
@@ -10,12 +11,24 @@ export type ResourceKind = typeof RESOURCE_KINDS[number];
|
|
|
10
11
|
type PolicyKey = "enabledTools" | "enabledSkills" | "enabledProfileSkills" | "enabledExtensions";
|
|
11
12
|
type SkillSources = { shared?: boolean; profile?: boolean };
|
|
12
13
|
type SkillResourceSource = "shared" | "profile" | "package";
|
|
13
|
-
type
|
|
14
|
+
export type ProfileMetadata = { description: string; tags: string[] };
|
|
15
|
+
type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & Partial<ProfileMetadata> & { skillSources?: SkillSources; contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } };
|
|
14
16
|
export type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy; sandbox?: boolean };
|
|
15
17
|
export type Resource = { name: string; kind: ResourceKind; path?: string; source: "builtin" | "shared" | "profile" | "extension" | "package"; package?: string; enabled: boolean; protected?: boolean };
|
|
16
18
|
export type Package = { name: string; version?: string; description?: string; enabled: boolean; installed: boolean };
|
|
17
19
|
|
|
18
20
|
const validName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name);
|
|
21
|
+
const maxProfileDescriptionLength = 500;
|
|
22
|
+
const maxProfileTags = 20;
|
|
23
|
+
const maxProfileTagLength = 50;
|
|
24
|
+
const profileMetadata = (value: unknown): ProfileMetadata => {
|
|
25
|
+
const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
26
|
+
if (input.description !== undefined && (typeof input.description !== "string" || input.description.length > maxProfileDescriptionLength)) throw Object.assign(new Error(`description must be a string of at most ${maxProfileDescriptionLength} characters.`), { status: 400 });
|
|
27
|
+
if (input.tags !== undefined && (!Array.isArray(input.tags) || input.tags.some((tag) => typeof tag !== "string"))) throw Object.assign(new Error("tags must be an array of strings."), { status: 400 });
|
|
28
|
+
const tags = [...new Map((input.tags as string[] | undefined ?? []).map((tag) => tag.trim()).filter(Boolean).map((tag) => [tag.toLocaleLowerCase(), tag])).values()];
|
|
29
|
+
if (tags.length > maxProfileTags || tags.some((tag) => tag.length > maxProfileTagLength)) throw Object.assign(new Error(`Use at most ${maxProfileTags} tags of at most ${maxProfileTagLength} characters.`), { status: 400 });
|
|
30
|
+
return { description: (input.description as string | undefined ?? "").trim(), tags };
|
|
31
|
+
};
|
|
19
32
|
const policyKey: Record<ResourceKind, PolicyKey> = {
|
|
20
33
|
tools: "enabledTools",
|
|
21
34
|
skills: "enabledSkills",
|
|
@@ -80,14 +93,13 @@ export class ProfileStore {
|
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
async list(): Promise<Array<{ name: string; path: string }>> {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
96
|
+
async list(): Promise<Array<{ name: string; path: string } & ProfileMetadata>> {
|
|
97
|
+
const names = ["default"];
|
|
98
|
+
if (existsSync(this.profilesDir())) for (const entry of await readdir(this.profilesDir(), { withFileTypes: true })) if (entry.isDirectory() && existsSync(join(this.profilesDir(), entry.name, "settings.json"))) names.push(entry.name);
|
|
99
|
+
const profiles = await Promise.all(names.map(async (name) => {
|
|
100
|
+
const settings = await this.readSettings(name);
|
|
101
|
+
return { name, path: this.directory(name), ...profileMetadata(settings.profile) };
|
|
102
|
+
}));
|
|
91
103
|
return profiles.sort((a, b) => a.name.localeCompare(b.name));
|
|
92
104
|
}
|
|
93
105
|
|
|
@@ -183,11 +195,7 @@ export class ProfileStore {
|
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
private async packageExtensions(settings: ProfileSettings): Promise<Array<{ name: string; path: string; package: string }>> {
|
|
186
|
-
|
|
187
|
-
for (const pkg of await this.configuredPackages(settings)) {
|
|
188
|
-
for (const extension of Array.isArray(pkg.manifest?.pi?.extensions) ? pkg.manifest.pi.extensions : []) if (typeof extension === "string") entries.push({ name: extension.split("/").pop()?.replace(/\.(?:ts|js)$/, "") ?? pkg.name, path: join(pkg.base, extension), package: pkg.name });
|
|
189
|
-
}
|
|
190
|
-
return entries;
|
|
198
|
+
return packageExtensions(await this.configuredPackages(settings)).map(({ name, path, packageName }) => ({ name, path, package: packageName }));
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
private async packageToolSources(settings: ProfileSettings, names: Iterable<string>): Promise<Map<string, string>> {
|
|
@@ -323,28 +331,67 @@ export class ProfileStore {
|
|
|
323
331
|
await this.refreshSkills(profile);
|
|
324
332
|
}
|
|
325
333
|
|
|
326
|
-
async create(name: string): Promise<{ name: string; path: string }> {
|
|
334
|
+
async create(name: string, metadata: unknown = {}, cloneFrom?: string): Promise<{ name: string; path: string } & ProfileMetadata> {
|
|
327
335
|
if (!validName(name) || name === "default") throw Object.assign(new Error("Invalid profile name"), { status: 400 });
|
|
328
336
|
const destination = this.directory(name);
|
|
329
337
|
if (existsSync(destination)) throw Object.assign(new Error("Profile already exists"), { status: 409 });
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
338
|
+
const details = profileMetadata(metadata);
|
|
339
|
+
if (cloneFrom !== undefined && (typeof cloneFrom !== "string" || !validName(cloneFrom) || cloneFrom === "default")) throw Object.assign(new Error("Clone source must be an existing named profile."), { status: 400 });
|
|
340
|
+
|
|
341
|
+
let sourceSettings: ProfileSettings | undefined;
|
|
342
|
+
let sourceDirectory: string | undefined;
|
|
343
|
+
if (cloneFrom) {
|
|
344
|
+
sourceSettings = await this.readSettings(cloneFrom);
|
|
345
|
+
sourceDirectory = this.directory(cloneFrom);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await mkdir(join(destination, "sessions"), { recursive: true });
|
|
350
|
+
let settings: ProfileSettings;
|
|
351
|
+
if (sourceSettings && sourceDirectory) {
|
|
352
|
+
const sourceSkills = join(sourceDirectory, "skills");
|
|
353
|
+
if (existsSync(sourceSkills)) await cp(sourceSkills, join(destination, "skills"), { recursive: true, force: false });
|
|
354
|
+
for (const file of ["SOUL.md", "REFINE.md", "guardrails.json", ".env", "auth.json"]) {
|
|
355
|
+
const source = join(sourceDirectory, file);
|
|
356
|
+
if (existsSync(source)) await copyFile(source, join(destination, file));
|
|
357
|
+
}
|
|
358
|
+
settings = {
|
|
359
|
+
...sourceSettings,
|
|
360
|
+
sandbox: true,
|
|
361
|
+
profile: { ...(sourceSettings.profile ?? {}), ...details },
|
|
362
|
+
};
|
|
363
|
+
} else {
|
|
364
|
+
let base: ProfileSettings = {};
|
|
365
|
+
try { base = await this.readSettings("default"); } catch {}
|
|
366
|
+
const { packages: _packages, extensions: _extensions, ...profileBase } = base;
|
|
367
|
+
settings = {
|
|
368
|
+
...profileBase,
|
|
369
|
+
defaultTools: BUILTIN_TOOLS,
|
|
370
|
+
sandbox: true,
|
|
371
|
+
profile: { enabledTools: ["*"], enabledSkills: ["*"], enabledProfileSkills: ["*"], skillSources: { shared: true, profile: false }, ...details },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
await this.writeSettings(name, settings);
|
|
375
|
+
await ensureProfileSandbox(destination, resolve(process.argv[1]));
|
|
376
|
+
if (!sourceSettings) {
|
|
377
|
+
await writeFile(join(destination, "SOUL.md"), "", "utf8");
|
|
378
|
+
await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n", "utf8");
|
|
379
|
+
const auth = join(this.agentDir, "auth.json");
|
|
380
|
+
if (existsSync(auth)) await copyFile(auth, join(destination, "auth.json"));
|
|
381
|
+
}
|
|
382
|
+
await this.linkSharedModels(name);
|
|
383
|
+
return { name, path: destination, ...details };
|
|
384
|
+
} catch (error) {
|
|
385
|
+
await rm(destination, { recursive: true, force: true });
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async updateMetadata(name: string, metadata: unknown): Promise<{ name: string; path: string } & ProfileMetadata> {
|
|
391
|
+
const details = profileMetadata(metadata);
|
|
392
|
+
const settings = await this.readSettings(name);
|
|
393
|
+
await this.writeSettings(name, { ...settings, profile: { ...(settings.profile ?? {}), ...details } });
|
|
394
|
+
return { name, path: this.directory(name), ...details };
|
|
348
395
|
}
|
|
349
396
|
|
|
350
397
|
async delete(name: string): Promise<void> {
|