@flowapt/flowiq-cli 0.1.6 → 0.1.8
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 +136 -3
- package/package.json +2 -2
- package/src/commands/agent-config.js +96 -0
- package/src/commands/agent-test.js +232 -0
- package/src/commands/agent-updates.js +186 -0
- package/src/commands/agents.js +76 -0
- package/src/commands/export.js +131 -0
- package/src/commands/fine-tuning.js +4 -3
- package/src/commands/org.js +33 -0
- package/src/commands/pinboard.js +174 -0
- package/src/commands/prompts.js +4 -3
- package/src/commands/questionnaires.js +4 -3
- package/src/commands/templates.js +156 -0
- package/src/data/scenarios.qa.json +41 -0
- package/src/index.js +165 -9
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, outbound messaging webhooks,
|
|
5
|
-
master-group prompts
|
|
6
|
-
|
|
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
|
|
|
@@ -210,6 +212,137 @@ flowiq grp pull 120363408455897717@g.us --last 48h # or pass the group JID
|
|
|
210
212
|
View-once/disappearing messages and raw media bodies can't be read (captions
|
|
211
213
|
and text come through); history before the instance joined a group may be gappy.
|
|
212
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
|
+
### Agents — `flowiq agent list|create <org>`
|
|
256
|
+
|
|
257
|
+
List an org's agents (to discover ids) and create a new one.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
flowiq agent list <organization_id> # ★ marks the active agent; shows each id
|
|
261
|
+
flowiq agent create <organization_id> --name "Zara" \
|
|
262
|
+
--system-prompt-file ./zara-prompt.txt --make-active
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`create` inserts a new agent (`created_by` = your staff-key identity), optionally
|
|
266
|
+
seeds its prompt into a single "Main" section, and — with `--make-active` — sets
|
|
267
|
+
it as the org's `active_whatsapp_agent`. New agents start on the app default
|
|
268
|
+
model; set the house model afterwards with `agent config … --model gpt-5.4-mini
|
|
269
|
+
--use-settings-prompt`.
|
|
270
|
+
|
|
271
|
+
### Targeting a specific agent — `--agent <id>`
|
|
272
|
+
|
|
273
|
+
By default `prompts`, `questionnaires`, `fine-tuning`, and `agent config` all act
|
|
274
|
+
on the org's **active** agent. To target a **different** agent, pass `--agent <id>`
|
|
275
|
+
(get ids from `agent list`):
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
flowiq prompts pull <org> --agent <agent_id> # pull a NON-active agent's prompt
|
|
279
|
+
flowiq prompts push <slug> # pushes back to that same agent (id is in the file)
|
|
280
|
+
flowiq agent config <org> --agent <agent_id> --model gpt-5.4-mini
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`pull` records the agent id in the JSON, so `push` always writes back to the exact
|
|
284
|
+
agent you pulled from (never "whatever is active now").
|
|
285
|
+
|
|
286
|
+
### Agent config — `flowiq agent config <organization_id> [flags]`
|
|
287
|
+
|
|
288
|
+
Set an allowlisted set of the active agent's config fields (the prompt-builder
|
|
289
|
+
"Step 5"). With no flags, prints the current config.
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
flowiq agent config <organization_id> # show current
|
|
293
|
+
flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.4-mini \
|
|
294
|
+
--rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
|
|
298
|
+
the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
|
|
299
|
+
`view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
|
|
300
|
+
`shopify_products_web_chat`), and `discount.enabled`. Anything else is rejected;
|
|
301
|
+
every change is reported before → after.
|
|
302
|
+
|
|
303
|
+
### Agent updates — `flowiq agent-updates pull|list` (alias `au`)
|
|
304
|
+
|
|
305
|
+
Read-only pull of an org's client-raised "change the agent" requests
|
|
306
|
+
(`agent_updates`), each with the chat context around the triggering message +
|
|
307
|
+
the linked contact. Attachments are downloaded locally.
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
flowiq au pull <organization_id> # pending, with context
|
|
311
|
+
flowiq au pull <organization_id> --status all --titles "EFT, bank"
|
|
312
|
+
flowiq au pull <organization_id> --before 20 --after 10 --no-files
|
|
313
|
+
# → ./.flowiq/agent-updates/<slug>.json + files/<update-id>/<attachment>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
317
|
+
|
|
318
|
+
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
|
319
|
+
(keyset-paginated server-side so it can't time out on big orgs).
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
flowiq export chats <organization_id> # → ./.flowiq/exports/<slug>-chats.txt
|
|
323
|
+
flowiq export chats <organization_id> --out ./phyto-chats.txt
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Agent testing — `flowiq test <subcommand>`
|
|
327
|
+
|
|
328
|
+
Drive an org's **live** agent through the web/test channel and read the reply
|
|
329
|
+
back — **never sends a real WhatsApp** (the web branch only inserts the reply).
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
flowiq test send <organization_id> "What are your bestsellers?" --clear
|
|
333
|
+
flowiq test send <organization_id> "What is this?" --image https://…/photo.jpg
|
|
334
|
+
flowiq test qa <organization_id> # bundled generic smoke pack
|
|
335
|
+
flowiq test scenario <organization_id> ./pack.json # your own pack (expect/expectNot)
|
|
336
|
+
flowiq test clear <organization_id>
|
|
337
|
+
# phase 2 — adversarial parallel stress:
|
|
338
|
+
flowiq test stress <organization_id> ./stress.json --concurrency 6
|
|
339
|
+
flowiq test extract <organization_id> # rebuild transcripts for a judge
|
|
340
|
+
flowiq test cleanup <organization_id> --confirm # delete the stress contacts
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`--agent <id>` targets a specific (non-active) agent. `test stress cleanup` is
|
|
344
|
+
destructive and requires `--confirm`.
|
|
345
|
+
|
|
213
346
|
## Environment variable overrides
|
|
214
347
|
|
|
215
348
|
| Var | Default | Use |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires,
|
|
3
|
+
"version": "0.1.8",
|
|
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"
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
if (opts.agent !== undefined) body.agent_id = opts.agent;
|
|
41
|
+
const settings = {};
|
|
42
|
+
if (opts.useSettingsPrompt !== undefined) settings.use_settings_prompt = parseBool(opts.useSettingsPrompt, "--use-settings-prompt");
|
|
43
|
+
if (opts.model !== undefined) settings.model = opts.model;
|
|
44
|
+
if (Object.keys(settings).length) body.settings = settings;
|
|
45
|
+
if (opts.rename !== undefined) body.name = opts.rename;
|
|
46
|
+
if (opts.discount !== undefined) body.discount_enabled = parseBool(opts.discount, "--discount");
|
|
47
|
+
|
|
48
|
+
if (opts.tool && opts.tool.length) {
|
|
49
|
+
const flags = {};
|
|
50
|
+
for (const pair of opts.tool) {
|
|
51
|
+
const eq = pair.indexOf("=");
|
|
52
|
+
if (eq < 0) {
|
|
53
|
+
console.error(`Error: --tool expects flag=bool (got "${pair}").`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const key = pair.slice(0, eq).trim();
|
|
57
|
+
flags[key] = parseBool(pair.slice(eq + 1), `--tool ${key}`);
|
|
58
|
+
}
|
|
59
|
+
body.tool_flags = flags;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hasWrite =
|
|
63
|
+
body.settings || body.name !== undefined || body.tool_flags || body.discount_enabled !== undefined;
|
|
64
|
+
|
|
65
|
+
// No write flags → just show current config.
|
|
66
|
+
if (!hasWrite) {
|
|
67
|
+
let resp;
|
|
68
|
+
try {
|
|
69
|
+
resp = await http.get("agent-config", { organization_id: orgId, agent_id: opts.agent });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error(`Lookup failed: ${e.message}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
console.log(`${resp.organization_name} → agent ${resp.agent_id}${resp.is_active === false ? " [NON-active]" : ""}`);
|
|
75
|
+
printSnapshot("current", resp.current);
|
|
76
|
+
console.log("\n(pass --use-settings-prompt / --model / --rename / --tool / --discount to change)");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let resp;
|
|
81
|
+
try {
|
|
82
|
+
resp = await http.post("agent-config", body);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(`Config update failed: ${e.message}`);
|
|
85
|
+
if (e.body?.error) console.error(` ${e.body.error}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
console.log(`Updated agent ${resp.agent_id} (${resp.organization_name})${resp.is_active === false ? " [NON-active]" : ""}`);
|
|
90
|
+
console.log(` changed: ${resp.changed.join(", ") || "(nothing)"}`);
|
|
91
|
+
for (const k of resp.changed) {
|
|
92
|
+
const b = resp.before?.[k];
|
|
93
|
+
const a = resp.after?.[k];
|
|
94
|
+
console.log(` ${k.padEnd(30)} ${b === null || b === undefined ? "(unset)" : b} → ${a === null || a === undefined ? "(unset)" : a}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `flowiq agent-updates pull <org_id> [flags]` / `list`.
|
|
2
|
+
// Read-only: fetches an org's agent_updates + chat context via /cli/agent-updates,
|
|
3
|
+
// then downloads any attachments client-side into ./.flowiq/agent-updates/files/.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { http } from "../http.js";
|
|
8
|
+
|
|
9
|
+
const AU_DIR = path.resolve(process.cwd(), ".flowiq", "agent-updates");
|
|
10
|
+
const FILES_DIRNAME = "files";
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const DEFAULT_MAX_MB = 25;
|
|
13
|
+
|
|
14
|
+
function slugify(name, fallback) {
|
|
15
|
+
const s = String(name || "")
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/-+/g, "-")
|
|
19
|
+
.replace(/^-|-$/g, "");
|
|
20
|
+
return s || fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function fileExists(p) {
|
|
24
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function oneLine(s, n = 120) {
|
|
28
|
+
const t = String(s ?? "").replace(/\s+/g, " ").trim();
|
|
29
|
+
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Filesystem-safe filename (strip path, control chars, separators).
|
|
33
|
+
function safeFilename(name, fallback) {
|
|
34
|
+
const base = String(name || "").split("?")[0].split(/[\\/]/).pop().trim();
|
|
35
|
+
const cleaned = base.replace(/[\x00-\x1f]/g, "").replace(/[\\/]/g, "_");
|
|
36
|
+
return cleaned || fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Download one URL to destPath, respecting a byte cap. Never throws.
|
|
40
|
+
async function downloadOne(url, destPath, maxBytes) {
|
|
41
|
+
let res;
|
|
42
|
+
try { res = await fetch(url); }
|
|
43
|
+
catch (e) { return { ok: false, reason: `fetch error: ${e.message}` }; }
|
|
44
|
+
if (!res.ok) return { ok: false, reason: `http ${res.status}` };
|
|
45
|
+
const len = Number(res.headers.get("content-length") || 0);
|
|
46
|
+
if (len && len > maxBytes) return { ok: false, reason: `too large (${len} bytes > cap)` };
|
|
47
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
48
|
+
if (buf.length > maxBytes) return { ok: false, reason: `too large (${buf.length} bytes > cap)` };
|
|
49
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
|
50
|
+
await fs.writeFile(destPath, buf);
|
|
51
|
+
return { ok: true, bytes: buf.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function downloadUpdateFiles(update, maxBytes) {
|
|
55
|
+
const baseDir = path.join(AU_DIR, FILES_DIRNAME, update.id);
|
|
56
|
+
const atts = Array.isArray(update.attachments) ? update.attachments : [];
|
|
57
|
+
for (let i = 0; i < atts.length; i++) {
|
|
58
|
+
const a = atts[i];
|
|
59
|
+
if (!a || !a.url) continue;
|
|
60
|
+
const fname = safeFilename(a.filename, `attachment-${i + 1}`);
|
|
61
|
+
if (a.size && a.size > maxBytes) {
|
|
62
|
+
a.download = { ok: false, reason: `declared size ${a.size} bytes > cap` };
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const r = await downloadOne(a.url, path.join(baseDir, fname), maxBytes);
|
|
66
|
+
a.download = r;
|
|
67
|
+
if (r.ok) a.local_path = path.join(baseDir, fname);
|
|
68
|
+
}
|
|
69
|
+
if (update.superadmin_response_image) {
|
|
70
|
+
const fname = "admin-" + safeFilename(update.superadmin_response_image, "response-image");
|
|
71
|
+
const r = await downloadOne(update.superadmin_response_image, path.join(baseDir, fname), maxBytes);
|
|
72
|
+
update.superadmin_response_image_download = r;
|
|
73
|
+
if (r.ok) update.superadmin_response_image_local = path.join(baseDir, fname);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function pull(orgId, opts = {}) {
|
|
78
|
+
if (!UUID_RE.test(orgId)) {
|
|
79
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID.`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const downloadFiles = !opts.noFiles;
|
|
84
|
+
const maxMb = opts.maxMb != null ? Number.parseFloat(opts.maxMb) : DEFAULT_MAX_MB;
|
|
85
|
+
if (!Number.isFinite(maxMb) || maxMb <= 0) {
|
|
86
|
+
console.error(`Invalid --max-mb: ${opts.maxMb}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
const maxBytes = Math.round(maxMb * 1024 * 1024);
|
|
90
|
+
|
|
91
|
+
const query = { organization_id: orgId };
|
|
92
|
+
if (opts.status) query.status = opts.status;
|
|
93
|
+
if (opts.titles) query.titles = opts.titles;
|
|
94
|
+
if (opts.before != null) query.before = String(opts.before);
|
|
95
|
+
if (opts.after != null) query.after = String(opts.after);
|
|
96
|
+
if (opts.recent != null) query.recent = String(opts.recent);
|
|
97
|
+
|
|
98
|
+
let resp;
|
|
99
|
+
try {
|
|
100
|
+
resp = await http.get("agent-updates", query);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.error(`Pull failed: ${e.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Download attachments client-side (mutates the update objects with local_path).
|
|
107
|
+
if (downloadFiles) {
|
|
108
|
+
for (const u of resp.updates || []) {
|
|
109
|
+
try { await downloadUpdateFiles(u, maxBytes); }
|
|
110
|
+
catch (e) { u._file_download_error = e.message; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
resp.attachments_downloaded = downloadFiles;
|
|
114
|
+
resp.attachment_max_mb = maxMb;
|
|
115
|
+
|
|
116
|
+
await fs.mkdir(AU_DIR, { recursive: true });
|
|
117
|
+
const slug = slugify(resp.organization_name, resp.organization_id);
|
|
118
|
+
const filePath = path.join(AU_DIR, `${slug}.json`);
|
|
119
|
+
const overwriting = await fileExists(filePath);
|
|
120
|
+
await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
121
|
+
|
|
122
|
+
const updates = resp.updates || [];
|
|
123
|
+
console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
|
|
124
|
+
console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
|
|
125
|
+
console.log(` status filter: ${resp.status_filter}`);
|
|
126
|
+
console.log(` title filter: ${resp.title_filter ? resp.title_filter.join(" | ") : "(none — all)"}`);
|
|
127
|
+
console.log(` attachments: ${downloadFiles ? `auto-download ON (cap ${maxMb}MB)` : "OFF (--no-files)"}`);
|
|
128
|
+
console.log(` updates: ${updates.length}`);
|
|
129
|
+
if (!updates.length) {
|
|
130
|
+
console.log(" (no matching updates)");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
console.log("");
|
|
134
|
+
updates.forEach((u, i) => {
|
|
135
|
+
const date = String(u.created_at || "").slice(0, 16).replace("T", " ");
|
|
136
|
+
console.log(` [${i + 1}] ${u.title}`);
|
|
137
|
+
console.log(` priority=${u.priority} status=${u.status} raised=${date}${u.created_by_name ? ` by ${u.created_by_name}` : ""}`);
|
|
138
|
+
console.log(` desc: ${oneLine(u.description, 140)}`);
|
|
139
|
+
if (u.contact) console.log(` contact: ${u.contact.full_name ?? "(no name)"} (${u.contact.whatsapp_id ?? u.contact.id})`);
|
|
140
|
+
if (u.attachments && u.attachments.length) {
|
|
141
|
+
console.log(` attachments: ${u.attachments.length} (${u.attachments.map((a) => a.filename || a.type || "file").join(", ")})`);
|
|
142
|
+
for (const a of u.attachments) {
|
|
143
|
+
if (a.local_path) console.log(` ↓ ${a.local_path}`);
|
|
144
|
+
else if (a.download && !a.download.ok) console.log(` ✗ ${a.filename || "file"}: ${a.download.reason}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (u.superadmin_response_image_local) console.log(` admin response image: ↓ ${u.superadmin_response_image_local}`);
|
|
148
|
+
if (u.context && u.context.messages && u.context.messages.length) {
|
|
149
|
+
const trig = u.context.messages.find((m) => m.is_trigger);
|
|
150
|
+
console.log(` context: ${u.context.total} msgs (anchor=${u.context.anchor})${trig ? ` · trigger: "${oneLine(trig.message, 80)}"` : ""}`);
|
|
151
|
+
} else {
|
|
152
|
+
console.log(` context: (none linked)`);
|
|
153
|
+
}
|
|
154
|
+
console.log("");
|
|
155
|
+
});
|
|
156
|
+
console.log("Read the JSON for full update text + chat context. Attachments (paths marked ↓) were downloaded to");
|
|
157
|
+
console.log(`${path.join(AU_DIR, FILES_DIRNAME)}/<update-id>/ — Read those files for visual context, then act.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function list() {
|
|
161
|
+
let files;
|
|
162
|
+
try {
|
|
163
|
+
files = (await fs.readdir(AU_DIR)).filter((f) => f.endsWith(".json"));
|
|
164
|
+
} catch {
|
|
165
|
+
console.log("(no local snapshots — run `agent-updates pull <organization_id>`)");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!files.length) {
|
|
169
|
+
console.log("(no local snapshots — run `agent-updates pull <organization_id>`)");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
files.sort();
|
|
173
|
+
for (const f of files) {
|
|
174
|
+
const fp = path.join(AU_DIR, f);
|
|
175
|
+
try {
|
|
176
|
+
const d = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
177
|
+
console.log(f);
|
|
178
|
+
console.log(` org: ${d.organization_name ?? "(unknown)"} (${d.organization_id ?? "?"})`);
|
|
179
|
+
console.log(` filter: status=${d.status_filter ?? "?"} title=${d.title_filter ? d.title_filter.join("|") : "(all)"}`);
|
|
180
|
+
console.log(` updates: ${d.total_updates ?? "?"}`);
|
|
181
|
+
console.log(` pulled: ${d.pulled_at ?? "(unknown)"}`);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.log(`${f} [invalid JSON: ${e.message}]`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|