@flowapt/flowiq-cli 0.1.6 → 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, outbound messaging webhooks, and FlowMod
5
- master-group prompts through `https://api.flowiq.live/cli/*` so the Supabase
6
- service role never leaves 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
 
@@ -210,6 +212,106 @@ 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
+ ### 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
+
213
315
  ## Environment variable overrides
214
316
 
215
317
  | Var | Default | Use |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.6",
4
- "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, FlowMod master-group prompts, 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"
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,131 @@
1
+ // `flowiq export chats <organization_id> [--out <path>]`.
2
+ // Full chat export → TXT, byte-identical to the in-app "Export Settings TXT".
3
+ // The server (/cli/export-chats) supplies keyset pages (ordered, whatsapp_id
4
+ // resolved); ALL grouping + formatting happens here, verbatim from the legacy
5
+ // export-chats.mjs, so the output matches the deployed feature exactly.
6
+
7
+ import fs from "node:fs";
8
+ import fsp from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { http } from "../http.js";
11
+
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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
+ // Verbatim from export-chats.mjs.
24
+ function getSenderLabel(senderType) {
25
+ if (senderType.startsWith("user-")) return "customer";
26
+ if (senderType.startsWith("bot-")) return "bot";
27
+ if (senderType.startsWith("human-")) return "agent";
28
+ if (senderType.startsWith("flowmod-")) return "flowmod";
29
+ if (senderType.startsWith("call-")) return "call";
30
+ if (senderType === "tool-call") return "tool";
31
+ return senderType;
32
+ }
33
+
34
+ export async function chats(orgId, opts = {}) {
35
+ if (!UUID_RE.test(orgId)) {
36
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const t0 = Date.now();
41
+
42
+ // --- Drive the keyset page loop, accumulating messages in global order ---
43
+ const messages = [];
44
+ let organizationName = null;
45
+ let cursor = null;
46
+ let pages = 0;
47
+ for (;;) {
48
+ const query = { organization_id: orgId };
49
+ if (cursor) {
50
+ query.cursor_created_at = cursor.created_at;
51
+ query.cursor_id = cursor.id;
52
+ }
53
+ let resp;
54
+ try {
55
+ resp = await http.get("export-chats", query);
56
+ } catch (e) {
57
+ console.error(`Export failed on page ${pages + 1}: ${e.message}`);
58
+ process.exit(1);
59
+ }
60
+ if (resp.organization_name != null) organizationName = resp.organization_name;
61
+ const page = resp.page || [];
62
+ for (const r of page) messages.push(r);
63
+ pages++;
64
+ if (pages % 20 === 0) {
65
+ console.log(` ...${messages.length.toLocaleString("en-US")} messages (${pages} pages)`);
66
+ }
67
+ if (resp.done || !resp.next_cursor) break;
68
+ cursor = resp.next_cursor;
69
+ }
70
+ console.log(`Messages fetched: ${messages.length} in ${pages} page${pages === 1 ? "" : "s"}`);
71
+
72
+ // --- Group exactly like the deployed exportTXT: plain object, waId key ---
73
+ const grouped = {};
74
+ for (const row of messages) {
75
+ const whatsappId = row.whatsapp_id ?? null;
76
+ const waId = (whatsappId || "unknown").replace(/[+ ]/g, "");
77
+ if (!grouped[waId]) grouped[waId] = [];
78
+ grouped[waId].push(row);
79
+ }
80
+ const uniqueContacts = Object.keys(grouped).length;
81
+
82
+ // --- Resolve output path ---
83
+ let outPath = opts.out;
84
+ if (!outPath) {
85
+ const dir = path.resolve(process.cwd(), ".flowiq", "exports");
86
+ await fsp.mkdir(dir, { recursive: true });
87
+ outPath = path.join(dir, `${slugify(organizationName, orgId)}-chats.txt`);
88
+ } else {
89
+ outPath = path.resolve(outPath);
90
+ await fsp.mkdir(path.dirname(outPath), { recursive: true });
91
+ }
92
+
93
+ // --- Write the TXT (streamed; content identical to the deployed string build) ---
94
+ const out = fs.createWriteStream(outPath, { encoding: "utf8" });
95
+ const write = (s) =>
96
+ new Promise((resolve, reject) => {
97
+ if (out.write(s)) resolve();
98
+ else out.once("drain", resolve);
99
+ out.once("error", reject);
100
+ });
101
+
102
+ let header = `${organizationName || "Organization"} — Chat Export\n`;
103
+ header += `Total messages: ${messages.length.toLocaleString()} | Contacts: ${uniqueContacts}\n`;
104
+ header += `Exported: ${new Date().toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg" })}\n`;
105
+ header += `${"=".repeat(60)}\n\n`;
106
+ await write(header);
107
+
108
+ for (const [waId, msgs] of Object.entries(grouped)) {
109
+ let chunk = `--- ${waId} ---\n`;
110
+ for (const msg of msgs) {
111
+ const date = msg.created_at
112
+ ? new Date(msg.created_at).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg" })
113
+ : "unknown";
114
+ const label = getSenderLabel(msg.sender_type || "");
115
+ let line = `[${date}] (${label}) ${msg.message || ""}`;
116
+ if (msg.media_url) line += ` [Media: ${msg.media_type || "file"} - ${msg.media_url}]`;
117
+ if (msg.voice_note_transcription) line += ` [Voice: ${msg.voice_note_transcription}]`;
118
+ chunk += `${line}\n`;
119
+ }
120
+ chunk += `\n`;
121
+ await write(chunk);
122
+ }
123
+
124
+ await new Promise((resolve, reject) => out.end((err) => (err ? reject(err) : resolve())));
125
+
126
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
127
+ console.log(`\nDone in ${secs}s`);
128
+ console.log(` total messages: ${messages.length.toLocaleString()}`);
129
+ console.log(` groups: ${uniqueContacts}`);
130
+ console.log(` file: ${outPath}`);
131
+ }
@@ -0,0 +1,33 @@
1
+ // `flowiq org info <organization_id>` — read-only org summary (platform detect
2
+ // + active agent) for the prompt-builder skill's Step 0. Raw store/Meta creds
3
+ // are stripped server-side; this only prints derived booleans + public urls.
4
+
5
+ import { http } from "../http.js";
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ export async function info(orgId, opts = {}) {
10
+ if (!UUID_RE.test(orgId)) {
11
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
12
+ process.exit(1);
13
+ }
14
+ let resp;
15
+ try {
16
+ resp = await http.get("org", { organization_id: orgId });
17
+ } catch (e) {
18
+ console.error(`Lookup failed: ${e.message}`);
19
+ process.exit(1);
20
+ }
21
+
22
+ if (opts.json) {
23
+ console.log(JSON.stringify(resp, null, 2));
24
+ return;
25
+ }
26
+
27
+ console.log(`${resp.name} (${resp.id})`);
28
+ console.log(` platform: ${resp.platform || "(neither shopify nor woo)"}${resp.ip_whitelist ? " [ip_whitelist]" : ""}`);
29
+ if (resp.shopify_domain) console.log(` shopify: ${resp.shopify_domain}`);
30
+ if (resp.woo_store_url) console.log(` woo store: ${resp.woo_store_url}`);
31
+ console.log(` provider: ${resp.provider || "(unset → meta)"}`);
32
+ console.log(` active agent: ${resp.active_whatsapp_agent || "(NONE — set one before building a prompt)"}`);
33
+ }
@@ -0,0 +1,174 @@
1
+ // `flowiq pinboard pull <task_id>` / `push <slug>` / `list` / `list-remote [status]`.
2
+ // Round-trips a single Pin Board task (flowapt_tasks row) via /cli/pinboard.
3
+ // No DB credentials on disk — all work happens server-side.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+
9
+ const PIN_DIR = path.resolve(process.cwd(), ".flowiq", "pinboard");
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const VALID_STATUS = ["open", "in_progress", "done"];
12
+
13
+ function slugify(name, fallback) {
14
+ const s = String(name || "")
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/-+/g, "-")
18
+ .replace(/^-|-$/g, "");
19
+ return (s || fallback).slice(0, 60);
20
+ }
21
+
22
+ async function fileExists(p) {
23
+ try { await fs.access(p); return true; } catch { return false; }
24
+ }
25
+
26
+ async function resolvePushPath(identifier) {
27
+ const candidates = [];
28
+ if (path.isAbsolute(identifier)) {
29
+ candidates.push(identifier);
30
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
31
+ candidates.push(path.resolve(identifier));
32
+ } else {
33
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
34
+ candidates.push(path.join(PIN_DIR, base));
35
+ candidates.push(path.resolve(base));
36
+ }
37
+ for (const c of candidates) {
38
+ if (await fileExists(c)) return c;
39
+ }
40
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
41
+ }
42
+
43
+ export async function pull(taskId) {
44
+ if (!UUID_RE.test(taskId)) {
45
+ console.error(`Error: "${taskId}" is not a valid task UUID.`);
46
+ process.exit(1);
47
+ }
48
+ let resp;
49
+ try {
50
+ resp = await http.get("pinboard", { task_id: taskId });
51
+ } catch (e) {
52
+ console.error(`Pull failed: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+
56
+ await fs.mkdir(PIN_DIR, { recursive: true });
57
+ const slug = slugify(resp.name, resp.id);
58
+ const filePath = path.join(PIN_DIR, `${slug}.json`);
59
+ const overwriting = await fileExists(filePath);
60
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
61
+
62
+ const orgs = resp.organizations_resolved || [];
63
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
64
+ console.log(` id: ${resp.id}`);
65
+ console.log(` name: ${resp.name}`);
66
+ console.log(` status: ${resp.status} / ${resp.urgency}`);
67
+ console.log(` due: ${resp.preferred_due_date || "(none)"}`);
68
+ console.log(` orgs: ${(resp.organizations || []).length}${orgs.length ? " (" + orgs.map((o) => o.name || o.id.slice(0, 8)).join(", ") + ")" : ""}`);
69
+ console.log(` media: ${(resp.media || []).length}`);
70
+ console.log(` messages: ${(resp.messages || []).length}`);
71
+ console.log(` created: ${resp.created_by} @ ${resp.created_at}`);
72
+ }
73
+
74
+ export async function push(identifier) {
75
+ let filePath;
76
+ try {
77
+ filePath = await resolvePushPath(identifier);
78
+ } catch (e) {
79
+ console.error(`Error: ${e.message}`);
80
+ process.exit(1);
81
+ }
82
+ const raw = await fs.readFile(filePath, "utf8");
83
+ let data;
84
+ try { data = JSON.parse(raw); }
85
+ catch (e) {
86
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
87
+ process.exit(1);
88
+ }
89
+ if (!data.id || !UUID_RE.test(data.id)) {
90
+ console.error(`${filePath}: id missing or invalid`);
91
+ process.exit(1);
92
+ }
93
+
94
+ let resp;
95
+ try {
96
+ resp = await http.post("pinboard", {
97
+ id: data.id,
98
+ name: data.name,
99
+ description: data.description ?? null,
100
+ status: data.status,
101
+ urgency: data.urgency,
102
+ preferred_due_date: data.preferred_due_date ?? null,
103
+ organizations: data.organizations ?? [],
104
+ media: data.media ?? [],
105
+ messages: data.messages ?? [],
106
+ created_by: data.created_by,
107
+ });
108
+ } catch (e) {
109
+ console.error(`Push failed: ${e.message}`);
110
+ process.exit(1);
111
+ }
112
+
113
+ console.log(`Pushed ${filePath}`);
114
+ console.log(` id: ${resp.id}`);
115
+ console.log(` name: ${resp.name}`);
116
+ console.log(` status: ${resp.status} / ${resp.urgency}`);
117
+ console.log(` due: ${resp.preferred_due_date || "(none)"}`);
118
+ console.log(` orgs: ${resp.orgs}`);
119
+ console.log(` media: ${resp.media}`);
120
+ console.log(` messages: ${resp.messages}`);
121
+ }
122
+
123
+ export async function list() {
124
+ let files;
125
+ try {
126
+ files = (await fs.readdir(PIN_DIR)).filter((f) => f.endsWith(".json"));
127
+ } catch {
128
+ console.log("(no local task files; run `pinboard pull <task_id>`, or `pinboard list-remote` to see ids)");
129
+ return;
130
+ }
131
+ if (!files.length) {
132
+ console.log("(no local task files; run `pinboard pull <task_id>`, or `pinboard list-remote` to see ids)");
133
+ return;
134
+ }
135
+ files.sort();
136
+ for (const f of files) {
137
+ const fp = path.join(PIN_DIR, f);
138
+ try {
139
+ const d = JSON.parse(await fs.readFile(fp, "utf8"));
140
+ console.log(f);
141
+ console.log(` id: ${d.id || "?"}`);
142
+ console.log(` name: ${d.name || "(unknown)"}`);
143
+ console.log(` status: ${d.status || "?"} / ${d.urgency || "?"}`);
144
+ console.log(` msgs: ${Array.isArray(d.messages) ? d.messages.length : "?"}`);
145
+ console.log(` pulled: ${d.pulled_at || "(unknown)"}`);
146
+ } catch (e) {
147
+ console.log(`${f} [invalid JSON: ${e.message}]`);
148
+ }
149
+ }
150
+ }
151
+
152
+ export async function listRemote(statusFilter) {
153
+ if (statusFilter && !VALID_STATUS.includes(statusFilter)) {
154
+ console.error(`Invalid status filter "${statusFilter}". Use one of ${VALID_STATUS.join(" | ")}.`);
155
+ process.exit(1);
156
+ }
157
+ let resp;
158
+ try {
159
+ resp = await http.get("pinboard", { list: "1", status: statusFilter });
160
+ } catch (e) {
161
+ console.error(`List failed: ${e.message}`);
162
+ process.exit(1);
163
+ }
164
+ const tasks = resp.tasks || [];
165
+ if (!tasks.length) {
166
+ console.log(statusFilter ? `(no tasks with status=${statusFilter})` : "(no tasks in flowapt_tasks)");
167
+ return;
168
+ }
169
+ console.log(`Found ${tasks.length} task${tasks.length === 1 ? "" : "s"}${statusFilter ? ` (status=${statusFilter})` : ""}:\n`);
170
+ for (const t of tasks) {
171
+ const updated = t.updated_at ? new Date(t.updated_at).toISOString().replace("T", " ").slice(0, 16) : "?";
172
+ console.log(` ${t.id} [${String(t.status).padEnd(11)}] [${String(t.urgency).padEnd(8)}] ${updated} ${t.created_by} — ${t.name}`);
173
+ }
174
+ }
@@ -0,0 +1,156 @@
1
+ // `flowiq templates pull <org_id>` / `list` — read WhatsApp templates from Meta.
2
+ // `flowiq templates create <org> --request-file f.json` / `status` — §12 (create).
3
+ // Pull is read-only via /cli/templates; create proxies /cli/meta-templates.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+
9
+ const TPL_DIR = path.resolve(process.cwd(), ".flowiq", "templates");
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ function slugify(name, fallback) {
13
+ const s = String(name || "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "");
18
+ return s || fallback;
19
+ }
20
+
21
+ async function fileExists(p) {
22
+ try { await fs.access(p); return true; } catch { return false; }
23
+ }
24
+
25
+ export async function pull(orgId) {
26
+ if (!UUID_RE.test(orgId)) {
27
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
28
+ process.exit(1);
29
+ }
30
+ let resp;
31
+ try {
32
+ resp = await http.get("templates", { organization_id: orgId });
33
+ } catch (e) {
34
+ console.error(`Pull failed: ${e.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ await fs.mkdir(TPL_DIR, { recursive: true });
39
+ const slug = slugify(resp.organization_name, resp.organization_id);
40
+ const filePath = path.join(TPL_DIR, `${slug}.json`);
41
+ const overwriting = await fileExists(filePath);
42
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
43
+
44
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
45
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
46
+ console.log(` waba: ${resp.waba_id}`);
47
+ if (resp.meta_phone_number) console.log(` phone: ${resp.meta_phone_number}`);
48
+ console.log(` templates: ${resp.template_count}`);
49
+ console.log(` status: ${JSON.stringify(resp.summary?.byStatus || {})}`);
50
+ console.log(` category: ${JSON.stringify(resp.summary?.byCategory || {})}`);
51
+ console.log(` language: ${JSON.stringify(resp.summary?.byLanguage || {})}`);
52
+ }
53
+
54
+ export async function create(orgId, opts = {}) {
55
+ if (!opts.requestFile) {
56
+ console.error("Error: --request-file <path> is required (JSON with template_request, optional template_data/media_header/cards_media).");
57
+ process.exit(1);
58
+ }
59
+ let filePath = opts.requestFile;
60
+ if (!path.isAbsolute(filePath)) filePath = path.resolve(filePath);
61
+ let data;
62
+ try {
63
+ data = JSON.parse(await fs.readFile(filePath, "utf8"));
64
+ } catch (e) {
65
+ console.error(`Cannot read request file ${filePath}: ${e.message}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ // The file may be the full edge-fn body, or just the inner template_request.
70
+ const organizationId = orgId || data.organization_id;
71
+ if (!organizationId || !UUID_RE.test(organizationId)) {
72
+ console.error("Error: organization_id missing/invalid (pass <org_id> or include it in the request file).");
73
+ process.exit(1);
74
+ }
75
+ const templateRequest = data.template_request || (data.name ? data : null);
76
+ if (!templateRequest) {
77
+ console.error("Error: request file must contain a `template_request` object (or be one, with a `name`).");
78
+ process.exit(1);
79
+ }
80
+
81
+ let resp;
82
+ try {
83
+ resp = await http.post("meta-templates", {
84
+ organization_id: organizationId,
85
+ template_request: templateRequest,
86
+ template_data: data.template_data,
87
+ media_header: data.media_header,
88
+ cards_media: data.cards_media,
89
+ });
90
+ } catch (e) {
91
+ console.error(`Create failed: ${e.message}`);
92
+ if (e.body) console.error(JSON.stringify(e.body, null, 2));
93
+ process.exit(1);
94
+ }
95
+
96
+ console.log(`Submitted template "${templateRequest.name}" for org ${organizationId}`);
97
+ console.log(JSON.stringify(resp, null, 2));
98
+ }
99
+
100
+ export async function status(orgId, opts = {}) {
101
+ if (!UUID_RE.test(orgId)) {
102
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
103
+ process.exit(1);
104
+ }
105
+ let resp;
106
+ try {
107
+ resp = await http.get("meta-templates", { organization_id: orgId, name: opts.name });
108
+ } catch (e) {
109
+ console.error(`Status lookup failed: ${e.message}`);
110
+ process.exit(1);
111
+ }
112
+ const rows = resp.templates || [];
113
+ if (!rows.length) {
114
+ console.log(opts.name ? `(no templates matching "${opts.name}")` : "(no templates on record for this org)");
115
+ return;
116
+ }
117
+ console.log(`${rows.length} template row${rows.length === 1 ? "" : "s"}${opts.name ? ` matching "${opts.name}"` : ""}:`);
118
+ for (const t of rows) {
119
+ const name = t.template_name || t.name || "(unnamed)";
120
+ const st = t.status || "(no status)";
121
+ const cat = t.category ? ` [${t.category}]` : "";
122
+ console.log(` ${name.padEnd(40)} ${String(st).padEnd(16)}${cat}`);
123
+ }
124
+ console.log("\nNote: status is captured at submit time (older rows may be null). For the live");
125
+ console.log("Meta-side status + rejection reasons, run `templates pull <org_id>`.");
126
+ }
127
+
128
+ export async function list() {
129
+ let files;
130
+ try {
131
+ files = (await fs.readdir(TPL_DIR)).filter((f) => f.endsWith(".json"));
132
+ } catch {
133
+ console.log("(no local template files; run `templates pull <org_id>`)");
134
+ return;
135
+ }
136
+ if (!files.length) {
137
+ console.log("(no local template files; run `templates pull <org_id>`)");
138
+ return;
139
+ }
140
+ files.sort();
141
+ for (const f of files) {
142
+ const fp = path.join(TPL_DIR, f);
143
+ try {
144
+ const d = JSON.parse(await fs.readFile(fp, "utf8"));
145
+ console.log(f);
146
+ console.log(` org: ${d.organization_name ?? "(unknown)"} (${d.organization_id ?? "?"})`);
147
+ console.log(` waba: ${d.waba_id ?? "?"}`);
148
+ console.log(` phone: ${d.meta_phone_number ?? "(none)"}`);
149
+ console.log(` templates: ${d.template_count ?? (Array.isArray(d.templates) ? d.templates.length : "?")}`);
150
+ if (d.summary?.byStatus) console.log(` status: ${JSON.stringify(d.summary.byStatus)}`);
151
+ console.log(` pulled: ${d.pulled_at ?? "(unknown)"}`);
152
+ } catch (e) {
153
+ console.log(`${f} [invalid JSON: ${e.message}]`);
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "Generic agent smoke pack (org-agnostic)",
3
+ "note": "Universal FlowIQ-agent behaviours. Assertion-light on purpose — replies are dumped for inspection since exact copy varies per org. Safe to run against any org via `qa <org_id>`. For org-specific assertions, author a pack and run it with `scenario <org_id> <file>`.",
4
+ "scenarios": [
5
+ {
6
+ "id": "greeting",
7
+ "title": "Greeting / warmth",
8
+ "turns": [
9
+ { "text": "Hi there, how are you doing today?", "expectNot": ["👉"] }
10
+ ]
11
+ },
12
+ {
13
+ "id": "off-topic",
14
+ "title": "Off-topic request (should decline in one message)",
15
+ "turns": [
16
+ { "text": "What's the weather like today?" }
17
+ ]
18
+ },
19
+ {
20
+ "id": "product-basic",
21
+ "title": "Basic product question",
22
+ "turns": [
23
+ { "text": "What are your best-selling products?" }
24
+ ]
25
+ },
26
+ {
27
+ "id": "human-handover",
28
+ "title": "Request a human",
29
+ "turns": [
30
+ { "text": "This isn't helping. Can I please speak to a real person?" }
31
+ ]
32
+ },
33
+ {
34
+ "id": "rating-reply",
35
+ "title": "End-of-chat rating reply",
36
+ "turns": [
37
+ { "text": "I'd rate this chat a 3 out of 5." }
38
+ ]
39
+ }
40
+ ]
41
+ }
package/src/index.js CHANGED
@@ -14,6 +14,13 @@ import * as messagingWebhooksCmd from "./commands/messaging-webhooks.js";
14
14
  import * as webhooksCmd from "./commands/webhooks.js";
15
15
  import * as flowmodCmd from "./commands/flowmod.js";
16
16
  import * as groupsCmd from "./commands/groups.js";
17
+ import * as pinboardCmd from "./commands/pinboard.js";
18
+ import * as templatesCmd from "./commands/templates.js";
19
+ import * as orgCmd from "./commands/org.js";
20
+ import * as agentConfigCmd from "./commands/agent-config.js";
21
+ import * as agentUpdatesCmd from "./commands/agent-updates.js";
22
+ import * as exportCmd from "./commands/export.js";
23
+ import * as testCmd from "./commands/agent-test.js";
17
24
 
18
25
  // Read version from package.json so it stays in sync with the published npm
19
26
  // version automatically (single source of truth — bumping package.json on each
@@ -134,5 +141,138 @@ export function run(argv) {
134
141
  .option("--json", "print the full JSON payload to stdout instead of the timeline")
135
142
  .action((nameOrJid, opts) => groupsCmd.pull(nameOrJid, opts));
136
143
 
144
+ // pinboard (one flowapt_tasks row: Pin Board team task board)
145
+ const pinboard = program.command("pinboard")
146
+ .alias("pin")
147
+ .description("Round-trip a single Pin Board task (flowapt_tasks row)");
148
+ pinboard.command("pull <task_id>")
149
+ .description("Fetch one task into a local JSON file")
150
+ .action((taskId) => pinboardCmd.pull(taskId));
151
+ pinboard.command("push <slug-or-path>")
152
+ .description("Apply a local JSON file's editable fields back to the task row (stamps updated_at)")
153
+ .action((id) => pinboardCmd.push(id));
154
+ pinboard.command("list")
155
+ .description("List local task JSON files")
156
+ .action(() => pinboardCmd.list());
157
+ pinboard.command("list-remote [status]")
158
+ .description("List every task in the DB (optionally filter: open | in_progress | done) to find an id")
159
+ .action((status) => pinboardCmd.listRemote(status));
160
+
161
+ // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
162
+ const templates = program.command("templates")
163
+ .alias("tpl")
164
+ .description("Read an org's WhatsApp templates from Meta (pull/list); create + submit to Meta (create/status)");
165
+ templates.command("pull <organization_id>")
166
+ .description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
167
+ .action((orgId) => templatesCmd.pull(orgId));
168
+ templates.command("list")
169
+ .description("List local template snapshots")
170
+ .action(() => templatesCmd.list());
171
+ templates.command("create [organization_id]")
172
+ .description("Submit a new WhatsApp template to Meta via the create-meta-template edge fn")
173
+ .requiredOption("--request-file <path>", "JSON file with template_request (+ optional template_data/media_header/cards_media)")
174
+ .action((orgId, opts) => templatesCmd.create(orgId, opts));
175
+ templates.command("status <organization_id>")
176
+ .description("List the org's template rows (name + status) to poll Meta approval")
177
+ .option("--name <substr>", "filter by template name substring")
178
+ .action((orgId, opts) => templatesCmd.status(orgId, opts));
179
+
180
+ // org (read-only org summary for the prompt-builder skill, creds stripped)
181
+ const org = program.command("org").description("Read-only org info (platform detect + active agent)");
182
+ org.command("info <organization_id>")
183
+ .description("Show the org's platform, storefront url + active agent (raw creds stripped)")
184
+ .option("--json", "print the raw JSON payload")
185
+ .action((orgId, opts) => orgCmd.info(orgId, opts));
186
+
187
+ // agent (agent-level config writes: the prompt-builder skill's Step 5)
188
+ const agent = program.command("agent").description("Configure the org's active agent");
189
+ agent.command("config <organization_id>")
190
+ .description("Set allowlisted agent config (no flags = show current)")
191
+ .option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
192
+ .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
193
+ .option("--rename <name>", "rename the agent")
194
+ .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat", agentConfigCmd.collectTool, [])
195
+ .option("--discount [bool]", "agent.discount.enabled (true if bare)")
196
+ .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
197
+
198
+ // agent-updates (pending client change-requests + chat context; read-only)
199
+ const au = program.command("agent-updates")
200
+ .alias("au")
201
+ .description("Pull pending agent_updates + the chat context around each (read-only)");
202
+ au.command("pull <organization_id>")
203
+ .description("Fetch updates + linked contact + trigger-message window; downloads attachments locally")
204
+ .option("--status <status>", "pending (default) | resolved | in_progress | all")
205
+ .option("--titles <list>", "only updates whose title contains any of these (comma-separated, ci substring)")
206
+ .option("--before <n>", "context messages before the trigger (default 12)")
207
+ .option("--after <n>", "context messages after the trigger (default 8)")
208
+ .option("--recent <n>", "fallback window when no linked message but a contact exists (default 20)")
209
+ .option("--no-files", "do NOT download attachments (default: download is ON)")
210
+ .option("--max-mb <n>", "skip attachments larger than N MB (default 25)")
211
+ .action((orgId, opts) => agentUpdatesCmd.pull(orgId, opts));
212
+ au.command("list")
213
+ .description("List local agent-updates snapshots")
214
+ .action(() => agentUpdatesCmd.list());
215
+
216
+ // export (full chat history → TXT, byte-identical to the in-app export)
217
+ const exp = program.command("export").description("Export data to local files");
218
+ exp.command("chats <organization_id>")
219
+ .description("Export the org's full chat history to a TXT file (keyset-paginated, byte-matches the in-app export)")
220
+ .option("--out <path>", "output path (default .flowiq/exports/<slug>-chats.txt)")
221
+ .action((orgId, opts) => exportCmd.chats(orgId, opts));
222
+
223
+ // test (drive the org's LIVE agent through the web/test channel; no real WhatsApp)
224
+ const test = program.command("test").description("Test an org's live agent via the web/test channel (never sends a real WhatsApp)");
225
+ test.command("resolve <organization_id>")
226
+ .description("Print the resolved agent + test contact + endpoint")
227
+ .option("--agent <id>", "test a specific (non-active) agent in the org")
228
+ .action((orgId, opts) => testCmd.resolve(orgId, opts));
229
+ test.command("send <organization_id> <message...>")
230
+ .description("Send one message, print the reply + any tool calls")
231
+ .option("--agent <id>", "test a specific (non-active) agent")
232
+ .option("--clear", "clear the conversation before the run")
233
+ .option("--sender <name>", "senderName in the payload (default the test contact's name)")
234
+ .option("--image <url>", "attach a publicly-fetchable image URL (text is the caption)")
235
+ .option("--timeout <ms>", "per-turn HTTP timeout (default 120000)")
236
+ .option("--json", "print raw JSON instead of the transcript")
237
+ .action((orgId, message, opts) => testCmd.send(orgId, message, opts));
238
+ test.command("scenario <organization_id> <file>")
239
+ .description("Run a scenario pack (JSON: {scenarios:[{id,title,turns:[{text,expect?,expectNot?}]}]})")
240
+ .option("--agent <id>", "test a specific agent")
241
+ .option("--no-clear-between", "do NOT clear between scenarios (one long thread)")
242
+ .option("--sender <name>", "senderName")
243
+ .option("--timeout <ms>", "per-turn HTTP timeout (default 120000)")
244
+ .option("--json", "print raw JSON results")
245
+ .action((orgId, file, opts) => testCmd.scenario(orgId, file, opts));
246
+ test.command("qa <organization_id>")
247
+ .description("Run the bundled generic smoke pack")
248
+ .option("--agent <id>", "test a specific agent")
249
+ .option("--no-clear-between", "do NOT clear between scenarios")
250
+ .option("--sender <name>", "senderName")
251
+ .option("--timeout <ms>", "per-turn HTTP timeout")
252
+ .option("--json", "print raw JSON results")
253
+ .action((orgId, opts) => testCmd.qa(orgId, opts));
254
+ test.command("clear <organization_id>")
255
+ .description("Clear the test conversation (hide history via memory_cutoff)")
256
+ .option("--agent <id>", "target a specific agent's test contact")
257
+ .action((orgId, opts) => testCmd.clear(orgId, opts));
258
+ test.command("stress <organization_id> [pack]")
259
+ .description("Run an adversarial pack in parallel, one isolated contact per scenario (phase 2)")
260
+ .option("--agent <id>", "test a specific agent")
261
+ .option("--concurrency <n>", "parallel scenarios (default 6)")
262
+ .option("--timeout <ms>", "per-turn HTTP timeout")
263
+ .option("--out <path>", "results file (default .flowiq/agent-test/stress-results.json)")
264
+ .option("--contact <id>", "use a fixed real contact instead of isolated ones (forces concurrency 1)")
265
+ .action((orgId, pack, opts) => testCmd.stress(orgId, pack, opts));
266
+ test.command("cleanup <organization_id>")
267
+ .description("DELETE the stress test contacts + their messages/sessions")
268
+ .option("--agent <id>", "target a specific agent's stress contacts")
269
+ .option("--confirm", "required — confirms the destructive delete")
270
+ .action((orgId, opts) => testCmd.cleanup(orgId, opts));
271
+ test.command("extract <organization_id>")
272
+ .description("Rebuild full stress transcripts from the DB (for an LLM judge)")
273
+ .option("--agent <id>", "target a specific agent's stress contacts")
274
+ .option("--out <path>", "output file (default .flowiq/agent-test/judge_input.json)")
275
+ .action((orgId, opts) => testCmd.extract(orgId, opts));
276
+
137
277
  program.parseAsync(argv);
138
278
  }