@flowapt/flowiq-cli 0.10.0 → 0.12.0

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
@@ -261,6 +261,16 @@ flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set i
261
261
  flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
262
262
  ```
263
263
 
264
+ **Coexistence numbers are sent at a steady pace (2 Oct 2026).** On an org whose number
265
+ is shared with the WhatsApp Business app (`feature_flags.coexistence.enabled`), every
266
+ send engine (python, `/api/broadcast`, `/api/send-template`) sends one message every
267
+ 200 ms (5 a second) instead of in bursts, and the `bc send` dry run says so with the
268
+ time it will take: `🐢 Coexistence number: sent one at a time at 5 a second, about 5 min
269
+ for 1500 contact(s)`. On every org, a send Meta refuses with **130429** (its send-speed
270
+ limit) is retried after 2 / 5 / 10 s before it is recorded as failed; Meta never sent it,
271
+ so a retry cannot double-message anyone. `flowiq org whatsapp` notes a coexistence
272
+ number (Meta's own `throughput` still reads STANDARD on them). Server-side, no new flags.
273
+
264
274
  - **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
265
275
  send instead of firing it. It writes the same `api_request_queue` row the
266
276
  dashboard writes, replayed by the `process-api-queue` cron (every 3 min, so it
@@ -2148,7 +2158,7 @@ flowiq rules disable <rule_id>
2148
2158
  ### Org views — `flowiq org channels|whatsapp|health|automations|abandoned` (0.10.0)
2149
2159
 
2150
2160
  ```bash
2151
- flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads, Zernio
2161
+ flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads (FlowIQ app or Zernio route), Zernio
2152
2162
  flowiq org channels --all # census across every active org
2153
2163
  flowiq org whatsapp <org> # the number's health straight from Meta: status, quality, tier, name status, webhook subscription
2154
2164
  flowiq org whatsapp --all # every Meta org, unhealthy first
@@ -2160,7 +2170,13 @@ flowiq org abandoned <org> --disable 2 --commit
2160
2170
  ```
2161
2171
 
2162
2172
  `health` probes the org's OpenAI key with a one-token embedding, so an
2163
- exhausted or invalid key shows as such rather than "present".
2173
+ exhausted or invalid key shows as such rather than "present". It probes the
2174
+ spare key (`feature_flags.spare_openai_key`) too: the agent, product search and
2175
+ both product syncs fall back to it, so a dead primary with a working spare is a
2176
+ `⚠` warning, not a problem (1 Oct 2026). The webhook count covers both hosts our
2177
+ Shopify webhooks use (`auth.flowiq.live` and the older `supabase.co`); before
2178
+ 1 Oct 2026 it counted `supabase.co` only and read 0 on every org registered by
2179
+ `flowiq webhooks push`.
2164
2180
 
2165
2181
  ### Messages, org-wide — `flowiq messages search|failures|stats` (0.10.0)
2166
2182
 
@@ -2182,6 +2198,111 @@ flowiq messages stats <org> --since 6m
2182
2198
  (`cli_message_volume_summary`): template = broadcast_id, JSON payload, or no
2183
2199
  customer message in the preceding 24 h.
2184
2200
 
2201
+ ### Members · users · steers · notes · products (0.11.0)
2202
+
2203
+ Batch F of the request list. Three new topics, plus a long tail of verbs on
2204
+ existing ones (next section).
2205
+
2206
+ ```bash
2207
+ flowiq members list <org> # role · name · email · last sign-in · pending invitations
2208
+ flowiq members add <org> <email> --role member # an EXISTING account (no account → invite from the app)
2209
+ flowiq members role <org> <email> admin|member|viewer # the last admin cannot be demoted
2210
+ flowiq members rename <email> "Full Name" # profiles.full_name, shown in every org
2211
+ flowiq members remove <org> <email> --confirm # owner + last admin refused
2212
+ flowiq users lookup <email> # sign-in method, confirmation, last sign-in, every org + role
2213
+ flowiq steer list <contact_id> · steer add <contact_id> "Offer the 10% code" [--once] · steer clear <contact_id> [--id <id>] --confirm
2214
+ flowiq notes list <contact_id> · notes add <contact_id> "Vet to CS: …"
2215
+ flowiq products status <org> · products search <org> "whey protein" · products reconcile <org> --confirm
2216
+ ```
2217
+
2218
+ - **`steer`** is the inbox's supervisor instruction: vert injects every
2219
+ unconsumed steer into the agent's history. An ongoing steer applies to EVERY
2220
+ reply until it is deleted, and the inbox has no delete, which is why
2221
+ `steer clear` exists (dry-run; the texts stay in the audit row).
2222
+ - **`products search`** calls the agent's own `fuzzy_match_products` RPC with
2223
+ the agent's threshold (0.15), so "what does the agent find for X?" is answered
2224
+ without a test send. `products status` counts rows with no embedding (the
2225
+ agent cannot find those by meaning) and shows the last syncs; `reconcile`
2226
+ queues the org on `products-reconcile-scheduler` (re-sync + re-embed).
2227
+ - **`members add` takes an existing account only.** Someone who has never
2228
+ signed up goes through the app's invite flow (an invitation row + email);
2229
+ the CLI says so rather than minting a login.
2230
+
2231
+ ### The 0.11.0 long tail (verbs added to existing topics)
2232
+
2233
+ ```bash
2234
+ flowiq links shorten <org> --url <u> --slug spring-sale --commit # readable code; a taken slug falls back and says so
2235
+ flowiq links list <org> --code <code> | --to /collections/x | --all # one code · by destination · every page (500 a call)
2236
+ flowiq links repoint <org> <code> --to <url> --commit # move a live code (clicks + messages keep it)
2237
+ flowiq org list --number 27609751832 # the org behind a WhatsApp number / phone id / WABA
2238
+ flowiq org agent <org> on|off --commit [--yes] # the master switch; ON for everyone needs --yes
2239
+ flowiq org park <org> [--unpark] --confirm # organizations.inactive
2240
+ flowiq org flags set <org> product-tag-notes @notes.json # {tag: sentence}; instagram_comments.* are objects now
2241
+ flowiq agent flags [--tool product_lookup] [--model luna] # every active agent's model + tool gates
2242
+ flowiq tag ids <org> --ids-file ids.txt --tag vip --commit # one tag on an explicit id list
2243
+ flowiq tag holders <org> vip [--all] [--csv] # who carries a tag
2244
+ flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact # whole value, not a substring
2245
+ flowiq tag remove <org> vip --contacts a,b --confirm # only those contacts lose it
2246
+ flowiq prompts push <slug> --dry-run # added / removed / changed sections, nothing written
2247
+ flowiq bc scheduled list <org> --detail # the queued send's body / button values + header
2248
+ flowiq templates show <org> <name> # falls back to Meta when FlowIQ has no row
2249
+ flowiq templates attempts <org> --name x # + Meta's status / category history ("CATEGORY MOVED")
2250
+ flowiq test send <org> "hi" --tools --sender "Sofia" # tool arguments + results; renames a synthetic test contact
2251
+ flowiq messages pull <contact_id> # footer: sent by agent / broadcast / automation: <template>
2252
+ flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --confirm
2253
+ ```
2254
+
2255
+ - **`seg apply` sends one request per 4,000 ids** (the server already split its
2256
+ DB calls; one request still timed out on 18k), and a failure names the chunk;
2257
+ re-running is idempotent. **`seg plan --start-index N`** writes
2258
+ `<slug>-from-NN.json` instead of overwriting the earlier plan (the record of
2259
+ who got the earlier batches); an applied plan file is never overwritten.
2260
+ - **`bc send` audit rows and the `[CLI]` email now carry the audience** (python
2261
+ reports no count on a real fire; the server counts the tag's broadcast-safe
2262
+ contacts itself).
2263
+ - **`org agent`** is `organizations.wati_webhook_status`. Off = no customer gets
2264
+ the AI. On with `vert.active false` + `vert.allowed_whatsapp_ids` = test mode.
2265
+ On with `vert.active` not false = live for every customer, which needs
2266
+ `--yes`; the command prints which of the three it would produce.
2267
+
2268
+ ### Batch G (0.12.0): the 1 October filings
2269
+
2270
+ Eighteen requests filed by the team on 1 Oct 2026, each reproduced against the
2271
+ code before the fix.
2272
+
2273
+ ```bash
2274
+ flowiq contacts show <contact_id> # no --org needed any more: the org is resolved from the id
2275
+ flowiq contacts lint <org> # now also flags a 27 + 11 digit number (SA is 27 + 9) and suggests the real one
2276
+ flowiq contacts ai off <org> --tag <tag> [--commit] # AI off/on for every contact on a tag (or --contact / --ids-file), agent-off rows written
2277
+ flowiq contacts add <org> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit
2278
+ flowiq members invite <org> someone@client.co.za --role viewer # an invitation they accept at sign-up (no account needed)
2279
+ flowiq messages search <org> "being updated at the same time" --tool-calls # inside tool-call request / response
2280
+ flowiq messages failures <org> # rows with only an error_message no longer roll up as "?"
2281
+ flowiq messages purge <org> --subject-regex '^Automatic reply' --sender-type user-email --confirm # org-wide delete by pattern
2282
+ flowiq org automations <org> --set payment_received --template payment_received_v3 --body param1=full_name,param2=order_number --buttons param1=short_code --commit
2283
+ flowiq org whatsapp <org> | --all # LIMIT = the business portfolio's messaging limit (Meta's new field); code verification EXPIRED is info, not a problem
2284
+ flowiq org health <org> # flags an active agent with no human_notify (a handover alerts nobody)
2285
+ flowiq agent config <org> # human_notify / order_notify / tool_instructions print "(unset)" instead of vanishing
2286
+ flowiq tag field <org> --field whatsapp_id --any 2779…,2782… # a digit-only list is matched whole (exact) automatically; 0.7 s instead of a timeout
2287
+ flowiq test seed <org> --from <contact_id> --until "2026-10-01 09:08" --commit # copy a real chat onto a test contact; test unseed removes it
2288
+ flowiq plans create <org> --topic "Heritage Day" --type utility --date 2026-10-09 --time 10:00 --line-1 … --line-2 … --follow-up … --landing https://… --commit
2289
+ flowiq woo get <org> products --all --fields id,name,permalink,status # past the 3.5 MB cap; the truncation message now says how to trim and resume
2290
+ ```
2291
+
2292
+ - **`contacts ai off`** writes `bot_status` and the same `agent-off` row the
2293
+ inbox toggle writes, so the chat shows who switched it. It warns about the
2294
+ reactivation cron: an AI-off contact is turned back on once their last
2295
+ customer message is 12 h old and the agent-off row is older than 24 h
2296
+ (unless `bot_reactivation.enabled` is false on the org).
2297
+ - **`messages purge`** matches an email subject regex (`more_data.subject`),
2298
+ a text substring, sender types and a time window; 5,000 rows a run; the dry
2299
+ run lists subjects and contacts; `--confirm` asks you to type the org id.
2300
+ - **`plans create`** files the same row the MCP's `submit_plan` writes and
2301
+ alerts the team the same way; staff are exempt from the client lead time
2302
+ (a sub-24 h send is noted, not refused).
2303
+ - Not a CLI fix: #186 (Shopify store analytics need the `read_reports` scope
2304
+ on the FlowIQ app).
2305
+
2185
2306
  ### Guide — `flowiq guide`
2186
2307
 
2187
2308
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -143,12 +143,33 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
143
143
  | Did the agent say something wrong to anyone today? | `flowiq messages search <org_id> "the words" --since 24h` |
144
144
  | Why did messages not arrive / what failed to send? | `flowiq messages failures <org_id> --since 7d` (Meta error codes by reason, media and contact, plus sends Meta accepted and never confirmed) |
145
145
  | How many messages did an org send last month, free-form vs template? | `flowiq messages stats <org_id> --since 6m` |
146
- | Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all` |
146
+ | Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all`. A number shared with the WhatsApp Business app says "coexistence number … paced to 5 a second". |
147
+ | Why did a broadcast lose sends to error 130429? | The number is on **coexistence** (shared with the WhatsApp Business app) and was sent to in bursts. Since 2 Oct 2026 every engine sends those numbers one at a time (5 a second, the `bc send` dry run shows how long it takes) and retries a 130429 after a pause. Resend the old failures with `flowiq bc retry <org_id> <broadcastId>`. |
147
148
  | Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
148
- | Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed, prompt, catalogue, webhooks, order sync, traffic) |
149
+ | Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed incl. the spare key, prompt, catalogue, webhooks, order sync, traffic; ⚠ lines are warnings, ✗ lines block) |
149
150
  | What automated messages / flows / rules does an org have armed? | `flowiq org automations <org_id>` · `flowiq flows list <org_id>` (+ `flows status` for failures) · `flowiq rules list <org_id>` (+ `rules results <rule_id>`) · `flowiq org abandoned <org_id>` |
150
151
  | See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` · in the app: Changelog → **CLI requests** |
151
152
  | Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --fixed-in 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
153
+ | Who is on a client's account, and give someone access | `flowiq members list <org_id>` · `flowiq members add <org_id> <email> --role member` (they must already have an account; otherwise invite from the app) · `flowiq members role <org_id> <email> admin` |
154
+ | "I can't log in" / which orgs is this person on? | `flowiq users lookup <email>`: sign-in method, email confirmed or not, last sign-in, every org and role |
155
+ | Tell the agent how to handle ONE customer, or stop an ongoing steer | `flowiq steer add <contact_id> "Offer the 10% code once"` (`--once` for the next reply only) · `flowiq steer list <contact_id>` · `flowiq steer clear <contact_id> --confirm` |
156
+ | Leave a note for the team on a contact | `flowiq notes add <contact_id> "Vet to CS: …"` |
157
+ | The agent cannot find a product the store has | `flowiq products search <org_id> "<what the customer typed>"` (the agent's own match) · `flowiq products status <org_id>` (rows with no embedding) · `flowiq products reconcile <org_id> --confirm` |
158
+ | Which org owns this WhatsApp number? | `flowiq org list --number 27609751832` |
159
+ | Switch an org's agent off (or on) for every customer | `flowiq org agent <org_id> off --commit` · back on: `flowiq org agent <org_id> on --commit --yes` (test mode first: `org flags set <org> vert.allowed_whatsapp_ids <numbers>` + `vert.active false`) |
160
+ | A short link points at the wrong page | `flowiq links list <org_id> --code <code>` to see it · `flowiq links repoint <org_id> <code> --to <url> --commit` |
161
+ | Check a prompt edit before it goes live | `flowiq prompts push <slug> --dry-run` — added / removed / changed sections against the live agent |
162
+ | Tag a list of contact ids, or see who carries a tag | `flowiq tag ids <org_id> --ids-file ids.txt --tag vip --commit` · `flowiq tag holders <org_id> vip --csv` |
163
+ | Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
164
+ | Was that message the agent, a broadcast or an automation? | `flowiq messages pull <contact_id>` ends with a "sent by" line per source |
165
+ | Stop the agent answering everyone who got a broadcast (a whole tag) | `flowiq contacts ai off <org_id> --tag <tag>` (dry run) → `--commit`; back on with `ai on`. Read the reactivation warning it prints |
166
+ | Add a tester or a team member as a contact | `flowiq contacts add <org_id> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit` |
167
+ | Invite a client user who has no FlowIQ account | `flowiq members invite <org_id> <email> --role member` (they accept it when they sign up; FlowIQ sends no email, so tell them) |
168
+ | Remove imported junk emails (auto-replies) across the whole inbox | `flowiq messages purge <org_id> --subject-regex '^Automatic reply' --sender-type user-email` (dry run) → `--confirm` |
169
+ | Point an order message at a new template version, or fix its params | `flowiq org automations <org_id> --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code --commit` |
170
+ | Reproduce what the agent or the Coworker said to a real customer | `flowiq test seed <org_id> --from <contact_id> --until "YYYY-MM-DD HH:MM" --commit`, open the seeded contact in the inbox, then `flowiq test unseed <org_id> <id> --confirm` |
171
+ | File a broadcast plan for a client from the terminal | `flowiq plans create <org_id> --topic … --type utility\|marketing --date YYYY-MM-DD …` (dry run) → `--commit` |
172
+ | What did a tool actually return? | `flowiq messages search <org_id> "<text>" --tool-calls` |
152
173
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
153
174
  > from your working tree — `npm publish` packs whatever is on disk. A
154
175
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
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": {
@@ -0,0 +1,63 @@
1
+ // node --test src/batch-f.test.mjs — pure helpers behind the 0.11.0 topics.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { diffSections } from "./commands/prompts.js";
5
+ import { sentBy } from "./commands/messages.js";
6
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
7
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
8
+ const reg = await import("../../api/_shared/feature-flag-registry.js");
9
+
10
+ test("diffSections reports added / removed / changed by id and ignores untouched sections", () => {
11
+ const live = [
12
+ { id: "a", title: "Main", content: "hello" },
13
+ { id: "b", title: "Hours", content: "9-5", hidden: false },
14
+ { id: "c", title: "Old", content: "gone" },
15
+ ];
16
+ const local = [
17
+ { id: "a", title: "Main", content: "hello" },
18
+ { id: "b", title: "Hours", content: "9-6", hidden: true, channels: ["web"] },
19
+ { id: "d", title: "New", content: "x".repeat(12) },
20
+ ];
21
+ const d = diffSections(live, local);
22
+ assert.deepEqual(d.added, [{ title: "New", chars: 12 }]);
23
+ assert.deepEqual(d.removed, [{ title: "Old", chars: 4 }]);
24
+ assert.equal(d.changed.length, 1);
25
+ assert.equal(d.changed[0].title, "Hours");
26
+ assert.match(d.changed[0].what.join("; "), /content 3 → 3 chars; hidden false → true; channels "all" → \["web"\]/);
27
+ assert.equal(d.unchanged, false);
28
+ assert.equal(diffSections(live, live).unchanged, true);
29
+ });
30
+
31
+ test("diffSections notices a pure reorder", () => {
32
+ const live = [{ id: "a", title: "A", content: "1" }, { id: "b", title: "B", content: "2" }];
33
+ const d = diffSections(live, [live[1], live[0]]);
34
+ assert.equal(d.reordered, true);
35
+ assert.equal(d.unchanged, false);
36
+ });
37
+
38
+ test("sentBy labels broadcast, automation, rule and agent rows", () => {
39
+ assert.equal(sentBy({ broadcast_id: "x" }), "broadcast");
40
+ assert.equal(sentBy({ notification_template: "order_confirm_v1", notification_event: "orders/paid" }), "automation: order_confirm_v1 (orders/paid)");
41
+ assert.equal(sentBy({ notification_rule_id: "38f20704-aaaa-bbbb-cccc-dddddddddddd" }), "rule 38f20704");
42
+ assert.equal(sentBy({ sender_type: "bot-whatsapp" }), "agent");
43
+ });
44
+
45
+ test("product-tag-notes validator takes a {tag: sentence} map and refuses empties", () => {
46
+ const spec = reg.FLAG_REGISTRY["product-tag-notes"];
47
+ assert.deepEqual(reg.validateFlagValue(spec, '{"Free2024":" Ships free. "}'), { value: { Free2024: "Ships free." } });
48
+ assert.match(reg.validateFlagValue(spec, { Free2024: "" }).error, /non-empty sentence/);
49
+ assert.match(reg.validateFlagValue(spec, {}).error, /empty/);
50
+ assert.match(reg.validateFlagValue(spec, "true").error, /must be an object/);
51
+ });
52
+
53
+ test("instagram_comments legs are objects with a bounded message", () => {
54
+ const leg = reg.FLAG_REGISTRY["instagram_comments.private_reply"];
55
+ assert.equal(leg.type, "object");
56
+ assert.match(reg.validateFlagValue(leg, "true").error, /must be an object/);
57
+ assert.deepEqual(reg.validateFlagValue(leg, { enabled: true, message: " hi " }), { value: { enabled: true, message: "hi" } });
58
+ assert.match(reg.validateFlagValue(leg, { enabled: true }).error, /message is empty/);
59
+ assert.match(reg.validateFlagValue(leg, { enabled: true, message: "x".repeat(1001) }).error, /over 1000/);
60
+ const whole = reg.FLAG_REGISTRY["instagram_comments"];
61
+ assert.deepEqual(reg.validateFlagValue(whole, { enabled: true, ads_only: false, public_reply: { enabled: false } }), { value: { enabled: true, ads_only: false, public_reply: { enabled: false } } });
62
+ assert.match(reg.validateFlagValue(whole, { bogus: 1 }).error, /not a known key/);
63
+ });
@@ -0,0 +1,35 @@
1
+ // node --test src/batch-g.test.mjs — pure helpers behind the 0.12.0 verbs.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { withFields } from "./commands/store-api.js";
5
+ import { parseButton } from "./commands/plans.js";
6
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
7
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
8
+ const { lintNumber } = await import("../../api/cli/contacts.js");
9
+
10
+ test("lintNumber flags a wrong national length and suggests the last-n twin", () => {
11
+ assert.deepEqual(lintNumber("2729829037707"), { problem: "27 + 11 digits (a +27 number is 27 + 9)", suggested: "27829037707" });
12
+ assert.deepEqual(lintNumber("2782903770"), { problem: "27 + 8 digits (a +27 number is 27 + 9)", suggested: null });
13
+ assert.equal(lintNumber("27829037707"), null);
14
+ assert.equal(lintNumber("351912345678"), null);
15
+ assert.equal(lintNumber("447700900123"), null);
16
+ // variable-length countries stay untouched
17
+ assert.equal(lintNumber("4915112345678"), null);
18
+ assert.equal(lintNumber("12125551234"), null);
19
+ // the older rules still win first
20
+ assert.match(lintNumber("270821234567").problem, /trunk 0/);
21
+ assert.match(lintNumber("2727821234567").problem, /doubled/);
22
+ });
23
+
24
+ test("withFields adds the platform's field-trim parameter and leaves other query keys", () => {
25
+ assert.deepEqual(withFields({ status: "any" }, "id, name ,permalink", "_fields"), { status: "any", _fields: "id,name,permalink" });
26
+ assert.deepEqual(withFields({ status: "any" }, undefined, "fields"), { status: "any" });
27
+ assert.deepEqual(withFields(undefined, "id", "fields"), { fields: "id" });
28
+ });
29
+
30
+ test("parseButton reads the four button shapes", () => {
31
+ assert.deepEqual(parseButton("quick_reply:VIEW MORE"), { type: "quick_reply", text: "VIEW MORE" });
32
+ assert.deepEqual(parseButton("url:CLICK HERE=https://x.co/a=b"), { type: "url", text: "CLICK HERE", url: "https://x.co/a=b" });
33
+ assert.deepEqual(parseButton("phone:Call us=+27 60 975 1832"), { type: "phone", text: "Call us", phone_number: "+27 60 975 1832" });
34
+ assert.deepEqual(parseButton("copy_code:SAVE10"), { type: "copy_code", code: "SAVE10" });
35
+ });
@@ -72,10 +72,22 @@ function checkExpectations(turn, replyText) {
72
72
  return results;
73
73
  }
74
74
 
75
- function printTurn(sc, t, userText, r, checks) {
75
+ function printTurn(sc, t, userText, r, checks, opts = {}) {
76
76
  if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
77
77
  console.log(`\n 🧑 ${userText}`);
78
78
  if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
79
+ // --tools: what each tool was asked and what it answered (the tool-call rows
80
+ // vert writes). Without it a wrong answer could not be traced to the tool
81
+ // result that produced it.
82
+ if (opts.tools && r.tool_details?.length) {
83
+ for (const d of r.tool_details) {
84
+ console.log(` ⚙ ${d.tool_name}${d.duration_ms != null ? ` (${d.duration_ms} ms)` : ""}${d.error ? ` ERROR ${d.error}` : ""}`);
85
+ if (d.request != null) console.log(` in: ${String(d.request).split("\n").join("\n ")}`);
86
+ if (d.response != null) console.log(` out: ${String(d.response).split("\n").join("\n ")}`);
87
+ }
88
+ } else if (opts.tools && r.tool_calls?.length) {
89
+ console.log(` (tool arguments / results are not stored for this org: switch on feature flag tool_logging, or the API predates 1 Oct 2026)`);
90
+ }
79
91
  if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
80
92
  else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
81
93
  for (const c of checks || []) {
@@ -103,7 +115,7 @@ export async function send(orgId, messageParts, opts = {}) {
103
115
  printWarnings(r);
104
116
  if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
105
117
  console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
106
- printTurn({ title: "send", tq: "" }, 0, message, r, []);
118
+ printTurn({ title: "send", tq: "" }, 0, message, r, [], { tools: !!opts.tools });
107
119
  }
108
120
 
109
121
  async function runPack(orgId, pack, opts) {
@@ -128,7 +140,7 @@ async function runPack(orgId, pack, opts) {
128
140
  }
129
141
  const checks = checkExpectations(turn, r.reply);
130
142
  scResult.turns.push({ ...r, checks });
131
- if (!opts.json) printTurn(sc, t, turn.text, r, checks);
143
+ if (!opts.json) printTurn(sc, t, turn.text, r, checks, { tools: !!opts.tools });
132
144
  }
133
145
  results.push(scResult);
134
146
  }
@@ -153,6 +165,35 @@ export async function qa(orgId, opts = {}) {
153
165
  await runPack(orgId, pack, opts);
154
166
  }
155
167
 
168
+ // `flowiq test seed <org> --from <contact_id> [--until "2026-10-01 09:08"] [--name X] [--rows 60] [--with-tools] [--ai-on] [--commit]`
169
+ export async function seed(orgId, opts = {}) {
170
+ requireOrg(orgId);
171
+ if (!UUID_RE.test(opts.from || "")) { console.error("Error: --from <contact_id> is required (the real chat to copy)."); process.exit(1); }
172
+ let until = opts.until;
173
+ if (until && /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(until)) until = until.replace(" ", "T") + (until.length === 10 ? "T23:59:59" : "") + "+02:00";
174
+ let r;
175
+ try { r = await http.post("agent-test", { action: "seed", organization_id: orgId, agent_id: opts.agent, from_contact_id: opts.from, until, name: opts.name, rows: opts.rows, with_tools: !!opts.withTools, ai_on: !!opts.aiOn, copy_attributes: !!opts.copyAttributes, dry_run: !opts.commit }); }
176
+ catch (e) { console.error(`Seed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
177
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
178
+ console.log(`${r.organization_name}: copy ${r.rows} row(s) from ${r.from.name ?? r.from.whatsapp_id} (${r.from.id})${r.until ? ` up to ${r.until}` : ""}${r.first ? `, ${r.first.slice(0, 16)} → ${r.last.slice(0, 16)}` : ""}`);
179
+ console.log(` by sender: ${Object.entries(r.by_sender).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
180
+ console.log(` new contact: ${r.new_contact.whatsapp_id} "${r.new_contact.full_name}" · AI ${r.new_contact.bot_status ? "on" : "OFF"} · no broadcasts · tags ${r.new_contact.tags.join(", ")}`);
181
+ if (r.dry_run) { console.log("\nDRY RUN — add --commit to create the seeded contact."); return; }
182
+ console.log(`\n✓ seeded ${r.contact_id} (${r.copied} rows). Open it in the inbox or: flowiq test send ${orgId} "<question>" is NOT routed to it (the agent's test contact is separate); the Coworker reads it from the inbox. Remove: flowiq test unseed ${orgId} ${r.contact_id} --confirm`);
183
+ }
184
+
185
+ // `flowiq test unseed <org> <contact_id> --confirm`
186
+ export async function unseed(orgId, contactId, opts = {}) {
187
+ requireOrg(orgId);
188
+ if (!UUID_RE.test(contactId || "")) { console.error("Error: pass the seeded contact id."); process.exit(1); }
189
+ let r;
190
+ try { r = await http.post("agent-test", { action: "unseed", organization_id: orgId, agent_id: opts.agent, contact_id: contactId, confirm: !!opts.confirm }); }
191
+ catch (e) { console.error(`Unseed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
192
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
193
+ if (r.dry_run) { console.log(`Would delete ${r.whatsapp_id} "${r.name}" and its ${r.messages} message(s)${r.seeded_from ? ` (seeded from ${r.seeded_from})` : ""}. Add --confirm.`); return; }
194
+ console.log(`✓ deleted the seeded contact and ${r.messages} message(s).`);
195
+ }
196
+
156
197
  export async function clear(orgId, opts = {}) {
157
198
  requireOrg(orgId);
158
199
  let resp;
@@ -32,6 +32,37 @@ export async function list(orgId, opts = {}) {
32
32
  console.log("\n★ = active agent. Pass any id as --agent <id> to prompts/questionnaires/fine-tuning/agent config.");
33
33
  }
34
34
 
35
+ // `flowiq agent flags [--tool product_lookup] [--model x] [--all] [--json]` —
36
+ // every active org's active agent: model, reasoning, prompt switch and the
37
+ // tool gates, one row per org. Filters narrow to the orgs that matter.
38
+ export async function flags(opts = {}) {
39
+ let resp;
40
+ try { resp = await http.get("agents", { census: "flags", all: opts.all ? "1" : undefined }); }
41
+ catch (e) { console.error(`Census failed: ${e.message}`); process.exit(1); }
42
+ let rows = resp.agents || [];
43
+ if (opts.tool) {
44
+ const [k, v] = String(opts.tool).split("=");
45
+ const want = v === undefined ? true : !["false", "0", "off", "no"].includes(v.toLowerCase());
46
+ rows = rows.filter((r) => !r.missing && (r.tools?.[k] === want || (want === false && r.tools?.[k] === undefined)));
47
+ }
48
+ if (opts.model) rows = rows.filter((r) => String(r.model || "").includes(opts.model));
49
+ if (opts.json) { console.log(JSON.stringify({ ...resp, count: rows.length, agents: rows }, null, 2)); return; }
50
+ const pad = (s, n) => String(s ?? "").padEnd(n);
51
+ const short = { product_lookup: "lookup", ticket_tool_status: "tickets", postal_code_tool_status: "postal", restock_tool: "restock", view_cart_tool: "cart", block_tool_status: "block", email_request_tool: "email-req", shopify_products_web_chat: "web-products", woo_order_build: "woo-order", woo_tip_field: "woo-tip", woo_order_note_field: "woo-note", collapse_product_variants: "collapse", silent_option: "silent" };
52
+ console.log(`${rows.length} active agent(s)${opts.tool ? ` with ${opts.tool}` : ""}${opts.model ? ` on model ~${opts.model}` : ""}`);
53
+ console.log("");
54
+ console.log(` ${pad("ORG", 26)} ${pad("AGENT", 18)} ${pad("MODEL", 20)} ${pad("REASON", 7)} ${pad("PROMPT", 9)} ${pad("SW", 3)} TOOLS ON`);
55
+ for (const r of rows) {
56
+ if (r.missing) { console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ⚠ active_whatsapp_agent ${r.agent_id} does not exist`); continue; }
57
+ const on = Object.entries(r.tools || {}).filter(([, v]) => v).map(([k]) => short[k] || k);
58
+ if (r.discount) on.push("discount");
59
+ if (r.custom_tools) on.push(`${r.custom_tools} custom`);
60
+ if (r.disabled_base_tools?.length) on.push(`-${r.disabled_base_tools.join(",-")}`);
61
+ console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ${pad(String(r.agent_name || "").slice(0, 18), 18)} ${pad(r.model || "house", 20)} ${pad(r.reasoning_effort || "—", 7)} ${pad(`${r.prompt_chars.toLocaleString()}${r.use_settings_prompt ? "" : " OFF"}`, 9)} ${pad(r.master_switch ? "on" : "OFF", 3)} ${on.join(" ") || "—"}`);
62
+ }
63
+ console.log(`\n SW = agent master switch · PROMPT = chars (OFF = use_settings_prompt false) · columns: ${(resp.tool_columns || []).map((k) => `${short[k] || k}=${k}`).join(", ")}`);
64
+ }
65
+
35
66
  export async function create(orgId, opts = {}) {
36
67
  if (!UUID_RE.test(orgId)) {
37
68
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -1129,6 +1129,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
1129
1129
  try {
1130
1130
  const dry = await http.post("broadcast", reqBody(true));
1131
1131
  pyTotal = dry.total_contacts_found ?? null;
1132
+ printPacing(dry.pacing, pyTotal);
1132
1133
  } catch (e) {
1133
1134
  console.error(`Python dry-run failed after tagging: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
1134
1135
  console.error(`Nothing sent. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the fan-off.`);
@@ -1391,6 +1392,14 @@ function reportRoutingResult(orgId, rr, broadcastId) {
1391
1392
  }
1392
1393
  }
1393
1394
 
1395
+ // Coexistence numbers (shared with the WhatsApp Business app) are sent one at a time
1396
+ // at a fixed pace by python (flowiq gaps #196); the server says so on the dry run.
1397
+ export function printPacing(pacing, total) {
1398
+ if (!pacing?.coexistence) return;
1399
+ const time = pacing.estimated_minutes != null ? `, about ${pacing.estimated_minutes} min for ${total} contact(s)` : "";
1400
+ console.log(`🐢 Coexistence number: sent one at a time at ${pacing.per_second} a second${time}. Meta's 130429 throttles are retried, not dropped.`);
1401
+ }
1402
+
1394
1403
  async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
1395
1404
  const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
1396
1405
  const reqBody = (dryRun) => ({
@@ -1412,6 +1421,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
1412
1421
  console.log("");
1413
1422
  console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
1414
1423
  console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
1424
+ printPacing(dry.pacing, total);
1415
1425
  const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
1416
1426
  renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
1417
1427
  printCarouselPlan(cardOverrides, cardLines);
@@ -1815,6 +1825,15 @@ export async function scheduledList(orgId, opts = {}) {
1815
1825
  console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1816
1826
  if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1817
1827
  if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
1828
+ if (opts.detail) {
1829
+ const bp = s.body_parameters && Object.keys(s.body_parameters).length ? s.body_parameters : null;
1830
+ const bt = s.button_parameters && Object.keys(s.button_parameters).length ? s.button_parameters : null;
1831
+ console.log(` body: ${bp ? Object.entries(bp).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") : "(no body values)"}`);
1832
+ if (bt) console.log(` buttons: ${Object.entries(bt).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ")}`);
1833
+ if (s.header_media) console.log(` header: ${typeof s.header_media === "string" ? s.header_media : JSON.stringify(s.header_media)}`);
1834
+ if (s.card_overrides != null) console.log(` cards: ${s.card_overrides} card override(s)`);
1835
+ if (!bp && !bt && !s.header_media && s.engine !== "python") console.log(` (a node-engine row carries its values inside the request; --json shows them)`);
1836
+ }
1818
1837
  if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1819
1838
  if (s.error_message) console.log(` error: ${s.error_message}`);
1820
1839
  }
@@ -47,8 +47,49 @@ export async function find(orgId, opts = {}) {
47
47
 
48
48
  export async function show(contactId, opts = {}) {
49
49
  if (!UUID_RE.test(contactId || "")) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
50
- if (!UUID_RE.test(opts.org || "")) { console.error("Error: --org <organization_id> is required (a contact id alone is not scoped)."); process.exit(1); }
51
- return find(opts.org, { id: contactId, json: opts.json });
50
+ if (opts.org && !UUID_RE.test(opts.org)) { console.error(`Error: "${opts.org}" is not a valid organization UUID.`); process.exit(1); }
51
+ if (opts.org) return find(opts.org, { id: contactId, json: opts.json });
52
+ // No --org: the server resolves the contact's own org (one indexed read).
53
+ let resp;
54
+ try { resp = await http.get("contacts", { find: "1", contact_id: contactId }); } catch (e) { fail("Lookup failed", e); }
55
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
56
+ if (!resp.contacts.length) { console.log(`No contact ${contactId}.`); return; }
57
+ console.log(`${resp.organization_name} (${resp.organization_id}):\n`);
58
+ for (const c of resp.contacts) { printContact(c); console.log(""); }
59
+ }
60
+
61
+ // `flowiq contacts ai off|on <org> --tag <tag> | --contact <id> | --ids-file f [--reason] [--commit]`
62
+ export async function ai(state, orgId, opts = {}) {
63
+ requireOrg(orgId);
64
+ const s = String(state || "").toLowerCase();
65
+ if (!["on", "off"].includes(s)) { console.error('Error: flowiq contacts ai off|on <org> --tag <tag> (or --contact / --ids-file)'); process.exit(1); }
66
+ if (!opts.tag && !opts.contact?.length && !opts.idsFile) { console.error("Error: pass --tag <tag>, --contact <id> (repeatable) or --ids-file <file>."); process.exit(1); }
67
+ if (opts.tag && (opts.contact?.length || opts.idsFile)) { console.error("Error: --tag or an id list, not both."); process.exit(1); }
68
+ const ids = opts.tag ? undefined : await readIds(opts);
69
+ let resp;
70
+ try { resp = await http.post("contacts", { action: "ai", organization_id: orgId, enabled: s === "on", tag: opts.tag, contact_ids: ids, reason: opts.reason, dry_run: !opts.commit }); }
71
+ catch (e) { fail("AI switch failed", e); }
72
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
73
+ console.log(`${resp.organization_name}: ${resp.matched} contact(s)${resp.tag ? ` on tag ${resp.tag}` : ""} · ${resp.to_change} to switch ${s.toUpperCase()} · ${resp.already} already ${s}${resp.not_found ? ` · ${resp.not_found} not in this org` : ""}`);
74
+ for (const c of resp.sample) console.log(` ${pad(c.whatsapp_id ?? "—", 16)} ${c.name ?? ""} ${c.id}`);
75
+ if (resp.to_change > resp.sample.length) console.log(` … ${resp.to_change - resp.sample.length} more`);
76
+ if (resp.caveat) console.log(`\n⚠ ${resp.caveat}`);
77
+ if (resp.dry_run) { console.log(`\nDRY RUN — nothing changed. Add --commit to set bot_status=${s === "on"} and write the agent-${s === "on" ? "on" : "off"} row on each chat.`); return; }
78
+ console.log(`\n✓ AI ${s.toUpperCase()} for ${resp.changed} contact(s).${resp.tag ? ` Undo: flowiq contacts ai ${s === "on" ? "off" : "on"} ${orgId} --tag ${resp.tag} --commit` : ""}`);
79
+ }
80
+
81
+ // `flowiq contacts add <org> --whatsapp-id <n> [--name] [--email] [--no-broadcast] [--tag a,b] [--ai-off] [--commit]`
82
+ export async function add(orgId, opts = {}) {
83
+ requireOrg(orgId);
84
+ if (!opts.whatsappId) { console.error("Error: --whatsapp-id <digits> is required (country code first, e.g. 27637186304)."); process.exit(1); }
85
+ let resp;
86
+ try { resp = await http.post("contacts", { action: "add", organization_id: orgId, whatsapp_id: opts.whatsappId, full_name: opts.name, email: opts.email, allow_broadcast: opts.broadcast !== false, tags: opts.tag ? String(opts.tag).split(",").map((t) => t.trim()).filter(Boolean) : [], bot_status: opts.aiOff ? false : true, dry_run: !opts.commit }); }
87
+ catch (e) { fail("Add failed", e); }
88
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
89
+ const r = resp.would_insert ?? resp.inserted;
90
+ console.log(`${resp.organization_name}: ${r.whatsapp_id} ${r.full_name ?? "(no name)"}${r.email ? ` · ${r.email}` : ""} · broadcasts ${r.allow_broadcast ? "yes" : "NO"} · AI ${r.bot_status ? "on" : "off"}${r.tags?.length ? ` · tags ${r.tags.join(", ")}` : ""}`);
91
+ if (resp.dry_run) { console.log("DRY RUN — add --commit to create the contact."); return; }
92
+ console.log(`✓ created ${resp.contact_id}. Show: flowiq contacts show ${resp.contact_id}`);
52
93
  }
53
94
 
54
95
  export async function census(orgId, opts = {}) {