@flowapt/flowiq-cli 0.1.5 → 0.1.7

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 CHANGED
@@ -1,9 +1,11 @@
1
1
  # flowiq
2
2
 
3
3
  FlowIQ staff CLI. Round-trips agent prompts, questionnaires, fine-tuning,
4
- contact chats, platform webhooks, and outbound messaging webhooks through
5
- `https://api.flowiq.live/cli/*` so the Supabase service role never leaves
6
- the server.
4
+ contact chats, platform webhooks, outbound messaging webhooks, FlowMod
5
+ master-group prompts, Pin Board tasks, WhatsApp templates, agent-update
6
+ requests, full chat exports, and live agent testing through
7
+ `https://api.flowiq.live/cli/*` so the Supabase service role never leaves the
8
+ server.
7
9
 
8
10
  ## Install
9
11
 
@@ -150,12 +152,174 @@ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
150
152
  allowed enum, valid http(s) URL, no duplicate (platform, type, url)
151
153
  tuples.
152
154
 
155
+ ### FlowMod prompts — `flowiq flowmod pull|push <slug>` (alias `fm`)
156
+
157
+ Round-trips a FlowMod org's **master-group** prompts + config. FlowMod groups
158
+ are a separate system from regular WhatsApp agents — their prompts live in the
159
+ `flowmod_master_groups` (model/pre-filter/config/signatures/custom_tools) and
160
+ `flowmod_prompts` (the `main` / `extra` prompt rows, keyed by `master_group_id`)
161
+ tables, read per-message by the `flowmod-group-*` webhooks.
162
+
163
+ ```bash
164
+ flowiq flowmod pull <organization_id>
165
+ # edit ./.flowiq/flowmod/<slug>.json
166
+ flowiq fm push <slug>
167
+ ```
168
+
169
+ The pulled JSON has a `master_groups[]` array; each entry carries its editable
170
+ fields plus a `prompts[]` array (`{ id, type, group_ids, text, data }`). Push
171
+ updates only editable columns — prompt `text`/`data`/`group_ids`/`type`, and
172
+ master-group `name`/`description`/`config`/`pre_filter_prompt`/`events_markdown`/
173
+ `custom_signature*`/`custom_tools` — scoped by row id; it never touches PKs,
174
+ FKs, or timestamps, and there's no `system_prompt` regeneration (FlowMod reads
175
+ the prompt rows directly).
176
+
177
+ Common uses: edit a master-group's moderator prompt, or flip its reasoning
178
+ effort via `config.reasoningEffort` (`none` / `low` / `medium` / `high`).
179
+
180
+ ### Group chats — `flowiq groups list|pull <name-or-jid>` (alias `grp`)
181
+
182
+ Read-only pull of **WhatsApp group** chat history (e.g. the `… x Flowapt`
183
+ client comms groups) straight out of the FlowMod **Evolution** Postgres. This
184
+ is the analogue of `messages pull`, but for a group instead of a contact, so
185
+ you can pull a window and summarise it.
186
+
187
+ > ⚠️ Unlike every other command, this talks to a database **directly** (the
188
+ > Evolution DB is not Supabase and not the `fiq_staff_…` API). To keep this
189
+ > published package secret-free, the connection string comes from your shell
190
+ > environment — set `FLOWMOD_EVO_DB_URL` (or the five `FLOWMOD_DB_*` vars the
191
+ > `flowmod-copilot` edge fn uses) before running. No auth token is needed.
192
+
193
+ ```bash
194
+ export FLOWMOD_EVO_DB_URL='postgres://user:pass@app.flowmod.ai:5555/flowiq-db'
195
+
196
+ flowiq groups list # default instance: flowapt
197
+ flowiq groups list --instance FlowMod # the other connected instance
198
+
199
+ flowiq groups pull "Phytoceutics x Flowapt" --last 7d
200
+ flowiq groups pull "Zorora x Flowapt" --since 2026-06-24 --until "2026-06-25 12:00"
201
+ flowiq grp pull 120363408455897717@g.us --last 48h # or pass the group JID
202
+ # writes ./.flowiq/groups/<instance>-<group-slug>.json + prints a timeline
203
+ ```
204
+
205
+ - Group is matched by JID, exact name, or a unique case-insensitive substring
206
+ (ambiguous matches list the candidates and exit — pass the JID to disambiguate).
207
+ - Window: `--last <48h|7d|2w|90m>` (default `48h`), or an explicit
208
+ `--since`/`--until` (SAST; `--until` defaults to now). `--limit` (default
209
+ 1000) keeps the most-recent N in the window.
210
+ - `--json` prints the full payload to stdout instead of the timeline.
211
+ - Read-only — there is no `push`; group history is a system of record.
212
+ View-once/disappearing messages and raw media bodies can't be read (captions
213
+ and text come through); history before the instance joined a group may be gappy.
214
+
215
+ ### Pin Board tasks — `flowiq pinboard pull|push|list|list-remote` (alias `pin`)
216
+
217
+ Round-trips a single internal Pin Board task (`flowapt_tasks` row).
218
+
219
+ ```bash
220
+ flowiq pin list-remote open # find the task id you want
221
+ flowiq pin pull <task_id> # → ./.flowiq/pinboard/<slug>.json
222
+ flowiq pin push <slug> # writes editable fields back, stamps updated_at
223
+ ```
224
+
225
+ Editable fields: `name`, `description`, `status` (open / in_progress / done),
226
+ `urgency` (low / normal / high / critical), `preferred_due_date` (YYYY-MM-DD or
227
+ null), `organizations[]`, `media[]`, `messages[]`, `created_by`. Array columns
228
+ are full-replace within the row.
229
+
230
+ ### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
231
+
232
+ Read an org's live templates straight from Meta (read-only), and submit new
233
+ ones through the `create-meta-template` edge function.
234
+
235
+ ```bash
236
+ flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
237
+ flowiq templates create <organization_id> --request-file req.json
238
+ flowiq templates status <organization_id> --name booking # poll approval
239
+ ```
240
+
241
+ `req.json` holds `{ template_request, template_data?, media_header?, cards_media? }`.
242
+ Media headers: pass `media_header.file_url` (a public URL) — the edge function
243
+ uploads it to Meta server-side. Approval is async; re-`pull` for the
244
+ authoritative Meta status.
245
+
246
+ ### Org info — `flowiq org info <organization_id>`
247
+
248
+ Read-only platform detect + active-agent lookup for an org (raw store/Meta
249
+ credentials are stripped server-side).
250
+
251
+ ```bash
252
+ flowiq org info <organization_id> # platform, storefront url, active agent
253
+ ```
254
+
255
+ ### Agent config — `flowiq agent config <organization_id> [flags]`
256
+
257
+ Set an allowlisted set of the active agent's config fields (the prompt-builder
258
+ "Step 5"). With no flags, prints the current config.
259
+
260
+ ```bash
261
+ flowiq agent config <organization_id> # show current
262
+ flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.4-mini \
263
+ --rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
264
+ ```
265
+
266
+ Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
267
+ the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
268
+ `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
269
+ `shopify_products_web_chat`), and `discount.enabled`. Anything else is rejected;
270
+ every change is reported before → after.
271
+
272
+ ### Agent updates — `flowiq agent-updates pull|list` (alias `au`)
273
+
274
+ Read-only pull of an org's client-raised "change the agent" requests
275
+ (`agent_updates`), each with the chat context around the triggering message +
276
+ the linked contact. Attachments are downloaded locally.
277
+
278
+ ```bash
279
+ flowiq au pull <organization_id> # pending, with context
280
+ flowiq au pull <organization_id> --status all --titles "EFT, bank"
281
+ flowiq au pull <organization_id> --before 20 --after 10 --no-files
282
+ # → ./.flowiq/agent-updates/<slug>.json + files/<update-id>/<attachment>
283
+ ```
284
+
285
+ ### Chat export — `flowiq export chats <organization_id> [--out <path>]`
286
+
287
+ Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
288
+ (keyset-paginated server-side so it can't time out on big orgs).
289
+
290
+ ```bash
291
+ flowiq export chats <organization_id> # → ./.flowiq/exports/<slug>-chats.txt
292
+ flowiq export chats <organization_id> --out ./phyto-chats.txt
293
+ ```
294
+
295
+ ### Agent testing — `flowiq test <subcommand>`
296
+
297
+ Drive an org's **live** agent through the web/test channel and read the reply
298
+ back — **never sends a real WhatsApp** (the web branch only inserts the reply).
299
+
300
+ ```bash
301
+ flowiq test send <organization_id> "What are your bestsellers?" --clear
302
+ flowiq test send <organization_id> "What is this?" --image https://…/photo.jpg
303
+ flowiq test qa <organization_id> # bundled generic smoke pack
304
+ flowiq test scenario <organization_id> ./pack.json # your own pack (expect/expectNot)
305
+ flowiq test clear <organization_id>
306
+ # phase 2 — adversarial parallel stress:
307
+ flowiq test stress <organization_id> ./stress.json --concurrency 6
308
+ flowiq test extract <organization_id> # rebuild transcripts for a judge
309
+ flowiq test cleanup <organization_id> --confirm # delete the stress contacts
310
+ ```
311
+
312
+ `--agent <id>` targets a specific (non-active) agent. `test stress cleanup` is
313
+ destructive and requires `--confirm`.
314
+
153
315
  ## Environment variable overrides
154
316
 
155
317
  | Var | Default | Use |
156
318
  |---|---|---|
157
319
  | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
158
320
  | `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
321
+ | `FLOWMOD_EVO_DB_URL` | (none) | Full `postgres://` URL for `groups` (Evolution DB). Required for `groups`. |
322
+ | `FLOWMOD_DB_HOST/PORT/USER/PASS/NAME` | (none) | Alternative to `FLOWMOD_EVO_DB_URL` — assembled into a connection string. |
159
323
 
160
324
  ## Upgrading
161
325
 
package/bin/flowiq.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.5",
4
- "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and more without ever touching service-role credentials.",
3
+ "version": "0.1.7",
4
+ "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flowiq": "bin/flowiq.js"
@@ -18,7 +18,8 @@
18
18
  "start": "node bin/flowiq.js"
19
19
  },
20
20
  "dependencies": {
21
- "commander": "^12.1.0"
21
+ "commander": "^12.1.0",
22
+ "pg": "^8.22.0"
22
23
  },
23
24
  "homepage": "https://github.com/MattFlowapt/flow-v1/tree/main/cli",
24
25
  "repository": {
@@ -0,0 +1,95 @@
1
+ // `flowiq agent config <organization_id> [flags]` — the prompt-builder skill's
2
+ // Step 5 as a staff-key call. Writes an allowlisted set of agent fields
3
+ // (use_settings_prompt, model, tool flags, discount.enabled, rename) via
4
+ // /cli/agent-config. With no write flags, prints the current config.
5
+
6
+ import { http } from "../http.js";
7
+
8
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ function parseBool(v, label) {
11
+ if (v === true) return true; // bare presence of an optional flag
12
+ const s = String(v).toLowerCase();
13
+ if (s === "true" || s === "1" || s === "yes" || s === "on") return true;
14
+ if (s === "false" || s === "0" || s === "no" || s === "off") return false;
15
+ console.error(`Error: ${label} must be true or false (got "${v}").`);
16
+ process.exit(1);
17
+ }
18
+
19
+ // commander collector for repeatable --tool woo_order_build=true
20
+ export function collectTool(val, acc) {
21
+ acc = acc || [];
22
+ acc.push(val);
23
+ return acc;
24
+ }
25
+
26
+ function printSnapshot(title, snap) {
27
+ console.log(` ${title}:`);
28
+ for (const [k, v] of Object.entries(snap)) {
29
+ console.log(` ${k.padEnd(30)} ${v === null ? "(unset)" : v}`);
30
+ }
31
+ }
32
+
33
+ export async function config(orgId, opts = {}) {
34
+ if (!UUID_RE.test(orgId)) {
35
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
36
+ process.exit(1);
37
+ }
38
+
39
+ const body = { organization_id: orgId };
40
+ const settings = {};
41
+ if (opts.useSettingsPrompt !== undefined) settings.use_settings_prompt = parseBool(opts.useSettingsPrompt, "--use-settings-prompt");
42
+ if (opts.model !== undefined) settings.model = opts.model;
43
+ if (Object.keys(settings).length) body.settings = settings;
44
+ if (opts.rename !== undefined) body.name = opts.rename;
45
+ if (opts.discount !== undefined) body.discount_enabled = parseBool(opts.discount, "--discount");
46
+
47
+ if (opts.tool && opts.tool.length) {
48
+ const flags = {};
49
+ for (const pair of opts.tool) {
50
+ const eq = pair.indexOf("=");
51
+ if (eq < 0) {
52
+ console.error(`Error: --tool expects flag=bool (got "${pair}").`);
53
+ process.exit(1);
54
+ }
55
+ const key = pair.slice(0, eq).trim();
56
+ flags[key] = parseBool(pair.slice(eq + 1), `--tool ${key}`);
57
+ }
58
+ body.tool_flags = flags;
59
+ }
60
+
61
+ const hasWrite =
62
+ body.settings || body.name !== undefined || body.tool_flags || body.discount_enabled !== undefined;
63
+
64
+ // No write flags → just show current config.
65
+ if (!hasWrite) {
66
+ let resp;
67
+ try {
68
+ resp = await http.get("agent-config", { organization_id: orgId });
69
+ } catch (e) {
70
+ console.error(`Lookup failed: ${e.message}`);
71
+ process.exit(1);
72
+ }
73
+ console.log(`${resp.organization_name} → agent ${resp.agent_id}`);
74
+ printSnapshot("current", resp.current);
75
+ console.log("\n(pass --use-settings-prompt / --model / --rename / --tool / --discount to change)");
76
+ return;
77
+ }
78
+
79
+ let resp;
80
+ try {
81
+ resp = await http.post("agent-config", body);
82
+ } catch (e) {
83
+ console.error(`Config update failed: ${e.message}`);
84
+ if (e.body?.error) console.error(` ${e.body.error}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ console.log(`Updated agent ${resp.agent_id} (${resp.organization_name})`);
89
+ console.log(` changed: ${resp.changed.join(", ") || "(nothing)"}`);
90
+ for (const k of resp.changed) {
91
+ const b = resp.before?.[k];
92
+ const a = resp.after?.[k];
93
+ console.log(` ${k.padEnd(30)} ${b === null || b === undefined ? "(unset)" : b} → ${a === null || a === undefined ? "(unset)" : a}`);
94
+ }
95
+ }
@@ -0,0 +1,232 @@
1
+ // `flowiq test <subcommand>` — drive an org's LIVE agent through the web/test
2
+ // channel (never sends a real WhatsApp). The server (/cli/agent-test,
3
+ // /cli/agent-stress) does one turn per request; this CLI loops turns, runs the
4
+ // expect/expectNot checks client-side, and prints the transcript.
5
+ //
6
+ // test resolve <org_id> [--agent <id>]
7
+ // test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json]
8
+ // test scenario <org_id> <file.json> [--agent] [--no-clear-between] [--json] [--sender] [--timeout]
9
+ // test qa <org_id> [--agent] ... (bundled generic smoke pack)
10
+ // test clear <org_id> [--agent]
11
+ // test stress <org_id> [pack.json] [--agent] [--concurrency 6] [--timeout] [--out] [--contact]
12
+ // test cleanup <org_id> [--agent] --confirm (deletes stress contacts)
13
+ // test extract <org_id> [--out] (rebuild transcripts from DB)
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { http } from "../http.js";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const QA_PACK = path.resolve(__dirname, "..", "data", "scenarios.qa.json");
22
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+
24
+ function requireOrg(orgId) {
25
+ if (!UUID_RE.test(orgId)) {
26
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ async function runTurn(orgId, { agent, message, image, clear, sender, timeout }) {
32
+ return http.post("agent-test", {
33
+ action: "send",
34
+ organization_id: orgId,
35
+ agent_id: agent,
36
+ message,
37
+ image_url: image,
38
+ clear: !!clear,
39
+ sender,
40
+ timeout_ms: timeout,
41
+ });
42
+ }
43
+
44
+ async function clearConv(orgId, agent) {
45
+ return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
46
+ }
47
+
48
+ function checkExpectations(turn, replyText) {
49
+ const results = [];
50
+ const hay = (replyText || "").toLowerCase();
51
+ for (const e of turn.expect || []) results.push({ kind: "expect", needle: e, ok: hay.includes(String(e).toLowerCase()) });
52
+ for (const e of turn.expectNot || []) results.push({ kind: "expectNot", needle: e, ok: !hay.includes(String(e).toLowerCase()) });
53
+ return results;
54
+ }
55
+
56
+ function printTurn(sc, t, userText, r, checks) {
57
+ if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
58
+ console.log(`\n 🧑 ${userText}`);
59
+ if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
60
+ if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
61
+ else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
62
+ for (const c of checks || []) {
63
+ console.log(` ${c.ok ? "✅" : "❌"} ${c.kind === "expectNot" ? "must NOT contain" : "contains"} "${c.needle}"`);
64
+ }
65
+ }
66
+
67
+ export async function resolve(orgId, opts = {}) {
68
+ requireOrg(orgId);
69
+ let resp;
70
+ try { resp = await http.get("agent-test", { organization_id: orgId, agent_id: opts.agent }); }
71
+ catch (e) { console.error(`Resolve failed: ${e.message}`); process.exit(1); }
72
+ console.log(JSON.stringify(resp, null, 2));
73
+ }
74
+
75
+ export async function send(orgId, messageParts, opts = {}) {
76
+ requireOrg(orgId);
77
+ const message = (messageParts || []).join(" ");
78
+ if (!message) { console.error("usage: test send <org_id> <message...>"); process.exit(1); }
79
+ let r;
80
+ try {
81
+ r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout });
82
+ } catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
83
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
84
+ console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
85
+ printTurn({ title: "send", tq: "" }, 0, message, r, []);
86
+ }
87
+
88
+ async function runPack(orgId, pack, opts) {
89
+ const scenarios = pack.scenarios || pack;
90
+ const clearBetween = !opts.noClearBetween;
91
+ console.log(`# ${pack.name || "pack"} — ${scenarios.length} scenarios · org ${orgId}`);
92
+ const results = [];
93
+ for (let s = 0; s < scenarios.length; s++) {
94
+ const sc = scenarios[s];
95
+ if (clearBetween) {
96
+ try { await clearConv(orgId, opts.agent); } catch (e) { console.error(` (clear failed: ${e.message})`); }
97
+ }
98
+ const scResult = { id: sc.id, tq: sc.tq, title: sc.title, turns: [] };
99
+ for (let t = 0; t < sc.turns.length; t++) {
100
+ const turn = sc.turns[t];
101
+ let r;
102
+ try {
103
+ r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout });
104
+ } catch (e) {
105
+ r = { status: "error", reply: null, tool_calls: [], raw: { error: e.message } };
106
+ }
107
+ const checks = checkExpectations(turn, r.reply);
108
+ scResult.turns.push({ ...r, checks });
109
+ if (!opts.json) printTurn(sc, t, turn.text, r, checks);
110
+ }
111
+ results.push(scResult);
112
+ }
113
+ let pass = 0, fail = 0;
114
+ for (const sc of results) for (const t of sc.turns) for (const c of t.checks || []) (c.ok ? pass++ : fail++);
115
+ if (opts.json) console.log(JSON.stringify(results, null, 2));
116
+ else console.log(`\n━━ assertions: ${pass} passed, ${fail} failed ━━`);
117
+ }
118
+
119
+ export async function scenario(orgId, file, opts = {}) {
120
+ requireOrg(orgId);
121
+ const filePath = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
122
+ let pack;
123
+ try { pack = JSON.parse(await fs.readFile(filePath, "utf8")); }
124
+ catch (e) { console.error(`Cannot read scenario file ${filePath}: ${e.message}`); process.exit(1); }
125
+ await runPack(orgId, pack, opts);
126
+ }
127
+
128
+ export async function qa(orgId, opts = {}) {
129
+ requireOrg(orgId);
130
+ const pack = JSON.parse(await fs.readFile(QA_PACK, "utf8"));
131
+ await runPack(orgId, pack, opts);
132
+ }
133
+
134
+ export async function clear(orgId, opts = {}) {
135
+ requireOrg(orgId);
136
+ let resp;
137
+ try { resp = await clearConv(orgId, opts.agent); }
138
+ catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
139
+ console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; onboarding/disclaimer state NOT reset).`);
140
+ }
141
+
142
+ // ---------- phase 2: stress + extract ----------
143
+
144
+ async function pool(items, limit, worker) {
145
+ const results = new Array(items.length);
146
+ let next = 0;
147
+ async function lane() {
148
+ while (next < items.length) {
149
+ const i = next++;
150
+ try { results[i] = await worker(items[i], i); }
151
+ catch (e) { results[i] = { id: items[i].id, error: String(e?.message || e) }; }
152
+ }
153
+ }
154
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
155
+ return results;
156
+ }
157
+
158
+ export async function stress(orgId, file, opts = {}) {
159
+ requireOrg(orgId);
160
+ const packFile = file || "scenarios.stress.json";
161
+ const filePath = path.isAbsolute(packFile) ? packFile : path.resolve(process.cwd(), packFile);
162
+ let pack;
163
+ try { pack = JSON.parse(await fs.readFile(filePath, "utf8")); }
164
+ catch (e) { console.error(`Cannot read stress pack ${filePath}: ${e.message}`); process.exit(1); }
165
+ const scenarios = pack.scenarios || pack;
166
+
167
+ let head;
168
+ try { head = await http.get("agent-stress", { organization_id: orgId, agent_id: opts.agent }); }
169
+ catch (e) { console.error(`Resolve failed: ${e.message}`); process.exit(1); }
170
+
171
+ const fixedContact = opts.contact || null;
172
+ const concurrency = fixedContact ? 1 : (opts.concurrency ? Number.parseInt(opts.concurrency, 10) : 6);
173
+ const timeout = opts.timeout;
174
+ const t0 = Date.now();
175
+ console.error(`# ${pack.name || packFile}: ${scenarios.length} scenarios · org=${head.org.name} agent=${head.agent.name} · concurrency=${concurrency}`);
176
+
177
+ let done = 0;
178
+ const results = await pool(scenarios, concurrency, async (sc) => {
179
+ let contactId = fixedContact;
180
+ if (!contactId) {
181
+ const ec = await http.post("agent-stress", { action: "ensure_contact", organization_id: orgId, agent_id: opts.agent, scenario_id: sc.id, fresh: !!sc.fresh });
182
+ contactId = ec.contact_id;
183
+ }
184
+ const turns = [];
185
+ for (let i = 0; i < sc.turns.length; i++) {
186
+ const r = await http.post("agent-stress", {
187
+ action: "turn", organization_id: orgId, agent_id: opts.agent, contact_id: contactId,
188
+ text: sc.turns[i].text, sender: "Stress Tester", idx: `${sc.id}_${i}`, timeout_ms: timeout, reenable_bot: !!fixedContact,
189
+ });
190
+ turns.push(r);
191
+ }
192
+ const rec = { id: sc.id, category: sc.category, severity: sc.severity || "medium", tq: sc.tq || null, title: sc.title, criteria: sc.criteria, fresh: !!sc.fresh, contactId, turns };
193
+ done++;
194
+ const last = rec.turns[rec.turns.length - 1];
195
+ const snippet = last?.reply ? last.reply.replace(/\s+/g, " ").slice(0, 70) : `⚠ ${last?.status}`;
196
+ console.error(` [${done}/${scenarios.length}] ${String(rec.severity).toUpperCase().padEnd(8)} ${String(rec.id).padEnd(22)} ${snippet}`);
197
+ return rec;
198
+ });
199
+
200
+ const outPath = opts.out ? path.resolve(opts.out) : path.resolve(process.cwd(), ".flowiq", "agent-test", "stress-results.json");
201
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
202
+ const payload = {
203
+ org: head.org, agent: head.agent, ran_at: new Date().toISOString(),
204
+ duration_ms: Date.now() - t0, scenario_count: scenarios.length, results,
205
+ };
206
+ await fs.writeFile(outPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
207
+ const noReply = results.filter((r) => r.turns?.some((t) => !t.reply)).length;
208
+ console.error(`\n# done in ${Math.round((Date.now() - t0) / 1000)}s · ${results.length} scenarios · ${noReply} with a no-reply turn · → ${outPath}`);
209
+ }
210
+
211
+ export async function cleanup(orgId, opts = {}) {
212
+ requireOrg(orgId);
213
+ if (!opts.confirm) {
214
+ console.error("Refusing to delete stress contacts without --confirm (this DELETEs contacts + their messages/sessions).");
215
+ process.exit(1);
216
+ }
217
+ let resp;
218
+ try { resp = await http.post("agent-stress", { action: "cleanup", organization_id: orgId, agent_id: opts.agent, confirm: true }); }
219
+ catch (e) { console.error(`Cleanup failed: ${e.message}`); process.exit(1); }
220
+ console.error(`Cleaned up ${resp.deleted} stress contact${resp.deleted === 1 ? "" : "s"} + their messages/sessions.`);
221
+ }
222
+
223
+ export async function extract(orgId, opts = {}) {
224
+ requireOrg(orgId);
225
+ let resp;
226
+ try { resp = await http.post("agent-stress", { action: "extract", organization_id: orgId, agent_id: opts.agent }); }
227
+ catch (e) { console.error(`Extract failed: ${e.message}`); process.exit(1); }
228
+ const outPath = opts.out ? path.resolve(opts.out) : path.resolve(process.cwd(), ".flowiq", "agent-test", "judge_input.json");
229
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
230
+ await fs.writeFile(outPath, JSON.stringify(resp.scenarios, null, 2) + "\n", "utf8");
231
+ console.log(`Wrote ${outPath} — ${resp.scenarios.length} scenario transcript${resp.scenarios.length === 1 ? "" : "s"}.`);
232
+ }