pi-feats 0.1.2 → 0.1.4
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 +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`pi-feats` turns the lightweight, extensible Pi Coding Agent into an operational workspace without replacing Pi's native configuration model. It adds persistent profiles, a policy-controlled sandbox, HTTP and browser interfaces, application runtimes, scheduled work, Git-backed Skill discovery, and command-line administration.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
## Contents
|
|
8
10
|
|
|
9
11
|
- [Glossary](#glossary)
|
|
@@ -291,9 +293,18 @@ Refreshing synchronizes the source and atomically replaces the complete installe
|
|
|
291
293
|
|
|
292
294
|
### Sequential Workflow
|
|
293
295
|
|
|
294
|
-
- Create persistent workflows made of strictly ordered **Action**, **Collect**,
|
|
296
|
+
- Create persistent workflows made of strictly ordered **Action**, **Collect**, **Evaluate**, and subworkflow tasks.
|
|
297
|
+
- Run multiple workflows per profile, independently or in parent/child hierarchies, with execution ownership and persisted focus isolated by Pi session ID.
|
|
298
|
+
- Enforce current-task IDs, ownership, phase gates, and one tool call per model turn in the harness. External tools are allowed only during running Action tasks. Collect requires a subsequent user message. These controls enforce ordering, not the semantic truth of agent-reported results.
|
|
299
|
+
- Restore focus on same-session resume/reload, never automatically on new/forked sessions. `/workflow-suspend`, `/workflow-focus <id>`, and `/workflow-adopt <rootId>` provide explicit user control; adoption transfers the entire tree and revokes the former owner's access. Legacy executions remain unowned until adopted.
|
|
300
|
+
- Cancel any active workflow in the current profile with `sequential_workflow_cancel`, including one created by another session; cancellation does not require adoption and does not cascade to descendants. A cancelled child is returned to its parent for evaluation. `/workflow-cancel <id>` remains available for direct user control.
|
|
301
|
+
- Resolve `ative o sequential workflow basic` to `sequential_workflow_templates/basic.json` without inventing a new definition. Each activation creates a new execution. Missing templates fail closed.
|
|
302
|
+
- Refresh task context before every model request and request at most two automatic continuations per unchanged unfinished phase. Failure to progress leaves the task pending with a warning; it never auto-accepts a task.
|
|
303
|
+
- Let a subworkflow task wait for its linked child while unrelated workflows continue independently.
|
|
295
304
|
- Prevent advancement until the current task result has been recorded and accepted.
|
|
296
305
|
- Persist workflow state in SQLite for auditable retries and continuation.
|
|
306
|
+
- Validate reusable JSON definitions against [`schemas/sequential-workflow-template.schema.json`](schemas/sequential-workflow-template.schema.json), then create and start a workflow directly from the template.
|
|
307
|
+
- Include separate Skills for executing named Sequential Workflows and authoring their JSON templates. Templates created without an explicitly requested output path default to `<PI_CODING_AGENT_DIR>/sequential_workflow_templates/` (or `~/.pi/agent/sequential_workflow_templates/`); template tools resolve relative paths in that same directory.
|
|
297
308
|
|
|
298
309
|
## Command cookbook
|
|
299
310
|
|
|
@@ -410,6 +421,7 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
|
|
|
410
421
|
| Extension | Responsibility |
|
|
411
422
|
| --- | --- |
|
|
412
423
|
| API Server | HTTP API, Applications, profile administration, sessions, logs, and terminal tickets. |
|
|
424
|
+
| Browser Harness | Steel Profile-scoped CDP browser automation for active Pi Profiles. |
|
|
413
425
|
| CLI Resources | Resource, package, session, and profile commands. |
|
|
414
426
|
| Guardrails | Input, tool, and output policy stages. |
|
|
415
427
|
| Pi Console WebUI | Browser-based operations console and terminal client. |
|
|
@@ -418,6 +430,17 @@ The `remote:<name>` form forwards the rest of the command to the selected remote
|
|
|
418
430
|
| Sequential Workflow | Ordered Action, Collect, and Evaluate workflows. |
|
|
419
431
|
| Skill Sources | Git-backed Skill source catalog and explicit imports. |
|
|
420
432
|
|
|
433
|
+
### Steel Browser Harness
|
|
434
|
+
|
|
435
|
+
The Browser Harness maps each Pi Profile to a persisted Steel SessionContext. The Steel API creates and releases live browser sessions; the profile-local context restores cookies and storage for the next session. Configure the Steel endpoint and credential in each profile's `.env`:
|
|
436
|
+
|
|
437
|
+
```dotenv
|
|
438
|
+
STEEL_API_URL=http://127.0.0.1:3000
|
|
439
|
+
STEEL_API_KEY=...
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
On first browser use, the harness creates a Steel Session through the API. `browser_setup` and `/browser-setup` return Steel's interactive live viewer URL, so a person can observe and interact with the same browser and tabs while the agent works through CDP. When the session is released, the harness stores the SessionContext in `steel.json` inside that Pi Profile directory. Steel Session identifiers are never persisted. Use `/browser-viewer` to show the current viewer URL, `/browser-status` to inspect the active session, and `/browser-release` to release it.
|
|
443
|
+
|
|
421
444
|
## Installation
|
|
422
445
|
|
|
423
446
|
Install the latest published package:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## pi-browser-harness
|
|
4
|
+
|
|
5
|
+
`extensions/browser-harness` contains adapted source code from
|
|
6
|
+
[pi-browser-harness](https://github.com/amankumarsingh77/pi-browser-harness),
|
|
7
|
+
version 0.11.1 (commit `8d7a067`). The original project is licensed under the
|
|
8
|
+
MIT License. The adaptation replaces desktop-browser discovery and local
|
|
9
|
+
profile management with Pi Profile-scoped Steel SessionContext and Steel Session
|
|
10
|
+
management.
|
|
11
|
+
|
|
12
|
+
See the original project's `LICENSE` for the full license text.
|
|
Binary file
|
|
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import type { ContextMemoryExecutionLog } from "../lib/application-context.ts";
|
|
5
6
|
export type CallStage = { id: string; type: string; label: string; startedAt: string; finishedAt?: string; input?: unknown; output?: unknown; payload?: unknown; stdout: string[]; stderr: string[]; error?: string };
|
|
6
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
8
|
export class ApplicationLogStore {
|
|
@@ -12,9 +13,39 @@ export class ApplicationLogStore {
|
|
|
12
13
|
start(payload: unknown, headers: Record<string, unknown>) { const call: ApplicationCall = { id: randomUUID(), application: this.application, origin: String(headers["x-forwarded-for"] ?? headers.origin ?? headers.host ?? "unknown"), receivedAt: new Date().toISOString(), status: "running", headers, stages: [{ id: randomUUID(), type: "request", label: "Request", startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), payload, stdout: [], stderr: [] }] }; this.calls.set(call.id, call); void this.save(call); return call; }
|
|
13
14
|
stage(call: ApplicationCall, type: string, label: string, input: unknown) { const stage: CallStage = { id: randomUUID(), type, label, startedAt: new Date().toISOString(), input, stdout: [], stderr: [] }; call.stages.push(stage); void this.save(call); return stage; }
|
|
14
15
|
complete(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; stage.finishedAt = new Date().toISOString(); void this.save(call); }
|
|
16
|
+
annotate(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; void this.save(call); }
|
|
15
17
|
fail(call: ApplicationCall, stage: CallStage | undefined, error: unknown) { const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); if (stage) { stage.error = message; stage.finishedAt = new Date().toISOString(); } call.error = message; call.status = "error"; call.finishedAt = new Date().toISOString(); void this.save(call); }
|
|
16
18
|
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); }
|
|
19
|
+
recordContextMemory(input: { profile: string; identityKey: string; sessionId: string; execution: ContextMemoryExecutionLog }) {
|
|
20
|
+
const { execution } = input;
|
|
21
|
+
const stage: CallStage = {
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
type: "context-memory",
|
|
24
|
+
label: `Context Memory: ${execution.handler}`,
|
|
25
|
+
startedAt: execution.startedAt,
|
|
26
|
+
finishedAt: new Date(new Date(execution.startedAt).getTime() + execution.durationMs).toISOString(),
|
|
27
|
+
input: { profile: input.profile, identityKey: input.identityKey, sessionId: input.sessionId, handler: execution.handler },
|
|
28
|
+
output: { injected: execution.status === "success" && execution.outputCharacters > 0, outputCharacters: execution.outputCharacters },
|
|
29
|
+
stdout: execution.stdout,
|
|
30
|
+
stderr: execution.stderr,
|
|
31
|
+
...(execution.error ? { error: execution.error } : {}),
|
|
32
|
+
};
|
|
33
|
+
const call: ApplicationCall = {
|
|
34
|
+
id: randomUUID(), application: this.application, origin: "Pi Console session", receivedAt: execution.startedAt,
|
|
35
|
+
finishedAt: stage.finishedAt, status: execution.status === "success" ? "success" : "error", headers: {}, stages: [stage], ...(execution.error ? { error: execution.error } : {}),
|
|
36
|
+
};
|
|
37
|
+
this.calls.set(call.id, call); void this.save(call);
|
|
38
|
+
}
|
|
17
39
|
list() { return [...this.calls.values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)); }
|
|
40
|
+
listSummaries(limit = 100, start?: Date, end?: Date) {
|
|
41
|
+
return this.list().filter((call) => {
|
|
42
|
+
const receivedAt = new Date(call.receivedAt);
|
|
43
|
+
return (!start || receivedAt >= start) && (!end || receivedAt <= end);
|
|
44
|
+
}).slice(0, limit).map((call) => ({
|
|
45
|
+
id: call.id, application: call.application, origin: call.origin, receivedAt: call.receivedAt, finishedAt: call.finishedAt, status: call.status, error: call.error,
|
|
46
|
+
stages: call.stages.map((stage) => ({ id: stage.id, type: stage.type, label: stage.label, startedAt: stage.startedAt, finishedAt: stage.finishedAt, error: stage.error, completed: Boolean(stage.finishedAt) })),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
18
49
|
get(id: string) { return this.calls.get(id); }
|
|
19
50
|
async clear() { this.calls.clear(); await rm(this.directory, { recursive: true, force: true }); await mkdir(this.directory, { recursive: true }); this.events.emit("event", { type: "logs.cleared" }); }
|
|
20
51
|
on(listener: (event: unknown) => void) { this.events.on("event", listener); return () => this.events.off("event", listener); }
|
|
@@ -10,9 +10,11 @@ import { ApplicationLogStore } from "./application-log-store.ts";
|
|
|
10
10
|
type RecordValue = Record<string, any>;
|
|
11
11
|
export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
12
12
|
type Handler = (payload: RecordValue, headers?: RecordValue | null, query?: RecordValue | null, context?: RecordValue | null, state?: RecordValue | null, env?: Record<string, string | undefined>) => Promise<any> | any;
|
|
13
|
-
type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
|
|
13
|
+
type ApplicationApi = { agentSettings(): Promise<Record<string, unknown>>; listSessions(profile: string): Promise<Array<Record<string, unknown>>>; createSession(profile: string, input: unknown): Promise<Record<string, unknown>>; getSessionConversation(profile: string, sessionId: string): Promise<Record<string, unknown>>; complete(profile: string, prompt: string): Promise<string>; prompt(profile: string, sessionId: string, message: string, handoff?: string, applicationContext?: { application: string; identityKey: string }): Promise<{ profile: string; sessionId: string; response: string }>; getIdentityMapping(application: string, identityKey: string): Promise<{ profile: string; sessionMode: "fixed" | "automatic"; sessionPrefix: string | null } | undefined>; automaticApplicationSessionPrefix(application: string, identityKey: string): Promise<string>; getApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; getActiveApplicationSession(application: string, profile: string, prefix: string): Promise<{ sessionId: string } | undefined>; nextApplicationSessionId(application: string, profile: string, prefix: string): Promise<string>; recordApplicationSession(application: string, profile: string, prefix: string, sessionId: string, rollover: boolean): Promise<void>; touchApplicationSession(application: string, profile: string, prefix: string, sessionId: string, from?: string): Promise<void>; retargetPulseThreadSessions(profile: string, fromSessionId: string, toSessionId: string): Promise<void>; handlerEnvironment(profile: string): Promise<Record<string, string | undefined>> };
|
|
14
14
|
type Handoff = { sourceSessionId: string | null; summary: string; createdAt: string; targetCreatedAt: string };
|
|
15
15
|
type WorkingSession = { sessionId: string; updatedAt: string; reason: "message" | "rollover" };
|
|
16
|
+
type CoalescedEntry = { call: ReturnType<ApplicationLogStore["start"]>; stage: ReturnType<ApplicationLogStore["stage"]>; message: string; state: RecordValue; version: number; dispatch: (message: string, state: RecordValue) => Promise<unknown>; resolve: (output: unknown) => void; reject: (error: unknown) => void };
|
|
17
|
+
type CoalescingBuffer = { version: number; entries: CoalescedEntry[] };
|
|
16
18
|
export type ApplicationIdentity = { id: string; name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Record<string, unknown> };
|
|
17
19
|
|
|
18
20
|
const safeName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(name);
|
|
@@ -37,6 +39,8 @@ export class ApplicationRuntime {
|
|
|
37
39
|
readonly settings: Settings;
|
|
38
40
|
private readonly jiti = createJiti(process.cwd(), { moduleCache: false, fsCache: false });
|
|
39
41
|
private queues = new Map<string, Promise<void>>();
|
|
42
|
+
private coalescingBuffers = new Map<string, CoalescingBuffer>();
|
|
43
|
+
private coalescingTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
40
44
|
|
|
41
45
|
readonly logs: ApplicationLogStore;
|
|
42
46
|
constructor(readonly directory: string, settings: Settings, private readonly api: ApplicationApi, private readonly persistSettings: (settings: Settings) => Promise<void>, private readonly application: ApplicationIdentity) { this.name = application.slug; this.settings = settings; this.logs = new ApplicationLogStore(directory, this.name); }
|
|
@@ -139,11 +143,17 @@ export class ApplicationRuntime {
|
|
|
139
143
|
temporaryPath = join(dirname(path), `.pi-handler-test-${randomUUID()}.ts`);
|
|
140
144
|
await writeFile(temporaryPath, content, "utf8");
|
|
141
145
|
}
|
|
142
|
-
const loaded = this.jiti(temporaryPath ?? path) as { handle?: Handler };
|
|
143
|
-
if (typeof loaded.handle !== "function") throw new Error("Handler must export handle().");
|
|
144
|
-
const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: randomUUID(), headers: {}, query: {} }, test: true, log: { info: (...values: unknown[]) => stdout.push(logValues(values)), error: (...values: unknown[]) => stderr.push(logValues(values)) } };
|
|
146
|
+
const loaded = this.jiti(temporaryPath ?? path) as { handle?: Handler; resolveContextMemory?: (context: { application: { slug: string }; identityKey?: string; profile: string; sessionId: string }, env: Record<string, string | undefined>) => Promise<unknown> | unknown };
|
|
145
147
|
installConsoleCapture();
|
|
146
|
-
|
|
148
|
+
let output: unknown;
|
|
149
|
+
if (typeof loaded.resolveContextMemory === "function") {
|
|
150
|
+
const profile = typeof payload.profile === "string" ? payload.profile : "default", identityKey = typeof payload.identityKey === "string" ? payload.identityKey : undefined, sessionId = typeof payload.sessionId === "string" ? payload.sessionId : "test-session";
|
|
151
|
+
output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.resolveContextMemory!({ application: { slug: this.application.slug }, identityKey, profile, sessionId }, await this.api.handlerEnvironment(profile)));
|
|
152
|
+
} else {
|
|
153
|
+
if (typeof loaded.handle !== "function") throw new Error("Handler must export handle() or resolveContextMemory().");
|
|
154
|
+
const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: randomUUID(), headers: {}, query: {} }, test: true, log: { info: (...values: unknown[]) => stdout.push(logValues(values)), error: (...values: unknown[]) => stderr.push(logValues(values)) } };
|
|
155
|
+
output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle!(payload, {}, {}, context, {}, await this.environmentFor(payload)));
|
|
156
|
+
}
|
|
147
157
|
return { ok: true, output, logs: { stdout, stderr }, durationMs: Math.round(performance.now() - started) };
|
|
148
158
|
} catch (error) {
|
|
149
159
|
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
@@ -177,6 +187,52 @@ export class ApplicationRuntime {
|
|
|
177
187
|
});
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
private async collect(key: string, call: ReturnType<ApplicationLogStore["start"]>, message: string, state: RecordValue, dispatch: (message: string, state: RecordValue) => Promise<unknown>): Promise<unknown> {
|
|
191
|
+
const seconds = this.settings.messageCoalescing?.silenceDebounceSeconds ?? 0;
|
|
192
|
+
if (!this.settings.messageCoalescing?.enabled || seconds <= 0) return dispatch(message, state);
|
|
193
|
+
const buffer = this.coalescingBuffers.get(key) ?? { version: 0, entries: [] };
|
|
194
|
+
this.coalescingBuffers.set(key, buffer);
|
|
195
|
+
const version = ++buffer.version;
|
|
196
|
+
const stage = this.logs.stage(call, "coalescing", "Message coalescing", { key, version, silenceDebounceSeconds: seconds, messageCharacters: message.length });
|
|
197
|
+
this.logs.complete(call, stage, { status: "collected", version });
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const entry: CoalescedEntry = { call, stage, message, state, version, dispatch, resolve, reject };
|
|
200
|
+
buffer.entries.push(entry);
|
|
201
|
+
const timer = setTimeout(() => { this.coalescingTimers.delete(timer); void this.flushCoalesced(key, version); }, seconds * 1_000);
|
|
202
|
+
this.coalescingTimers.add(timer);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async flushCoalesced(key: string, version: number): Promise<void> {
|
|
207
|
+
const buffer = this.coalescingBuffers.get(key);
|
|
208
|
+
if (!buffer) return;
|
|
209
|
+
if (buffer.version !== version) {
|
|
210
|
+
for (const entry of buffer.entries.filter((item) => item.version === version)) this.logs.annotate(entry.call, entry.stage, { status: "superseded", version, latestVersion: buffer.version });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.coalescingBuffers.delete(key);
|
|
214
|
+
const entries = buffer.entries, leader = entries.at(-1)!;
|
|
215
|
+
const message = entries.map((entry) => entry.message).join("\n");
|
|
216
|
+
for (const entry of entries) this.logs.annotate(entry.call, entry.stage, { status: entry === leader ? "dispatched" : "merged", version, leaderCallId: leader.call.id, messageCount: entries.length, messageCharacters: message.length });
|
|
217
|
+
try {
|
|
218
|
+
const output = await leader.dispatch(message, leader.state);
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (entry !== leader) this.logs.finish(entry.call, { coalescing: { status: "merged", leaderCallId: leader.call.id, messageCount: entries.length } });
|
|
221
|
+
entry.resolve(output);
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
for (const entry of entries) { if (entry.call.status !== "error") this.logs.fail(entry.call, entry === leader ? undefined : entry.stage, error); entry.reject(error); }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
dispose(): void {
|
|
229
|
+
for (const timer of this.coalescingTimers) clearTimeout(timer);
|
|
230
|
+
this.coalescingTimers.clear();
|
|
231
|
+
const error = new Error("Message coalescing was interrupted because the Application runtime was reloaded.");
|
|
232
|
+
for (const buffer of this.coalescingBuffers.values()) for (const entry of buffer.entries) { this.logs.fail(entry.call, entry.stage, error); entry.reject(error); }
|
|
233
|
+
this.coalescingBuffers.clear();
|
|
234
|
+
}
|
|
235
|
+
|
|
180
236
|
async process(payload: unknown, headers: RecordValue, query: RecordValue): Promise<unknown> {
|
|
181
237
|
const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
|
|
182
238
|
const context = { application: this.application, settings: await this.api.agentSettings(), request: { id: call.id, headers, query }, log: { info: (...values: unknown[]) => active?.stdout.push(values.map(String).join(" ")), error: (...values: unknown[]) => active?.stderr.push(values.map(String).join(" ")) } };
|
|
@@ -185,19 +241,23 @@ export class ApplicationRuntime {
|
|
|
185
241
|
for (const name of this.settings.transformHandlers ?? []) { const result = await run("transform", `Transform: ${name}`, current, async () => this.handler("transform", name)(current, headers, query, context, state, await this.environmentFor(current, payload, query))); if (result && typeof result === "object" && "payload" in result) { current = asRecord(result.payload); state = { ...state, ...asRecord(result.state) }; } else current = asRecord(result); }
|
|
186
242
|
const inbound = await run("inbound-handler", `Inbound handler: ${this.settings.inboundHandler}`, current, async () => this.handler("inbound", this.settings.inboundHandler)(current, headers, query, context, state, await this.environmentFor(current, payload, query)));
|
|
187
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) };
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
const dispatch = async (coalescedMessage: string, coalescedState: RecordValue): Promise<unknown> => {
|
|
245
|
+
const sessionId = await this.resolveSession(profile!, sessionPrefix!);
|
|
246
|
+
let handoff: string | undefined;
|
|
247
|
+
try { handoff = await run("handoff-relevance", "Handoff relevance", { profile, sessionId, message: coalescedMessage }, () => this.relevantHandoff(profile!, sessionId, sessionPrefix, coalescedMessage)); }
|
|
248
|
+
catch (error) { context.log.error("Handoff skipped:", error instanceof Error ? error.message : String(error)); }
|
|
249
|
+
const result = await run("pi-agent", "Pi agent", { profile, sessionId, message: coalescedMessage, handoff: Boolean(handoff) }, () => this.api.prompt(profile!, sessionId, coalescedMessage, handoff, identityKey ? { application: this.application.slug, identityKey } : undefined));
|
|
250
|
+
await this.setWorkingSession(profile!, sessionPrefix!, sessionId, "message"); await this.api.touchApplicationSession(this.application.slug, profile!, sessionPrefix!, sessionId, identityKey);
|
|
251
|
+
let output: unknown = result;
|
|
252
|
+
if (this.settings.outboundHandler) {
|
|
253
|
+
const outboundInput = { profile, sessionId, message: { content: result.response } };
|
|
254
|
+
const handler = this.handler("outbound", this.settings.outboundHandler);
|
|
255
|
+
output = await run("outbound-handler", `Outbound handler: ${this.settings.outboundHandler}`, outboundInput, async () => handler(outboundInput, headers, query, context, coalescedState, await this.api.handlerEnvironment(profile!)));
|
|
256
|
+
}
|
|
257
|
+
this.logs.finish(call, output); return output;
|
|
258
|
+
};
|
|
259
|
+
const key = `${this.application.slug}:${identityKey ? `identity:${identityKey}` : `route:${profile}:${sessionPrefix}`}`;
|
|
260
|
+
return await this.collect(key, call, message, state, dispatch);
|
|
201
261
|
} catch (error) { if (call.status !== "error") this.logs.fail(call, undefined, error); throw error; }
|
|
202
262
|
}
|
|
203
263
|
}
|
|
@@ -208,5 +268,7 @@ export function validateApplicationSettings(raw: unknown): asserts raw is Settin
|
|
|
208
268
|
if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
|
|
209
269
|
if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
|
|
210
270
|
if (settings.transformHandlers !== undefined && (!Array.isArray(settings.transformHandlers) || settings.transformHandlers.some((name) => !safeName(name)))) throw Object.assign(new Error("Invalid transform handlers."), { status: 400 });
|
|
271
|
+
const coalescing = settings.messageCoalescing;
|
|
272
|
+
if (coalescing !== undefined && (!coalescing || typeof coalescing !== "object" || (coalescing.enabled !== undefined && typeof coalescing.enabled !== "boolean") || (coalescing.silenceDebounceSeconds !== undefined && (!Number.isInteger(coalescing.silenceDebounceSeconds) || coalescing.silenceDebounceSeconds < 1 || coalescing.silenceDebounceSeconds > 3600)))) throw Object.assign(new Error("Invalid message coalescing settings."), { status: 400 });
|
|
211
273
|
}
|
|
212
274
|
|
|
@@ -6,13 +6,13 @@ 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));`); this.migrate(); }
|
|
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
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;"); }
|
|
11
11
|
private value(row: Row): ApplicationRecord { return { id: String(row.id), name: String(row.name), slug: String(row.slug), enabled: Boolean(row.enabled), responseMode: row.response_mode as "ack" | "result", defaultProfile: row.default_profile == null ? null : String(row.default_profile), routingPolicy: row.routing_policy as "default_as_fallback" | "drop", settings: JSON.parse(String(row.settings_json)), createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; }
|
|
12
12
|
list() { return (this.db.prepare("SELECT * FROM applications ORDER BY name").all() as Row[]).map((row) => this.value(row)); } get(slug: string) { const row = this.db.prepare("SELECT * FROM applications WHERE slug=?").get(slug) as Row | undefined; return row ? this.value(row) : undefined; }
|
|
13
13
|
create(input: Pick<ApplicationRecord, "name" | "slug" | "enabled" | "responseMode" | "defaultProfile" | "routingPolicy" | "settings">) { const timestamp = now(), record = { ...input, id: randomUUID(), createdAt: timestamp, updatedAt: timestamp }; this.db.prepare("INSERT INTO applications VALUES (?,?,?,?,?,?,?,?,?,?)").run(record.id, record.name, record.slug, record.enabled ? 1 : 0, record.responseMode, record.defaultProfile, record.routingPolicy, JSON.stringify(record.settings), timestamp, timestamp); return this.get(record.slug)!; }
|
|
14
14
|
update(slug: string, input: Omit<ApplicationRecord, "id" | "slug" | "createdAt" | "updatedAt">) { const result = this.db.prepare("UPDATE applications SET name=?,enabled=?,response_mode=?,default_profile=?,routing_policy=?,settings_json=?,updated_at=? WHERE slug=?").run(input.name, input.enabled ? 1 : 0, input.responseMode, input.defaultProfile, input.routingPolicy, JSON.stringify(input.settings), now(), slug); if (!result.changes) throw new Error("Application not found."); return this.get(slug)!; }
|
|
15
|
-
delete(slug: string) { if (!this.db.prepare("DELETE FROM applications WHERE slug=?").run(slug).changes) throw new Error("Application not found."); for (const table of ["application_sessions", "application_session_state", "application_identity_mappings", "application_automatic_prefixes"]) this.db.prepare(`DELETE FROM ${table} WHERE application_slug=?`).run(slug); }
|
|
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
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
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
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)!; }
|
|
@@ -26,5 +26,9 @@ export class ApplicationStore {
|
|
|
26
26
|
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
27
|
activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
|
|
28
28
|
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 }); }
|
|
29
|
+
nextConsoleSessionId(application: string, identityKey: string) { const date = new Date().toISOString().slice(0, 10), hash = createHash("sha256").update(identityKey).digest("hex").slice(0, 12); const base = `console__app__${application}__${hash}__${date}`; const count = Number((this.db.prepare("SELECT COUNT(*) AS count FROM application_console_sessions WHERE application_slug=? AND identity_key=? AND created_at LIKE ?").get(application, identityKey, `${date}%`) as Row).count) + 1; return `${base}-${String(count).padStart(2, "0")}`; }
|
|
30
|
+
createConsoleSession(sessionId: string, application: string, profile: string, identityKey: string) { const timestamp = now(); this.db.prepare("INSERT INTO application_console_sessions VALUES (?,?,?,?,?,?)").run(sessionId, application, profile, identityKey, timestamp, timestamp); return this.consoleSession(profile, sessionId)!; }
|
|
31
|
+
consoleSession(profile: string, sessionId: string) { const row = this.db.prepare("SELECT * FROM application_console_sessions WHERE profile=? AND session_id=?").get(profile, sessionId) as Row | undefined; return row ? { sessionId: String(row.session_id), application: String(row.application_slug), profile: String(row.profile), identityKey: String(row.identity_key), createdAt: String(row.created_at), updatedAt: String(row.updated_at) } : undefined; }
|
|
32
|
+
deleteConsoleSession(profile: string, sessionId: string) { this.db.prepare("DELETE FROM application_console_sessions WHERE profile=? AND session_id=?").run(profile, sessionId); }
|
|
29
33
|
activeSessions(filters: { profile?: string; application?: string; identityKey?: string; prefix?: string; session?: string } = {}) { const clauses: string[] = [], values: string[] = []; const like = (column: string, value?: string) => { if (value?.trim()) { clauses.push(`${column} LIKE ?`); values.push(`%${value.trim()}%`); } }; like("st.profile", filters.profile); like("s.application_slug", filters.application); like("COALESCE(s.last_identity_key,'')", filters.identityKey); like("s.session_prefix", filters.prefix); like("s.session_id", filters.session); const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return (this.db.prepare(`SELECT s.*,st.reason FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id ${where} ORDER BY st.updated_at DESC`).all(...values) as Row[]).map((row) => ({ ...this.sessionValue(row), reason: String(row.reason) })); }
|
|
30
34
|
}
|
|
@@ -8,7 +8,8 @@ export const RESOURCE_KINDS = ["tools", "skills", "extensions"] as const;
|
|
|
8
8
|
export type ResourceKind = typeof RESOURCE_KINDS[number];
|
|
9
9
|
type PolicyKey = "enabledTools" | "enabledSkills" | "enabledProfileSkills" | "enabledExtensions";
|
|
10
10
|
type SkillSources = { shared?: boolean; profile?: boolean };
|
|
11
|
-
type
|
|
11
|
+
type SkillResourceSource = "shared" | "profile" | "package";
|
|
12
|
+
type ProfilePolicy = Partial<Record<PolicyKey, string[]>> & { skillSources?: SkillSources; contextMemory?: { mode: "file"; target: "profile" | "identity" } | { mode: "handler"; handler: string } };
|
|
12
13
|
export type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy; sandbox?: boolean };
|
|
13
14
|
export type Resource = { name: string; kind: ResourceKind; path?: string; source: "builtin" | "shared" | "profile" | "extension" | "package"; package?: string; enabled: boolean; protected?: boolean };
|
|
14
15
|
export type Package = { name: string; version?: string; description?: string; enabled: boolean; installed: boolean };
|
|
@@ -22,12 +23,23 @@ const policyKey: Record<ResourceKind, PolicyKey> = {
|
|
|
22
23
|
const protectedExtensions = new Set(["profiles", "api-server"]);
|
|
23
24
|
|
|
24
25
|
export class ProfileStore {
|
|
25
|
-
private readonly extensionTools = new Set<string
|
|
26
|
+
private readonly extensionTools = new Map<string, Set<string>>();
|
|
26
27
|
|
|
27
28
|
constructor(readonly agentDir: string) {}
|
|
28
29
|
|
|
29
|
-
registerExtensionTools(names: Iterable<string>): void {
|
|
30
|
-
|
|
30
|
+
registerExtensionTools(profile: string, names: Iterable<string>): void {
|
|
31
|
+
const tools = this.extensionTools.get(profile) ?? new Set<string>();
|
|
32
|
+
for (const name of names) if (name && !BUILTIN_TOOLS.includes(name)) tools.add(name);
|
|
33
|
+
this.extensionTools.set(profile, tools);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
extensionToolNames(profile: string): Iterable<string> {
|
|
37
|
+
return this.extensionTools.get(profile) ?? [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clearExtensionTools(profile?: string): void {
|
|
41
|
+
if (profile) this.extensionTools.delete(profile);
|
|
42
|
+
else this.extensionTools.clear();
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
private profilesDir(): string { return join(this.agentDir, "profiles"); }
|
|
@@ -99,14 +111,26 @@ export class ProfileStore {
|
|
|
99
111
|
return !path || !exclusions.includes(`!${path}`);
|
|
100
112
|
}
|
|
101
113
|
|
|
102
|
-
private async
|
|
114
|
+
private async packageSkillResources(settings: ProfileSettings): Promise<Resource[]> {
|
|
115
|
+
const resources: Resource[] = [];
|
|
116
|
+
for (const pkg of await this.configuredPackages(settings)) {
|
|
117
|
+
for (const entry of Array.isArray(pkg.manifest?.pi?.skills) ? pkg.manifest.pi.skills : []) {
|
|
118
|
+
if (typeof entry !== "string") continue;
|
|
119
|
+
for (const skill of await this.skillNames(join(pkg.base, entry))) resources.push({ name: skill.name, kind: "skills", path: skill.path, source: "package", package: pkg.name, enabled: this.skillEnabled(settings, "package", skill.name) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return resources;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async skillResources(profile: string, settings: ProfileSettings, runtimeSettings: ProfileSettings = settings): Promise<Resource[]> {
|
|
103
126
|
const sources = this.skillSources(profile, settings);
|
|
104
127
|
const entries = await Promise.all((["profile", "shared"] as const).filter((source) => sources[source]).map(async (source) => ({ source, entries: await this.skillNames(this.skillBase(profile, source)) })));
|
|
105
|
-
|
|
128
|
+
const local = entries.flatMap(({ source, entries }) => entries.map(({ name, path }) => ({ name, kind: "skills" as const, path, source, enabled: this.skillEnabled(settings, source, name) })));
|
|
129
|
+
return [...local, ...(sources.shared ? await this.packageSkillResources(runtimeSettings) : [])];
|
|
106
130
|
}
|
|
107
131
|
|
|
108
|
-
private skillEnabled(settings: ProfileSettings, source:
|
|
109
|
-
const values = settings.profile?.[source === "
|
|
132
|
+
private skillEnabled(settings: ProfileSettings, source: SkillResourceSource, name: string): boolean {
|
|
133
|
+
const values = settings.profile?.[source === "profile" ? "enabledProfileSkills" : "enabledSkills"];
|
|
110
134
|
return !values || values.includes("*") || values.includes(name);
|
|
111
135
|
}
|
|
112
136
|
|
|
@@ -120,12 +144,12 @@ export class ProfileStore {
|
|
|
120
144
|
return join(this.agentDir, "git", remote);
|
|
121
145
|
}
|
|
122
146
|
|
|
123
|
-
private async configuredPackages(settings: ProfileSettings): Promise<Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown } } }>> {
|
|
147
|
+
private async configuredPackages(settings: ProfileSettings): Promise<Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } } }>> {
|
|
124
148
|
const sources = Array.isArray(settings.packages) ? settings.packages.filter((value): value is string => typeof value === "string") : [];
|
|
125
|
-
const packages: Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown } } }> = [];
|
|
149
|
+
const packages: Array<{ source: string; base: string; name: string; manifest?: { version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } } }> = [];
|
|
126
150
|
for (const source of sources) {
|
|
127
151
|
const base = this.packageBase(source); if (!base) continue;
|
|
128
|
-
try { const manifest = JSON.parse(await readFile(join(base, "package.json"), "utf8")) as { name?: string; version?: string; description?: string; pi?: { extensions?: unknown } }; packages.push({ source, base, name: manifest.name ?? source, manifest }); }
|
|
152
|
+
try { const manifest = JSON.parse(await readFile(join(base, "package.json"), "utf8")) as { name?: string; version?: string; description?: string; pi?: { extensions?: unknown; skills?: unknown } }; packages.push({ source, base, name: manifest.name ?? source, manifest }); }
|
|
129
153
|
catch { packages.push({ source, base, name: source.replace(/^npm:/, "") }); }
|
|
130
154
|
}
|
|
131
155
|
return packages;
|
|
@@ -168,23 +192,25 @@ export class ProfileStore {
|
|
|
168
192
|
async resources(profile: string, kind: ResourceKind): Promise<Resource[]> {
|
|
169
193
|
const settings = await this.readSettings(profile);
|
|
170
194
|
const runtimeSettings = profile === "default" ? settings : await this.readSettings("default");
|
|
171
|
-
if (kind === "skills") return this.skillResources(profile, settings);
|
|
195
|
+
if (kind === "skills") return this.skillResources(profile, settings, runtimeSettings);
|
|
172
196
|
if (kind === "tools") {
|
|
173
|
-
const
|
|
174
|
-
const
|
|
197
|
+
const extensionTools = this.extensionToolNames(profile);
|
|
198
|
+
const sources = await this.packageToolSources(runtimeSettings, extensionTools);
|
|
199
|
+
const names = new Set([...extensionTools, ...sources.keys()]);
|
|
175
200
|
return [...BUILTIN_TOOLS.map((name) => ({ name, kind, source: "builtin" as const, enabled: this.enabled(settings, kind, name) })), ...[...names].sort().map((name) => sources.has(name) ? ({ name, kind, source: "package" as const, package: sources.get(name), enabled: this.enabled(settings, kind, name) }) : ({ name, kind, source: "extension" as const, enabled: this.enabled(settings, kind, name) }))];
|
|
176
201
|
}
|
|
177
|
-
return [...(await this.names("extensions")).map(({ name, path }) => ({ name, kind, path, source: "shared" as const, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined })), ...(await this.packageExtensions(runtimeSettings)).map(({ name, path, package: packageName }) => ({ name, kind, path, source: "package" as const, package: packageName, enabled: true }))];
|
|
202
|
+
return [...(await this.names("extensions")).map(({ name, path }) => ({ name, kind, path, source: "shared" as const, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined })), ...(await this.packageExtensions(runtimeSettings)).map(({ name, path, package: packageName }) => ({ name, kind, path, source: "package" as const, package: packageName, enabled: protectedExtensions.has(name) ? true : this.enabled(runtimeSettings, kind, name, path), protected: protectedExtensions.has(name) || undefined }))];
|
|
178
203
|
}
|
|
179
204
|
|
|
180
|
-
async resource(profile: string, kind: ResourceKind, name: string, source?:
|
|
205
|
+
async resource(profile: string, kind: ResourceKind, name: string, source?: SkillResourceSource): Promise<Resource> {
|
|
181
206
|
const resource = (await this.resources(profile, kind)).find((item) => item.name === name && (!source || item.source === source));
|
|
182
207
|
if (!resource) throw Object.assign(new Error("Resource not found"), { status: 404 });
|
|
183
208
|
return resource;
|
|
184
209
|
}
|
|
185
210
|
|
|
186
211
|
private async skillPaths(name: string, settings: ProfileSettings): Promise<string[]> {
|
|
187
|
-
|
|
212
|
+
const runtimeSettings = name === "default" ? settings : await this.readSettings("default");
|
|
213
|
+
return (await this.skillResources(name, settings, runtimeSettings)).filter((skill) => skill.enabled).map((skill) => skill.path!).filter(Boolean);
|
|
188
214
|
}
|
|
189
215
|
|
|
190
216
|
private async materializeResources(name: string, settings: ProfileSettings): Promise<ProfileSettings> {
|
|
@@ -232,7 +258,7 @@ export class ProfileStore {
|
|
|
232
258
|
return this.skillSources(profile, settings);
|
|
233
259
|
}
|
|
234
260
|
|
|
235
|
-
async readSkillDocument(profile: string, name: string, source?:
|
|
261
|
+
async readSkillDocument(profile: string, name: string, source?: SkillResourceSource): Promise<{ name: string; frontmatter: Record<string, string>; content: string }> {
|
|
236
262
|
const skill = await this.resource(profile, "skills", name, source);
|
|
237
263
|
if (!skill.path) throw Object.assign(new Error("Skill document not found"), { status: 404 });
|
|
238
264
|
const document = await readFile(join(skill.path, "SKILL.md"), "utf8");
|
|
@@ -247,7 +273,7 @@ export class ProfileStore {
|
|
|
247
273
|
return { name, frontmatter, content: match ? document.slice(match[0].length) : document };
|
|
248
274
|
}
|
|
249
275
|
|
|
250
|
-
async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?:
|
|
276
|
+
async setResource(profile: string, kind: ResourceKind, name: string, enabled: boolean, source?: SkillResourceSource): Promise<Resource> {
|
|
251
277
|
const current = await this.resource(profile, kind, name, source);
|
|
252
278
|
if (current.protected && !enabled) throw Object.assign(new Error("This extension is required and cannot be disabled."), { status: 422 });
|
|
253
279
|
// Extensions are shared runtime resources, so toggling one always updates
|
|
@@ -264,8 +290,9 @@ export class ProfileStore {
|
|
|
264
290
|
return this.resource(profile, kind, name, source);
|
|
265
291
|
}
|
|
266
292
|
|
|
267
|
-
async deleteSkill(profile: string, name: string, source?:
|
|
293
|
+
async deleteSkill(profile: string, name: string, source?: SkillResourceSource): Promise<void> {
|
|
268
294
|
const current = await this.resource(profile, "skills", name, source);
|
|
295
|
+
if (current.source === "package") throw Object.assign(new Error("Package Skills cannot be deleted here."), { status: 403 });
|
|
269
296
|
if (current.source === "shared" && profile !== "default") { await this.setResource(profile, "skills", name, false, "shared"); return; }
|
|
270
297
|
if (!current.path) throw Object.assign(new Error("Skill path not found."), { status: 404 });
|
|
271
298
|
await rm(current.path, { recursive: true, force: false });
|