@vatio-ai/cli 0.37.2

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.
@@ -0,0 +1,322 @@
1
+ // whatsapp / instagram — the two channels a workspace can answer on for real.
2
+ //
3
+ // Each has the same two halves, deliberately mirrored: the workspace's *own*
4
+ // account, which serves live, and a shared preview you can use immediately with
5
+ // no Meta account of your own. `numbers` and `accounts` are the preview halves,
6
+ // and they hang off the channel they belong to because "accounts" on its own
7
+ // would say nothing about which one.
8
+
9
+ import { deployClient } from "./deploy.mjs";
10
+ import { fail, openBrowser } from "../support.mjs";
11
+
12
+ export async function whatsapp(config, args) {
13
+ const workspace = config.resolveWorkspaceRequired();
14
+ const client = deployClient(config, workspace);
15
+ const sub = args.shift() ?? "status";
16
+
17
+ switch (sub) {
18
+ case "status": return printWhatsapp(await client.whatsappAccount(), workspace);
19
+ case "connect": return await connectWhatsapp(client, workspace);
20
+ case "check": return printWhatsapp(await client.checkWhatsappAccount(), workspace);
21
+ case "activate": {
22
+ await client.activateWhatsappAccount();
23
+ console.log(`Activated WhatsApp on workspace ${workspace} — it answers customers now.`);
24
+ return;
25
+ }
26
+ case "deactivate": {
27
+ await client.deactivateWhatsappAccount();
28
+ console.log(`Paused WhatsApp on workspace ${workspace}. The credentials are kept.`);
29
+ return;
30
+ }
31
+ case "disconnect": case "remove": case "rm": {
32
+ await client.disconnectWhatsappAccount();
33
+ console.log(`Disconnected WhatsApp from workspace ${workspace}. Vatio stops receiving its messages.`);
34
+ return;
35
+ }
36
+ case "numbers": return await numbers(client, workspace, args);
37
+ default: fail(whatsappUsage());
38
+ }
39
+ }
40
+
41
+ function whatsappUsage() {
42
+ return `Usage: vatio whatsapp status|connect|check|activate|deactivate|disconnect
43
+ vatio whatsapp numbers list|add PHONE|verify PHONE CODE|resend PHONE|remove PHONE
44
+
45
+ The bare commands drive your own WhatsApp number, which serves live.
46
+ \`numbers\` registers test phones on the shared WhatsApp preview, which always
47
+ reach preview — the counterpart of \`vatio instagram accounts\`.
48
+
49
+ connect
50
+ Prints a link to finish in a browser. Connecting a number is Meta's
51
+ Embedded Signup, a consent screen a person has to read, so there is
52
+ nothing for a command to do but hand the link over.`;
53
+ }
54
+
55
+ function printWhatsapp(account, workspace) {
56
+ if (!account.connected) {
57
+ console.log(`No WhatsApp number connected to workspace ${workspace}.`);
58
+ console.log(" Connect yours: vatio whatsapp connect");
59
+ console.log(" Or test on the shared preview number with no Meta account: vatio whatsapp numbers add +56912345678");
60
+ return;
61
+ }
62
+
63
+ const label = account.phone_number ?? "your WhatsApp number";
64
+ console.log(`${label} on workspace ${workspace}:`);
65
+ console.log(` status: ${account.status}`);
66
+ console.log(` receiving: ${account.receiving_messages ? "yes" : "no"}`);
67
+ console.log(` active: ${account.active ? "yes" : "no (paused)"}`);
68
+ if (account.last_health_error) console.log(` error: ${account.last_health_error}`);
69
+ console.log("");
70
+
71
+ // Three things that fail separately, so the advice names which one did.
72
+ if (account.live_ready) {
73
+ console.log(`Messages to ${label} reach your live deployment.`);
74
+ console.log("Change what it answers with: vatio push && vatio publish");
75
+ } else if (!account.receiving_messages) {
76
+ console.log("Meta is not delivering this number's messages to Vatio, so the agent");
77
+ console.log("never sees them. Repair the subscription: vatio whatsapp check");
78
+ } else if (!account.active) {
79
+ console.log("Connected and receiving, but paused — nobody gets an answer yet.");
80
+ console.log("Start answering: vatio whatsapp activate");
81
+ } else {
82
+ console.log("Not ready to answer yet. Re-check with: vatio whatsapp check");
83
+ }
84
+ }
85
+
86
+ // Prints the link and stops. Meta's Embedded Signup needs a logged-in browser
87
+ // and a person reading a consent screen; there is no headless form of it, and
88
+ // the ids a credentials flag would take do not exist until it has run. Keeping
89
+ // a number in the WhatsApp Business phone app is only possible through it too.
90
+ async function connectWhatsapp(client, workspace) {
91
+ const payload = await client.whatsappConnectLink();
92
+ const url = payload.connect_url;
93
+ if (!url) fail("The platform did not return a connect URL");
94
+
95
+ if (payload.connected_number) {
96
+ console.log(`Workspace ${workspace} is already connected to ${payload.connected_number}.`);
97
+ console.log("Connecting the same number again renews its access token. To connect a");
98
+ console.log("different one, run `vatio whatsapp disconnect` first.");
99
+ console.log("");
100
+ }
101
+
102
+ console.log("Open this to connect your WhatsApp number:");
103
+ console.log("");
104
+ console.log(` ${url}`);
105
+ console.log("");
106
+ console.log("Choose your number in Meta's popup. A number already in the WhatsApp");
107
+ console.log("Business app on your phone stays there — you keep answering from the");
108
+ console.log("phone and the agent answers on the same number.");
109
+ console.log("");
110
+ console.log("Then run `vatio whatsapp`.");
111
+ // The link is printed first and stays printed: openBrowser is a convenience
112
+ // that silently does nothing over SSH, in a container, or wherever there is
113
+ // no browser to open, and the developer still has the URL to paste.
114
+ openBrowser(url);
115
+ }
116
+
117
+ async function numbers(client, workspace, args) {
118
+ const sub = args.shift() ?? "list";
119
+ const usage = "Usage: vatio whatsapp numbers list|add PHONE|verify PHONE CODE|resend PHONE|remove PHONE";
120
+
121
+ if (sub === "list") {
122
+ const list = asArray((await client.listTestPhoneNumbers()).test_phone_numbers);
123
+ if (list.length === 0) {
124
+ console.log(`No test phone numbers on workspace ${workspace}`);
125
+ console.log(" Register one: vatio whatsapp numbers add +56912345678");
126
+ return;
127
+ }
128
+ console.log(`Test phone numbers on workspace ${workspace} (${list.length}):`);
129
+ for (const number of list) {
130
+ const suffix = number.status === "verified" ? `verified ${number.verified_at}` : "pending verification";
131
+ console.log(` ${number.phone_number} ${suffix}`);
132
+ }
133
+ console.log("");
134
+ console.log("They reach the preview deployment on the shared WhatsApp preview number, never live.");
135
+ return;
136
+ }
137
+ if (sub === "add") {
138
+ const phoneNumber = requireArg(args, "vatio whatsapp numbers add +56912345678");
139
+ const number = await client.addTestPhoneNumber({ phoneNumber });
140
+ if (number.already_verified) {
141
+ console.log(`${number.phone_number} is already verified on workspace ${workspace} — nothing to do.`);
142
+ } else if (number.moved_from) {
143
+ console.log(`Moved ${number.phone_number} from workspace ${number.moved_from} to ${workspace}.`);
144
+ console.log("It was already verified, so no new code was sent.");
145
+ } else {
146
+ console.log(`Sent a 6-digit code over WhatsApp to ${number.phone_number}.`);
147
+ console.log("It is valid for 10 minutes. When it arrives:");
148
+ console.log("");
149
+ console.log(` vatio whatsapp numbers verify ${number.phone_number} 123456`);
150
+ }
151
+ return;
152
+ }
153
+ if (sub === "verify") {
154
+ const phoneNumber = requireArg(args, "vatio whatsapp numbers verify +56912345678 123456");
155
+ const code = requireArg(args, "vatio whatsapp numbers verify +56912345678 123456");
156
+ const number = await client.verifyTestPhoneNumber({ phoneNumber, code });
157
+ console.log(
158
+ number.already_verified
159
+ ? `${number.phone_number} was already verified on workspace ${workspace}.`
160
+ : `Verified ${number.phone_number} on workspace ${workspace}.`
161
+ );
162
+ console.log("Anything you send from that phone now reaches the preview deployment.");
163
+ return;
164
+ }
165
+ if (sub === "resend") {
166
+ const phoneNumber = requireArg(args, "vatio whatsapp numbers resend +56912345678");
167
+ const number = await client.resendTestPhoneNumberCode({ phoneNumber });
168
+ console.log(
169
+ number.already_verified
170
+ ? `${number.phone_number} is already verified on workspace ${workspace} — no code needed.`
171
+ : `Sent a new 6-digit code to ${number.phone_number}.`
172
+ );
173
+ return;
174
+ }
175
+ if (sub === "remove" || sub === "delete" || sub === "rm") {
176
+ const phoneNumber = requireArg(args, "vatio whatsapp numbers remove +56912345678");
177
+ await client.removeTestPhoneNumber({ phoneNumber });
178
+ console.log(`Removed ${phoneNumber} from workspace ${workspace}.`);
179
+ return;
180
+ }
181
+
182
+ fail(usage);
183
+ }
184
+
185
+ export async function instagram(config, args) {
186
+ const workspace = config.resolveWorkspaceRequired();
187
+ const client = deployClient(config, workspace);
188
+ const sub = args.shift() ?? "status";
189
+
190
+ switch (sub) {
191
+ case "status": return printInstagram(await client.instagramAccount(), workspace);
192
+ case "connect": return await connectInstagram(client);
193
+ case "check": return printInstagram(await client.checkInstagramAccount(), workspace);
194
+ case "disconnect": case "remove": case "rm": {
195
+ await client.disconnectInstagramAccount();
196
+ console.log(`Disconnected Instagram from workspace ${workspace}. Vatio stops receiving its DMs.`);
197
+ return;
198
+ }
199
+ case "accounts": return await instagramAccounts(client, workspace, args);
200
+ default: fail(instagramUsage());
201
+ }
202
+ }
203
+
204
+ function instagramUsage() {
205
+ return `Usage: vatio instagram status|connect|check|disconnect
206
+ vatio instagram accounts list|add @HANDLE|verify CODE|resend ID|remove ID
207
+
208
+ The bare commands drive your own Instagram account, which serves live.
209
+ \`accounts\` registers test accounts on the shared Instagram preview, which
210
+ always reach preview — the counterpart of \`vatio whatsapp numbers\`.`;
211
+ }
212
+
213
+ function printInstagram(account, workspace) {
214
+ if (!account.connected) {
215
+ console.log(`No Instagram account connected to workspace ${workspace}.`);
216
+ console.log(" Connect yours: vatio instagram connect");
217
+ console.log(" Or test on the shared preview account: vatio instagram accounts add @yourhandle");
218
+ return;
219
+ }
220
+
221
+ const handle = account.username ? `@${account.username}` : "your Instagram account";
222
+ console.log(`${handle} on workspace ${workspace}:`);
223
+ console.log(` status: ${account.status}`);
224
+ console.log(` receiving: ${account.receiving_messages ? "yes" : "no"}`);
225
+ if (account.last_health_error) console.log(` error: ${account.last_health_error}`);
226
+ console.log("");
227
+
228
+ if (account.live_ready) {
229
+ console.log(`DMs to ${handle} reach your live deployment.`);
230
+ console.log("Change what it answers with: vatio push && vatio publish");
231
+ } else if (account.token_expired) {
232
+ console.log("The access token expired. Re-grant it: vatio instagram connect");
233
+ } else if (!account.receiving_messages) {
234
+ console.log("Instagram is not delivering this account's messages to Vatio, so the");
235
+ console.log("agent never sees them. Repair the subscription: vatio instagram check");
236
+ } else {
237
+ console.log("Not ready to answer DMs yet. Re-check with: vatio instagram check");
238
+ }
239
+ }
240
+
241
+ // Connecting mints a URL for Meta's consent screen rather than taking
242
+ // credentials: granting an app access to an Instagram account is a decision
243
+ // Meta insists a human makes in a browser.
244
+ async function connectInstagram(client) {
245
+ const payload = await client.instagramConnectLink();
246
+ const url = payload.url ?? payload.consent_url;
247
+ if (!url) fail("The platform did not return a consent URL");
248
+
249
+ console.log("Open this to grant Vatio access to an Instagram professional account:");
250
+ console.log("");
251
+ console.log(` ${url}`);
252
+ console.log("");
253
+ console.log("Then run `vatio instagram check`.");
254
+ openBrowser(url);
255
+ }
256
+
257
+ async function instagramAccounts(client, workspace, args) {
258
+ const sub = args.shift() ?? "list";
259
+ const usage = "Usage: vatio instagram accounts list|add @HANDLE|verify CODE|resend ID|remove ID";
260
+
261
+ if (sub === "list") {
262
+ const list = asArray((await client.listTestInstagramAccounts()).test_instagram_accounts);
263
+ if (list.length === 0) {
264
+ console.log(`No test Instagram accounts on workspace ${workspace}`);
265
+ console.log(" Register one: vatio instagram accounts add @yourhandle");
266
+ return;
267
+ }
268
+ console.log(`Test Instagram accounts on workspace ${workspace} (${list.length}):`);
269
+ for (const account of list) {
270
+ const suffix =
271
+ account.status === "verified" ? `verified ${account.verified_at}`
272
+ : account.status === "awaiting_code" ? "code sent — confirm it: vatio instagram accounts verify CODE"
273
+ : `declared — DM @${account.dm_to} from that account to get a code`;
274
+ console.log(` [${account.id}] @${account.username} ${suffix}`);
275
+ }
276
+ return;
277
+ }
278
+ if (sub === "add") {
279
+ const username = requireArg(args, "vatio instagram accounts add @yourhandle").replace(/^@/, "");
280
+ const account = await client.addTestInstagramAccount({ username });
281
+ console.log(`Declared @${account.username} on workspace ${workspace}.`);
282
+ console.log("");
283
+ // Meta will not let an app DM an account that has not written first, which
284
+ // is why the code travels in that direction and nothing happens until the
285
+ // developer sends that first message.
286
+ console.log(`Now DM @${account.dm_to} from that account. Vatio replies with a 6-digit code:`);
287
+ console.log("");
288
+ console.log(" vatio instagram accounts verify 123456");
289
+ return;
290
+ }
291
+ if (sub === "verify") {
292
+ const code = requireArg(args, "vatio instagram accounts verify 123456");
293
+ const account = await client.verifyTestInstagramAccount({ code });
294
+ console.log(`Verified @${account.username} on workspace ${workspace}.`);
295
+ console.log("DMs from that account now reach the preview deployment.");
296
+ return;
297
+ }
298
+ if (sub === "resend") {
299
+ const id = requireArg(args, "vatio instagram accounts resend ID");
300
+ const account = await client.resendTestInstagramAccountCode(id);
301
+ console.log(`Sent a new code to @${account.username}.`);
302
+ return;
303
+ }
304
+ if (sub === "remove" || sub === "delete" || sub === "rm") {
305
+ const id = requireArg(args, "vatio instagram accounts remove ID");
306
+ await client.removeTestInstagramAccount(id);
307
+ console.log(`Removed test Instagram account ${id} from workspace ${workspace}.`);
308
+ return;
309
+ }
310
+
311
+ fail(usage);
312
+ }
313
+
314
+ function requireArg(args, usage) {
315
+ const value = String(args.shift() ?? "").trim();
316
+ if (value === "") fail(`Usage: ${usage}`);
317
+ return value;
318
+ }
319
+
320
+ function asArray(value) {
321
+ return Array.isArray(value) ? value : [];
322
+ }
@@ -0,0 +1,242 @@
1
+ // `vatio chat …` — a conversation with one of your own deployments.
2
+ //
3
+ // You are always yourself here: the chat belongs to the token you are holding,
4
+ // on the `cli` channel. There is no flag that pretends to be a WhatsApp or
5
+ // Instagram visitor; the shared WhatsApp preview number and the Instagram test
6
+ // accounts do that for real, and a simulated channel was only ever a second
7
+ // answer to keep in step with the true one.
8
+
9
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { ApiClient } from "../api-client.mjs";
13
+ import { fail, sleep, takeEnv, takeValue } from "../support.mjs";
14
+
15
+ export const STATE_FILE = ".vatio-chat.json";
16
+
17
+ const SUBCOMMANDS = new Set(["transcript", "debug", "reset", "destroy", "delete", "help"]);
18
+ const REMOVED_FLAGS = ["--channel", "--from", "--as", "--sandbox-url"];
19
+
20
+ export async function chat(config, args) {
21
+ for (const arg of args) {
22
+ if (REMOVED_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`))) {
23
+ fail(
24
+ `\`${arg}\` was removed. A CLI chat is you, the developer holding the token. ` +
25
+ "To test a real channel use `vatio whatsapp numbers` or an Instagram test account; " +
26
+ "to pick a deployment use --env."
27
+ );
28
+ }
29
+ }
30
+
31
+ const environment = takeEnv(args, { fallback: "preview" });
32
+ const timeout = Number(takeValue(args, "--timeout") ?? 120);
33
+ const last = Number(takeValue(args, "--last") ?? 10);
34
+ const apiUrl = takeValue(args, "--api-url");
35
+ const token = takeValue(args, "--token");
36
+
37
+ const options = { environment, timeout, last, apiUrl, token };
38
+ const sub = SUBCOMMANDS.has(args[0]) ? args.shift() : null;
39
+
40
+ if (sub === "help") return console.log(chatHelp());
41
+ if (sub === "transcript") return await transcript(config, options);
42
+ if (sub === "debug") return await debugChat(config, options);
43
+ if (sub === "reset") return await resetChat(config, options);
44
+ if (sub === "destroy" || sub === "delete") return await destroyChat(config, options, args);
45
+
46
+ const message = args.join(" ").trim();
47
+ if (message === "") return console.log(chatHelp());
48
+
49
+ return await converse(config, options, message);
50
+ }
51
+
52
+ function chatHelp() {
53
+ return `Usage:
54
+ vatio chat "message" [--env NAME] [--timeout N]
55
+ vatio chat transcript [--last N]
56
+ vatio chat debug [--last N]
57
+ vatio chat reset
58
+ vatio chat destroy CHAT_ID
59
+
60
+ --env picks the deployment that answers: preview (default), live, or a preview
61
+ name like pr-42. A chat against live is a real conversation and shows up in
62
+ your inbox.
63
+
64
+ The open chat per deployment is remembered in ${STATE_FILE}, so a second
65
+ \`vatio chat\` continues the first. \`reset\` starts over as a visitor the
66
+ workspace has never met.`;
67
+ }
68
+
69
+ function client(config, options) {
70
+ const apiUrl = config.resolveApiUrl({ explicit: options.apiUrl });
71
+ const token = options.token ?? config.resolveToken();
72
+ if (!apiUrl) fail("A workspace API url is required (--api-url, or base_url + a vatio.yml with `workspace:`)");
73
+ if (!token) fail("VATIO_TOKEN is required (env or `vatio login`)");
74
+ return new ApiClient({ baseUrl: apiUrl, token });
75
+ }
76
+
77
+ function statePath(config) {
78
+ return join(config.workspaceRootRequired(), STATE_FILE);
79
+ }
80
+
81
+ function loadState(config) {
82
+ const path = statePath(config);
83
+ if (!existsSync(path)) return {};
84
+ try {
85
+ const data = JSON.parse(readFileSync(path, "utf8"));
86
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ // One chat per deployment, keyed by environment: switching to `--env live` and
93
+ // back must not throw away the preview conversation you were in the middle of.
94
+ // Chats are dropped wholesale when the api url changes, since a chat id only
95
+ // means anything on the server that issued it.
96
+ function chatsFor(config, apiUrl) {
97
+ const state = loadState(config);
98
+ if (state.api_url !== apiUrl) return {};
99
+ return state.chats && typeof state.chats === "object" ? state.chats : {};
100
+ }
101
+
102
+ function rememberChat(config, { apiUrl, environment, chatId }) {
103
+ const chats = { ...chatsFor(config, apiUrl), [environment]: chatId };
104
+ writeFileSync(statePath(config), `${JSON.stringify({ api_url: apiUrl, chats }, null, 2)}\n`);
105
+ }
106
+
107
+ async function ensureChat(config, options) {
108
+ const apiUrl = config.resolveApiUrl({ explicit: options.apiUrl });
109
+ const existing = chatsFor(config, apiUrl)[options.environment];
110
+ if (existing) return Number(existing);
111
+
112
+ const payload = await openChat(config, options, { apiUrl });
113
+ return Number(payload.chat_id);
114
+ }
115
+
116
+ async function openChat(config, options, { apiUrl, chatId = null }) {
117
+ const api = client(config, options);
118
+ const payload = chatId
119
+ ? await api.resetChat({ chatId, environment: options.environment })
120
+ : await api.createChat({ environment: options.environment });
121
+
122
+ rememberChat(config, { apiUrl, environment: options.environment, chatId: payload.chat_id });
123
+ announce(options.environment);
124
+ return payload;
125
+ }
126
+
127
+ // Live is not refused -- you asked for it -- but it is said out loud, once,
128
+ // when the chat opens. A conversation against live is a real one: it lands in
129
+ // the inbox next to the ones visitors started, and nothing downstream knows it
130
+ // came from a terminal.
131
+ function announce(environment) {
132
+ if (environment === "live") {
133
+ console.log("Talking to live — this is a real conversation and appears in your inbox.");
134
+ } else {
135
+ console.log(`Talking to ${environment}`);
136
+ }
137
+ }
138
+
139
+ async function converse(config, options, message) {
140
+ const api = client(config, options);
141
+ const chatId = await ensureChat(config, options);
142
+ const sent = await api.sendChatMessage({ chatId, content: message });
143
+ const assistant = await waitForAssistant(api, {
144
+ chatId,
145
+ after: sent.user_message_id,
146
+ timeout: options.timeout
147
+ });
148
+ console.log(formatVisitor(assistant));
149
+ }
150
+
151
+ // Conversations are asynchronous: post a message, then poll for the reply.
152
+ async function waitForAssistant(api, { chatId, after, timeout, interval = 2 }) {
153
+ const deadline = Date.now() + timeout * 1000;
154
+ for (;;) {
155
+ const payload = await api.chatMessages({ chatId, view: "visitor", after });
156
+ const messages = Array.isArray(payload.data) ? payload.data : [];
157
+ const assistant = [...messages].reverse().find(
158
+ (entry) => entry.role === "assistant" && String(entry.content ?? "").trim() !== ""
159
+ );
160
+ if (assistant) return assistant;
161
+ if (Date.now() >= deadline) fail(`Timed out waiting for assistant reply after ${timeout}s`);
162
+ await sleep(interval * 1000);
163
+ }
164
+ }
165
+
166
+ async function transcript(config, options) {
167
+ const api = client(config, options);
168
+ const chatId = await ensureChat(config, options);
169
+ const payload = await api.chatMessages({
170
+ chatId,
171
+ view: "visitor",
172
+ limit: Math.min(options.last * 6, 200)
173
+ });
174
+ const messages = (Array.isArray(payload.data) ? payload.data : [])
175
+ .filter((entry) => ["user", "assistant"].includes(entry.role) && !entry.discarded)
176
+ .slice(-options.last);
177
+ for (const message of messages) console.log(formatVisitor(message));
178
+ }
179
+
180
+ async function debugChat(config, options) {
181
+ const api = client(config, options);
182
+ const chatId = await ensureChat(config, options);
183
+
184
+ console.log("## Chat status (developer)");
185
+ console.log(JSON.stringify(await api.showChat({ chatId, view: "developer" }), null, 2));
186
+ console.log("");
187
+ console.log(`## Transcript (developer/debug, last ${options.last})`);
188
+ const payload = await api.chatMessages({ chatId, view: "developer", limit: options.last });
189
+ for (const message of Array.isArray(payload.data) ? payload.data : []) {
190
+ console.log(`[${message.role}] ${String(message.content ?? "").trim()}`);
191
+ }
192
+ }
193
+
194
+ async function resetChat(config, options) {
195
+ const apiUrl = config.resolveApiUrl({ explicit: options.apiUrl });
196
+ const payload = await openChat(config, options, {
197
+ apiUrl,
198
+ chatId: chatsFor(config, apiUrl)[options.environment] ?? null
199
+ });
200
+ console.log(`Reset ${options.environment} chat ${payload.chat_id}`);
201
+ }
202
+
203
+ // Destroying a chat on the platform must not leave the local state pointing at
204
+ // it: the next `vatio chat` would send a message to an id that is gone. Only
205
+ // that deployment's entry is dropped -- destroying the live chat should not
206
+ // close the preview one.
207
+ async function destroyChat(config, options, args) {
208
+ const chatId = args.shift();
209
+ if (!chatId) fail("Usage: vatio chat destroy CHAT_ID");
210
+
211
+ const workspace = config.resolveWorkspaceRequired();
212
+ await client(config, options).destroyChat({ chatId });
213
+
214
+ const state = loadState(config);
215
+ if (state.chats && typeof state.chats === "object") {
216
+ const kept = Object.fromEntries(
217
+ Object.entries(state.chats).filter(([, id]) => Number(id) !== Number(chatId))
218
+ );
219
+ const path = statePath(config);
220
+ if (Object.keys(kept).length === 0 && Object.keys(state.chats).length > 0) {
221
+ writeFileSync(path, `${JSON.stringify({ ...state, chats: {} }, null, 2)}\n`);
222
+ } else if (existsSync(path)) {
223
+ writeFileSync(path, `${JSON.stringify({ ...state, chats: kept }, null, 2)}\n`);
224
+ }
225
+ }
226
+
227
+ console.log(`Destroyed chat ${chatId} in ${workspace}`);
228
+ }
229
+
230
+ function formatVisitor(message) {
231
+ if (!message) return "";
232
+ const role = message.role === "assistant" ? "agent" : "you";
233
+ return `[${role}] ${String(message.content ?? "").trim()}`;
234
+ }
235
+
236
+ export function removeLegacyChatState(root) {
237
+ // `.vatio-sandbox.json` held a chat id *and* the simulated identity it was
238
+ // opened with. The identity half no longer means anything, so it is removed
239
+ // rather than migrated.
240
+ const legacy = join(root, ".vatio-sandbox.json");
241
+ if (existsSync(legacy)) rmSync(legacy);
242
+ }