@pylonsync/create-pylon 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/templates/_root/AGENTS.md +1 -1
  3. package/templates/agency/AGENTS.md +1 -1
  4. package/templates/ai-chat/AGENTS.md +1 -1
  5. package/templates/ai-studio/AGENTS.md +1 -1
  6. package/templates/barebones/AGENTS.md +1 -1
  7. package/templates/chat/AGENTS.md +1 -1
  8. package/templates/chat/app/chat-client.tsx +1 -1
  9. package/templates/consumer/AGENTS.md +1 -1
  10. package/templates/consumer/components/social/post-parts.tsx +1 -1
  11. package/templates/consumer/components/social/post-screen.tsx +1 -1
  12. package/templates/consumer/components/social/use-social.ts +7 -7
  13. package/templates/creator/AGENTS.md +1 -1
  14. package/templates/crm/AGENTS.md +1 -1
  15. package/templates/directory/AGENTS.md +1 -1
  16. package/templates/helpdesk/AGENTS.md +1 -1
  17. package/templates/imessage/.env.example +2 -4
  18. package/templates/imessage/AGENTS.md +5 -4
  19. package/templates/imessage/README.md +14 -14
  20. package/templates/imessage/app/console.tsx +3 -3
  21. package/templates/imessage/components/setup-view.tsx +5 -9
  22. package/templates/imessage/functions/assistant.ts +17 -34
  23. package/templates/imessage/functions/processTurn.ts +14 -13
  24. package/templates/imessage/functions/resolveTurn.ts +10 -15
  25. package/templates/imessage/functions/setupStatus.ts +2 -3
  26. package/templates/imessage/lib/turn.ts +25 -0
  27. package/templates/imessage/lib/urls.ts +20 -0
  28. package/templates/imessage/tests/turn.test.ts +141 -0
  29. package/templates/inventory/AGENTS.md +1 -1
  30. package/templates/invoices/AGENTS.md +1 -1
  31. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +2 -2
  32. package/templates/local-service/AGENTS.md +1 -1
  33. package/templates/marketplace/AGENTS.md +1 -1
  34. package/templates/marketplace/client/WatchButton.tsx +2 -2
  35. package/templates/projects/AGENTS.md +1 -1
  36. package/templates/projects/app/projects/[id]/project-view.tsx +2 -2
  37. package/templates/restaurant/AGENTS.md +1 -1
  38. package/templates/saas/AGENTS.md +1 -1
  39. package/templates/shop/AGENTS.md +1 -1
  40. package/templates/todo/AGENTS.md +1 -1
  41. package/templates/waitlist/AGENTS.md +1 -1
  42. package/templates/imessage/lib/agent-call.ts +0 -251
  43. package/templates/imessage/tests/agent-call.test.ts +0 -112
@@ -1,251 +0,0 @@
1
- // Run the `assistant` agent for an inbound text.
2
- //
3
- // `agent()` persists every turn into AgentRun/AgentMessage rows owned by the
4
- // caller, so it needs a signed-in user. Inbound texts arrive on a webhook or a
5
- // relay sync, where there is no user. The server therefore calls the agent
6
- // over HTTP as the owner: it mints a session for the owner's user id with
7
- // POST /api/auth/session (open in `pylon dev`; requires PYLON_ADMIN_TOKEN in
8
- // production), then POSTs /api/fn/assistant with that session. The owner's
9
- // session resolves to an admin (PYLON_ADMIN_EMAILS), which is the only caller
10
- // the agent accepts. The runs then show up live in the owner's dashboard
11
- // through the framework's owner-scoped AgentRun/AgentMessage sync.
12
- //
13
- // The session token lives only in this process's memory and is reused until
14
- // the server rejects it.
15
-
16
- import { AGENT_NAME } from "./assistant";
17
-
18
- type Env = Record<string, string | undefined>;
19
-
20
- export interface AgentResult {
21
- runId: string;
22
- text: string;
23
- steps: number;
24
- queued?: boolean;
25
- cancelled?: boolean;
26
- }
27
-
28
- export type AgentCallOutcome =
29
- | { ok: true; result: AgentResult }
30
- | { ok: false; code: string; message: string };
31
-
32
- export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
33
-
34
- /**
35
- * Origin the server calls itself on. The request carries PYLON_ADMIN_TOKEN, so
36
- * it stays on this machine whenever the port is known (PYLON_PORT). Otherwise
37
- * it goes to APP_URL / PYLON_PUBLIC_URL, and only over https: a plain-http
38
- * public origin would put the admin token on the wire. Falls back to the
39
- * default local port.
40
- */
41
- export function selfBaseUrl(env: Env): string {
42
- const port = Number.parseInt(env.PYLON_PORT ?? "", 10);
43
- if (Number.isFinite(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
44
- const raw = (env.APP_URL || env.PYLON_PUBLIC_URL || "").trim();
45
- if (raw !== "") {
46
- try {
47
- const u = new URL(raw);
48
- if (u.protocol === "https:") return u.origin;
49
- } catch {
50
- // Fall through to the local default.
51
- }
52
- }
53
- return "http://127.0.0.1:4321";
54
- }
55
-
56
- export interface SseEvent {
57
- event: string;
58
- data: string;
59
- }
60
-
61
- /** Parse a complete text/event-stream body into events. Comment and id lines are dropped. */
62
- export function parseSse(body: string): SseEvent[] {
63
- const events: SseEvent[] = [];
64
- for (const block of body.replace(/\r\n?/g, "\n").split("\n\n")) {
65
- let event = "message";
66
- const data: string[] = [];
67
- for (const line of block.split("\n")) {
68
- if (line.startsWith("event:")) event = line.slice(6).trim();
69
- else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
70
- }
71
- if (data.length > 0) events.push({ event, data: data.join("\n") });
72
- }
73
- return events;
74
- }
75
-
76
- /** True once `body` holds a complete `result` or `error` event. */
77
- export function hasTerminalEvent(body: string): boolean {
78
- const complete = body.replace(/\r\n?/g, "\n");
79
- const end = complete.lastIndexOf("\n\n");
80
- if (end === -1) return false;
81
- return parseSse(complete.slice(0, end)).some((e) => e.event === "result" || e.event === "error");
82
- }
83
-
84
- /**
85
- * Read an SSE response until its `result` or `error` event, then stop. The
86
- * server can hold a finished stream open (it stays resumable for a while), so
87
- * waiting for the connection to close would hang.
88
- */
89
- async function readUntilTerminalEvent(res: Response): Promise<string> {
90
- if (!res.body) return "";
91
- const reader = res.body.getReader();
92
- const decoder = new TextDecoder();
93
- let body = "";
94
- try {
95
- for (;;) {
96
- const { value, done } = await reader.read();
97
- if (done) break;
98
- body += decoder.decode(value, { stream: true });
99
- if (hasTerminalEvent(body)) break;
100
- }
101
- } finally {
102
- await reader.cancel().catch(() => {});
103
- }
104
- return body;
105
- }
106
-
107
- function asResult(value: unknown): AgentResult | null {
108
- if (typeof value !== "object" || value === null) return null;
109
- const v = value as Record<string, unknown>;
110
- if (typeof v.runId !== "string") return null;
111
- return {
112
- runId: v.runId,
113
- text: typeof v.text === "string" ? v.text : "",
114
- steps: typeof v.steps === "number" ? v.steps : 0,
115
- queued: v.queued === true,
116
- cancelled: v.cancelled === true,
117
- };
118
- }
119
-
120
- function asError(value: unknown, fallback: string): { code: string; message: string } {
121
- const v = (value ?? {}) as Record<string, unknown>;
122
- const e = (typeof v.error === "object" && v.error !== null ? v.error : v) as Record<string, unknown>;
123
- return {
124
- code: typeof e.code === "string" ? e.code : "AGENT_FAILED",
125
- message: typeof e.message === "string" ? e.message : fallback,
126
- };
127
- }
128
-
129
- /** Interpret the agent endpoint's response: SSE when it streamed, JSON otherwise. */
130
- export function readAgentResponse(status: number, contentType: string, body: string): AgentCallOutcome {
131
- if (contentType.includes("text/event-stream")) {
132
- const events = parseSse(body);
133
- const result = events.find((e) => e.event === "result");
134
- if (result) {
135
- try {
136
- const parsed = asResult(JSON.parse(result.data));
137
- if (parsed) return { ok: true, result: parsed };
138
- } catch {
139
- // Reported below.
140
- }
141
- }
142
- const error = events.find((e) => e.event === "error");
143
- if (error) {
144
- try {
145
- return { ok: false, ...asError(JSON.parse(error.data), error.data) };
146
- } catch {
147
- return { ok: false, code: "AGENT_FAILED", message: error.data.slice(0, 300) };
148
- }
149
- }
150
- return { ok: false, code: "AGENT_NO_RESULT", message: "The agent stream ended without a result" };
151
- }
152
- let json: unknown = null;
153
- try {
154
- json = JSON.parse(body);
155
- } catch {
156
- return { ok: false, code: `HTTP_${status}`, message: body.slice(0, 300) || `HTTP ${status}` };
157
- }
158
- if (status >= 200 && status < 300) {
159
- const parsed = asResult(json);
160
- if (parsed) return { ok: true, result: parsed };
161
- }
162
- return { ok: false, ...asError(json, `HTTP ${status}`) };
163
- }
164
-
165
- interface CachedSession {
166
- userId: string;
167
- token: string;
168
- }
169
-
170
- let cached: CachedSession | null = null;
171
-
172
- async function mintSession(
173
- base: string,
174
- userId: string,
175
- adminToken: string,
176
- fetchImpl: FetchLike,
177
- ): Promise<string> {
178
- const headers: Record<string, string> = { "content-type": "application/json" };
179
- if (adminToken) headers.authorization = `Bearer ${adminToken}`;
180
- const res = await fetchImpl(`${base}/api/auth/session`, {
181
- method: "POST",
182
- headers,
183
- body: JSON.stringify({ user_id: userId }),
184
- signal: AbortSignal.timeout(15_000),
185
- });
186
- const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
187
- if (!res.ok || typeof body.token !== "string") {
188
- const err = asError(body, `HTTP ${res.status}`);
189
- throw Object.assign(new Error(err.message), {
190
- code: res.status === 403 ? "OWNER_SESSION_FORBIDDEN" : err.code,
191
- });
192
- }
193
- return body.token;
194
- }
195
-
196
- export interface AgentCallArgs {
197
- input: string;
198
- runId?: string;
199
- title?: string;
200
- }
201
-
202
- export async function runAgentAsOwner(
203
- env: Env,
204
- ownerUserId: string,
205
- args: AgentCallArgs,
206
- fetchImpl: FetchLike = fetch,
207
- ): Promise<AgentCallOutcome> {
208
- const base = selfBaseUrl(env);
209
- const adminToken = (env.PYLON_ADMIN_TOKEN ?? "").trim();
210
-
211
- for (let attempt = 0; attempt < 2; attempt++) {
212
- if (!cached || cached.userId !== ownerUserId) {
213
- try {
214
- cached = { userId: ownerUserId, token: await mintSession(base, ownerUserId, adminToken, fetchImpl) };
215
- } catch (err) {
216
- const e = err as { code?: string; message?: string };
217
- return { ok: false, code: e.code ?? "OWNER_SESSION_FAILED", message: e.message ?? String(err) };
218
- }
219
- }
220
- let res: Response;
221
- try {
222
- res = await fetchImpl(`${base}/api/fn/${AGENT_NAME}`, {
223
- method: "POST",
224
- headers: {
225
- "content-type": "application/json",
226
- accept: "text/event-stream",
227
- authorization: `Bearer ${cached.token}`,
228
- },
229
- body: JSON.stringify(args),
230
- signal: AbortSignal.timeout(280_000),
231
- });
232
- } catch (err) {
233
- return { ok: false, code: "AGENT_UNREACHABLE", message: err instanceof Error ? err.message : String(err) };
234
- }
235
- const contentType = res.headers.get("content-type") ?? "";
236
- const body = contentType.includes("text/event-stream") ? await readUntilTerminalEvent(res) : await res.text();
237
- const outcome = readAgentResponse(res.status, contentType, body);
238
- // A session the server no longer accepts: mint a new one and retry once.
239
- if (!outcome.ok && (res.status === 401 || res.status === 403) && attempt === 0) {
240
- cached = null;
241
- continue;
242
- }
243
- return outcome;
244
- }
245
- return { ok: false, code: "AGENT_AUTH_FAILED", message: "Could not authenticate as the owner" };
246
- }
247
-
248
- /** Test seam: forget the cached owner session. */
249
- export function resetOwnerSessionCache(): void {
250
- cached = null;
251
- }
@@ -1,112 +0,0 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
2
- import {
3
- hasTerminalEvent,
4
- parseSse,
5
- readAgentResponse,
6
- resetOwnerSessionCache,
7
- runAgentAsOwner,
8
- selfBaseUrl,
9
- } from "../lib/agent-call";
10
-
11
- const RESULT = { runId: "run_1", text: "Hi there", steps: 1, usage: { input_tokens: 1, output_tokens: 1 } };
12
- const SSE_BODY = `retry: 1000\n\nid: 1\ndata: Hi \n\nid: 2\ndata: there\n\nid: 3\nevent: result\ndata: ${JSON.stringify(RESULT)}\n\n`;
13
-
14
- beforeEach(() => resetOwnerSessionCache());
15
-
16
- describe("SSE parsing", () => {
17
- test("parses events and multi-line data", () => {
18
- expect(parseSse("event: tool\ndata: a\ndata: b\n\ndata: plain\n\n")).toEqual([
19
- { event: "tool", data: "a\nb" },
20
- { event: "message", data: "plain" },
21
- ]);
22
- });
23
-
24
- test("detects a complete terminal event only", () => {
25
- expect(hasTerminalEvent(SSE_BODY)).toBe(true);
26
- expect(hasTerminalEvent(SSE_BODY.slice(0, -2))).toBe(false);
27
- expect(hasTerminalEvent("data: x\n\n")).toBe(false);
28
- });
29
-
30
- test("reads a streamed result, a streamed error, and a JSON error", () => {
31
- expect(readAgentResponse(200, "text/event-stream", SSE_BODY)).toEqual({
32
- ok: true,
33
- result: { runId: "run_1", text: "Hi there", steps: 1, queued: false, cancelled: false },
34
- });
35
- expect(
36
- readAgentResponse(200, "text/event-stream", `event: error\ndata: {"code":"LLM_NOT_CONFIGURED","message":"no key"}\n\n`),
37
- ).toEqual({ ok: false, code: "LLM_NOT_CONFIGURED", message: "no key" });
38
- expect(readAgentResponse(403, "application/json", `{"error":{"code":"FORBIDDEN","message":"admin only"}}`)).toEqual({
39
- ok: false,
40
- code: "FORBIDDEN",
41
- message: "admin only",
42
- });
43
- });
44
- });
45
-
46
- describe("selfBaseUrl", () => {
47
- test("prefers loopback, then an https public origin, never plain-http public", () => {
48
- expect(selfBaseUrl({ PYLON_PORT: "4541", APP_URL: "https://a.example.com" })).toBe("http://127.0.0.1:4541");
49
- expect(selfBaseUrl({ APP_URL: "https://a.example.com/path" })).toBe("https://a.example.com");
50
- expect(selfBaseUrl({ PYLON_PUBLIC_URL: "https://b.example.com" })).toBe("https://b.example.com");
51
- expect(selfBaseUrl({ APP_URL: "http://a.example.com" })).toBe("http://127.0.0.1:4321");
52
- expect(selfBaseUrl({ APP_URL: "javascript:alert(1)" })).toBe("http://127.0.0.1:4321");
53
- });
54
- });
55
-
56
- /** A streaming response that sends `body` and then never closes. */
57
- function openStream(body: string): Response {
58
- const stream = new ReadableStream({
59
- start(controller) {
60
- controller.enqueue(new TextEncoder().encode(body));
61
- },
62
- });
63
- return new Response(stream, { headers: { "content-type": "text/event-stream" } });
64
- }
65
-
66
- describe("runAgentAsOwner", () => {
67
- test("mints an owner session, calls the agent, and returns once the result arrives", async () => {
68
- const calls: { url: string; auth: string | null; body: unknown }[] = [];
69
- const fetchImpl = async (url: string, init: RequestInit) => {
70
- const headers = init.headers as Record<string, string>;
71
- calls.push({ url, auth: headers.authorization ?? null, body: JSON.parse(String(init.body)) });
72
- if (url.endsWith("/api/auth/session")) return Response.json({ token: "owner-session", user_id: "u1" }, { status: 201 });
73
- return openStream(SSE_BODY);
74
- };
75
- const env = { APP_URL: "https://app.example.com", PYLON_ADMIN_TOKEN: "admin-token" };
76
- const out = await runAgentAsOwner(env, "u1", { input: "hi", title: "conv_1" }, fetchImpl);
77
- expect(out.ok && out.result.text).toBe("Hi there");
78
- expect(calls.map((c) => c.url)).toEqual([
79
- "https://app.example.com/api/auth/session",
80
- "https://app.example.com/api/fn/assistant",
81
- ]);
82
- expect(calls[0].auth).toBe("Bearer admin-token");
83
- expect(calls[0].body).toEqual({ user_id: "u1" });
84
- expect(calls[1].auth).toBe("Bearer owner-session");
85
- expect(calls[1].body).toEqual({ input: "hi", title: "conv_1" });
86
-
87
- // The session is reused on the next call.
88
- await runAgentAsOwner(env, "u1", { input: "again" }, fetchImpl);
89
- expect(calls.filter((c) => c.url.endsWith("/api/auth/session")).length).toBe(1);
90
- });
91
-
92
- test("re-mints once when the cached session is rejected", async () => {
93
- let mints = 0;
94
- let agentCalls = 0;
95
- const fetchImpl = async (url: string) => {
96
- if (url.endsWith("/api/auth/session")) return Response.json({ token: `s${++mints}` });
97
- agentCalls += 1;
98
- if (agentCalls === 1) return Response.json({ error: { code: "AUTH_REQUIRED", message: "expired" } }, { status: 401 });
99
- return openStream(SSE_BODY);
100
- };
101
- const out = await runAgentAsOwner({}, "u1", { input: "hi" }, fetchImpl);
102
- expect(out.ok).toBe(true);
103
- expect(mints).toBe(2);
104
- });
105
-
106
- test("reports a refused session mint", async () => {
107
- const fetchImpl = async () =>
108
- Response.json({ error: { code: "FORBIDDEN", message: "/api/auth/session requires admin auth" } }, { status: 403 });
109
- const out = await runAgentAsOwner({}, "u1", { input: "hi" }, fetchImpl);
110
- expect(out).toEqual({ ok: false, code: "OWNER_SESSION_FORBIDDEN", message: "/api/auth/session requires admin auth" });
111
- });
112
- });