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.
Files changed (29) hide show
  1. package/README.md +43 -5
  2. package/extensions/api-server/application-log-store.ts +4 -2
  3. package/extensions/api-server/application-runtime.ts +16 -6
  4. package/extensions/api-server/application-store.ts +21 -8
  5. package/extensions/api-server/profile-store.ts +33 -10
  6. package/extensions/api-server/server.ts +43 -4
  7. package/extensions/cli-resources.ts +20 -20
  8. package/extensions/conversation-search/index.ts +42 -0
  9. package/extensions/lib/builtin-tools.ts +24 -0
  10. package/extensions/lib/conversation-search.ts +33 -0
  11. package/extensions/lib/profile-sandbox.ts +3 -0
  12. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  13. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  14. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  15. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  16. package/extensions/pi-console-webui/components/console-page.tsx +3 -0
  17. package/extensions/pi-console-webui/components/console.tsx +9 -5
  18. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  19. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  20. package/extensions/pi-console-webui/components/pulse-resources.tsx +5 -5
  21. package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
  22. package/extensions/pi-console-webui/lib/page-metadata.ts +3 -1
  23. package/extensions/pi-console-webui/next.config.js +1 -0
  24. package/extensions/profile-ssh.ts +274 -0
  25. package/extensions/profiles.ts +53 -17
  26. package/extensions/pulse/index.ts +128 -14
  27. package/extensions/pulse/store.ts +108 -16
  28. package/package.json +3 -1
  29. 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; one `*` wildcard can serve as a deliberate fallback.
129
+ - Route each external identity to a profile and session strategy through explicit Identity Key mappings. Mappings may use automatic sessions or fixed prefixes, or intentionally ignore an identity (`None` in the Console) after inbound handling without calling Pi. Exact mappings take precedence, so an ignore mapping can exclude an identity even when a single `*` wildcard fallback exists.
113
130
  - Choose acknowledgement mode for asynchronous webhook-style work or result mode when an integration needs the completed response.
114
131
  - Inspect active sessions, roll over a conversation, preserve private handoff context, test handlers with a JSON payload, and stream or clear application logs from the Console or API.
115
132
  - Include an Evolution API adapter implementation while keeping Applications structurally independent from adapters.
@@ -163,7 +180,7 @@ curl \
163
180
  What the API exposes:
164
181
 
165
182
  - Persistent chat and streaming chat sessions, including profile-specific sessions.
166
- - Profile lifecycle, settings, `SOUL.md`, environment variables, Guardrails, tools, Skills, and sessions.
183
+ - Profile lifecycle, settings, default model selection, `SOUL.md`, environment variables, Guardrails, Context Memory, tools, Skills, and sessions.
167
184
  - Runtime-wide extension and package inventory and configuration.
168
185
  - Pulse schedules and execution history.
169
186
  - Git-backed Skill Sources, document preview, synchronization, and explicit profile imports.
@@ -189,8 +206,9 @@ pi console stop
189
206
 
190
207
  The console provides dedicated workspaces for:
191
208
 
192
- - **Profiles:** settings, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
193
- - **Applications:** configuration, identity mappings, active sessions, handler editor, transform testing, rollover, handoff, and live logs.
209
+ - **Profiles:** settings, default model selection, environment, `SOUL.md`, Guardrails, tools, Skills, and sessions. Extensions and packages are shown and managed as shared runtime resources.
210
+ - **Context Memory:** personal-context source selection, persistent Markdown memory editing, and Application handler creation and testing.
211
+ - **Applications:** configuration, identity mappings (including `None`/ignore rules), active sessions, handler editor, transform testing, rollover, handoff, and live logs.
194
212
  - **Skill Sources:** source registration, Git synchronization, Skill preview, and deliberate import into a profile.
195
213
  - **Pulse:** schedules, enablement, run history, and profile/session association.
196
214
  - **Operations:** API Server and Console configuration, service controls, and resource visibility.
@@ -239,7 +257,7 @@ Skills remain explicit resources rather than hidden package behavior. A profile
239
257
  ~/.pi/agent/profiles/<profile>/skills/<skill>/SKILL.md
240
258
  ```
241
259
 
242
- - **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile.
260
+ - **Shared Skills** are centrally maintained under `~/.pi/agent/skills/` and can be selectively exposed to each profile. Skills declared by installed shared-runtime packages are also discovered for named profiles and follow the same shared-Skill policy and allowlist.
243
261
  - **Profile Skills** live under `~/.pi/agent/profiles/<profile>/skills/` and remain isolated from other profiles.
244
262
  - Profile policy controls whether shared and profile-local sources are available and can allow all Skills or a named allowlist.
245
263
  - List and enable or disable Skills from the CLI and Console without editing settings by hand.
@@ -275,6 +293,7 @@ Refreshing synchronizes the source and atomically replaces the complete installe
275
293
 
276
294
  - Create and manage persistent scheduled prompts, reminders, cron jobs, one-time jobs, and heartbeat jobs.
277
295
  - Bind scheduled work to the active profile and conversation session.
296
+ - Set `insertIntoApiSession: true` when a Pulse result should also be recorded in every active Application session for the same profile. This adds context only: it does not invoke an outbound handler or send a message through an Application channel.
278
297
  - Persist schedules and execution history in SQLite with WAL support.
279
298
  - Start and inspect the scheduler:
280
299
 
@@ -282,8 +301,21 @@ Refreshing synchronizes the source and atomically replaces the complete installe
282
301
  pi pulse start
283
302
  pi pulse status
284
303
  pi pulse list
304
+ # After confirming an interrupted worker is no longer running:
305
+ pi pulse recover
285
306
  ```
286
307
 
308
+ ### Context Memory
309
+
310
+ Context Memory keeps compact, durable facts separate from a session transcript and injects them when a new session starts. It is intended for stable operational context and personal context, not an unbounded conversation archive.
311
+
312
+ - Each profile can maintain shared operational context in `context-memory/OPERATIONAL.md` (maximum 2,750 characters).
313
+ - Personal context is configurable as a profile-wide `context-memory/PROFILE.md`, an Application-identity-specific `applications/<application>/context-memory/identities/<identity>/USER.md`, or a TypeScript Application handler that resolves it dynamically. Profile and identity memory are limited to 1,375 characters.
314
+ - Identity memory is available only in an Application session and is isolated by Application and Identity Key.
315
+ - The `context_memory` tool supports `read`, `insert`, `update`, `remove`, and `replace` actions against `operational` or `personal` memory without exposing file paths to the agent. Personal memory writes to the configured profile or identity target; storing sensitive values requires explicit confirmation.
316
+ - Dynamic handlers must return Markdown, run only for Application sessions, have a three-second timeout, and can be created, edited, and tested from the Console. Handler execution is recorded in Application logs.
317
+ - Stored memory is presented to the model as data rather than instructions. Changes apply to newly started sessions.
318
+
287
319
  ### CLI resource management
288
320
 
289
321
  - Inspect tools, Skills, extensions, sessions, packages, and profile resources from the terminal.
@@ -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
- constructor(applicationDirectory: string, readonly application: string) { this.directory = join(applicationDirectory, "logs", "calls"); }
10
+ readonly application: string;
11
+ constructor(applicationDirectory: string, application: string) { this.application = application; this.directory = join(applicationDirectory, "logs", "calls"); }
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
- const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
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.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
180
- await this.api.createSession(profile, { id: sessionId }); await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, sessionId, true); if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
189
+ const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.createApplicationSession(profile, sessionPrefix, true);
190
+ if (previous?.sessionId && previous.sessionId !== sessionId) await this.api.retargetPulseThreadSessions(profile, previous.sessionId, sessionId);
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) throw Object.assign(new Error("No Identity Key mapping was found for this Application."), { status: 422 }); profile = mapped.profile; sessionPrefix = mapped.sessionMode === "automatic" ? await this.api.automaticApplicationSessionPrefix(this.application.slug, identityKey) : mapped.sessionPrefix; if (!sessionPrefix) throw Object.assign(new Error("The Identity Key mapping has no session prefix."), { status: 422 }); } else { profile = typeof routed.profile === "string" && routed.profile.trim() ? routed.profile.trim() : undefined; sessionPrefix = typeof (routed.sessionPrefix ?? routed.session_prefix) === "string" ? String(routed.sessionPrefix ?? routed.session_prefix).trim() || undefined : undefined; if (!profile) throw Object.assign(new Error("Inbound must return profile when identityKey is not provided."), { status: 422 }); sessionPrefix ??= "default"; } if (!profile || !sessionPrefix) throw Object.assign(new Error("No Application route was resolved."), { status: 422 }); const message = asRecord(routed.payload).message; if (typeof message !== "string" || !message.trim()) throw Object.assign(new Error("The Application did not produce a text message."), { status: 422 }); state = { ...state, ...asRecord(routed.state) };
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 NOT NULL, session_mode TEXT NOT NULL DEFAULT 'fixed', session_prefix TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(application_slug, identity_key)); CREATE TABLE IF NOT EXISTS application_automatic_prefixes (application_slug TEXT NOT NULL, identity_key TEXT NOT NULL, session_prefix TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(application_slug, identity_key), UNIQUE(application_slug, session_prefix)); 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';"); 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
- identityMapping(slug: string, identityKey: string) { const row = this.db.prepare("SELECT profile, session_mode, session_prefix FROM application_identity_mappings WHERE application_slug=? AND identity_key IN (?, '*') ORDER BY CASE identity_key WHEN '*' THEN 1 ELSE 0 END LIMIT 1").get(slug, identityKey) as Row | undefined; return row ? { profile: String(row.profile), sessionMode: String(row.session_mode) as "fixed" | "automatic", sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix) } : undefined; }
17
- identityMappings(slug: string) { return (this.db.prepare("SELECT id, identity_key, profile, session_mode, session_prefix, created_at, updated_at FROM application_identity_mappings WHERE application_slug=? ORDER BY identity_key='*', identity_key").all(slug) as Row[]).map((row) => ({ id: String(row.id), identityKey: String(row.identity_key), profile: String(row.profile), sessionMode: String(row.session_mode), sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix), createdAt: String(row.created_at), updatedAt: String(row.updated_at) })); }
18
- saveIdentityMapping(slug: string, identityKey: string, profile: string, sessionMode: "fixed" | "automatic", sessionPrefix: string | null) { const timestamp = now(), current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (current) this.db.prepare("UPDATE application_identity_mappings SET profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(profile, sessionMode, sessionPrefix, timestamp, current.id); else this.db.prepare("INSERT INTO application_identity_mappings VALUES (?,?,?,?,?,?,?,?)").run(randomUUID(), slug, identityKey, profile, sessionMode, sessionPrefix, timestamp, timestamp); return this.identityMapping(slug, identityKey)!; }
19
- renameIdentityMapping(slug: string, previousIdentityKey: string, identityKey: string, profile: string, sessionMode: "fixed" | "automatic", sessionPrefix: string | null) { if (previousIdentityKey === identityKey) return this.saveIdentityMapping(slug, identityKey, profile, sessionMode, sessionPrefix); const current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, previousIdentityKey) as Row | undefined; if (!current) throw Object.assign(new Error("Identity mapping not found."), { status: 404 }); if (this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey)) throw Object.assign(new Error("An Identity Key mapping with this key already exists."), { status: 409 }); this.db.prepare("UPDATE application_identity_mappings SET identity_key=?,profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(identityKey, profile, sessionMode, sessionPrefix, now(), current.id); return this.identityMapping(slug, identityKey)!; }
16
+ deleteProfile(profile: string) {
17
+ this.db.exec("BEGIN IMMEDIATE");
18
+ try {
19
+ this.db.prepare("DELETE FROM application_automatic_prefixes WHERE EXISTS (SELECT 1 FROM application_identity_mappings m WHERE m.profile=? AND m.application_slug=application_automatic_prefixes.application_slug AND m.identity_key=application_automatic_prefixes.identity_key)").run(profile);
20
+ this.db.prepare("DELETE FROM application_console_sessions WHERE profile=?").run(profile);
21
+ this.db.prepare("DELETE FROM application_session_state WHERE profile=?").run(profile);
22
+ this.db.prepare("DELETE FROM application_sessions WHERE profile=?").run(profile);
23
+ this.db.prepare("DELETE FROM application_identity_mappings WHERE profile=?").run(profile);
24
+ this.db.exec("COMMIT");
25
+ } catch (error) { this.db.exec("ROLLBACK"); throw error; }
26
+ }
27
+ identityMapping(slug: string, identityKey: string) { const row = this.db.prepare("SELECT profile, session_mode, session_prefix FROM application_identity_mappings WHERE application_slug=? AND identity_key IN (?, '*') ORDER BY CASE identity_key WHEN '*' THEN 1 ELSE 0 END LIMIT 1").get(slug, identityKey) as Row | undefined; return row ? { profile: row.profile == null ? null : String(row.profile), sessionMode: String(row.session_mode) as "fixed" | "automatic" | "ignore", sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix) } : undefined; }
28
+ identityMappings(slug: string) { return (this.db.prepare("SELECT id, identity_key, profile, session_mode, session_prefix, created_at, updated_at FROM application_identity_mappings WHERE application_slug=? ORDER BY identity_key='*', identity_key").all(slug) as Row[]).map((row) => ({ id: String(row.id), identityKey: String(row.identity_key), profile: row.profile == null ? null : String(row.profile), sessionMode: String(row.session_mode), sessionPrefix: row.session_prefix == null ? null : String(row.session_prefix), createdAt: String(row.created_at), updatedAt: String(row.updated_at) })); }
29
+ saveIdentityMapping(slug: string, identityKey: string, profile: string | null, sessionMode: "fixed" | "automatic" | "ignore", sessionPrefix: string | null) { const timestamp = now(), current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (current) this.db.prepare("UPDATE application_identity_mappings SET profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(profile, sessionMode, sessionPrefix, timestamp, current.id); else this.db.prepare("INSERT INTO application_identity_mappings VALUES (?,?,?,?,?,?,?,?)").run(randomUUID(), slug, identityKey, profile, sessionMode, sessionPrefix, timestamp, timestamp); return this.identityMapping(slug, identityKey)!; }
30
+ renameIdentityMapping(slug: string, previousIdentityKey: string, identityKey: string, profile: string | null, sessionMode: "fixed" | "automatic" | "ignore", sessionPrefix: string | null) { if (previousIdentityKey === identityKey) return this.saveIdentityMapping(slug, identityKey, profile, sessionMode, sessionPrefix); const current = this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, previousIdentityKey) as Row | undefined; if (!current) throw Object.assign(new Error("Identity mapping not found."), { status: 404 }); if (this.db.prepare("SELECT id FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").get(slug, identityKey)) throw Object.assign(new Error("An Identity Key mapping with this key already exists."), { status: 409 }); this.db.prepare("UPDATE application_identity_mappings SET identity_key=?,profile=?,session_mode=?,session_prefix=?,updated_at=? WHERE id=?").run(identityKey, profile, sessionMode, sessionPrefix, now(), current.id); return this.identityMapping(slug, identityKey)!; }
20
31
  deleteIdentityMapping(slug: string, identityKey: string) { if (!this.db.prepare("DELETE FROM application_identity_mappings WHERE application_slug=? AND identity_key=?").run(slug, identityKey).changes) throw new Error("Identity mapping not found."); }
21
32
  automaticPrefix(slug: string, identityKey: string) { const existing = this.db.prepare("SELECT session_prefix FROM application_automatic_prefixes WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (existing) return String(existing.session_prefix); const normalized = identityKey.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^[_-]+|[_-]+$/g, ""); const base = (normalized && /^[a-z]/.test(normalized) ? normalized : `id_${normalized || "identity"}`).slice(0, 63); let prefix = base; const collision = () => this.db.prepare("SELECT identity_key FROM application_automatic_prefixes WHERE application_slug=? AND session_prefix=?").get(slug, prefix) as Row | undefined; if (collision()) { const suffix = `--${createHash("sha256").update(identityKey).digest("hex").slice(0, 8)}`; prefix = `${base.slice(0, 63 - suffix.length)}${suffix}`; } this.db.prepare("INSERT INTO application_automatic_prefixes VALUES (?,?,?,?)").run(slug, identityKey, prefix, now()); return prefix; }
22
33
  session(slug: string, profile: string, prefix: string) { const row = this.db.prepare("SELECT s.* FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id WHERE st.application_slug=? AND st.profile=? AND st.session_prefix=?").get(slug, profile, prefix) as Row | undefined; return row ? this.sessionValue(row) : undefined; }
23
34
  private sessionValue(row: Row) { return { application: String(row.application_slug), profile: String(row.profile), sessionPrefix: String(row.session_prefix), sessionId: String(row.session_id), sessionDate: String(row.session_date), sequence: Number(row.sequence), createdAt: String(row.created_at), updatedAt: String(row.updated_at), lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), messageCount: Number(row.message_count), createdFromRollover: Boolean(row.created_from_rollover), lastIdentityKey: row.last_identity_key == null ? null : String(row.last_identity_key) }; }
24
- nextSessionId(slug: string, profile: string, prefix: string) { const date = new Date().toISOString().slice(0, 10), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND profile=? AND session_prefix=? AND session_date=?").get(slug, profile, prefix, date) as Row).sequence); return `${slug}_${prefix}_${date}-${String(sequence).padStart(2, "0")}`; }
25
- createSession(slug: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { const date = new Date().toISOString().slice(0, 10), timestamp = now(), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND profile=? AND session_prefix=? AND session_date=?").get(slug, profile, prefix, date) as Row).sequence); this.db.prepare("INSERT INTO application_sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(randomUUID(), slug, profile, prefix, sessionId, date, sequence, timestamp, timestamp, null, 0, rollover ? 1 : 0, null); this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix)!; }
35
+ // Session IDs omit the profile name and are globally unique in SQLite, so
36
+ // their sequence must be shared by profiles that use the same Application/prefix.
37
+ nextSessionId(slug: string, profile: string, prefix: string) { void profile; const date = new Date().toISOString().slice(0, 10), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); return `${slug}_${prefix}_${date}-${String(sequence).padStart(2, "0")}`; }
38
+ createSession(slug: string, profile: string, prefix: string, sessionId: string, rollover: boolean) { const date = new Date().toISOString().slice(0, 10), timestamp = now(), sequence = Number((this.db.prepare("SELECT COALESCE(MAX(sequence),0)+1 AS sequence FROM application_sessions WHERE application_slug=? AND session_prefix=? AND session_date=?").get(slug, prefix, date) as Row).sequence); this.db.prepare("INSERT INTO application_sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)").run(randomUUID(), slug, profile, prefix, sessionId, date, sequence, timestamp, timestamp, null, 0, rollover ? 1 : 0, null); this.db.prepare("INSERT INTO application_session_state VALUES (?,?,?,?,?,?) ON CONFLICT(application_slug,profile,session_prefix) DO UPDATE SET current_session_id=excluded.current_session_id,updated_at=excluded.updated_at,reason=excluded.reason").run(slug, profile, prefix, sessionId, timestamp, rollover ? "rollover" : "message"); return this.session(slug, profile, prefix)!; }
26
39
  touchSession(slug: string, profile: string, prefix: string, sessionId: string, identityKey?: string) { const timestamp = now(); this.db.prepare("UPDATE application_sessions SET updated_at=?,last_message_at=?,message_count=message_count+1,last_identity_key=COALESCE(?,last_identity_key) WHERE application_slug=? AND profile=? AND session_prefix=? AND session_id=?").run(timestamp, timestamp, identityKey ?? null, slug, profile, prefix, sessionId); this.db.prepare("UPDATE application_session_state SET updated_at=?,reason='message' WHERE application_slug=? AND profile=? AND session_prefix=?").run(timestamp, slug, profile, prefix); }
27
40
  activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
28
41
  removeActiveSession(slug: string, profile: string, sessionId: string) { const session = this.db.prepare("SELECT session_prefix FROM application_sessions WHERE application_slug=? AND profile=? AND session_id=?").get(slug, profile, sessionId) as Row | undefined; if (!session) throw Object.assign(new Error("Application session not found."), { status: 404 }); if (!this.db.prepare("DELETE FROM application_session_state WHERE application_slug=? AND profile=? AND session_prefix=? AND current_session_id=?").run(slug, profile, String(session.session_prefix), sessionId).changes) throw Object.assign(new Error("Application session is no longer active."), { status: 409 }); }
@@ -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 const BUILTIN_TOOLS = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
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(readonly agentDir: string) {}
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
- for (const file of ["auth.json", "models.json"]) {
321
- const source = join(this.agentDir, file);
322
- if (existsSync(source)) await copyFile(source, join(destination, file));
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.profiles.delete(profileName(request));
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 body.profile !== "string" || !body.profile || (!automatic && typeof prefix !== "string")) throw Object.assign(new Error("profile and a fixed sessionPrefix are required."), { 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 }); await api.profiles.readSettings(body.profile); return { mapping: typeof body.previousIdentityKey === "string" ? applicationStore.renameIdentityMapping(request.params.slug, previousIdentityKey, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) : applicationStore.saveIdentityMapping(request.params.slug, identityKey, body.profile, automatic ? "automatic" : "fixed", automatic ? null : prefix) }; });
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(); });