pi-feats 0.1.1
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/LICENSE +21 -0
- package/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Pi API Server Extension
|
|
2
|
+
|
|
3
|
+
HTTP server for pi with CLI-compatible persistent sessions.
|
|
4
|
+
|
|
5
|
+
## Control
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi api start
|
|
9
|
+
pi api stop
|
|
10
|
+
pi api status
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`start` creates `~/.pi/agent/api-server.json` when absent and generates its `apiToken`. Existing `api` settings are migrated automatically from `settings.json`. Runtime state and logs are stored at:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
~/.pi/agent/api-server.state.json
|
|
17
|
+
~/.pi/agent/api-server.log
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"host": "0.0.0.0",
|
|
25
|
+
"port": 8767,
|
|
26
|
+
"apiToken": "automatically-generated-token",
|
|
27
|
+
"cors": { "origin": "*" }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Restart with `pi api stop && pi api start` after changing host, port, or CORS.
|
|
32
|
+
|
|
33
|
+
## Authentication
|
|
34
|
+
|
|
35
|
+
Every endpoint except health requires:
|
|
36
|
+
|
|
37
|
+
```http
|
|
38
|
+
Authorization: Bearer <apiToken>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Endpoints
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
GET /api/health
|
|
45
|
+
POST /api/sessions/:sessionId/chat
|
|
46
|
+
POST /api/sessions/:sessionId/chat/stream
|
|
47
|
+
POST /profile/:profileName/api/sessions/:sessionId/chat
|
|
48
|
+
POST /profile/:profileName/api/sessions/:sessionId/chat/stream
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Request body:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{ "message": "Explain this project" }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`/chat` returns the complete response:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{ "profile": "default", "sessionId": "abc", "response": "..." }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`/chat/stream` uses Server-Sent Events: `session`, `token`, and `done`.
|
|
64
|
+
|
|
65
|
+
## Sessions and profiles
|
|
66
|
+
|
|
67
|
+
Sessions are created in the same persistent location used by the CLI. For example, a session created through the API can be resumed with:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pi --session abc
|
|
71
|
+
pi profile n1 --session abc
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Profiles follow the `profiles.ts` convention: `default` uses `~/.pi/agent`, while `n1` uses `~/.pi/agent/profiles/n1`.
|
|
75
|
+
|
|
76
|
+
Different sessions run in parallel. Concurrent requests to one session return `409 SESSION_BUSY`.
|
|
77
|
+
|
|
78
|
+
## Profile management
|
|
79
|
+
|
|
80
|
+
All routes below require Bearer authentication. Resource responses expose only metadata and status; they never return the contents of skills, tools, or extensions.
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
GET /api/profiles
|
|
84
|
+
POST /api/profiles { "name": "work" }
|
|
85
|
+
GET /api/profiles/:profile
|
|
86
|
+
DELETE /api/profiles/:profile { "force": true }
|
|
87
|
+
|
|
88
|
+
GET /api/profiles/:profile/settings
|
|
89
|
+
PUT /api/profiles/:profile/settings <settings JSON object>
|
|
90
|
+
|
|
91
|
+
GET /api/profiles/:profile/soul
|
|
92
|
+
PUT /api/profiles/:profile/soul { "content": "..." }
|
|
93
|
+
GET /api/profiles/:profile/refine
|
|
94
|
+
PUT /api/profiles/:profile/refine { "content": "..." }
|
|
95
|
+
|
|
96
|
+
GET /api/profiles/:profile/resources/tools
|
|
97
|
+
GET /api/profiles/:profile/resources/skills
|
|
98
|
+
GET /api/profiles/:profile/resources/extensions
|
|
99
|
+
GET /api/profiles/:profile/resources/:kind/:name
|
|
100
|
+
PATCH /api/profiles/:profile/resources/:kind/:name { "enabled": false }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`kind` is `tools`, `skills`, or `extensions`. Changes to resource state are persisted in the profile settings and apply when a profile runtime is loaded again. The `profiles` and `api-server` extensions are protected because they are required for profile and API operation.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
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
|
+
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 class ApplicationLogStore {
|
|
8
|
+
private calls = new Map<string, ApplicationCall>(); private events = new EventEmitter(); private directory: string;
|
|
9
|
+
constructor(applicationDirectory: string, readonly application: string) { this.directory = join(applicationDirectory, "logs", "calls"); }
|
|
10
|
+
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 {} } }
|
|
11
|
+
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 }); }
|
|
12
|
+
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
|
+
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
|
+
complete(call: ApplicationCall, stage: CallStage, output: unknown) { stage.output = output; stage.finishedAt = new Date().toISOString(); void this.save(call); }
|
|
15
|
+
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
|
+
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); }
|
|
17
|
+
list() { return [...this.calls.values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)); }
|
|
18
|
+
get(id: string) { return this.calls.get(id); }
|
|
19
|
+
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
|
+
on(listener: (event: unknown) => void) { this.events.on("event", listener); return () => this.events.off("event", listener); }
|
|
21
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createJiti } from "jiti";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import { inspect } from "node:util";
|
|
8
|
+
import { ApplicationLogStore } from "./application-log-store.ts";
|
|
9
|
+
|
|
10
|
+
type RecordValue = Record<string, any>;
|
|
11
|
+
export type Settings = { responseMode: "ack" | "result"; transformHandlers?: string[]; inboundHandler: string; outboundHandler?: string; defaultProfile?: string | null; messageCoalescing?: { enabled?: boolean; silenceDebounceSeconds?: number } };
|
|
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>> };
|
|
14
|
+
type Handoff = { sourceSessionId: string | null; summary: string; createdAt: string; targetCreatedAt: string };
|
|
15
|
+
type WorkingSession = { sessionId: string; updatedAt: string; reason: "message" | "rollover" };
|
|
16
|
+
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
|
+
|
|
18
|
+
const safeName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(name);
|
|
19
|
+
const day = () => new Date().toISOString().slice(0, 10);
|
|
20
|
+
const asRecord = (value: unknown): RecordValue => value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
|
|
21
|
+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
+
type TestLogs = { stdout: string[]; stderr: string[] };
|
|
23
|
+
const testConsoleLogs = new AsyncLocalStorage<TestLogs>();
|
|
24
|
+
let consoleCaptureInstalled = false;
|
|
25
|
+
const logValues = (values: unknown[]) => values.map((value) => typeof value === "string" ? value : inspect(value, { depth: 6, colors: false })).join(" ");
|
|
26
|
+
function installConsoleCapture(): void {
|
|
27
|
+
if (consoleCaptureInstalled) return;
|
|
28
|
+
consoleCaptureInstalled = true;
|
|
29
|
+
for (const [method, stream] of [["log", "stdout"], ["info", "stdout"], ["debug", "stdout"], ["warn", "stderr"], ["error", "stderr"]] as const) {
|
|
30
|
+
const original = console[method].bind(console);
|
|
31
|
+
console[method] = (...values: any[]) => { const logs = testConsoleLogs.getStore(); if (logs) logs[stream].push(logValues(values)); else original(...values); };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ApplicationRuntime {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly settings: Settings;
|
|
38
|
+
private readonly jiti = createJiti(process.cwd(), { moduleCache: false, fsCache: false });
|
|
39
|
+
private queues = new Map<string, Promise<void>>();
|
|
40
|
+
|
|
41
|
+
readonly logs: ApplicationLogStore;
|
|
42
|
+
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); }
|
|
43
|
+
|
|
44
|
+
static async load(directory: string, api: ApplicationApi, settings: unknown, persistSettings: (settings: Settings) => Promise<void>, application: ApplicationIdentity): Promise<ApplicationRuntime> {
|
|
45
|
+
validateApplicationSettings(settings);
|
|
46
|
+
const runtime = new ApplicationRuntime(directory, settings, api, persistSettings, application); await runtime.logs.initialize(); return runtime;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async updateSettings(settings: Settings): Promise<void> {
|
|
50
|
+
await this.persistSettings(settings);
|
|
51
|
+
for (const key of Object.keys(this.settings)) delete (this.settings as Record<string, unknown>)[key];
|
|
52
|
+
Object.assign(this.settings, settings);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private handler(kind: "inbound" | "outbound" | "transform", name: string): Handler {
|
|
56
|
+
const path = kind === "transform" ? join(this.directory, "handlers", "transforms", `${name}.ts`) : join(this.directory, "handlers", `${name}.ts`);
|
|
57
|
+
if (!existsSync(path)) throw new Error(`Handler not found: ${name}`);
|
|
58
|
+
const loaded = this.jiti(path) as { handle?: Handler };
|
|
59
|
+
if (typeof loaded.handle !== "function") throw new Error(`Handler "${name}" must export handle().`);
|
|
60
|
+
return loaded.handle;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private profileFrom(value: unknown): string | undefined {
|
|
64
|
+
if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as RecordValue).profile === "string") return (value as RecordValue).profile;
|
|
65
|
+
const mappings = (this.settings as any).mappings?.remoteJid as Record<string, { profile?: unknown }> | undefined;
|
|
66
|
+
if (!mappings) return undefined;
|
|
67
|
+
const pending: unknown[] = [value];
|
|
68
|
+
const seen = new Set<object>();
|
|
69
|
+
while (pending.length) {
|
|
70
|
+
const current = pending.pop();
|
|
71
|
+
if (typeof current === "string" && typeof mappings[current]?.profile === "string") return mappings[current].profile as string;
|
|
72
|
+
if (!current || typeof current !== "object" || seen.has(current)) continue;
|
|
73
|
+
seen.add(current);
|
|
74
|
+
if (Array.isArray(current)) pending.push(...current);
|
|
75
|
+
else pending.push(...Object.values(current));
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async environmentFor(...values: unknown[]): Promise<Record<string, string | undefined>> {
|
|
81
|
+
const profile = values.map((value) => this.profileFrom(value)).find((value): value is string => Boolean(value)) ?? "default";
|
|
82
|
+
return this.api.handlerEnvironment(profile);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async withQueue<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
|
86
|
+
const previous = this.queues.get(key) ?? Promise.resolve();
|
|
87
|
+
let release!: () => void;
|
|
88
|
+
const current = new Promise<void>((resolve) => { release = resolve; });
|
|
89
|
+
this.queues.set(key, previous.then(() => current));
|
|
90
|
+
await previous;
|
|
91
|
+
try { return await operation(); } finally { release(); if (this.queues.get(key) === current) this.queues.delete(key); }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async resolveSession(profile: string, sessionPrefix: string): Promise<string> {
|
|
95
|
+
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix."), { status: 422 });
|
|
96
|
+
return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
|
|
97
|
+
const current = await this.api.getApplicationSession(this.application.slug, profile, sessionPrefix); if (current) return current.sessionId;
|
|
98
|
+
const id = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
|
|
99
|
+
await this.api.createSession(profile, { id }); await this.api.recordApplicationSession(this.application.slug, profile, sessionPrefix, id, false); return id;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private handoffPath(profile: string, sessionId: string): string { return join(this.directory, "handoffs", profile, `${sessionId}.json`); }
|
|
104
|
+
|
|
105
|
+
private async handoff(profile: string, sessionId: string, prefix: string | null | undefined, force = false): Promise<Handoff | undefined> {
|
|
106
|
+
if (!prefix) return undefined;
|
|
107
|
+
const path = this.handoffPath(profile, sessionId);
|
|
108
|
+
const expression = new RegExp("^" + escapeRegExp(`${this.application.slug}_${prefix}`) + "_([0-9]{4}-[0-9]{2}-[0-9]{2})-([0-9]{2})$");
|
|
109
|
+
const sessions = await this.api.listSessions(profile);
|
|
110
|
+
const target = sessions.find((item) => item.id === sessionId);
|
|
111
|
+
if (!target) return undefined;
|
|
112
|
+
if (existsSync(path)) { const cached = JSON.parse(await readFile(path, "utf8")) as Handoff; if (cached.targetCreatedAt === String(target.createdAt)) return cached; }
|
|
113
|
+
if (!force && Number(target?.messageCount ?? 0) > 0) return undefined;
|
|
114
|
+
const prior = sessions.map((item) => ({ id: String(item.id), match: String(item.id).match(expression) })).filter((item): item is { id: string; match: RegExpMatchArray } => item.match !== null && item.id !== sessionId).sort((a, b) => `${b.match[1]}-${b.match[2]}`.localeCompare(`${a.match[1]}-${a.match[2]}`))[0];
|
|
115
|
+
let summary = "";
|
|
116
|
+
if (prior) {
|
|
117
|
+
const conversation = await this.api.getSessionConversation(profile, prior.id);
|
|
118
|
+
summary = await this.api.complete(profile, `Summarize this completed Application conversation for a future handoff. Return ONLY unresolved user-facing tasks, unanswered questions, promised follow-ups, and durable preferences that are necessary to continue them. Exclude completed work, casual discussion, speculation, and sensitive details that are not necessary. If nothing is pending, return exactly NONE.\n\nConversation:\n${JSON.stringify(conversation.entries ?? [])}`);
|
|
119
|
+
if (/^\s*none\b/i.test(summary)) summary = "";
|
|
120
|
+
}
|
|
121
|
+
const value: Handoff = { sourceSessionId: prior?.id ?? null, summary, createdAt: new Date().toISOString(), targetCreatedAt: String(target.createdAt) };
|
|
122
|
+
await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async relevantHandoff(profile: string, sessionId: string, prefix: string | null | undefined, message: string): Promise<string | undefined> {
|
|
127
|
+
const handoff = await this.handoff(profile, sessionId, prefix);
|
|
128
|
+
if (!handoff?.summary) return undefined;
|
|
129
|
+
const verdict = await this.api.complete(profile, `Reply with exactly YES or NO. Is the user message meaningfully related to any unresolved item in the handoff?\n\nHandoff:\n${handoff.summary}\n\nUser message:\n${message}`);
|
|
130
|
+
return /^\s*yes\b/i.test(verdict) ? handoff.summary : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async testHandler(path: string, payload: RecordValue, content?: string): Promise<{ ok: boolean; output?: unknown; logs: { stdout: string[]; stderr: string[] }; durationMs: number; error?: string }> {
|
|
134
|
+
const stdout: string[] = [], stderr: string[] = [];
|
|
135
|
+
const started = performance.now();
|
|
136
|
+
let temporaryPath: string | undefined;
|
|
137
|
+
try {
|
|
138
|
+
if (content !== undefined) {
|
|
139
|
+
temporaryPath = join(dirname(path), `.pi-handler-test-${randomUUID()}.ts`);
|
|
140
|
+
await writeFile(temporaryPath, content, "utf8");
|
|
141
|
+
}
|
|
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)) } };
|
|
145
|
+
installConsoleCapture();
|
|
146
|
+
const output = await testConsoleLogs.run({ stdout, stderr }, async () => loaded.handle(payload, {}, {}, context, {}, await this.environmentFor(payload)));
|
|
147
|
+
return { ok: true, output, logs: { stdout, stderr }, durationMs: Math.round(performance.now() - started) };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
150
|
+
stderr.push(message);
|
|
151
|
+
return { ok: false, logs: { stdout, stderr }, durationMs: Math.round(performance.now() - started), error: message };
|
|
152
|
+
} finally {
|
|
153
|
+
if (temporaryPath) await rm(temporaryPath, { force: true }).catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async setWorkingSession(profile: string, _prefix: string, sessionId: string, _reason: WorkingSession["reason"]): Promise<void> {
|
|
158
|
+
// The database is the authoritative active-session state. Pulses are retargeted by explicit rollover callers.
|
|
159
|
+
void profile; void sessionId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async rollover(input: unknown): Promise<RecordValue> {
|
|
164
|
+
const body = asRecord(input); const profile = body.profile; const sessionPrefix = body.sessionPrefix ?? null;
|
|
165
|
+
if (typeof profile !== "string" || !profile) throw Object.assign(new Error("The 'profile' field is required."), { status: 400 });
|
|
166
|
+
if (typeof sessionPrefix !== "string" || !sessionPrefix) throw Object.assign(new Error("The 'sessionPrefix' field is required."), { status: 400 });
|
|
167
|
+
if (!safeName(sessionPrefix)) throw Object.assign(new Error("Invalid sessionPrefix"), { status: 400 });
|
|
168
|
+
return this.withQueue(`${this.application.slug}:${profile}:${sessionPrefix}:${day()}`, async () => {
|
|
169
|
+
const previous = await this.api.getActiveApplicationSession(this.application.slug, profile, sessionPrefix); const sessionId = await this.api.nextApplicationSessionId(this.application.slug, profile, sessionPrefix);
|
|
170
|
+
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);
|
|
171
|
+
try {
|
|
172
|
+
const handoff = await this.handoff(profile, sessionId, sessionPrefix, true);
|
|
173
|
+
return { profile, sessionId, messageCount: 0, handoff: { status: handoff?.summary ? "ready" : "none", sourceSessionId: handoff?.sourceSessionId ?? null } };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { profile, sessionId, messageCount: 0, handoff: { status: "failed", sourceSessionId: null, error: error instanceof Error ? error.message : String(error) } };
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async process(payload: unknown, headers: RecordValue, query: RecordValue): Promise<unknown> {
|
|
181
|
+
const call = this.logs.start(payload, headers); let active: any; let current = asRecord(payload); let state: RecordValue = {};
|
|
182
|
+
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(" ")) } };
|
|
183
|
+
const run = async (type: string, label: string, input: unknown, operation: () => Promise<any>) => { active = this.logs.stage(call, type, label, input); try { const output = await operation(); this.logs.complete(call, active, output); return output; } catch (error) { this.logs.fail(call, active, error); throw error; } finally { active = undefined; } };
|
|
184
|
+
try {
|
|
185
|
+
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
|
+
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
|
+
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 sessionId = await this.resolveSession(profile, sessionPrefix);
|
|
189
|
+
let handoff: string | undefined;
|
|
190
|
+
try { handoff = await run("handoff-relevance", "Handoff relevance", { profile, sessionId, message }, () => this.relevantHandoff(profile, sessionId, sessionPrefix, message)); }
|
|
191
|
+
catch (error) { context.log.error("Handoff skipped:", error instanceof Error ? error.message : String(error)); }
|
|
192
|
+
const result = await run("pi-agent", "Pi agent", { profile, sessionId, message, handoff: Boolean(handoff) }, () => this.api.prompt(profile, sessionId, message, handoff));
|
|
193
|
+
await this.setWorkingSession(profile, sessionPrefix, sessionId, "message"); await this.api.touchApplicationSession(this.application.slug, profile, sessionPrefix, sessionId, identityKey);
|
|
194
|
+
let output: unknown = result;
|
|
195
|
+
if (this.settings.outboundHandler) {
|
|
196
|
+
const outboundInput = { profile, sessionId, message: { content: result.response } };
|
|
197
|
+
const handler = this.handler("outbound", this.settings.outboundHandler);
|
|
198
|
+
output = await run("outbound-handler", `Outbound handler: ${this.settings.outboundHandler}`, outboundInput, async () => handler(outboundInput, headers, query, context, state, await this.api.handlerEnvironment(profile)));
|
|
199
|
+
}
|
|
200
|
+
this.logs.finish(call, output); return output;
|
|
201
|
+
} catch (error) { if (call.status !== "error") this.logs.fail(call, undefined, error); throw error; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function validateApplicationSettings(raw: unknown): asserts raw is Settings {
|
|
206
|
+
const settings = raw as Settings;
|
|
207
|
+
if (!settings || (settings.responseMode !== "ack" && settings.responseMode !== "result")) throw Object.assign(new Error("Invalid Application settings."), { status: 400 });
|
|
208
|
+
if (!safeName(settings.inboundHandler)) throw Object.assign(new Error("Invalid inbound handler."), { status: 400 });
|
|
209
|
+
if (settings.outboundHandler !== undefined && !safeName(settings.outboundHandler)) throw Object.assign(new Error("Invalid outbound handler."), { status: 400 });
|
|
210
|
+
if (settings.transformHandlers !== undefined && (!Array.isArray(settings.transformHandlers) || settings.transformHandlers.some((name) => !safeName(name)))) throw Object.assign(new Error("Invalid transform handlers."), { status: 400 });
|
|
211
|
+
}
|
|
212
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
export type ApplicationRecord = { id: string; name: string; slug: string; enabled: boolean; responseMode: "ack" | "result"; defaultProfile: string | null; routingPolicy: "default_as_fallback" | "drop"; settings: Record<string, unknown>; createdAt: string; updatedAt: string };
|
|
6
|
+
type Row = Record<string, unknown>; const now = () => new Date().toISOString();
|
|
7
|
+
export class ApplicationStore {
|
|
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(); }
|
|
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
|
+
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
|
+
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
|
+
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
|
+
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); }
|
|
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)!; }
|
|
20
|
+
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
|
+
automaticPrefix(slug: string, identityKey: string) { const existing = this.db.prepare("SELECT session_prefix FROM application_automatic_prefixes WHERE application_slug=? AND identity_key=?").get(slug, identityKey) as Row | undefined; if (existing) return String(existing.session_prefix); const normalized = identityKey.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9_-]+/g, "_").replace(/_+/g, "_").replace(/^[_-]+|[_-]+$/g, ""); const base = (normalized && /^[a-z]/.test(normalized) ? normalized : `id_${normalized || "identity"}`).slice(0, 63); let prefix = base; const collision = () => this.db.prepare("SELECT identity_key FROM application_automatic_prefixes WHERE application_slug=? AND session_prefix=?").get(slug, prefix) as Row | undefined; if (collision()) { const suffix = `--${createHash("sha256").update(identityKey).digest("hex").slice(0, 8)}`; prefix = `${base.slice(0, 63 - suffix.length)}${suffix}`; } this.db.prepare("INSERT INTO application_automatic_prefixes VALUES (?,?,?,?)").run(slug, identityKey, prefix, now()); return prefix; }
|
|
22
|
+
session(slug: string, profile: string, prefix: string) { const row = this.db.prepare("SELECT s.* FROM application_session_state st JOIN application_sessions s ON s.session_id=st.current_session_id WHERE st.application_slug=? AND st.profile=? AND st.session_prefix=?").get(slug, profile, prefix) as Row | undefined; return row ? this.sessionValue(row) : undefined; }
|
|
23
|
+
private sessionValue(row: Row) { return { application: String(row.application_slug), profile: String(row.profile), sessionPrefix: String(row.session_prefix), sessionId: String(row.session_id), sessionDate: String(row.session_date), sequence: Number(row.sequence), createdAt: String(row.created_at), updatedAt: String(row.updated_at), lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), messageCount: Number(row.message_count), createdFromRollover: Boolean(row.created_from_rollover), lastIdentityKey: row.last_identity_key == null ? null : String(row.last_identity_key) }; }
|
|
24
|
+
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)!; }
|
|
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
|
+
activeSession(slug: string, profile: string, prefix: string) { return this.session(slug, profile, prefix); }
|
|
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
|
+
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, openSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
type ApiSettings = { host?: string; port?: number; apiToken?: string; cors?: { origin?: string } };
|
|
10
|
+
type LegacySettings = Record<string, unknown> & { api?: ApiSettings };
|
|
11
|
+
type State = { pid: number; host: string; port: number; startedAt: string };
|
|
12
|
+
const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
13
|
+
const configPath = () => join(agentDir(), "api-server.json");
|
|
14
|
+
const legacySettingsPath = () => join(agentDir(), "settings.json");
|
|
15
|
+
const statePath = () => join(agentDir(), "api-server.state.json");
|
|
16
|
+
const logPath = () => join(agentDir(), "api-server.log");
|
|
17
|
+
|
|
18
|
+
async function readJson<T>(path: string): Promise<T | undefined> { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw new Error(`could not read ${path}: ${error instanceof Error ? error.message : String(error)}`); } }
|
|
19
|
+
async function writeJson(path: string, value: unknown) { await mkdir(agentDir(), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); }
|
|
20
|
+
async function migrateLegacyConfig(): Promise<ApiSettings> {
|
|
21
|
+
const current = await readJson<ApiSettings>(configPath());
|
|
22
|
+
if (current) return current;
|
|
23
|
+
const legacy = await readJson<LegacySettings>(legacySettingsPath());
|
|
24
|
+
const api = legacy?.api ?? {};
|
|
25
|
+
await writeJson(configPath(), api);
|
|
26
|
+
if (legacy?.api) { const { api: _api, ...remaining } = legacy; await writeJson(legacySettingsPath(), remaining); }
|
|
27
|
+
return api;
|
|
28
|
+
}
|
|
29
|
+
async function getApiSettings(): Promise<Required<Pick<ApiSettings, "host" | "port" | "apiToken">> & ApiSettings> {
|
|
30
|
+
const api = await migrateLegacyConfig();
|
|
31
|
+
const host = typeof api.host === "string" && api.host ? api.host : "0.0.0.0";
|
|
32
|
+
const port = typeof api.port === "number" && Number.isInteger(api.port) && api.port > 0 && api.port <= 65535 ? api.port : 8767;
|
|
33
|
+
const apiToken = typeof api.apiToken === "string" && api.apiToken.length >= 24 ? api.apiToken : randomBytes(32).toString("base64url");
|
|
34
|
+
const resolved = { ...api, host, port, apiToken, cors: api.cors ?? { origin: "*" } };
|
|
35
|
+
if (api.apiToken !== apiToken || api.host !== host || api.port !== port || !api.cors) await writeJson(configPath(), resolved);
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
async function readState(): Promise<State | undefined> { return readJson<State>(statePath()); }
|
|
39
|
+
function isAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
40
|
+
async function clearStaleState() { const state = await readState(); if (state && !isAlive(state.pid)) await unlink(statePath()).catch(() => {}); }
|
|
41
|
+
async function start() {
|
|
42
|
+
const api = await getApiSettings(); await clearStaleState(); const current = await readState();
|
|
43
|
+
if (current && isAlive(current.pid)) return console.log(`API is already running (PID ${current.pid}) at http://${current.host}:${current.port}`);
|
|
44
|
+
const fd = openSync(logPath(), "a");
|
|
45
|
+
const child = spawn("sh", ["-c", "tail -f /dev/null | \"$@\"", "pi-api-worker", process.execPath, process.argv[1], "--mode", "rpc", "--no-session"], { cwd: process.cwd(), detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, PI_API_WORKER: "1" } });
|
|
46
|
+
child.unref(); await writeJson(statePath(), { pid: child.pid!, host: api.host, port: api.port, startedAt: new Date().toISOString() });
|
|
47
|
+
console.log(`API started at http://${api.host}:${api.port} (PID ${child.pid}).`); console.log(`The token is stored in ${configPath()}.`);
|
|
48
|
+
}
|
|
49
|
+
async function stop() { const state = await readState(); if (!state || !isAlive(state.pid)) { await unlink(statePath()).catch(() => {}); return console.log("API is not running."); } try { process.kill(-state.pid, "SIGTERM"); } catch { process.kill(state.pid, "SIGTERM"); } for (let i = 0; i < 30 && isAlive(state.pid); i++) await new Promise((resolve) => setTimeout(resolve, 100)); if (isAlive(state.pid)) { try { process.kill(-state.pid, "SIGKILL"); } catch { process.kill(state.pid, "SIGKILL"); } } await unlink(statePath()).catch(() => {}); console.log("API stopped."); }
|
|
50
|
+
async function status() { await clearStaleState(); const state = await readState(); console.log(!state || !isAlive(state.pid) ? "API: stopped" : `API: running (PID ${state.pid}) at http://${state.host}:${state.port}`); }
|
|
51
|
+
async function handleCliCommand(): Promise<boolean> { const args = process.argv.slice(2); if (args[0] !== "api" || process.env.PI_API_WORKER === "1") return false; switch (args[1]) { case "start": await start(); break; case "stop": await stop(); break; case "status": await status(); break; case "restart": await stop(); await start(); break; default: console.error("Usage: pi api start | stop | restart | status"); process.exitCode = 1; } return true; }
|
|
52
|
+
export default async function (pi: ExtensionAPI) { if (await handleCliCommand()) process.exit(); if (process.env.PI_API_WORKER !== "1") return; pi.on("session_start", async () => { const { startApiServer } = await import("./server.ts"); await startApiServer({ agentDir: agentDir(), cwd: process.cwd() }); }); }
|