pi-feats 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -5
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +16 -6
- package/extensions/api-server/application-store.ts +21 -8
- package/extensions/api-server/profile-store.ts +33 -10
- package/extensions/api-server/server.ts +43 -4
- package/extensions/cli-resources.ts +20 -20
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- 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/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/console-page.tsx +3 -0
- package/extensions/pi-console-webui/components/console.tsx +9 -5
- 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 +5 -5
- package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +3 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +53 -17
- package/extensions/pulse/index.ts +128 -14
- package/extensions/pulse/store.ts +108 -16
- package/package.json +3 -1
- package/skills/profile-ssh/SKILL.md +65 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
- [Glossary](#glossary)
|
|
12
12
|
- [Features](#features)
|
|
13
|
+
- [Context Memory](#context-memory)
|
|
13
14
|
- [Command cookbook](#command-cookbook)
|
|
14
15
|
- [Included extensions](#included-extensions)
|
|
15
16
|
- [Installation](#installation)
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
| **Identity Key mapping** | A rule that maps an external identity to an Application profile and session strategy. A single `*` mapping can be an explicit fallback. |
|
|
29
30
|
| **Handoff** | Private context retained when an Application rolls a conversation into a replacement session. |
|
|
30
31
|
| **Guardrail** | An ordered policy instruction executed at an input, tool, or output lifecycle stage. |
|
|
32
|
+
| **Context Memory** | Bounded persistent operational and personal context loaded when a new session starts. |
|
|
31
33
|
| **Shared Skill** | A Skill stored in the main Pi agent directory and selectively exposed to profiles. |
|
|
32
34
|
| **Profile Skill** | A Skill stored in one profile directory and unavailable to other profiles unless explicitly copied or imported. |
|
|
33
35
|
| **Skill Source** | A Git-backed staging catalog. Synchronizing it discovers Skills but does not activate them. |
|
|
@@ -56,6 +58,21 @@ Pi Coding Agent intentionally keeps its core lightweight and extensible. Pi Feat
|
|
|
56
58
|
- Limit profile tools, shared Skills, and profile Skills through profile policy.
|
|
57
59
|
- Keep extensions and packages in the default runtime; named profiles load those shared resources read-only and never clone or install them.
|
|
58
60
|
- Load a profile-specific `SOUL.md` into every agent turn.
|
|
61
|
+
- Select the default provider and model for each profile from the Console. After changing it, administrators can roll over active Application sessions so subsequent conversations use the new model.
|
|
62
|
+
|
|
63
|
+
### Profile SSH
|
|
64
|
+
|
|
65
|
+
Profile SSH is an optional, isolated host-registration mechanism. It complements normal SSH rather than replacing a user's global `~/.ssh/config`, agent, or keys.
|
|
66
|
+
|
|
67
|
+
- Register, list, and delete SSH hosts for the active profile with `pi ssh add|list|delete <alias>`, or target a named profile with `pi profile <profile> ssh ...`.
|
|
68
|
+
- Store aliases, keys, and known-host entries under `<PI_CODING_AGENT_DIR>/.ssh/`, keeping profile credentials separate from the host user's SSH configuration.
|
|
69
|
+
- Require a hostname and IP address, display the scanned server fingerprint for explicit confirmation, and keep strict host-key verification enabled.
|
|
70
|
+
- Generate Ed25519 keys for modern servers and RSA-4096 keys with legacy compatibility settings for older OpenSSH servers. The setup validates key-based access before saving; when needed, it invokes `ssh-copy-id` interactively once with a profile-isolated home directory.
|
|
71
|
+
- Connect to a registered host explicitly through its profile configuration:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ssh -F "$PI_CODING_AGENT_DIR/.ssh/config" <alias>
|
|
75
|
+
```
|
|
59
76
|
|
|
60
77
|
### Remote Hosts
|
|
61
78
|
|
|
@@ -109,7 +126,7 @@ Applications are the integration boundary between Pi and external systems. Use o
|
|
|
109
126
|
```
|
|
110
127
|
|
|
111
128
|
- Normalize provider-specific payloads in an inbound handler, enrich or redact data through transforms, and shape integration responses in an outbound handler.
|
|
112
|
-
- Route each external identity to a profile and session strategy through explicit Identity Key mappings. Mappings may use automatic sessions or fixed prefixes
|
|
129
|
+
- Route each external identity to a profile and session strategy through explicit Identity Key mappings. Mappings may use automatic sessions or fixed prefixes, or intentionally ignore an identity (`None` in the Console) after inbound handling without calling Pi. Exact mappings take precedence, so an ignore mapping can exclude an identity even when a single `*` wildcard fallback exists.
|
|
113
130
|
- Choose acknowledgement mode for asynchronous webhook-style work or result mode when an integration needs the completed response.
|
|
114
131
|
- Inspect active sessions, roll over a conversation, preserve private handoff context, test handlers with a JSON payload, and stream or clear application logs from the Console or API.
|
|
115
132
|
- Include an Evolution API adapter implementation while keeping Applications structurally independent from adapters.
|
|
@@ -163,7 +180,7 @@ curl \
|
|
|
163
180
|
What the API exposes:
|
|
164
181
|
|
|
165
182
|
- Persistent chat and streaming chat sessions, including profile-specific sessions.
|
|
166
|
-
- Profile lifecycle, settings, `SOUL.md`, environment variables, Guardrails, tools, Skills, and sessions.
|
|
183
|
+
- Profile lifecycle, settings, default model selection, `SOUL.md`, environment variables, Guardrails, Context Memory, tools, Skills, and sessions.
|
|
167
184
|
- Runtime-wide extension and package inventory and configuration.
|
|
168
185
|
- Pulse schedules and execution history.
|
|
169
186
|
- Git-backed Skill Sources, document preview, synchronization, and explicit profile imports.
|
|
@@ -189,8 +206,9 @@ pi console stop
|
|
|
189
206
|
|
|
190
207
|
The console provides dedicated workspaces for:
|
|
191
208
|
|
|
192
|
-
- **Profiles:** settings, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
|
|
193
|
-
- **
|
|
209
|
+
- **Profiles:** settings, default model selection, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
|
|
210
|
+
- **Context Memory:** personal-context source selection, persistent Markdown memory editing, and Application handler creation and testing.
|
|
211
|
+
- **Applications:** configuration, identity mappings (including `None`/ignore rules), active sessions, handler editor, transform testing, rollover, handoff, and live logs.
|
|
194
212
|
- **Skill Sources:** source registration, Git synchronization, Skill preview, and deliberate import into a profile.
|
|
195
213
|
- **Pulse:** schedules, enablement, run history, and profile/session association.
|
|
196
214
|
- **Operations:** API Server and Console configuration, service controls, and resource visibility.
|
|
@@ -239,7 +257,7 @@ Skills remain explicit resources rather than hidden package behavior. A profile
|
|
|
239
257
|
~/.pi/agent/profiles/<profile>/skills/<skill>/SKILL.md
|
|
240
258
|
```
|
|
241
259
|
|
|
242
|
-
- **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile.
|
|
260
|
+
- **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile. Skills declared by installed shared-runtime packages are also discovered for named profiles and follow the same shared-Skill policy and allowlist.
|
|
243
261
|
- **Profile Skills** live under `~/.pi/agent/profiles/<profile>/skills/` and remain isolated from other profiles.
|
|
244
262
|
- Profile policy controls whether shared and profile-local sources are available and can allow all Skills or a named allowlist.
|
|
245
263
|
- List and enable or disable Skills from the CLI and Console without editing settings by hand.
|
|
@@ -275,6 +293,7 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
275
293
|
|
|
276
294
|
- Create and manage persistent scheduled prompts, reminders, cron jobs, one-time jobs, and heartbeat jobs.
|
|
277
295
|
- Bind scheduled work to the active profile and conversation session.
|
|
296
|
+
- Set `insertIntoApiSession: true` when a Pulse result should also be recorded in every active Application session for the same profile. This adds context only: it does not invoke an outbound handler or send a message through an Application channel.
|
|
278
297
|
- Persist schedules and execution history in SQLite with WAL support.
|
|
279
298
|
- Start and inspect the scheduler:
|
|
280
299
|
|
|
@@ -282,8 +301,21 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
282
301
|
pi pulse start
|
|
283
302
|
pi pulse status
|
|
284
303
|
pi pulse list
|
|
304
|
+
# After confirming an interrupted worker is no longer running:
|
|
305
|
+
pi pulse recover
|
|
285
306
|
```
|
|
286
307
|
|
|
308
|
+
### Context Memory
|
|
309
|
+
|
|
310
|
+
Context Memory keeps compact, durable facts separate from a session transcript and injects them when a new session starts. It is intended for stable operational context and personal context, not an unbounded conversation archive.
|
|
311
|
+
|
|
312
|
+
- Each profile can maintain shared operational context in `context-memory/OPERATIONAL.md` (maximum 2,750 characters).
|
|
313
|
+
- Personal context is configurable as a profile-wide `context-memory/PROFILE.md`, an Application-identity-specific `applications/<application>/context-memory/identities/<identity>/USER.md`, or a TypeScript Application handler that resolves it dynamically. Profile and identity memory are limited to 1,375 characters.
|
|
314
|
+
- Identity memory is available only in an Application session and is isolated by Application and Identity Key.
|
|
315
|
+
- The `context_memory` tool supports `read`, `insert`, `update`, `remove`, and `replace` actions against `operational` or `personal` memory without exposing file paths to the agent. Personal memory writes to the configured profile or identity target; storing sensitive values requires explicit confirmation.
|
|
316
|
+
- Dynamic handlers must return Markdown, run only for Application sessions, have a three-second timeout, and can be created, edited, and tested from the Console. Handler execution is recorded in Application logs.
|
|
317
|
+
- Stored memory is presented to the model as data rather than instructions. Changes apply to newly started sessions.
|
|
318
|
+
|
|
287
319
|
### CLI resource management
|
|
288
320
|
|
|
289
321
|
- Inspect tools, Skills, extensions, sessions, packages, and profile resources from the terminal.
|
|
@@ -330,6 +362,11 @@ pi profile support extensions list
|
|
|
330
362
|
pi profile support sessions list
|
|
331
363
|
pi profile support packages list
|
|
332
364
|
|
|
365
|
+
# Register, list, or delete hosts scoped to this profile
|
|
366
|
+
pi profile support ssh add production-db
|
|
367
|
+
pi profile support ssh list
|
|
368
|
+
pi profile support ssh delete production-db
|
|
369
|
+
|
|
333
370
|
# Resume the latest session or open a specific one
|
|
334
371
|
pi profile resume support
|
|
335
372
|
pi profile open support <session-id>
|
|
@@ -423,6 +460,7 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
|
|
|
423
460
|
| API Server | HTTP API, Applications, profile administration, sessions, logs, and terminal tickets. |
|
|
424
461
|
| Browser Harness | Steel Profile-scoped CDP browser automation for active Pi Profiles. |
|
|
425
462
|
| CLI Resources | Resource, package, session, and profile commands. |
|
|
463
|
+
| Context Memory | Persistent operational and personal context for new sessions. |
|
|
426
464
|
| Guardrails | Input, tool, and output policy stages. |
|
|
427
465
|
| Pi Console WebUI | Browser-based operations console and terminal client. |
|
|
428
466
|
| Profiles | Profile lifecycle, policy, sandbox, and remote routing. |
|
|
@@ -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 = {
|
|
@@ -10,7 +10,7 @@ import { ApplicationLogStore } from "./application-log-store.ts";
|
|
|
10
10
|
type RecordValue = Record<string, any>;
|
|
11
11
|
export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
12
12
|
type Handler = (payload: RecordValue, headers?: RecordValue | null, query?: RecordValue | null, context?: RecordValue | null, state?: RecordValue | null, env?: Record<string, string | undefined>) => Promise<any> | any;
|
|
13
|
-
type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string, 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>> };
|
|
13
|
+
type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string | null; sessionMode: "fixed" | "automatic" | "ignore"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
|
|
14
14
|
type Handoff = { sourceSessionId: string | null; summary: string; createdAt: string; targetCreatedAt: string };
|
|
15
15
|
type WorkingSession = { sessionId: string; updatedAt: string; reason: "message" | "rollover" };
|
|
16
16
|
type CoalescedEntry = { call: ReturnType<ApplicationLogStore["start"]>; stage: ReturnType<ApplicationLogStore["stage"]>; message: string; state: RecordValue; version: number; dispatch: (message: string, state: RecordValue) => Promise<unknown>; resolve: (output: unknown) => void; reject: (error: unknown) => void };
|
|
@@ -95,12 +95,22 @@ export class ApplicationRuntime {
|
|
|
95
95
|
try { return await operation(); } finally { release(); if (this.queues.get(key) === current) this.queues.delete(key); }
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
private async createApplicationSession(profile: string, sessionPrefix: string, rollover: boolean): Promise<string> {
|
|
99
|
+
const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
|
|
100
|
+
// A process can stop after Pi has persisted the session file but before the
|
|
101
|
+
// Application row is written. Reclaim that deterministic orphan instead of
|
|
102
|
+
// attempting to create it again and permanently failing with a 409.
|
|
103
|
+
const exists = (await this.api.listSessions(profile)).some((session) => session.id === id);
|
|
104
|
+
if (!exists) await this.api.createSession(profile, { id });
|
|
105
|
+
await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, rollover);
|
|
106
|
+
return id;
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
private async resolveSession(profile: string, sessionPrefix: string): Promise<string> {
|
|
99
110
|
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 422 });
|
|
100
111
|
return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
|
|
101
112
|
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;
|
|
113
|
+
return this.createApplicationSession(profile, sessionPrefix, false);
|
|
104
114
|
});
|
|
105
115
|
}
|
|
106
116
|
|
|
@@ -176,8 +186,8 @@ export class ApplicationRuntime {
|
|
|
176
186
|
if (typeof sessionPrefix !== "string" || !sessionPrefix) throw Object.assign(new Error("The 'sessionPrefix' field is required."), { status: 400 });
|
|
177
187
|
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix"), { status: 400 });
|
|
178
188
|
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
|
-
|
|
189
|
+
const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.createApplicationSession(profile, sessionPrefix, true);
|
|
190
|
+
if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
|
|
181
191
|
try {
|
|
182
192
|
const handoff = await this.handoff(profile, sessionId, sessionPrefix, true);
|
|
183
193
|
return { profile, sessionId, messageCount: 0, handoff: { status: handoff?.summary ? "ready" : "none", sourceSessionId: handoff?.sourceSessionId ?? null } };
|
|
@@ -240,7 +250,7 @@ export class ApplicationRuntime {
|
|
|
240
250
|
try {
|
|
241
251
|
for (const name of this.settings.transformHandlers ?? []) { const result = await run("transform", `Transform: ${name}`, current, async () => this.handler("transform", name)(current, headers, query, context, state, await this.environmentFor(current, payload, query))); if (result && typeof result === "object" && "payload" in result) { current = asRecord(result.payload); state = { ...state, ...asRecord(result.state) }; } else current = asRecord(result); }
|
|
242
252
|
const inbound = await run("inbound-handler", `Inbound handler: ${this.settings.inboundHandler}`, current, async () => this.handler("inbound", this.settings.inboundHandler)(current, headers, query, context, state, await this.environmentFor(current, payload, query)));
|
|
243
|
-
const routed = asRecord(inbound); const hasIdentityKey = Object.prototype.hasOwnProperty.call(routed, "identityKey"); const identityKey = typeof routed.identityKey === "string" ? routed.identityKey.trim() : undefined; if (hasIdentityKey && !identityKey) throw Object.assign(new Error("identityKey must be a non-empty string when provided."), { status: 422 }); let profile: string | undefined, sessionPrefix: string | null | undefined; if (identityKey) { const mapped = await this.api.getIdentityMapping(this.application.slug, identityKey); if (!mapped)
|
|
253
|
+
const routed = asRecord(inbound); const hasIdentityKey = Object.prototype.hasOwnProperty.call(routed, "identityKey"); const identityKey = typeof routed.identityKey === "string" ? routed.identityKey.trim() : undefined; if (hasIdentityKey && !identityKey) throw Object.assign(new Error("identityKey must be a non-empty string when provided."), { status: 422 }); let profile: string | undefined, sessionPrefix: string | null | undefined; if (identityKey) { const mapped = await this.api.getIdentityMapping(this.application.slug, identityKey); if (!mapped) return this.logs.ignore(call, identityKey, "identity_miss"); if (mapped.sessionMode === "ignore") return this.logs.ignore(call, identityKey, "identity_mapping_none"); profile = mapped.profile ?? undefined; sessionPrefix = mapped.sessionMode === "automatic" ? await this.api.automaticApplicationSessionPrefix(this.application.slug, identityKey) : mapped.sessionPrefix; if (!sessionPrefix) throw Object.assign(new Error("The Identity Key mapping has no session prefix."), { status: 422 }); } else { profile = typeof routed.profile === "string" && routed.profile.trim() ? routed.profile.trim() : undefined; sessionPrefix = typeof (routed.sessionPrefix ?? routed.session_prefix) === "string" ? String(routed.sessionPrefix ?? routed.session_prefix).trim() || undefined : undefined; if (!profile) throw Object.assign(new Error("Inbound must return profile when identityKey is not provided."), { status: 422 }); sessionPrefix ??= "default"; } if (!profile || !sessionPrefix) throw Object.assign(new Error("No Application route was resolved."), { status: 422 }); const message = asRecord(routed.payload).message; if (typeof message !== "string" || !message.trim()) throw Object.assign(new Error("The Application did not produce a text message."), { status: 422 }); state = { ...state, ...asRecord(routed.state) };
|
|
244
254
|
const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
|
|
245
255
|
const sessionId = await this.resolveSession(profile!, sessionPrefix!);
|
|
246
256
|
let handoff: string | undefined;
|
|
@@ -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, UNIQUE(application_slug, profile, session_prefix, session_date, sequence)); CREATE TABLE IF NOT EXISTS application_session_state (application_slug TEXT NOT NULL, profile TEXT NOT NULL, session_prefix TEXT NOT NULL, current_session_id TEXT NOT NULL, updated_at TEXT NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(application_slug, profile, session_prefix)); CREATE TABLE IF NOT EXISTS application_identity_mappings (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, profile TEXT, session_mode TEXT NOT NULL DEFAULT 'fixed', session_prefix TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(application_slug, identity_key)); CREATE TABLE IF NOT EXISTS application_automatic_prefixes (application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, session_prefix TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(application_slug, identity_key), UNIQUE(application_slug, session_prefix)); CREATE TABLE IF NOT EXISTS application_console_sessions (session_id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, profile TEXT NOT NULL, identity_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);`); this.migrate(); }
|
|
10
|
+
private migrate() { const tables = new Set((this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Row[]).map((row) => String(row.name))); if (tables.has("application_from_mappings")) { if (!tables.has("application_identity_mappings")) this.db.exec("ALTER TABLE application_from_mappings RENAME TO application_identity_mappings; ALTER TABLE application_identity_mappings RENAME COLUMN from_identifier TO identity_key;"); else this.db.exec("INSERT OR IGNORE INTO application_identity_mappings (id,application_slug,identity_key,profile,session_mode,session_prefix,created_at,updated_at) SELECT id,application_slug,from_identifier,profile,'fixed',session_prefix,created_at,updated_at FROM application_from_mappings; DROP TABLE application_from_mappings;"); } const columns = (table: string) => new Set((this.db.prepare(`PRAGMA table_info(${table})`).all() as Row[]).map((row) => String(row.name))); const mapping = columns("application_identity_mappings"); if (!mapping.has("session_mode")) this.db.exec("ALTER TABLE application_identity_mappings ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'fixed';"); if (mapping.has("profile") && (this.db.prepare("PRAGMA table_info(application_identity_mappings)").all() as Row[]).find((row) => String(row.name) === "profile")?.notnull) this.db.exec("BEGIN; CREATE TABLE application_identity_mappings_next (id TEXT PRIMARY KEY, application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, profile TEXT, session_mode TEXT NOT NULL DEFAULT 'fixed', session_prefix TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(application_slug, identity_key)); INSERT INTO application_identity_mappings_next SELECT id,application_slug,identity_key,profile,session_mode,session_prefix,created_at,updated_at FROM application_identity_mappings; DROP TABLE application_identity_mappings; ALTER TABLE application_identity_mappings_next RENAME TO application_identity_mappings; COMMIT;"); const sessions = columns("application_sessions"); if (sessions.has("last_from") && !sessions.has("last_identity_key")) this.db.exec("ALTER TABLE application_sessions RENAME COLUMN last_from TO last_identity_key;"); }
|
|
11
11
|
private value(row: Row): ApplicationRecord { return { id: String(row.id), name: String(row.name), slug: String(row.slug), enabled: Boolean(row.enabled), responseMode: row.response_mode as "ack" | "result", defaultProfile: row.default_profile == null ? null : String(row.default_profile), routingPolicy: row.routing_policy as "default_as_fallback" | "drop", settings: JSON.parse(String(row.settings_json)), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; }
|
|
12
12
|
list() { return (this.db.prepare("SELECT * FROM applications ORDER BY name").all() as Row[]).map((row) => this.value(row)); } get(slug: string) { const row = this.db.prepare("SELECT * FROM applications WHERE slug=?").get(slug) as Row | undefined; return row ? this.value(row) : undefined; }
|
|
13
13
|
create(input: Pick<ApplicationRecord, "name" | "slug" | "enabled" | "responseMode" | "defaultProfile" | "routingPolicy" | "settings">) { const timestamp = now(), record = { ...input, id: randomUUID(), createdAt: timestamp, updatedAt: timestamp }; this.db.prepare("INSERT INTO applications VALUES (?,?,?,?,?,?,?,?,?,?)").run(record.id, record.name, record.slug, record.enabled ? 1 : 0, record.responseMode, record.defaultProfile, record.routingPolicy, JSON.stringify(record.settings), timestamp, timestamp); return this.get(record.slug)!; }
|
|
14
14
|
update(slug: string, input: Omit<ApplicationRecord, "id" | "slug" | "createdAt" | "updatedAt">) { const result = this.db.prepare("UPDATE applications SET name=?,enabled=?,response_mode=?,default_profile=?,routing_policy=?,settings_json=?,updated_at=? WHERE slug=?").run(input.name, input.enabled ? 1 : 0, input.responseMode, input.defaultProfile, input.routingPolicy, JSON.stringify(input.settings), now(), slug); if (!result.changes) throw new Error("Application not found."); return this.get(slug)!; }
|
|
15
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
33
|
session(slug: string, profile: string, prefix: string) { const row = this.db.prepare("SELECT s.* FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id WHERE st.application_slug=? AND st.profile=? AND st.session_prefix=?").get(slug, profile, prefix) as Row | undefined; return row ? this.sessionValue(row) : undefined; }
|
|
23
34
|
private sessionValue(row: Row) { return { application: String(row.application_slug), profile: String(row.profile), sessionPrefix: String(row.session_prefix), sessionId: String(row.session_id), sessionDate: String(row.session_date), sequence: Number(row.sequence), createdAt: String(row.created_at), updatedAt: String(row.updated_at), lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), messageCount: Number(row.message_count), createdFromRollover: Boolean(row.created_from_rollover), lastIdentityKey: row.last_identity_key == null ? null : String(row.last_identity_key) }; }
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
// Session IDs omit the profile name and are globally unique in SQLite, so
|
|
36
|
+
// their sequence must be shared by profiles that use the same Application/prefix.
|
|
37
|
+
nextSessionId(slug: string, profile: string, prefix: string) { void profile; const date = new Date().toISOString().slice(0, 10), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); return `${slug}_${prefix}_${date}-${String(sequence).padStart(2, "0")}`; }
|
|
38
|
+
createSession(slug: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { const date = new Date().toISOString().slice(0, 10), timestamp = now(), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); this.db.prepare("INSERT INTO application_sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(randomUUID(), slug, profile, prefix, sessionId, date, sequence, timestamp, timestamp, null, 0, rollover ? 1 : 0, null); this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix)!; }
|
|
26
39
|
touchSession(slug: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { const timestamp = now(); this.db.prepare("UPDATE application_sessions SET updated_at=?,last_message_at=?,message_count=message_count+1,last_identity_key=COALESCE(?,last_identity_key) WHERE application_slug=? AND profile=? AND session_prefix=? AND session_id=?").run(timestamp, timestamp, identityKey ?? null, slug, profile, prefix, sessionId); this.db.prepare("UPDATE application_session_state SET updated_at=?,reason='message' WHERE application_slug=? AND profile=? AND session_prefix=?").run(timestamp, slug, profile, prefix); }
|
|
27
40
|
activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
|
|
28
41
|
removeActiveSession(slug: string, profile: string, sessionId: string) { const session = this.db.prepare("SELECT session_prefix FROM application_sessions WHERE application_slug=? AND profile=? AND session_id=?").get(slug, profile, sessionId) as Row | undefined; if (!session) throw Object.assign(new Error("Application session not found."), { status: 404 }); if (!this.db.prepare("DELETE FROM application_session_state WHERE application_slug=? AND profile=? AND session_prefix=? AND current_session_id=?").run(slug, profile, String(session.session_prefix), sessionId).changes) throw Object.assign(new Error("Application session is no longer active."), { status: 409 }); }
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { copyFile, 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
|
+
import { BUILTIN_TOOLS, activeBuiltinTools, updatedBuiltinTools } from "../lib/builtin-tools.ts";
|
|
5
6
|
|
|
6
|
-
export
|
|
7
|
+
export { BUILTIN_TOOLS } from "../lib/builtin-tools.ts";
|
|
7
8
|
export const RESOURCE_KINDS = ["tools", "skills", "extensions"] as const;
|
|
8
9
|
export type ResourceKind = typeof RESOURCE_KINDS[number];
|
|
9
10
|
type PolicyKey = "enabledTools" | "enabledSkills" | "enabledProfileSkills" | "enabledExtensions";
|
|
@@ -24,12 +25,13 @@ const protectedExtensions = new Set(["profiles", "api-server"]);
|
|
|
24
25
|
|
|
25
26
|
export class ProfileStore {
|
|
26
27
|
private readonly extensionTools = new Map<string, Set<string>>();
|
|
28
|
+
readonly agentDir: string;
|
|
27
29
|
|
|
28
|
-
constructor(
|
|
30
|
+
constructor(agentDir: string) { this.agentDir = agentDir; }
|
|
29
31
|
|
|
30
32
|
registerExtensionTools(profile: string, names: Iterable<string>): void {
|
|
31
33
|
const tools = this.extensionTools.get(profile) ?? new Set<string>();
|
|
32
|
-
for (const name of names) if (name && !BUILTIN_TOOLS.includes(name)) tools.add(name);
|
|
34
|
+
for (const name of names) if (name && !(BUILTIN_TOOLS as readonly string[]).includes(name)) tools.add(name);
|
|
33
35
|
this.extensionTools.set(profile, tools);
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -49,6 +51,13 @@ export class ProfileStore {
|
|
|
49
51
|
return join(this.profilesDir(), name);
|
|
50
52
|
}
|
|
51
53
|
private settingsPath(name: string): string { return join(this.directory(name), "settings.json"); }
|
|
54
|
+
private async linkSharedModels(name: string): Promise<void> {
|
|
55
|
+
if (name === "default") return;
|
|
56
|
+
const source = join(this.agentDir, "models.json"), target = join(this.directory(name), "models.json");
|
|
57
|
+
try { if (resolve(this.directory(name), await readlink(target)) === source) return; } catch { /* Missing or non-symlink target must be replaced. */ }
|
|
58
|
+
await rm(target, { force: true });
|
|
59
|
+
if (existsSync(source)) await symlink(source, target);
|
|
60
|
+
}
|
|
52
61
|
private skillSources(name: string, settings: ProfileSettings): Required<SkillSources> {
|
|
53
62
|
if (name === "default") return { shared: true, profile: false };
|
|
54
63
|
return { shared: settings.profile?.skillSources?.shared !== false, profile: settings.profile?.skillSources?.profile === true };
|
|
@@ -62,6 +71,7 @@ export class ProfileStore {
|
|
|
62
71
|
try {
|
|
63
72
|
const value = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
64
73
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("settings.json must contain an object");
|
|
74
|
+
await this.linkSharedModels(name);
|
|
65
75
|
return value as ProfileSettings;
|
|
66
76
|
} catch (error) {
|
|
67
77
|
if ((error as NodeJS.ErrnoException).code === "ENOENT" && name === "default") return {};
|
|
@@ -106,6 +116,7 @@ export class ProfileStore {
|
|
|
106
116
|
private enabled(settings: ProfileSettings, kind: ResourceKind, name: string, path?: string): boolean {
|
|
107
117
|
const values = settings.profile?.[policyKey[kind]];
|
|
108
118
|
if (values) return values.includes("*") || values.includes(name);
|
|
119
|
+
if (kind === "tools" && (BUILTIN_TOOLS as readonly string[]).includes(name)) return activeBuiltinTools(settings).includes(name);
|
|
109
120
|
const configured = settings[kind];
|
|
110
121
|
const exclusions = Array.isArray(configured) ? configured.filter((value): value is string => typeof value === "string" && value.startsWith("!")) : [];
|
|
111
122
|
return !path || !exclusions.includes(`!${path}`);
|
|
@@ -183,8 +194,8 @@ export class ProfileStore {
|
|
|
183
194
|
const sources = new Map<string, string>(), tools = [...names];
|
|
184
195
|
for (const extension of await this.packageExtensions(settings)) try {
|
|
185
196
|
const source = await readFile(extension.path, "utf8");
|
|
186
|
-
for (const tool of tools) if (source.includes(`"${tool}"`) || source.includes(`'${tool}'`) || source.includes(`\`${tool}\``)) sources.set(tool, extension.package);
|
|
187
|
-
for (const block of source.matchAll(/TOOL_NAMES[^=]*=\s*\{([\s\S]*?)\}/g)) for (const name of block[1].matchAll(/:\s*["']([A-Za-z][A-Za-z0-9_-]{0,63})["']/g)) sources.set(name[1], extension.package);
|
|
197
|
+
for (const tool of tools) if (!(BUILTIN_TOOLS as readonly string[]).includes(tool) && (source.includes(`"${tool}"`) || source.includes(`'${tool}'`) || source.includes(`\`${tool}\``))) sources.set(tool, extension.package);
|
|
198
|
+
for (const block of source.matchAll(/TOOL_NAMES[^=]*=\s*\{([\s\S]*?)\}/g)) for (const name of block[1].matchAll(/:\s*["']([A-Za-z][A-Za-z0-9_-]{0,63})["']/g)) if (!(BUILTIN_TOOLS as readonly string[]).includes(name[1])) sources.set(name[1], extension.package);
|
|
188
199
|
} catch {}
|
|
189
200
|
return sources;
|
|
190
201
|
}
|
|
@@ -276,6 +287,19 @@ export class ProfileStore {
|
|
|
276
287
|
async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?: SkillResourceSource): Promise<Resource> {
|
|
277
288
|
const current = await this.resource(profile, kind, name, source);
|
|
278
289
|
if (current.protected && !enabled) throw Object.assign(new Error("This extension is required and cannot be disabled."), { status: 422 });
|
|
290
|
+
// The default profile uses Pi's defaultTools setting for native tool state
|
|
291
|
+
// unless it already has a legacy explicit enabledTools policy. Named profiles
|
|
292
|
+
// retain their explicit enabledTools policy.
|
|
293
|
+
if (kind === "tools" && current.source === "builtin" && profile === "default") {
|
|
294
|
+
const settings = await this.readSettings(profile);
|
|
295
|
+
if (!settings.profile?.enabledTools) {
|
|
296
|
+
const tools = updatedBuiltinTools(settings, name, enabled);
|
|
297
|
+
if (tools) settings.defaultTools = tools;
|
|
298
|
+
else delete settings.defaultTools;
|
|
299
|
+
await this.writeSettings(profile, settings);
|
|
300
|
+
return this.resource(profile, kind, name, source);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
279
303
|
// Extensions are shared runtime resources, so toggling one always updates
|
|
280
304
|
// the default runtime rather than materializing it in a profile workspace.
|
|
281
305
|
const settingsProfile = kind === "extensions" ? "default" : profile;
|
|
@@ -317,10 +341,9 @@ export class ProfileStore {
|
|
|
317
341
|
await ensureProfileSandbox(destination, resolve(process.argv[1]));
|
|
318
342
|
await writeFile(join(destination, "SOUL.md"), "", "utf8");
|
|
319
343
|
await writeFile(join(destination, "guardrails.json"), "{\n \"guardrails\": []\n}\n", "utf8");
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
344
|
+
const auth = join(this.agentDir, "auth.json");
|
|
345
|
+
if (existsSync(auth)) await copyFile(auth, join(destination, "auth.json"));
|
|
346
|
+
await this.linkSharedModels(name);
|
|
324
347
|
return { name, path: destination };
|
|
325
348
|
}
|
|
326
349
|
|
|
@@ -200,6 +200,20 @@ class ApiServer {
|
|
|
200
200
|
return SessionManager.listAll(sessionDir);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
async deleteProfile(profile: string): Promise<void> {
|
|
204
|
+
if (profile === "default") throw Object.assign(new Error("The default profile cannot be deleted"), { status: 400 });
|
|
205
|
+
await this.profiles.readSettings(profile);
|
|
206
|
+
const handles = [...this.sessions.values()].filter((handle) => handle.profile === profile);
|
|
207
|
+
if (handles.some((handle) => handle.busy) || [...this.sandboxBusy].some((key) => key.startsWith(`${profile}:`))) throw Object.assign(new Error("The profile has a session that is processing a request."), { status: 409 });
|
|
208
|
+
for (const handle of handles) { handle.session.dispose(); this.sessions.delete(`${profile}:${handle.sessionId}`); }
|
|
209
|
+
this.runtimes.delete(this.profileDirectory(profile));
|
|
210
|
+
this.toolDiscoveries.delete(profile);
|
|
211
|
+
this.profiles.clearExtensionTools(profile);
|
|
212
|
+
await this.profiles.delete(profile);
|
|
213
|
+
this.applications.deleteProfile(profile);
|
|
214
|
+
this.pulses.deleteProfile(profile);
|
|
215
|
+
}
|
|
216
|
+
|
|
203
217
|
async listSessions(profile: string): Promise<Array<Record<string, unknown>>> {
|
|
204
218
|
await this.profiles.readSettings(profile);
|
|
205
219
|
const sessions = await this.listProfileSessions(profile);
|
|
@@ -617,10 +631,35 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
617
631
|
server.delete<{ Params: { profile: string } }>("/api/profiles/:profile", async (request, reply) => {
|
|
618
632
|
if (!guard(request, reply)) return;
|
|
619
633
|
if (objectBody(request.body).force !== true) throw Object.assign(new Error('Profile deletion requires { "force": true }.'), { status: 400 });
|
|
620
|
-
await api.
|
|
634
|
+
await api.deleteProfile(profileName(request));
|
|
621
635
|
return reply.code(204).send();
|
|
622
636
|
});
|
|
623
637
|
|
|
638
|
+
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
639
|
+
if (!guard(request, reply)) return;
|
|
640
|
+
const profile = profileName(request), directory = api.profiles.directory(profile);
|
|
641
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
642
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
643
|
+
const providers = new Map<string, Array<{ id: string; name: string }>>();
|
|
644
|
+
for (const model of runtime.getModels()) {
|
|
645
|
+
const models = providers.get(model.provider) ?? [];
|
|
646
|
+
models.push({ id: model.id, name: model.name ?? model.id });
|
|
647
|
+
providers.set(model.provider, models);
|
|
648
|
+
}
|
|
649
|
+
return { settings: await api.profiles.readSettings(profile), providers: [...providers.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([id, models]) => ({ id, models: models.sort((left, right) => left.name.localeCompare(right.name)) })) };
|
|
650
|
+
});
|
|
651
|
+
server.put<{ Params: { profile: string } }>("/api/profiles/:profile/model", async (request, reply) => {
|
|
652
|
+
if (!guard(request, reply)) return;
|
|
653
|
+
const profile = profileName(request), body = objectBody(request.body), provider = body.provider, model = body.model;
|
|
654
|
+
if (typeof provider !== "string" || !provider || typeof model !== "string" || !model) throw Object.assign(new Error("provider and model must be non-empty strings."), { status: 400 });
|
|
655
|
+
const directory = api.profiles.directory(profile);
|
|
656
|
+
const runtime = await ModelRuntime.create({ authPath: join(directory, "auth.json"), modelsPath: join(directory, "models.json"), refreshOnCreate: false });
|
|
657
|
+
if (runtime.getError()) throw Object.assign(new Error(runtime.getError()), { status: 400 });
|
|
658
|
+
if (!runtime.getModel(provider, model)) throw Object.assign(new Error("The selected provider and model are not available."), { status: 400 });
|
|
659
|
+
const settings = await api.profiles.readSettings(profile);
|
|
660
|
+
return { settings: await api.profiles.writeSettings(profile, { ...settings, defaultProvider: provider, defaultModel: model }) };
|
|
661
|
+
});
|
|
662
|
+
|
|
624
663
|
server.get<{ Params: { profile: string } }>("/api/profiles/:profile/settings", async (request, reply) => {
|
|
625
664
|
if (!guard(request, reply)) return;
|
|
626
665
|
return { settings: await api.profiles.readSettings(profileName(request)) };
|
|
@@ -796,7 +835,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
796
835
|
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
797
836
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
798
837
|
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
799
|
-
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, profile: profileName(request) }) }); }
|
|
838
|
+
try { return reply.code(201).send({ pulse: api.pulses.create({ name: body.name as string, description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: body.insertIntoApiSession === true, profile: profileName(request) }) }); }
|
|
800
839
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
801
840
|
});
|
|
802
841
|
server.patch<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -810,7 +849,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
810
849
|
for (const field of fields) if (typeof body[field] !== "string" || !String(body[field]).trim()) throw Object.assign(new Error(`The '${field}' field must be a non-empty string.`), { status: 400 });
|
|
811
850
|
const sessionId = typeof body.thread_session_id === "string" ? body.thread_session_id : "";
|
|
812
851
|
if (sessionId && !(await api.listSessions(profileName(request))).some((session) => session.id === sessionId)) throw Object.assign(new Error("The thread_session_id session was not found."), { status: 404 });
|
|
813
|
-
try { return { pulse: api.pulses.update(request.params.name, profileName(request), { description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId }) }; }
|
|
852
|
+
try { return { pulse: api.pulses.update(request.params.name, profileName(request), { description: body.description as string, schedule: body.schedule as string, prompt: body.prompt as string, thread_session_id: sessionId, insertIntoApiSession: body.insertIntoApiSession === true }) }; }
|
|
814
853
|
catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 }); }
|
|
815
854
|
});
|
|
816
855
|
server.delete<{ Params: { profile: string; name: string } }>("/api/profiles/:profile/pulses/:name", async (request, reply) => {
|
|
@@ -936,7 +975,7 @@ export async function startApiServer(options: ServerOptions): Promise<FastifyIns
|
|
|
936
975
|
});
|
|
937
976
|
server.delete<{ Params: { slug: string } }>("/api/applications/:slug", async (request, reply) => { if (!guard(request, reply)) return; applicationBySlug.get(request.params.slug)?.runtime.dispose(); applicationStore.delete(request.params.slug); applicationBySlug.delete(request.params.slug); await rm(join(applicationsRoot, request.params.slug), { recursive: true, force: true }); return reply.code(204).send(); });
|
|
938
977
|
server.get<{ Params: { slug: string } }>("/api/applications/:slug/identity-mappings", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); return { mappings: applicationStore.identityMappings(request.params.slug) }; });
|
|
939
|
-
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, automatic = identityKey === "*" || body.sessionMode === "automatic", prefix = automatic ? null : body.sessionPrefix; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || typeof
|
|
978
|
+
server.put<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; registeredApplication(request.params.slug); const body = objectBody(request.body), previousIdentityKey = request.params.identityKey, identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : previousIdentityKey, ignored = body.sessionMode === "ignore" || body.profile === null, automatic = !ignored && (identityKey === "*" || body.sessionMode === "automatic"), prefix = ignored || automatic ? null : body.sessionPrefix, profile = ignored ? null : body.profile; if (identityKey === "*" && identityKey !== previousIdentityKey && applicationStore.identityMapping(request.params.slug, "*")) throw Object.assign(new Error("Only one wildcard (*) Identity Key mapping is allowed per Application."), { status: 409 }); if (!identityKey || (!ignored && (typeof profile !== "string" || !profile || (!automatic && typeof prefix !== "string")))) throw Object.assign(new Error("profile and a fixed sessionPrefix are required unless this mapping ignores requests."), { status: 400 }); if (typeof prefix === "string" && !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(prefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 400 }); if (!ignored) await api.profiles.readSettings(profile as string); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, profile as string | null, ignored ? "ignore" : automatic ? "automatic" : "fixed", prefix as string | null) }; });
|
|
940
979
|
server.delete<{ Params: { slug: string; identityKey: string } }>("/api/applications/:slug/identity-mappings/:identityKey", async (request, reply) => { if (!guard(request, reply)) return; try { applicationStore.deleteIdentityMapping(request.params.slug, request.params.identityKey); return reply.code(204).send(); } catch (error) { throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 404 }); } });
|
|
941
980
|
server.get<{ Querystring: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } }>("/api/application-sessions", async (request, reply) => { if (!guard(request, reply)) return; const filters = request.query; return { sessions: applicationStore.activeSessions(filters) }; });
|
|
942
981
|
server.delete<{ Params: { sessionId: string }; Querystring: { application?: string; profile?: string } }>("/api/application-sessions/:sessionId", async (request, reply) => { if (!guard(request, reply)) return; const { application, profile } = request.query; if (!application || !profile) throw Object.assign(new Error("application and profile are required."), { status: 400 }); applicationStore.removeActiveSession(application, profile, request.params.sessionId); return reply.code(204).send(); });
|