ghostykit 0.3.2 → 0.4.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.
@@ -1,375 +0,0 @@
1
- // src/transport/types.ts
2
- var PilotHttpError = class extends Error {
3
- constructor(message, status, needsLogin = false) {
4
- super(message);
5
- this.name = "PilotHttpError";
6
- this.status = status;
7
- this.needsLogin = needsLogin;
8
- }
9
- };
10
-
11
- // src/transport/postStream.ts
12
- async function readSse(body, onMessage, onBytes) {
13
- const reader = body.getReader();
14
- const decoder = new TextDecoder();
15
- let buffer = "";
16
- let event = "message";
17
- let data = [];
18
- const flush = () => {
19
- if (data.length) onMessage({ event, data: data.join("\n") });
20
- event = "message";
21
- data = [];
22
- };
23
- for (; ; ) {
24
- const { value, done } = await reader.read();
25
- if (done) break;
26
- onBytes?.();
27
- buffer += decoder.decode(value, { stream: true });
28
- let nl;
29
- while ((nl = buffer.search(/\r\n|\r|\n/)) >= 0) {
30
- const line = buffer.slice(0, nl);
31
- buffer = buffer.slice(nl + (buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1));
32
- if (line === "") flush();
33
- else if (line.startsWith(":")) continue;
34
- else {
35
- const i = line.indexOf(":");
36
- const field = i < 0 ? line : line.slice(0, i);
37
- const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
38
- if (field === "event") event = val;
39
- else if (field === "data") data.push(val);
40
- }
41
- }
42
- }
43
- flush();
44
- }
45
- var TERMINAL = /* @__PURE__ */ new Set(["done", "capacity", "interrupted", "idle"]);
46
- function parse(raw) {
47
- if (!raw) return null;
48
- try {
49
- return JSON.parse(raw);
50
- } catch {
51
- return null;
52
- }
53
- }
54
- function defaultHistory(json) {
55
- const j = json;
56
- const turns = (j?.messages ?? []).filter((m) => typeof m.text === "string").map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text }));
57
- const permissions = (j?.pending ?? []).filter((p) => typeof p === "object" && p !== null && typeof p.id === "string").map((p) => ({
58
- id: p.id,
59
- title: String(p.title ?? ""),
60
- ...typeof p.tool === "string" ? { tool: p.tool } : {},
61
- options: Array.isArray(p.options) ? p.options : [],
62
- ...p.expiresAt != null ? { expiresAt: p.expiresAt } : {}
63
- }));
64
- return { turns, permissions };
65
- }
66
- function uuid() {
67
- const c = globalThis.crypto;
68
- if (c?.randomUUID) return c.randomUUID();
69
- return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
70
- }
71
- function createPostStreamTransport(target, options = {}) {
72
- const url = typeof target === "string" ? target : target.url;
73
- const opts = typeof target === "string" ? options : { ...target, ...options };
74
- const doFetch = (...a) => (opts.fetch ?? fetch)(...a);
75
- const credentials = opts.credentials ?? "same-origin";
76
- const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
77
- const retries = opts.retries ?? 6;
78
- const silenceMs = opts.silenceMs ?? 6e4;
79
- const attachUrl = opts.attachUrl === void 0 ? `${url}${url.includes("?") ? "&" : "?"}attach=1` : opts.attachUrl;
80
- const historyUrl = opts.historyUrl === void 0 ? url : opts.historyUrl;
81
- const buildBody = opts.body ?? ((i) => ({
82
- text: i.text,
83
- ...i.context !== void 0 ? { context: i.context } : {},
84
- turnId: i.turnId,
85
- ...i.resume && i.resumeTurnId ? { continueTurnId: i.resumeTurnId } : {}
86
- }));
87
- let listener = null;
88
- let isBusy = () => false;
89
- let closed = false;
90
- let current = null;
91
- const emit = (name, data) => listener?.(name, data);
92
- async function consume(res, ctrl) {
93
- if (!res.body) return false;
94
- let terminal = false;
95
- let errored = false;
96
- let lastByte = Date.now();
97
- const watchdog = setInterval(() => {
98
- if (Date.now() - lastByte > silenceMs) ctrl.abort();
99
- }, Math.min(15e3, silenceMs));
100
- try {
101
- await readSse(
102
- res.body,
103
- ({ event, data }) => {
104
- if (ctrl.signal.aborted) return;
105
- const d = parse(data);
106
- emit(event, d);
107
- if (event === "error") errored = true;
108
- if (TERMINAL.has(event)) terminal = true;
109
- },
110
- () => {
111
- lastByte = Date.now();
112
- }
113
- );
114
- } catch {
115
- } finally {
116
- clearInterval(watchdog);
117
- }
118
- if (errored && !terminal) {
119
- emit("done", {});
120
- return true;
121
- }
122
- return terminal;
123
- }
124
- async function attach() {
125
- if (!attachUrl) return;
126
- for (let attempt = 0; attempt <= retries && !closed; attempt++) {
127
- const ctrl = new AbortController();
128
- current?.abort();
129
- current = ctrl;
130
- try {
131
- const res = await doFetch(attachUrl, { credentials, headers: { accept: "text/event-stream" }, signal: ctrl.signal });
132
- if (res.ok && await consume(res, ctrl)) return;
133
- if (ctrl.signal.aborted && current !== ctrl) return;
134
- } catch {
135
- if (current !== ctrl) return;
136
- }
137
- if (!isBusy()) return;
138
- await sleep(Math.min(1e3 * 2 ** attempt, 1e4));
139
- }
140
- }
141
- return {
142
- // Sin steer: con un turno corriendo el servidor contesta 409. El composer no deja mandar.
143
- steer: false,
144
- // Cada turno trae su stream: no hay «conectando» que enseñar.
145
- stream: "per-turn",
146
- subscribe(l, sub = {}) {
147
- listener = l;
148
- isBusy = sub.isBusy ?? (() => false);
149
- closed = false;
150
- void attach();
151
- return () => {
152
- closed = true;
153
- listener = null;
154
- current?.abort();
155
- current = null;
156
- };
157
- },
158
- async history() {
159
- if (!historyUrl) return { turns: [] };
160
- const res = await doFetch(historyUrl, { credentials, headers: { accept: "application/json" } });
161
- if (!res.ok) return { turns: [] };
162
- return (opts.parseHistory ?? defaultHistory)(await res.json().catch(() => null));
163
- },
164
- async send(input) {
165
- const turnId = (opts.newTurnId ?? uuid)();
166
- const body = JSON.stringify(buildBody({ ...input, turnId }));
167
- for (let attempt = 0; ; attempt++) {
168
- const ctrl = new AbortController();
169
- current?.abort();
170
- current = ctrl;
171
- const res = await doFetch(url, {
172
- method: "POST",
173
- credentials,
174
- headers: { "content-type": "application/json", accept: "text/event-stream" },
175
- body,
176
- signal: ctrl.signal
177
- });
178
- if (res.status === 503 && attempt < retries) {
179
- await sleep(Number(res.headers.get("Retry-After")) * 1e3 || 3e3);
180
- continue;
181
- }
182
- if (!res.ok) {
183
- const json = await res.json().catch(() => null);
184
- const err = new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, res.headers.get("X-Needs-Login") != null);
185
- if (json?.error) err.code = json.error;
186
- if (res.status === 409 && json?.error === "turn_busy") void attach();
187
- throw err;
188
- }
189
- void consume(res, ctrl).then((ok) => {
190
- if (!ok && current === ctrl && !closed) void attach();
191
- });
192
- return { turnId, state: "running" };
193
- }
194
- },
195
- async cancel() {
196
- if (opts.cancelUrl) await doFetch(opts.cancelUrl, { method: "POST", credentials }).catch(() => {
197
- });
198
- current?.abort();
199
- current = null;
200
- }
201
- };
202
- }
203
-
204
- // src/transport/index.ts
205
- var SSE_EVENTS = [
206
- "started",
207
- "status",
208
- "caps",
209
- "models",
210
- "chunk",
211
- "thought",
212
- "tool",
213
- "artifact",
214
- "usage",
215
- "title",
216
- "done",
217
- "error",
218
- "warning",
219
- "reemplazo",
220
- "permission",
221
- "permission-resolved",
222
- "closed",
223
- "page-plan",
224
- "section-building",
225
- "section-update",
226
- "section",
227
- "page-ready",
228
- "page-error",
229
- "page-planning",
230
- "page-drop",
231
- "review",
232
- "page-version",
233
- "page-edit",
234
- // Los de un servidor de turnos durables (EasyBits): re-enganche, sin turno, cortado, sin lugar.
235
- "snapshot",
236
- "idle",
237
- "interrupted",
238
- "capacity"
239
- ];
240
- var LIFE_EVENTS = /* @__PURE__ */ new Set([
241
- "started",
242
- "status",
243
- "chunk",
244
- "thought",
245
- "tool",
246
- "artifact",
247
- "usage",
248
- "done",
249
- "error",
250
- "warning",
251
- "reemplazo",
252
- "permission",
253
- "page-plan",
254
- "section-building",
255
- "section-update",
256
- "section",
257
- "page-ready",
258
- "page-error",
259
- "page-planning",
260
- "page-drop",
261
- "review",
262
- "page-version",
263
- "page-edit",
264
- "snapshot",
265
- "idle",
266
- "interrupted",
267
- "capacity"
268
- ]);
269
- var SILENCE_MAX_MS = 11 * 6e4;
270
- var MISSED_BEATS = 4;
271
- var MIN_SILENCE_MS = 9e4;
272
- function parse2(raw) {
273
- if (typeof raw !== "string" || !raw) return null;
274
- try {
275
- return JSON.parse(raw);
276
- } catch {
277
- return null;
278
- }
279
- }
280
- function createSseTransport(base, opts = {}) {
281
- const doFetch = (...args) => (opts.fetch ?? fetch)(...args);
282
- const now = opts.now ?? Date.now;
283
- const root = base.replace(/\/+$/, "");
284
- let lastEventAt = now();
285
- const post = (path, body) => doFetch(`${root}/${path}`, {
286
- method: "POST",
287
- headers: body === void 0 ? void 0 : { "content-type": "application/json" },
288
- body: body === void 0 ? void 0 : JSON.stringify(body),
289
- credentials: opts.withCredentials ? "include" : void 0
290
- });
291
- return {
292
- subscribe(listener, sub = {}) {
293
- const ES = opts.EventSource ?? globalThis.EventSource;
294
- if (!ES) throw new Error("ghostykit: no hay EventSource en este entorno (pasa `EventSource` en las opciones)");
295
- let es = null;
296
- let heartbeatMs = null;
297
- let closed = false;
298
- const open = () => {
299
- const source = new ES(`${root}/events`, { withCredentials: Boolean(opts.withCredentials) });
300
- es = source;
301
- const names = /* @__PURE__ */ new Set([...SSE_EVENTS, ...opts.events ?? []]);
302
- for (const name of names) {
303
- source.addEventListener(name, (e) => {
304
- if (es !== source) return;
305
- if (LIFE_EVENTS.has(name)) lastEventAt = now();
306
- const raw = e.data;
307
- if (name === "error" && (raw === void 0 || raw === "")) return;
308
- const data = parse2(raw);
309
- if (name === "started") {
310
- const d = data;
311
- heartbeatMs = typeof d?.latidoMs === "number" && d.latidoMs > 0 ? d.latidoMs : null;
312
- }
313
- listener(name, data);
314
- if (name === "closed") source.close();
315
- });
316
- }
317
- };
318
- open();
319
- const timer = setInterval(() => {
320
- if (closed || !sub.isBusy?.()) return;
321
- const cap = heartbeatMs ? Math.max(heartbeatMs * MISSED_BEATS, MIN_SILENCE_MS) : SILENCE_MAX_MS;
322
- if (now() - lastEventAt < cap) return;
323
- lastEventAt = now();
324
- es?.close();
325
- open();
326
- }, opts.checkEveryMs ?? 15e3);
327
- return () => {
328
- closed = true;
329
- clearInterval(timer);
330
- es?.close();
331
- es = null;
332
- };
333
- },
334
- async send(input) {
335
- lastEventAt = now();
336
- const res = await post("messages", {
337
- content: input.text,
338
- images: input.attachments,
339
- // Lo que hace que el hilo se BAUTICE al cerrar este turno.
340
- primerTurno: input.firstTurn,
341
- ...input.resume ? { retomar: true } : {},
342
- ...input.context !== void 0 ? { context: input.context } : {}
343
- });
344
- if (!res.ok) {
345
- const text = await res.text().catch(() => "");
346
- throw new PilotHttpError(text || `Error ${res.status}`, res.status, Boolean(res.headers.get("X-Needs-Login")));
347
- }
348
- const r = await res.json().catch(() => ({}));
349
- return { injected: r.inyectado, state: r.estado, turnId: r.turnId };
350
- },
351
- async cancel() {
352
- await post("cancel").catch(() => {
353
- });
354
- },
355
- async setModel(value) {
356
- await post("model", { value });
357
- },
358
- async respondPermission(id, optionId) {
359
- const res = await post("permission", { id, optionId });
360
- const r = await res.json().catch(() => ({}));
361
- return { resolved: Boolean(r.resuelto ?? r.resolved) };
362
- }
363
- };
364
- }
365
-
366
- export {
367
- PilotHttpError,
368
- readSse,
369
- createPostStreamTransport,
370
- SSE_EVENTS,
371
- SILENCE_MAX_MS,
372
- MISSED_BEATS,
373
- MIN_SILENCE_MS,
374
- createSseTransport
375
- };
@@ -1,7 +0,0 @@
1
- import type { Turn } from "./types";
2
- /**
3
- * Si la copia trae MÁS mensajes del usuario que la pantalla, faltan los de otro cliente: se repinta
4
- * con la copia, conservando el turno vivo (pasos, segundos, uso) de cada mensaje que coincide. Con
5
- * los mismos mensajes del usuario no se toca nada (las burbujas en vivo son más ricas que la copia).
6
- */
7
- export declare function reconcileTurns(current: Turn[], fresh: Turn[]): Turn[];
@@ -1,17 +0,0 @@
1
- /** Sin conexión por este rato, se enseña. */
2
- export declare const CONNECTING_DELAY_MS = 1200;
3
- /** Una vez a la vista, se queda al menos esto. */
4
- export declare const CONNECTING_MIN_MS = 700;
5
- export interface ConnectingGate {
6
- /** Desde cuándo está a la vista (null = oculto). */
7
- shownAt: number | null;
8
- /** Desde cuándo falta la conexión (null = conectado). */
9
- waitingSince: number | null;
10
- }
11
- export declare const initialGate: (now: number, connected: boolean) => ConnectingGate;
12
- /** El estado a `now`, y cuándo volver a mirar (null = no hace falta). */
13
- export declare function stepGate(g: ConnectingGate, connected: boolean, now: number, delay?: number, min?: number): {
14
- gate: ConnectingGate;
15
- visible: boolean;
16
- recheckAt: number | null;
17
- };
@@ -1,44 +0,0 @@
1
- import type { SendInput, Turn } from "../core/types";
2
- import { type PilotHistory, type PilotTransport } from "./types";
3
- /** Un evento SSE ya separado: nombre + data crudo. */
4
- export interface SseMessage {
5
- event: string;
6
- data: string;
7
- }
8
- /**
9
- * Lee un stream SSE (`text/event-stream`) de un `fetch`. Llama `onMessage` por cada evento con
10
- * nombre y `onBytes` con CUALQUIER cosa que llegue (los comentarios `: ka` cuentan como vida).
11
- */
12
- export declare function readSse(body: ReadableStream<Uint8Array>, onMessage: (m: SseMessage) => void, onBytes?: () => void): Promise<void>;
13
- export interface PostStreamTransportOptions {
14
- fetch?: typeof fetch;
15
- /** Default `same-origin` (la cookie de sesión viaja; EasyBits rechaza cualquier Bearer). */
16
- credentials?: RequestCredentials;
17
- /** Cuerpo del POST. Default: el contrato de EasyBits. */
18
- body?: (input: SendInput & {
19
- turnId: string;
20
- }) => unknown;
21
- /** URL del re-enganche. Default `${url}?attach=1`; `null` = sin re-enganche. */
22
- attachUrl?: string | null;
23
- /** URL del historial (JSON). Default `url`; `null` = sin historial. */
24
- historyUrl?: string | null;
25
- /** Cómo leer el historial. Default: `{messages:[{role:"user"|"agent",text}], pending:[permission…]}`. */
26
- parseHistory?: (json: unknown) => Turn[] | PilotHistory;
27
- /** URL para cancelar (POST). Sin ella, «detener» sólo deja de escuchar: el turno sigue en el servidor. */
28
- cancelUrl?: string;
29
- /** Silencio (sin un solo byte, ni `: ka`) tras el que la conexión se da por muerta. Default 60 s. */
30
- silenceMs?: number;
31
- /** Reintentos de un 503 (deploy) o de un re-enganche. Default 6. */
32
- retries?: number;
33
- newTurnId?: () => string;
34
- /** Reloj y espera inyectables (pruebas). */
35
- sleep?: (ms: number) => Promise<void>;
36
- }
37
- /**
38
- * El transporte de «POST que devuelve el stream». Cada turno es su propio stream; si se corta sin
39
- * evento final (red, deploy), se re-engancha con el GET de attach y el servidor dice la verdad
40
- * (`snapshot` y sigue, o el evento final, o `idle`).
41
- */
42
- export declare function createPostStreamTransport(target: string | (PostStreamTransportOptions & {
43
- url: string;
44
- }), options?: PostStreamTransportOptions): PilotTransport;
@@ -1,47 +0,0 @@
1
- import type { PermissionRequest, SendInput, SendResult, Turn } from "../core/types";
2
- /** El hilo guardado y las tarjetas de permiso que siguen abiertas (EasyBits: `pending`). */
3
- export interface PilotHistory {
4
- turns: Turn[];
5
- permissions?: PermissionRequest[];
6
- }
7
- /** Un evento con nombre y su `data` ya parseado (`null` si no traía o no era JSON). */
8
- export type PilotListener = (name: string, data: unknown) => void;
9
- export interface SubscribeOptions {
10
- /** ¿Hay un turno en vuelo? Con turno vivo, un silencio largo = conexión muerta → se reabre. */
11
- isBusy?: () => boolean;
12
- }
13
- export interface PilotTransport {
14
- /** Abre el stream de eventos. Devuelve con qué cerrarlo. */
15
- subscribe(listener: PilotListener, opts?: SubscribeOptions): () => void;
16
- /** Encarga un turno (o lo mete al turno en vuelo). Rechaza con `PilotHttpError` si el servidor dice que no. */
17
- send(input: SendInput): Promise<SendResult>;
18
- /** Detiene el turno en curso. */
19
- cancel(): Promise<void>;
20
- /** Cambia el modelo de la sesión (el stream confirma con `models`). */
21
- setModel?(value: string): Promise<void>;
22
- /** Contesta un pedido de permiso. `resolved: false` = ya no estaba abierto (se agotó el plazo). */
23
- respondPermission?(id: string, optionId: string): Promise<{
24
- resolved: boolean;
25
- }>;
26
- /**
27
- * `"per-turn"`: no hay un stream vivo que esperar (cada turno trae el suyo, como el POST de
28
- * EasyBits). Piloto pinta el hilo al instante y nunca enseña «conectando». Default `"live"`.
29
- */
30
- stream?: "live" | "per-turn";
31
- /** El hilo guardado, para pintarlo al montar (si la app no pasa `initialTurns`). */
32
- history?(): Promise<Turn[] | PilotHistory>;
33
- /**
34
- * ¿Se puede mandar un mensaje con un turno en vuelo (steer)? Default `true` (Ghosty). Con
35
- * `false` (el servidor contesta 409) el composer no deja mandar hasta que el turno cierre.
36
- */
37
- steer?: boolean;
38
- }
39
- /** El servidor rechazó el encargo (sin saldo, sin cuenta, agente de otro). */
40
- export declare class PilotHttpError extends Error {
41
- status: number;
42
- /** El servidor pide cuenta (`X-Needs-Login`). */
43
- needsLogin: boolean;
44
- /** El `error` del cuerpo, si el servidor lo dio (`turn_busy`, `draining`…). */
45
- code?: string;
46
- constructor(message: string, status: number, needsLogin?: boolean);
47
- }