ghostykit 0.3.2 → 0.4.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/README.md +44 -57
- package/dist/chunks/chunk-JQEB37IY.js +479 -0
- package/dist/chunks/chunk-TMEHQZWL.js +89 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +13 -23
- package/dist/core/reducer.d.ts +27 -45
- package/dist/core/steps.d.ts +9 -1
- package/dist/core/types.d.ts +42 -159
- package/dist/react/Bubble.d.ts +2 -6
- package/dist/react/Composer.d.ts +6 -1
- package/dist/react/ConfirmCard.d.ts +3 -4
- package/dist/react/Piloto.d.ts +3 -10
- package/dist/react/Status.d.ts +1 -13
- package/dist/react/index.d.ts +6 -6
- package/dist/react/index.js +219 -409
- package/dist/react/strings.d.ts +2 -18
- package/dist/react/usePilot.d.ts +31 -43
- package/dist/styles.css +2 -103
- package/dist/transport/index.d.ts +40 -30
- package/dist/transport/index.js +3 -13
- package/package.json +16 -9
- package/dist/chunks/chunk-O6FCE4CS.js +0 -670
- package/dist/chunks/chunk-XZHX2NZT.js +0 -375
- package/dist/core/reconcile.d.ts +0 -7
- package/dist/react/connectingGate.d.ts +0 -17
- package/dist/transport/postStream.d.ts +0 -44
- package/dist/transport/types.d.ts +0 -47
package/README.md
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Ghosty Autopilot · `ghostykit`
|
|
2
2
|
|
|
3
|
-
**An
|
|
3
|
+
**An autopilot for your React app, not a copilot or kit.** You don't drive: you say where you're going and
|
|
4
|
+
the agent flies the app for you, stopping only to ask before anything risky. Ghosty Autopilot is the agent chat from
|
|
4
5
|
[Ghosty Studio](https://www.ghosty.studio) (`/c`), packaged as a library: same components, same
|
|
5
6
|
markup, same look ([side by side](https://raw.githubusercontent.com/blissito/ghostykit/main/docs/screenshots/parity/desktop-done-gs-vs-ghostykit.png)). You drop it into an app you already
|
|
6
7
|
have. It sees what the user has on screen, streams what the agent says and does, and **stops to ask
|
|
7
8
|
before anything risky** (human in the loop).
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
It speaks **[AG-UI](https://docs.ag-ui.com)**, the open agent ↔ app protocol, so it works with
|
|
11
|
+
**LangGraph**, Mastra, CrewAI, Pydantic AI, Google ADK, or your own backend. No adapter of ours in
|
|
12
|
+
between.
|
|
13
|
+
|
|
14
|
+
> El piloto de tu app, no el copiloto: tú no manejas, vas de pasajero y le dices a dónde ir. Es el chat de Ghosty, probado en producción, como biblioteca. Español por default, inglés incluido.
|
|
10
15
|
|
|
11
16
|
**[Open the playground →](https://blissito.github.io/ghostykit/)** (a fake backend, a real chat)
|
|
12
17
|
|
|
@@ -18,9 +23,13 @@ before anything risky** (human in the loop).
|
|
|
18
23
|
- **Agent steps**: every tool call as a line ("Read 2 files", "Searched the web"). Steps fold into a card when the turn ends, and you can show your own labels and icons.
|
|
19
24
|
- **The permission pass**: risky actions arrive as a card with a countdown, *Approve* and *Reject*. A stamp lands when you answer, and the decision stays recorded under the reply.
|
|
20
25
|
- **Context and references**: what the user is looking at travels with every turn. Rows from your app can be "@-mentioned" into the message.
|
|
21
|
-
- **Resilience**:
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
- **Resilience**: the thread lives on your server. A dropped connection or a reload asks for it again
|
|
27
|
+
and repaints from `MESSAGES_SNAPSHOT`, pending approvals included; a deploy (`503`) is retried with
|
|
28
|
+
the same run.
|
|
29
|
+
- **The composer**: attachments (you provide `upload`), voice notes (you provide `transcribe`), stop and reply-option buttons.
|
|
30
|
+
- **Solid core**: the whole chat state is a pure reducer over AG-UI events, `reduceAgUiEvent(state, event)`, tested by replaying streams recorded from a real LangGraph agent.
|
|
31
|
+
|
|
32
|
+
> **En español:** Ghosty Autopilot le pone piloto a tu app con un solo componente: la persona dice qué quiere y el agente lo hace por ella. Tus usuarios ven la respuesta escribirse en vivo y cada paso del agente («Leí 2 archivos»), y aprueban o rechazan cualquier acción riesgosa con una tarjeta antes de que ocurra. El agente sabe qué pantalla y qué registro está viendo la persona. Habla AG-UI, el protocolo abierto entre agentes y apps, así que funciona con LangGraph, Mastra, CrewAI o tu propio backend. El chat se recupera solo de cortes de red y de deploys, y trae adjuntos, notas de voz y botón para detener. Por dentro, todo el estado es una función pura probada con sesiones reales, así que se puede actualizar sin miedo.
|
|
24
33
|
|
|
25
34
|
## 30-second quickstart
|
|
26
35
|
|
|
@@ -29,70 +38,47 @@ npm i ghostykit motion streamdown
|
|
|
29
38
|
```
|
|
30
39
|
|
|
31
40
|
```tsx
|
|
32
|
-
import { Piloto,
|
|
41
|
+
import { Piloto, createAgUiTransport } from "ghostykit";
|
|
33
42
|
import "ghostykit/styles.css";
|
|
34
|
-
import "ghostykit/fonts.css"; // optional: the house typefaces
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
const transport = createAgUiTransport({ url: "/api/agent" }); // any AG-UI endpoint
|
|
45
|
+
|
|
46
|
+
export function Pilot() {
|
|
38
47
|
return <Piloto transport={transport} />;
|
|
39
48
|
}
|
|
40
49
|
```
|
|
41
50
|
|
|
42
51
|
`<Piloto transport={…} />` works with nothing else. Give its container a height: Piloto fills it.
|
|
52
|
+
The product is **Ghosty Autopilot**; the component is `<Piloto>`.
|
|
43
53
|
|
|
44
|
-
##
|
|
45
|
-
|
|
46
|
-
A transport is how Piloto talks to your agent. It is a small interface: `subscribe`, `send`, `cancel`,
|
|
47
|
-
plus optional `setModel`, `respondPermission` and `history`. Two are included.
|
|
48
|
-
|
|
49
|
-
### 1. Split stream: `createSseTransport(base)` (Ghosty's contract)
|
|
50
|
-
|
|
51
|
-
The event stream stays open (`EventSource`) and messages go by POST.
|
|
54
|
+
## Works with LangGraph (and any AG-UI agent)
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|---|---|
|
|
55
|
-
| `GET {base}/events` | Named SSE events: `started`, `status`, `chunk`, `thought`, `tool`, `permission`, `permission-resolved`, `usage`, `models`, `title`, `done{reposo?,interrumpido?,ultimo?}`, `error`… |
|
|
56
|
-
| `POST {base}/messages` | `{content, images, primerTurno, retomar?, context?}` → `{inyectado?, estado?, turnId?}` |
|
|
57
|
-
| `POST {base}/cancel` · `/model` · `/permission` | stop, switch model, answer a card |
|
|
56
|
+
Serve your LangGraph graph with its official AG-UI adapter and point Piloto at it:
|
|
58
57
|
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
```ts
|
|
59
|
+
// server: one POST endpoint (full file in examples/langgraph/src/server.ts)
|
|
60
|
+
import { LangGraphAgent } from "@ag-ui/langgraph";
|
|
61
|
+
import { EventEncoder } from "@ag-ui/encoder";
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
| | |
|
|
67
|
-
|---|---|
|
|
68
|
-
| `POST {url}` | `{text, context?, turnId, continueTurnId?}` → SSE `started`, `chunk`, `tool{title,status}`, `permission{…, expiresAt}`, `done{turnId,text}`, `error`, `capacity`, `interrupted{turnId,canContinue}`, `idle` |
|
|
69
|
-
| `GET {url}?attach=1` | `started`, one `permission` per open card, `snapshot{turnId,text,tools}`, then live |
|
|
70
|
-
| `GET {url}` | `{messages:[{role,text}]}`, loaded as the thread |
|
|
71
|
-
|
|
72
|
-
A 503 (deploy draining) is retried with the same `turnId`. A 409 `turn_busy` re-attaches to the turn
|
|
73
|
-
that is already running. Approvals usually go through your own route:
|
|
63
|
+
const agent = new LangGraphAgent({ graphId: "crons", deploymentUrl: "http://localhost:2024", emitInterruptOutcome: true });
|
|
64
|
+
// POST /agent → agent.run(runAgentInput).subscribe(e => res.write(new EventEncoder().encode(e)))
|
|
65
|
+
```
|
|
74
66
|
|
|
75
67
|
```tsx
|
|
76
|
-
<Piloto
|
|
77
|
-
transport={useMemo(() => createPostStreamTransport({ url: "/api/v2/admin/copilot/chat" }), [])}
|
|
78
|
-
context={{ page: "crons", job: selectedJob }}
|
|
79
|
-
onPermission={async (id, optionId) => {
|
|
80
|
-
const res = await fetch(`/api/v2/admin/agent-actions/${id}`, {
|
|
81
|
-
method: "POST",
|
|
82
|
-
headers: { "content-type": "application/json" },
|
|
83
|
-
body: JSON.stringify({ decision: optionId === "approve" ? "approve" : "reject" }),
|
|
84
|
-
});
|
|
85
|
-
if (res.status === 409 || res.status === 404) return { resolved: false }; // expired or already answered
|
|
86
|
-
if (!res.ok) throw new Error("approval failed");
|
|
87
|
-
return { resolved: true, detail: (await res.json()).status };
|
|
88
|
-
}}
|
|
89
|
-
/>
|
|
68
|
+
<Piloto transport={createAgUiTransport({ url: "http://localhost:8000/agent" })} />
|
|
90
69
|
```
|
|
91
70
|
|
|
92
|
-
|
|
71
|
+
`interrupt()` in your graph becomes the approval card; *Approve*/*Reject* resume it
|
|
72
|
+
(`Command(resume={approved})` on the graph side). A runnable example with a tool and an approval is in
|
|
73
|
+
[`examples/langgraph`](examples/langgraph); the playground can point at it with
|
|
74
|
+
`?backend=agui&url=http://localhost:8000/agent`.
|
|
75
|
+
|
|
76
|
+
## The contract
|
|
93
77
|
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
One `POST <url>` with an AG-UI `RunAgentInput`, answered by a stream of standard AG-UI events. That's
|
|
79
|
+
all a backend serves. **[docs/protocol.md](docs/protocol.md)** is the exact spec: request, events,
|
|
80
|
+
approvals (`RUN_FINISHED.outcome: interrupt` + `resume`), reopening a thread (`MESSAGES_SNAPSHOT`),
|
|
81
|
+
stopping and errors.
|
|
96
82
|
|
|
97
83
|
## Make it yours
|
|
98
84
|
|
|
@@ -109,7 +95,8 @@ const ref = useRef<PilotoHandle>(null);
|
|
|
109
95
|
welcome="How can I help with your crons?"
|
|
110
96
|
suggestions={["Why did the monitor fail?"]}
|
|
111
97
|
draft={{ text: "Why did the monitor fail?", key: row.id }} // prefilled + focused, not sent
|
|
112
|
-
|
|
98
|
+
threadId={conversationId} // reopen a stored thread
|
|
99
|
+
context={() => ({ screen: "crons", selected })} // AG-UI `context`, every run
|
|
113
100
|
toolVerbs={{ run_cron: { one: "Ran a cron", many: "Ran %n crons", running: "Running the cron…" } }}
|
|
114
101
|
upload={async (file) => ({ url: await myUpload(file) })}
|
|
115
102
|
transcribe={async (audio) => myWhisper(audio)}
|
|
@@ -122,9 +109,9 @@ ref.current?.send("Why did this fail?"); // ask on the user'
|
|
|
122
109
|
```
|
|
123
110
|
|
|
124
111
|
Other options: `strings`, `toolIcons`, `formatUserText`, `markdown`, `features`
|
|
125
|
-
(`attachments`, `retry`, `copy`, `usage`, `
|
|
112
|
+
(`attachments`, `retry`, `copy`, `usage`, `replyOptions`, `duration`), `references` /
|
|
126
113
|
`onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, and the callbacks
|
|
127
|
-
`onTurnStart`, `onTitle`, `
|
|
114
|
+
`onTurnStart`, `onTitle`, `onEvent` (every raw AG-UI event: STATE_*, CUSTOM…), `onSend`. To read the state
|
|
128
115
|
outside the chat, call `usePilot(transport)` yourself and pass the result as `pilot`. Every piece is
|
|
129
116
|
exported (`AgentBubble`, `AgentSteps`, `Composer`, `ConfirmCard`, `Markdown`, …) if you want to build
|
|
130
117
|
your own layout.
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// src/core/reducer.ts
|
|
2
|
+
function createPilotState(turns = []) {
|
|
3
|
+
return { turns, busy: false, error: null, notice: null, usage: null, permissions: [], turnStartedAt: 0, runId: null, effects: [] };
|
|
4
|
+
}
|
|
5
|
+
function patchAssistant(s, patch) {
|
|
6
|
+
const turns = [...s.turns];
|
|
7
|
+
const last = turns[turns.length - 1];
|
|
8
|
+
if (last?.role === "assistant") turns[turns.length - 1] = patch(last);
|
|
9
|
+
else turns.push(patch({ role: "assistant", text: "" }));
|
|
10
|
+
return { ...s, turns };
|
|
11
|
+
}
|
|
12
|
+
function argsDetail(json) {
|
|
13
|
+
if (!json) return void 0;
|
|
14
|
+
try {
|
|
15
|
+
const v = JSON.parse(json);
|
|
16
|
+
if (!v || typeof v !== "object") return String(v);
|
|
17
|
+
const parts = Object.entries(v).filter(([, x]) => x !== null && x !== void 0 && x !== "").map(([k, x]) => `${k}: ${typeof x === "object" ? JSON.stringify(x) : String(x)}`);
|
|
18
|
+
const s = parts.join(", ");
|
|
19
|
+
return s ? s.length > 80 ? `${s.slice(0, 79)}\u2026` : s : void 0;
|
|
20
|
+
} catch {
|
|
21
|
+
return void 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function upsertTool(s, id, patch) {
|
|
25
|
+
return patchAssistant(s, (t) => {
|
|
26
|
+
const tools = [...t.tools ?? []];
|
|
27
|
+
const i = tools.findIndex((x) => x.id === id);
|
|
28
|
+
let cuts = t.cuts;
|
|
29
|
+
if (i === -1) {
|
|
30
|
+
tools.push({ id, ...patch });
|
|
31
|
+
const len = t.text.length;
|
|
32
|
+
if (t.text.trim() && cuts?.[cuts.length - 1] !== len) cuts = [...cuts ?? [], len];
|
|
33
|
+
} else tools[i] = { ...tools[i], ...patch };
|
|
34
|
+
return { ...t, tools, cuts };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
var APPROVE = { optionId: "approve", name: "Aprobar", kind: "allow_once" };
|
|
38
|
+
var REJECT = { optionId: "reject", name: "Rechazar", kind: "reject_once" };
|
|
39
|
+
function toPermission(i, turns, clockOffset = 0) {
|
|
40
|
+
const meta = i.metadata ?? {};
|
|
41
|
+
const toolName2 = (typeof meta.tool === "string" ? meta.tool : void 0) ?? turns.flatMap((t) => t.tools ?? []).find((x) => x.id === i.toolCallId)?.title;
|
|
42
|
+
return {
|
|
43
|
+
id: i.id,
|
|
44
|
+
title: i.message ?? (toolName2 ? `${toolName2}` : i.reason),
|
|
45
|
+
...toolName2 ? { tool: toolName2 } : {},
|
|
46
|
+
...i.toolCallId ? { toolCallId: i.toolCallId } : {},
|
|
47
|
+
reason: i.reason,
|
|
48
|
+
options: [REJECT, APPROVE],
|
|
49
|
+
...i.expiresAt ? { expiresAt: i.expiresAt, deadline: expiryMs(i.expiresAt) + clockOffset } : {}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function closeOpenTools(turns, status) {
|
|
53
|
+
return turns.map(
|
|
54
|
+
(t) => t.tools?.some((x) => x.status === "in_progress") ? { ...t, tools: t.tools.map((x) => x.status === "in_progress" ? { ...x, status } : x) } : t
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
function textOf(content) {
|
|
58
|
+
if (typeof content === "string") return content;
|
|
59
|
+
if (Array.isArray(content)) return content.map((p) => p && typeof p === "object" && p.type === "text" ? p.text ?? "" : "").join("");
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
function attachmentsOf(content) {
|
|
63
|
+
if (!Array.isArray(content)) return void 0;
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const p of content) {
|
|
66
|
+
if (!p || p.type === "text" || !p.source) continue;
|
|
67
|
+
const mime = p.mimeType ?? p.source.mimeType ?? (p.type === "image" ? "image/*" : "application/octet-stream");
|
|
68
|
+
out.push(p.source.type === "data" ? { mimeType: mime, data: p.source.value, name: p.filename } : { mimeType: mime, url: p.source.value, name: p.filename });
|
|
69
|
+
}
|
|
70
|
+
return out.length ? out : void 0;
|
|
71
|
+
}
|
|
72
|
+
function messagesToTurns(messages) {
|
|
73
|
+
const turns = [];
|
|
74
|
+
let cur = null;
|
|
75
|
+
const assistant = () => {
|
|
76
|
+
if (!cur) {
|
|
77
|
+
cur = { role: "assistant", text: "" };
|
|
78
|
+
turns.push(cur);
|
|
79
|
+
}
|
|
80
|
+
return cur;
|
|
81
|
+
};
|
|
82
|
+
for (const m of messages) {
|
|
83
|
+
if (m.role === "user") {
|
|
84
|
+
cur = null;
|
|
85
|
+
turns.push({ role: "user", text: textOf(m.content), ...attachmentsOf(m.content) ? { attachments: attachmentsOf(m.content) } : {} });
|
|
86
|
+
} else if (m.role === "assistant") {
|
|
87
|
+
const t = assistant();
|
|
88
|
+
const text = m.content ?? "";
|
|
89
|
+
if (text) t.text = t.text ? `${t.text}
|
|
90
|
+
|
|
91
|
+
${text}` : text;
|
|
92
|
+
for (const c of m.toolCalls ?? []) {
|
|
93
|
+
t.tools = [...t.tools ?? [], { id: c.id, title: c.function.name, status: "pending", args: c.function.arguments, detail: argsDetail(c.function.arguments) }];
|
|
94
|
+
if (t.text.trim() && t.cuts?.[t.cuts.length - 1] !== t.text.length) t.cuts = [...t.cuts ?? [], t.text.length];
|
|
95
|
+
}
|
|
96
|
+
} else if (m.role === "tool") {
|
|
97
|
+
const t = assistant();
|
|
98
|
+
const failed = Boolean(m.error);
|
|
99
|
+
t.tools = (t.tools ?? []).map((x) => x.id === m.toolCallId ? { ...x, status: failed ? "failed" : "completed" } : x);
|
|
100
|
+
} else if (m.role === "reasoning") {
|
|
101
|
+
const t = assistant();
|
|
102
|
+
t.thought = (t.thought ?? "") + textOf(m.content);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return turns;
|
|
106
|
+
}
|
|
107
|
+
function applySnapshot(s, messages) {
|
|
108
|
+
const fresh = messagesToTurns(messages);
|
|
109
|
+
const old = s.turns;
|
|
110
|
+
const turns = fresh.map((t, i) => {
|
|
111
|
+
const o = old[i];
|
|
112
|
+
if (!o || o.role !== t.role) return t;
|
|
113
|
+
return {
|
|
114
|
+
...t,
|
|
115
|
+
...o.seconds !== void 0 ? { seconds: o.seconds } : {},
|
|
116
|
+
...o.usage ? { usage: o.usage } : {},
|
|
117
|
+
...o.decisions ? { decisions: o.decisions } : {},
|
|
118
|
+
...o.thought && !t.thought ? { thought: o.thought } : {},
|
|
119
|
+
...o.attachments && !t.attachments ? { attachments: o.attachments } : {},
|
|
120
|
+
...o.tools && t.tools ? { tools: t.tools.map((x) => ({ ...o.tools.find((y) => y.id === x.id), ...x })) } : {}
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
return { ...s, turns };
|
|
124
|
+
}
|
|
125
|
+
function reduceAgUiEvent(prev, e, now = Date.now()) {
|
|
126
|
+
const s = prev.effects.length ? { ...prev, effects: [] } : prev;
|
|
127
|
+
switch (e.type) {
|
|
128
|
+
case "RUN_STARTED":
|
|
129
|
+
return { ...s, busy: true, error: null, runId: e.runId ?? s.runId };
|
|
130
|
+
case "TEXT_MESSAGE_START":
|
|
131
|
+
if (e.role && e.role !== "assistant") return s;
|
|
132
|
+
return patchAssistant(s, (t) => ({ ...t, text: t.text && !t.text.endsWith("\n") ? `${t.text}
|
|
133
|
+
|
|
134
|
+
` : t.text }));
|
|
135
|
+
case "TEXT_MESSAGE_CONTENT":
|
|
136
|
+
case "TEXT_MESSAGE_CHUNK":
|
|
137
|
+
if (e.role && e.role !== "assistant") return s;
|
|
138
|
+
if (typeof e.delta !== "string" || !e.delta) return s;
|
|
139
|
+
return patchAssistant(s, (t) => ({ ...t, text: t.text + e.delta }));
|
|
140
|
+
case "TEXT_MESSAGE_END":
|
|
141
|
+
return s;
|
|
142
|
+
case "TOOL_CALL_START":
|
|
143
|
+
return upsertTool(s, e.toolCallId, { title: e.toolCallName, status: "in_progress" });
|
|
144
|
+
case "TOOL_CALL_CHUNK": {
|
|
145
|
+
const known = s.turns[s.turns.length - 1]?.tools?.find((x) => x.id === e.toolCallId);
|
|
146
|
+
let next = known ? s : upsertTool(s, e.toolCallId, { title: e.toolCallName, status: "in_progress" });
|
|
147
|
+
if (typeof e.delta === "string" && e.delta) next = upsertTool(next, e.toolCallId, { args: (known?.args ?? "") + e.delta });
|
|
148
|
+
return next;
|
|
149
|
+
}
|
|
150
|
+
case "TOOL_CALL_ARGS": {
|
|
151
|
+
const known = s.turns[s.turns.length - 1]?.tools?.find((x) => x.id === e.toolCallId);
|
|
152
|
+
return upsertTool(s, e.toolCallId, { args: (known?.args ?? "") + (e.delta ?? "") });
|
|
153
|
+
}
|
|
154
|
+
case "TOOL_CALL_END": {
|
|
155
|
+
const known = s.turns[s.turns.length - 1]?.tools?.find((x) => x.id === e.toolCallId);
|
|
156
|
+
return upsertTool(s, e.toolCallId, { detail: argsDetail(known?.args) });
|
|
157
|
+
}
|
|
158
|
+
case "TOOL_CALL_RESULT": {
|
|
159
|
+
const failed = Boolean(e.metadata?.error);
|
|
160
|
+
return upsertTool(s, e.toolCallId, { status: failed ? "failed" : "completed" });
|
|
161
|
+
}
|
|
162
|
+
case "REASONING_MESSAGE_CONTENT":
|
|
163
|
+
case "REASONING_MESSAGE_CHUNK":
|
|
164
|
+
if (typeof e.delta !== "string" || !e.delta) return s;
|
|
165
|
+
return patchAssistant(s, (t) => ({ ...t, thought: (t.thought ?? "") + e.delta }));
|
|
166
|
+
case "MESSAGES_SNAPSHOT":
|
|
167
|
+
return Array.isArray(e.messages) ? applySnapshot(s, e.messages) : s;
|
|
168
|
+
case "RUN_FINISHED":
|
|
169
|
+
return finish(s, e, now);
|
|
170
|
+
case "RUN_ERROR":
|
|
171
|
+
return finish({ ...s, error: typeof e.message === "string" ? e.message : "El agente fall\xF3." }, e, now, true);
|
|
172
|
+
case "CUSTOM":
|
|
173
|
+
if (e.name === "ghosty.title") return { ...s, effects: [{ type: "title", title: e.value?.title }] };
|
|
174
|
+
return s;
|
|
175
|
+
default:
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function finish(s0, e, now, failed = false) {
|
|
180
|
+
let s = { ...s0, busy: false };
|
|
181
|
+
const outcome = e.outcome;
|
|
182
|
+
const interrupted = outcome?.type === "interrupt" && Array.isArray(outcome.interrupts) && outcome.interrupts.length > 0;
|
|
183
|
+
const clockOffset = typeof e.timestamp === "number" ? now - e.timestamp : 0;
|
|
184
|
+
s.permissions = interrupted ? outcome.interrupts.map((i) => toPermission(i, s.turns, clockOffset)) : [];
|
|
185
|
+
if (outcome?.type === "cancelled" || failed) s = { ...s, turns: closeOpenTools(s.turns, failed ? "failed" : "cancelled") };
|
|
186
|
+
if (interrupted) {
|
|
187
|
+
const waiting = new Set(s.permissions.map((p) => p.toolCallId).filter(Boolean));
|
|
188
|
+
if (waiting.size) {
|
|
189
|
+
s = { ...s, turns: s.turns.map((t) => t.tools?.some((x) => waiting.has(x.id)) ? { ...t, tools: t.tools.map((x) => waiting.has(x.id) && x.status !== "completed" ? { ...x, status: "pending" } : x) } : t) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const usage = Array.isArray(e.usage) && e.usage.length ? e.usage.reduce(
|
|
193
|
+
(a, u) => ({ ...a, used: a.used + (u.totalTokens ?? (u.inputTokens ?? 0) + (u.outputTokens ?? 0)), input: (a.input ?? 0) + (u.inputTokens ?? 0), output: (a.output ?? 0) + (u.outputTokens ?? 0) }),
|
|
194
|
+
{ used: 0, size: 0, cost: 0 }
|
|
195
|
+
) : null;
|
|
196
|
+
const t0 = s.turnStartedAt;
|
|
197
|
+
const seconds = t0 ? Math.max(1, Math.round((now - t0) / 1e3)) : void 0;
|
|
198
|
+
const turns = [...s.turns];
|
|
199
|
+
const last = turns[turns.length - 1];
|
|
200
|
+
if (last?.role === "assistant") turns[turns.length - 1] = { ...last, ...seconds !== void 0 ? { seconds } : {}, ...usage ? { usage } : {} };
|
|
201
|
+
s = { ...s, turns, turnStartedAt: 0, ...usage ? { usage } : {} };
|
|
202
|
+
if (!t0) return s;
|
|
203
|
+
const effect = {
|
|
204
|
+
type: "turn-done",
|
|
205
|
+
text: last?.role === "assistant" ? last.text : "",
|
|
206
|
+
runId: e.runId ?? s.runId ?? void 0,
|
|
207
|
+
...seconds !== void 0 ? { seconds } : {},
|
|
208
|
+
...failed ? { error: s.error ?? void 0 } : {},
|
|
209
|
+
...interrupted ? { interrupted: true } : {}
|
|
210
|
+
};
|
|
211
|
+
return { ...s, effects: [effect] };
|
|
212
|
+
}
|
|
213
|
+
function beginSend(prev, text, attachments = [], now = Date.now()) {
|
|
214
|
+
const turns = text || attachments.length ? [...prev.turns, { role: "user", text, ...attachments.length ? { attachments } : {} }] : prev.turns;
|
|
215
|
+
return { ...prev, turns, busy: true, error: null, notice: null, turnStartedAt: now, effects: [{ type: "turn-start" }] };
|
|
216
|
+
}
|
|
217
|
+
function sendFailed(prev, message) {
|
|
218
|
+
return { ...prev, busy: false, turnStartedAt: 0, error: message, effects: [] };
|
|
219
|
+
}
|
|
220
|
+
function stopTurn(prev) {
|
|
221
|
+
return { ...prev, busy: false, turnStartedAt: 0, turns: closeOpenTools(prev.turns, "cancelled"), effects: [] };
|
|
222
|
+
}
|
|
223
|
+
function dropLastExchange(prev) {
|
|
224
|
+
const turns = [...prev.turns];
|
|
225
|
+
while (turns.length && turns[turns.length - 1].role === "assistant") turns.pop();
|
|
226
|
+
return { ...prev, turns: turns.slice(0, -1), effects: [] };
|
|
227
|
+
}
|
|
228
|
+
function lastUserTurn(turns) {
|
|
229
|
+
for (let i = turns.length - 1; i >= 0; i--) if (turns[i].role === "user") return turns[i];
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function resolvePermission(prev, id, status, asked) {
|
|
233
|
+
const req = prev.permissions.find((p) => p.id === id) ?? asked;
|
|
234
|
+
const s = { ...prev, effects: [], permissions: prev.permissions.filter((p) => p.id !== id) };
|
|
235
|
+
if (!req) return s;
|
|
236
|
+
const decision = { id, title: req.title, status };
|
|
237
|
+
const turns = [...s.turns];
|
|
238
|
+
let i = turns.length - 1;
|
|
239
|
+
while (i >= 0 && turns[i].role !== "assistant") i--;
|
|
240
|
+
if (i >= 0) turns[i] = { ...turns[i], decisions: [...turns[i].decisions ?? [], decision] };
|
|
241
|
+
return { ...s, turns };
|
|
242
|
+
}
|
|
243
|
+
function expiryMs(v) {
|
|
244
|
+
return typeof v === "number" ? v : Date.parse(v);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/core/narration.ts
|
|
248
|
+
var MARK = "\u2713";
|
|
249
|
+
var LINE = /^- ✓ (.+)$/;
|
|
250
|
+
function isNarration(chunk) {
|
|
251
|
+
const t = chunk.trim();
|
|
252
|
+
return t.length > 0 && t.length <= 300 && !/\n\s*\n/.test(t) && !t.includes("```");
|
|
253
|
+
}
|
|
254
|
+
function splitNarration(text, cuts) {
|
|
255
|
+
if (cuts?.length) {
|
|
256
|
+
const valid = cuts.filter((c) => c > 0 && c <= text.length);
|
|
257
|
+
if (valid.length) {
|
|
258
|
+
const chunks = [];
|
|
259
|
+
let from = 0;
|
|
260
|
+
for (const c of valid) {
|
|
261
|
+
const t = text.slice(from, c).trim();
|
|
262
|
+
if (t) chunks.push(t);
|
|
263
|
+
from = c;
|
|
264
|
+
}
|
|
265
|
+
if (chunks.every(isNarration)) {
|
|
266
|
+
return { steps: chunks.map((t) => t.replace(/\s*\n\s*/g, " ")), rest: text.slice(from).replace(/^\s+/, "") };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { steps: [], rest: text };
|
|
270
|
+
}
|
|
271
|
+
const lines = text.split("\n");
|
|
272
|
+
const steps = [];
|
|
273
|
+
let i = 0;
|
|
274
|
+
for (; i < lines.length; i++) {
|
|
275
|
+
const m = LINE.exec(lines[i]);
|
|
276
|
+
if (!m) break;
|
|
277
|
+
steps.push(m[1].trim());
|
|
278
|
+
}
|
|
279
|
+
if (!steps.length) return { steps, rest: text };
|
|
280
|
+
return { steps, rest: lines.slice(i).join("\n").replace(/^\s+/, "") };
|
|
281
|
+
}
|
|
282
|
+
function narrationToMarkdown(chunks, answer) {
|
|
283
|
+
const steps = chunks.map((t) => t.trim()).filter(Boolean);
|
|
284
|
+
if (!steps.length || !steps.every(isNarration)) return answer;
|
|
285
|
+
const list = steps.map((p) => `- ${MARK} ${p.replace(/\s*\n\s*/g, " ")}`).join("\n");
|
|
286
|
+
return answer.trim() ? `${list}
|
|
287
|
+
|
|
288
|
+
${answer.trim()}` : list;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/core/options.ts
|
|
292
|
+
var BULLET = /^\s*(?:[-*•+]|(\d{1,2})[.)])\s+(.+?)\s*$/;
|
|
293
|
+
var INVITES = /(puedo|quieres|elige|eliges|prefieres|opciones|caminos|can i|would you|want me|choose|prefer|options)/i;
|
|
294
|
+
function extractReplyOptions(text) {
|
|
295
|
+
const lines = text.replace(/\s+$/, "").split("\n");
|
|
296
|
+
const items = [];
|
|
297
|
+
let numbered = false;
|
|
298
|
+
let i = lines.length - 1;
|
|
299
|
+
for (; i >= 0; i--) {
|
|
300
|
+
const m = BULLET.exec(lines[i]);
|
|
301
|
+
if (!m) break;
|
|
302
|
+
if (m[1]) numbered = true;
|
|
303
|
+
items.unshift(m[2]);
|
|
304
|
+
}
|
|
305
|
+
if (items.length < 2 || items.length > 5) return null;
|
|
306
|
+
if (items.some((t) => t.length > 140 || /```|`|https?:\/\/|\]\(/.test(t))) return null;
|
|
307
|
+
let j = i;
|
|
308
|
+
while (j >= 0 && !lines[j].trim()) j--;
|
|
309
|
+
if (j < 0) return null;
|
|
310
|
+
const invitation = lines[j].trim();
|
|
311
|
+
if (!invitation.endsWith(":") && !INVITES.test(invitation)) return null;
|
|
312
|
+
return { before: lines.slice(0, i + 1).join("\n").trimEnd(), items, numbered };
|
|
313
|
+
}
|
|
314
|
+
function cleanReplyOption(t) {
|
|
315
|
+
let s = t.replace(/\*\*|__/g, "").trim();
|
|
316
|
+
s = s.replace(/^(?:o|y|u|or|and)\s+/i, "").replace(/[\s,.;]+$/, "").trim();
|
|
317
|
+
return s.charAt(0).toLocaleUpperCase() + s.slice(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/core/steps.ts
|
|
321
|
+
var isRunning = (t) => t.status !== "completed" && t.status !== "failed" && t.status !== "cancelled";
|
|
322
|
+
function toolNames(t) {
|
|
323
|
+
const title = t.title ?? "";
|
|
324
|
+
if (!title) return [];
|
|
325
|
+
const out = [title];
|
|
326
|
+
const double = /^mcp__.+?__(.+)$/i.exec(title);
|
|
327
|
+
if (double) out.push(double[1]);
|
|
328
|
+
else if (/^mcp_/i.test(title)) {
|
|
329
|
+
const parts = title.slice(4).split("_");
|
|
330
|
+
for (let i = 0; i < parts.length; i++) out.push(parts.slice(i).join("_"));
|
|
331
|
+
}
|
|
332
|
+
return [...new Set(out)];
|
|
333
|
+
}
|
|
334
|
+
function toolName(t) {
|
|
335
|
+
const names = toolNames(t);
|
|
336
|
+
return /^mcp__/i.test(names[0] ?? "") ? names[1] ?? names[0] : names[0] ?? null;
|
|
337
|
+
}
|
|
338
|
+
function namedVerb(t, verbs) {
|
|
339
|
+
for (const name of toolNames(t)) if (verbs[name]) return { key: name, verb: verbs[name] };
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
function stepLabel(t, verbs, fallback = "Paso") {
|
|
343
|
+
const named = namedVerb(t, verbs);
|
|
344
|
+
if (named) return isRunning(t) && named.verb.running ? named.verb.running : named.verb.one;
|
|
345
|
+
return t.title ?? t.kind ?? fallback;
|
|
346
|
+
}
|
|
347
|
+
function summarizeTools(tools, verbs) {
|
|
348
|
+
const groups = /* @__PURE__ */ new Map();
|
|
349
|
+
for (const t of tools) {
|
|
350
|
+
const named = namedVerb(t, verbs);
|
|
351
|
+
const key = named?.key ?? t.kind ?? "other";
|
|
352
|
+
const g = groups.get(key) ?? { n: 0, verb: named?.verb ?? verbs[key] };
|
|
353
|
+
g.n++;
|
|
354
|
+
groups.set(key, g);
|
|
355
|
+
}
|
|
356
|
+
const parts = [];
|
|
357
|
+
for (const [key, { n, verb }] of groups) {
|
|
358
|
+
if (verb) parts.push(n === 1 ? verb.one : verb.many.replace("%n", String(n)));
|
|
359
|
+
else parts.push(`${n} \xD7 ${key.replace(/[_-]/g, " ")}`);
|
|
360
|
+
}
|
|
361
|
+
return parts.join(" \xB7 ");
|
|
362
|
+
}
|
|
363
|
+
function groupSteps(tools, label) {
|
|
364
|
+
const groups = /* @__PURE__ */ new Map();
|
|
365
|
+
for (const t of tools) {
|
|
366
|
+
const k = `${label(t)}\0${t.detail ?? ""}`;
|
|
367
|
+
const g = groups.get(k);
|
|
368
|
+
if (!g) {
|
|
369
|
+
groups.set(k, { tool: t, times: 1 });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
g.times++;
|
|
373
|
+
const wins = isRunning(t) || t.status === "failed" || !(isRunning(g.tool) || g.tool.status === "failed");
|
|
374
|
+
if (wins) g.tool = { ...t, id: g.tool.id };
|
|
375
|
+
}
|
|
376
|
+
return [...groups.values()];
|
|
377
|
+
}
|
|
378
|
+
var TOOL_VERBS_ES = {
|
|
379
|
+
read: { one: "Le\xED un archivo", many: "Le\xED %n archivos" },
|
|
380
|
+
read_file: { one: "Le\xED un archivo", many: "Le\xED %n archivos" },
|
|
381
|
+
write: { one: "Escrib\xED un archivo", many: "Escrib\xED %n archivos" },
|
|
382
|
+
write_file: { one: "Escrib\xED un archivo", many: "Escrib\xED %n archivos" },
|
|
383
|
+
edit: { one: "Edit\xE9 un archivo", many: "Edit\xE9 %n archivos" },
|
|
384
|
+
edit_file: { one: "Edit\xE9 un archivo", many: "Edit\xE9 %n archivos" },
|
|
385
|
+
bash: { one: "Corr\xED un comando", many: "Corr\xED %n comandos" },
|
|
386
|
+
shell: { one: "Corr\xED un comando", many: "Corr\xED %n comandos" },
|
|
387
|
+
terminal: { one: "Corr\xED un comando", many: "Corr\xED %n comandos" },
|
|
388
|
+
execute: { one: "Corr\xED un comando", many: "Corr\xED %n comandos" },
|
|
389
|
+
grep: { one: "Busqu\xE9 en el c\xF3digo", many: "Busqu\xE9 en el c\xF3digo" },
|
|
390
|
+
glob: { one: "Busqu\xE9 archivos", many: "Busqu\xE9 archivos" },
|
|
391
|
+
search: { one: "Busqu\xE9", many: "Busqu\xE9 %n veces" },
|
|
392
|
+
tool_search: { one: "Busqu\xE9 una herramienta", many: "Busqu\xE9 %n herramientas" },
|
|
393
|
+
web_search: { one: "Busqu\xE9 en la web", many: "Busqu\xE9 en la web" },
|
|
394
|
+
web_fetch: { one: "Abr\xED una p\xE1gina", many: "Abr\xED %n p\xE1ginas" },
|
|
395
|
+
fetch: { one: "Abr\xED una p\xE1gina", many: "Abr\xED %n p\xE1ginas" },
|
|
396
|
+
site_publish: { one: "Publiqu\xE9 una p\xE1gina", many: "Publiqu\xE9 %n p\xE1ginas" },
|
|
397
|
+
pdf: { one: "Gener\xE9 un PDF", many: "Gener\xE9 %n PDF" },
|
|
398
|
+
connector: { one: "Us\xE9 tu sistema", many: "Us\xE9 tu sistema %n veces" },
|
|
399
|
+
mcp: { one: "Us\xE9 una herramienta", many: "Us\xE9 %n herramientas" },
|
|
400
|
+
browser: { one: "Us\xE9 el navegador", many: "Us\xE9 el navegador" },
|
|
401
|
+
todo: { one: "Anot\xE9 el plan", many: "Anot\xE9 el plan" },
|
|
402
|
+
delete: { one: "Borr\xE9 un archivo", many: "Borr\xE9 %n archivos" },
|
|
403
|
+
move: { one: "Mov\xED un archivo", many: "Mov\xED %n archivos" },
|
|
404
|
+
think: { one: "Lo pens\xE9", many: "Lo pens\xE9" },
|
|
405
|
+
image: { one: "Cre\xE9 una imagen", many: "Cre\xE9 %n im\xE1genes" },
|
|
406
|
+
delegate: { one: "Delegu\xE9 una tarea", many: "Delegu\xE9 %n tareas" },
|
|
407
|
+
collect: { one: "Recog\xED un resultado", many: "Recog\xED %n resultados" },
|
|
408
|
+
status: { one: "Pregunt\xE9 c\xF3mo iba", many: "Pregunt\xE9 %n veces c\xF3mo iba" },
|
|
409
|
+
stop: { one: "Detuve una tarea", many: "Detuve %n tareas" },
|
|
410
|
+
guide: { one: "Le\xED una gu\xEDa", many: "Le\xED %n gu\xEDas" },
|
|
411
|
+
deliver: { one: "Entregu\xE9 un archivo", many: "Entregu\xE9 %n archivos" },
|
|
412
|
+
sheet: { one: "Consult\xE9 una hoja", many: "Consult\xE9 %n hojas" },
|
|
413
|
+
view: { one: "Arm\xE9 una vista", many: "Arm\xE9 %n vistas" }
|
|
414
|
+
};
|
|
415
|
+
var TOOL_VERBS_EN = {
|
|
416
|
+
read: { one: "Read a file", many: "Read %n files" },
|
|
417
|
+
read_file: { one: "Read a file", many: "Read %n files" },
|
|
418
|
+
write: { one: "Wrote a file", many: "Wrote %n files" },
|
|
419
|
+
write_file: { one: "Wrote a file", many: "Wrote %n files" },
|
|
420
|
+
edit: { one: "Edited a file", many: "Edited %n files" },
|
|
421
|
+
edit_file: { one: "Edited a file", many: "Edited %n files" },
|
|
422
|
+
bash: { one: "Ran a command", many: "Ran %n commands" },
|
|
423
|
+
shell: { one: "Ran a command", many: "Ran %n commands" },
|
|
424
|
+
terminal: { one: "Ran a command", many: "Ran %n commands" },
|
|
425
|
+
execute: { one: "Ran a command", many: "Ran %n commands" },
|
|
426
|
+
grep: { one: "Searched the code", many: "Searched the code" },
|
|
427
|
+
glob: { one: "Searched for files", many: "Searched for files" },
|
|
428
|
+
search: { one: "Searched", many: "Searched %n times" },
|
|
429
|
+
tool_search: { one: "Looked for a tool", many: "Looked for %n tools" },
|
|
430
|
+
web_search: { one: "Searched the web", many: "Searched the web" },
|
|
431
|
+
web_fetch: { one: "Opened a page", many: "Opened %n pages" },
|
|
432
|
+
fetch: { one: "Opened a page", many: "Opened %n pages" },
|
|
433
|
+
site_publish: { one: "Published a page", many: "Published %n pages" },
|
|
434
|
+
pdf: { one: "Generated a PDF", many: "Generated %n PDFs" },
|
|
435
|
+
connector: { one: "Used your system", many: "Used your system %n times" },
|
|
436
|
+
mcp: { one: "Used a tool", many: "Used %n tools" },
|
|
437
|
+
browser: { one: "Used the browser", many: "Used the browser" },
|
|
438
|
+
todo: { one: "Wrote down the plan", many: "Wrote down the plan" },
|
|
439
|
+
delete: { one: "Deleted a file", many: "Deleted %n files" },
|
|
440
|
+
move: { one: "Moved a file", many: "Moved %n files" },
|
|
441
|
+
think: { one: "Thought it through", many: "Thought it through" },
|
|
442
|
+
image: { one: "Created an image", many: "Created %n images" },
|
|
443
|
+
delegate: { one: "Delegated a task", many: "Delegated %n tasks" },
|
|
444
|
+
collect: { one: "Collected a result", many: "Collected %n results" },
|
|
445
|
+
status: { one: "Checked how it was going", many: "Checked %n times how it was going" },
|
|
446
|
+
stop: { one: "Stopped a task", many: "Stopped %n tasks" },
|
|
447
|
+
guide: { one: "Read a guide", many: "Read %n guides" },
|
|
448
|
+
deliver: { one: "Delivered a file", many: "Delivered %n files" },
|
|
449
|
+
sheet: { one: "Checked a sheet", many: "Checked %n sheets" },
|
|
450
|
+
view: { one: "Built a view", many: "Built %n views" }
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
export {
|
|
454
|
+
createPilotState,
|
|
455
|
+
argsDetail,
|
|
456
|
+
toPermission,
|
|
457
|
+
messagesToTurns,
|
|
458
|
+
reduceAgUiEvent,
|
|
459
|
+
beginSend,
|
|
460
|
+
sendFailed,
|
|
461
|
+
stopTurn,
|
|
462
|
+
dropLastExchange,
|
|
463
|
+
lastUserTurn,
|
|
464
|
+
resolvePermission,
|
|
465
|
+
expiryMs,
|
|
466
|
+
isNarration,
|
|
467
|
+
splitNarration,
|
|
468
|
+
narrationToMarkdown,
|
|
469
|
+
extractReplyOptions,
|
|
470
|
+
cleanReplyOption,
|
|
471
|
+
isRunning,
|
|
472
|
+
toolNames,
|
|
473
|
+
toolName,
|
|
474
|
+
stepLabel,
|
|
475
|
+
summarizeTools,
|
|
476
|
+
groupSteps,
|
|
477
|
+
TOOL_VERBS_ES,
|
|
478
|
+
TOOL_VERBS_EN
|
|
479
|
+
};
|