@flowapt/flowiq-cli 0.6.3 → 0.6.6
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 +52 -2
- package/TEAM-GUIDE.md +18 -4
- package/package.json +1 -1
- package/src/commands/agent-config.js +14 -2
- package/src/commands/report.js +163 -0
- package/src/commands/segments.js +30 -3
- package/src/index.js +55 -0
package/README.md
CHANGED
|
@@ -187,8 +187,8 @@ flowiq ct list
|
|
|
187
187
|
|
|
188
188
|
- **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
|
|
189
189
|
- **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
|
|
190
|
-
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`
|
|
191
|
-
- **Warnings (non-blocking):**
|
|
190
|
+
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates or built-in collisions), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, scalar headers, object-typed `parameters`/`injected_parameters`, valid auth/channels, timeout and response caps. Recursive schemas support strict nested objects, typed arrays, enums and numeric/string/item limits. Unknown tool/schema keys are rejected before anything writes.
|
|
191
|
+
- **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, and `openai_api_key`.
|
|
192
192
|
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
193
193
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
194
194
|
|
|
@@ -485,6 +485,14 @@ flowiq seg untag <org_id> repeat-60d --commit --confirm # ROLLBACK (its own ta
|
|
|
485
485
|
shuffle reproducible, `--ordered` keeps server order. `--from-attribute
|
|
486
486
|
allow_broadcast_true --split 3` is the "whole broadcast list → 3 balanced
|
|
487
487
|
tagged cohorts" one-liner — no id file, no database access.
|
|
488
|
+
- **`--start-index N` continues an existing batch series** (v0.6.4, 3 Sep 2026).
|
|
489
|
+
Batch tags are always numbered from `01`, so a campaign already holding
|
|
490
|
+
`f500-batch-01…12` could not get its 13th batch from the CLI — the plan would
|
|
491
|
+
have produced `f500-batch-01` again and, because apply is append-only, quietly
|
|
492
|
+
MERGED a new cohort into the oldest one. `--start-index 13` names the batch
|
|
493
|
+
`f500-batch-13`. `plan` now also **refuses outright** when any batch tag it
|
|
494
|
+
would create already holds contacts, printing the correct `--start-index` to
|
|
495
|
+
use; `--allow-existing-tag` overrides when the merge is deliberate.
|
|
488
496
|
- Apply is **append-only** — it never touches a contact's other tags, names,
|
|
489
497
|
or anything else, and never double-adds.
|
|
490
498
|
- **Large cohorts are chunked server-side** (fixed 3 Aug 2026): the tagging RPC
|
|
@@ -943,6 +951,37 @@ flowiq hours summary # every org with logged work this
|
|
|
943
951
|
- Corrections (delete/edit) live in the Changelog → Client hours page, not the
|
|
944
952
|
CLI. Every `log` is audited.
|
|
945
953
|
|
|
954
|
+
### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
|
|
955
|
+
|
|
956
|
+
The monthly 10-slide client deck (`/reporting/deck` in the app), driven from the
|
|
957
|
+
terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
|
|
958
|
+
function: the metrics are built from the FROZEN month snapshot, the copy is
|
|
959
|
+
written by the model under the report rules and a numeric guard (every figure
|
|
960
|
+
in the copy must exist in the data), approval needs the required inputs, and
|
|
961
|
+
`send` renders the approved slides with headless Chrome and emails the PDF
|
|
962
|
+
from flowiq@flowapt.com.
|
|
963
|
+
|
|
964
|
+
```bash
|
|
965
|
+
flowiq report deck status <org_id> 2026-08 # what exists, workflow status, what blocks approval
|
|
966
|
+
flowiq report deck generate <org_id> 2026-08 # build the metrics (if missing) + write the copy
|
|
967
|
+
flowiq report deck generate <org_id> 2026-08 --refresh --force # rebuild everything
|
|
968
|
+
flowiq report deck build <org_id> 2026-08 --refresh # metrics only
|
|
969
|
+
flowiq report deck narrate <org_id> 2026-08 # copy only (hand edits are kept)
|
|
970
|
+
flowiq report deck inputs <org_id> 2026-08 --file inputs.json # the super-admin input form
|
|
971
|
+
flowiq report deck approve <org_id> 2026-08 # refused until every required input is present
|
|
972
|
+
flowiq report deck send <org_id> 2026-08 --test-to me@flowapt.com # one test copy, status untouched
|
|
973
|
+
flowiq report deck send <org_id> 2026-08 --client # approved deck → configured client recipients
|
|
974
|
+
flowiq report deck print <org_id> 2026-08 # 15-minute print URL (what Chrome renders)
|
|
975
|
+
flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/reports/<slug>-2026-08.json
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
- Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
|
|
979
|
+
Comparisons need the prior month's deck, which `build` creates on the fly.
|
|
980
|
+
- `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
|
|
981
|
+
Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
|
|
982
|
+
- `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
|
|
983
|
+
- Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
|
|
984
|
+
|
|
946
985
|
### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
|
|
947
986
|
|
|
948
987
|
Read an org's live templates straight from Meta (read-only), and submit new
|
|
@@ -1188,6 +1227,8 @@ flowiq agent config <organization_id> --use-settings-prompt --model gpt-5.6-luna
|
|
|
1188
1227
|
--rename Zara --tool woo_order_build=true --tool view_cart_tool=true --discount true
|
|
1189
1228
|
flowiq agent config <organization_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"
|
|
1190
1229
|
flowiq agent config <organization_id> --model gpt-5.6-luna --reasoning-effort high
|
|
1230
|
+
flowiq agent config <organization_id> --disable-base-tool get_product_info
|
|
1231
|
+
flowiq agent config <organization_id> --enable-base-tool get_product_info
|
|
1191
1232
|
```
|
|
1192
1233
|
|
|
1193
1234
|
Settable: `settings.use_settings_prompt`, `settings.model`,
|
|
@@ -1201,6 +1242,10 @@ the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field
|
|
|
1201
1242
|
(`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
|
|
1202
1243
|
rejected; every change is reported before → after.
|
|
1203
1244
|
|
|
1245
|
+
`--disable-base-tool` / `--enable-base-tool` can be repeated. They manage only the
|
|
1246
|
+
allowlisted discovery/commerce built-ins; opt-out, human handover and channel-send
|
|
1247
|
+
tools cannot be disabled.
|
|
1248
|
+
|
|
1204
1249
|
**`--tool product_lookup=true` (added 11 Aug 2026).** Turns on the `product_lookup`
|
|
1205
1250
|
tool: a typo-tolerant **pg_trgm fuzzy match on `product_title`** (plus badge/tag
|
|
1206
1251
|
search), as opposed to `get_product_info`, which is **semantic**. This matters far
|
|
@@ -1339,3 +1384,8 @@ interactive terminal. Silence it with `FLOWIQ_NO_UPDATE_CHECK=1`.
|
|
|
1339
1384
|
|
|
1340
1385
|
UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
|
|
1341
1386
|
key issued by a FlowIQ super-admin.
|
|
1387
|
+
|
|
1388
|
+
ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
|
|
1389
|
+
Enable its gateway before adding it to the agent tools. Cart retries use
|
|
1390
|
+
`{{operation_idempotency_key}}`; configure `timeout_ms` up to 30000 and
|
|
1391
|
+
`max_response_bytes` up to 1048576 for bounded batch results.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -98,6 +98,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
98
98
|
| See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` — a new agent arrives ready to work (gpt-5.6-luna, high reasoning, prompt switched ON); no follow-up `agent config` needed |
|
|
99
99
|
| Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
|
|
100
100
|
| Set the house model + reasoning tier | `flowiq agent config <org_id> --model gpt-5.6-luna --reasoning-effort high` |
|
|
101
|
+
| Remove a conflicting built-in tool from one agent | `flowiq agent config <org_id> --disable-base-tool get_product_info` (repeatable; safety/delivery tools cannot be disabled) |
|
|
101
102
|
| Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
|
|
102
103
|
| Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
|
|
103
104
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
@@ -114,6 +115,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
114
115
|
| Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
|
|
115
116
|
| Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
|
|
116
117
|
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
118
|
+
| Send the NEXT batch of a campaign that already has batches 01-12 | `flowiq seg plan <org_id> --tag-prefix f500 --ids-file next100.txt --batch-size 100 --start-index 13` → `flowiq seg apply <org_id> f500 --commit` (makes `f500-batch-13`; without `--start-index` the plan is REFUSED, because it would re-use `f500-batch-01` and merge the two cohorts) |
|
|
117
119
|
| Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
|
|
118
120
|
| Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time). **Works with --csv too (v0.6.1):** rows import+tag immediately, the send queues server-side, and it shows on the dashboard's Scheduled sends page. Nothing depends on your laptop being on at send time. |
|
|
119
121
|
| See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
|
|
@@ -141,6 +143,10 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
141
143
|
| Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
|
|
142
144
|
| Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
|
|
143
145
|
| Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
|
|
146
|
+
| Generate a client's monthly deck (metrics + copy) after the month has frozen | `flowiq report deck generate <org_id> 2026-08` — then review it at /reporting/deck |
|
|
147
|
+
| See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
|
|
148
|
+
| Send yourself a test copy of a client deck (PDF from flowiq@flowapt.com) | `flowiq report deck send <org_id> 2026-08 --test-to you@flowapt.com` — status untouched |
|
|
149
|
+
| Approve a client deck / send it to the client | `flowiq report deck approve <org_id> 2026-08` then `… send … --client` (or let the 09:00 SAST schedule send it on the 5th) |
|
|
144
150
|
| Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
|
|
145
151
|
|
|
146
152
|
The flow is the same everywhere: **pull → edit the JSON → push**. Slugs are the
|
|
@@ -174,12 +180,15 @@ flowiq ct push <slug>
|
|
|
174
180
|
```
|
|
175
181
|
|
|
176
182
|
Custom tools define real HTTP calls the agent can execute, so the server
|
|
177
|
-
validates
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
validates names, URLs, methods and parameter shapes. Unknown keys and unresolved
|
|
184
|
+
`{{placeholders}}` block the write; an
|
|
185
|
+
object with no required fields remains a warning because it can be intentional.
|
|
186
|
+
Nested object/array schemas are strict and supported. For ChatCart retailer tools, use
|
|
180
187
|
`{{retailer_tools_internal_key}}` only as the `x-api-key` auth value on the
|
|
181
188
|
trusted `express.chatcart.io/retailer-tools/*` gateway; org/contact identity is
|
|
182
|
-
injected server-side and mutations use `{{
|
|
189
|
+
injected server-side and mutations use `{{operation_idempotency_key}}` for stable,
|
|
190
|
+
operation-scoped idempotency. Batch independent product requests in one
|
|
191
|
+
`search_retailer_products.queries` array (1–12) instead of repeated search calls.
|
|
183
192
|
|
|
184
193
|
### Example: tag a segment of contacts (Advanced Tagging)
|
|
185
194
|
|
|
@@ -320,3 +329,8 @@ Three things worth knowing:
|
|
|
320
329
|
| "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
|
|
321
330
|
| Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
|
|
322
331
|
| Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
|
|
332
|
+
|
|
333
|
+
ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
|
|
334
|
+
Enable its gateway before adding it to the agent tools. Cart retries use
|
|
335
|
+
`{{operation_idempotency_key}}`; configure `timeout_ms` up to 30000 and
|
|
336
|
+
`max_response_bytes` up to 1048576 for bounded batch results.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
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": {
|
|
@@ -23,6 +23,12 @@ export function collectTool(val, acc) {
|
|
|
23
23
|
return acc;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export function collectToolName(val, acc) {
|
|
27
|
+
acc = acc || [];
|
|
28
|
+
acc.push(val);
|
|
29
|
+
return acc;
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
function printSnapshot(title, snap) {
|
|
27
33
|
console.log(` ${title}:`);
|
|
28
34
|
for (const [k, v] of Object.entries(snap)) {
|
|
@@ -47,6 +53,12 @@ export async function config(orgId, opts = {}) {
|
|
|
47
53
|
if (Object.keys(settings).length) body.settings = settings;
|
|
48
54
|
if (opts.rename !== undefined) body.name = opts.rename;
|
|
49
55
|
if (opts.discount !== undefined) body.discount_enabled = parseBool(opts.discount, "--discount");
|
|
56
|
+
if (opts.disableBaseTool?.length || opts.enableBaseTool?.length) {
|
|
57
|
+
body.base_tool_changes = {
|
|
58
|
+
disable: opts.disableBaseTool || [],
|
|
59
|
+
enable: opts.enableBaseTool || [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
50
62
|
|
|
51
63
|
if (opts.tool && opts.tool.length) {
|
|
52
64
|
const flags = {};
|
|
@@ -63,7 +75,7 @@ export async function config(orgId, opts = {}) {
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
const hasWrite =
|
|
66
|
-
body.settings || body.name !== undefined || body.tool_flags || body.discount_enabled !== undefined;
|
|
78
|
+
body.settings || body.name !== undefined || body.tool_flags || body.base_tool_changes || body.discount_enabled !== undefined;
|
|
67
79
|
|
|
68
80
|
// No write flags → just show current config.
|
|
69
81
|
if (!hasWrite) {
|
|
@@ -76,7 +88,7 @@ export async function config(orgId, opts = {}) {
|
|
|
76
88
|
}
|
|
77
89
|
console.log(`${resp.organization_name} → agent ${resp.agent_id}${resp.is_active === false ? " [NON-active]" : ""}`);
|
|
78
90
|
printSnapshot("current", resp.current);
|
|
79
|
-
console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --discount / --test-contact-number / --test-contact-name to change)");
|
|
91
|
+
console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --disable-base-tool / --enable-base-tool / --discount / --test-contact-number / --test-contact-name to change)");
|
|
80
92
|
return;
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `flowiq report deck <verb> <org_id> <YYYY-MM>` — the monthly client deck.
|
|
2
|
+
// status what exists for the month + workflow status + what blocks approval
|
|
3
|
+
// build rebuild the deck metrics from the frozen month (--refresh)
|
|
4
|
+
// narrate rewrite the copy (always forces a fresh write)
|
|
5
|
+
// generate build (if missing or --refresh) + write the copy
|
|
6
|
+
// approve / unapprove
|
|
7
|
+
// send --test-to me@x → one test copy, status untouched; without it the
|
|
8
|
+
// deck must be approved and goes to the org's configured recipients
|
|
9
|
+
// inputs push a JSON file of inputs (the §3.2 form) — see README
|
|
10
|
+
// print mint a 15-minute print URL (the page headless Chrome renders)
|
|
11
|
+
// pull write ./.flowiq/reports/<slug>-<month>.json (doc + deck + copy + inputs)
|
|
12
|
+
// All the work happens server-side (/cli/report → the flowiq-reporting-deck
|
|
13
|
+
// edge fn); every mutating verb is audited.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { http } from "../http.js";
|
|
18
|
+
|
|
19
|
+
const REPORT_DIR = path.resolve(process.cwd(), ".flowiq", "reports");
|
|
20
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
const MONTH_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
22
|
+
|
|
23
|
+
function slugify(name, fallback) {
|
|
24
|
+
const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
25
|
+
return s || fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireArgs(orgId, month) {
|
|
29
|
+
if (!UUID_RE.test(orgId || "")) {
|
|
30
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <search>\`).`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (!MONTH_RE.test(month || "")) {
|
|
34
|
+
console.error(`Error: month must be YYYY-MM (got "${month}").`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function printStatus(r) {
|
|
40
|
+
const s = r.status || (r.deck_available ? "draft" : "no deck");
|
|
41
|
+
console.log(`${r.organization?.name ?? r.organization?.id} — ${r.month}`);
|
|
42
|
+
console.log(` snapshot: ${r.snapshot ? (r.frozen ? "frozen" : "not frozen") : "MISSING (months freeze on the 1st)"}`);
|
|
43
|
+
console.log(` metrics: ${r.deck_available ? `built ${r.deck_computed_at}` : "not built"}${r.deck_available && !r.prior_available ? " (no prior month deck, comparisons limited)" : ""}`);
|
|
44
|
+
console.log(` copy: ${r.narrative_available ? `written ${r.narrative_generated_at}` : "not written"}`);
|
|
45
|
+
console.log(` status: ${s}${r.approved_at ? ` (approved ${r.approved_at}${r.approved_by ? ` by ${r.approved_by}` : ""})` : ""}${r.sent_at ? ` · sent ${r.sent_at}` : ""}`);
|
|
46
|
+
if (Array.isArray(r.missing) && r.missing.length && s !== "sent") console.log(` needed: ${r.missing.join(" · ")}`);
|
|
47
|
+
console.log(` client recipients: ${Array.isArray(r.recipients) && r.recipients.length ? r.recipients.join(", ") : "none configured (Control center → Report delivery)"}`);
|
|
48
|
+
console.log(` from flowiq@flowapt.com · reply-to ${r.reply_to ?? "matt@flowapt.com"} · signed ${r.account_owner ?? "Matt Cronson"} · schedule ${r.deck_enabled ? "ON" : "off"}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function status(orgId, month, opts) {
|
|
52
|
+
requireArgs(orgId, month);
|
|
53
|
+
let r;
|
|
54
|
+
try { r = await http.get("report", { organization_id: orgId, month }); }
|
|
55
|
+
catch (e) { console.error(`Status failed: ${e.message}`); process.exit(1); }
|
|
56
|
+
if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
57
|
+
printStatus(r);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function post(action, orgId, month, extra, label) {
|
|
61
|
+
let r;
|
|
62
|
+
try { r = await http.post("report", { organization_id: orgId, month, action, ...extra }); }
|
|
63
|
+
catch (e) { console.error(`${label} failed: ${e.message}`); process.exit(1); }
|
|
64
|
+
return r;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function build(orgId, month, opts) {
|
|
68
|
+
requireArgs(orgId, month);
|
|
69
|
+
const r = await post("build", orgId, month, { refresh: !!opts.refresh }, "Build");
|
|
70
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
71
|
+
console.log(r.built ? `Built the ${month} deck metrics for ${r.organization.name} (${r.broadcasts} broadcast${r.broadcasts === 1 ? "" : "s"} read${r.timings ? `, ${Object.values(r.timings).reduce((a, b) => a + b, 0)} ms` : ""})` : `Metrics already built for ${month}; pass --refresh to rebuild.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function narrate(orgId, month, opts) {
|
|
75
|
+
requireArgs(orgId, month);
|
|
76
|
+
const r = await post("narrate", orgId, month, {}, "Narrate");
|
|
77
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
78
|
+
reportCopy(r, month);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function generate(orgId, month, opts) {
|
|
82
|
+
requireArgs(orgId, month);
|
|
83
|
+
const r = await post("generate", orgId, month, { refresh: !!opts.refresh, force: !!opts.force }, "Generate");
|
|
84
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
85
|
+
console.log(`${r.organization.name} — ${month}: metrics ${r.built ? "built" : "kept"}, copy ${r.generated ? `written by ${r.model}` : "kept"}`);
|
|
86
|
+
reportCopy(r, month);
|
|
87
|
+
if (r.status) console.log(` status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reportCopy(r, month) {
|
|
91
|
+
if (r.guard && r.guard.passed === false) {
|
|
92
|
+
const n = Object.keys(r.guard.offenders || {}).length;
|
|
93
|
+
console.log(` ⚠ numeric guard: ${n} field${n === 1 ? "" : "s"} carry figures that are not in the data — flagged on the slides: ${Object.keys(r.guard.offenders).join(", ")}`);
|
|
94
|
+
} else if (r.guard) console.log(" numeric guard: passed (every figure in the copy exists in the data)");
|
|
95
|
+
if (Array.isArray(r.length_issues) && r.length_issues.length) console.log(` trimmed to limit: ${r.length_issues.map((i) => `${i.path} (${i.length}→${i.limit})`).join(", ")}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function approve(orgId, month, opts) {
|
|
99
|
+
requireArgs(orgId, month);
|
|
100
|
+
const r = await post("approve", orgId, month, {}, "Approve");
|
|
101
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
102
|
+
console.log(`Approved the ${month} deck for ${r.organization.name}. It goes to the client recipients at 09:00 SAST on the 5th (or the next morning if the 5th has passed).`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function unapprove(orgId, month, opts) {
|
|
106
|
+
requireArgs(orgId, month);
|
|
107
|
+
const r = await post("unapprove", orgId, month, {}, "Unapprove");
|
|
108
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
109
|
+
console.log(`Unapproved. Status is now ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : ""}.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function send(orgId, month, opts) {
|
|
113
|
+
requireArgs(orgId, month);
|
|
114
|
+
if (!opts.testTo && !opts.client) {
|
|
115
|
+
console.error("Error: pass --test-to <email> for a test copy, or --client to send to the org's configured recipients (needs an approved deck).");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
if (opts.testTo && opts.client) {
|
|
119
|
+
console.error("Error: --test-to and --client are alternatives.");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
const r = await post("send", orgId, month, opts.testTo ? { test_to: opts.testTo } : {}, "Send");
|
|
123
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
124
|
+
const ok = (r.results || []).filter((x) => x.status === "sent").map((x) => x.to);
|
|
125
|
+
const failed = (r.results || []).filter((x) => x.status !== "sent");
|
|
126
|
+
console.log(`${r.test ? "Test copy" : "Deck"} ${ok.length ? `sent to ${ok.join(", ")}` : "not sent"} — ${r.filename} (${Math.round((r.pdf_bytes || 0) / 1024)} KB, rendered in ${((r.pdf_ms || 0) / 1000).toFixed(1)} s)${r.test ? " · status unchanged" : " · status: sent"}`);
|
|
127
|
+
for (const f of failed) console.log(` ✗ ${f.to}: ${f.error}`);
|
|
128
|
+
if (failed.length) process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function inputs(orgId, month, opts) {
|
|
132
|
+
requireArgs(orgId, month);
|
|
133
|
+
if (!opts.file) { console.error("Error: --file <inputs.json> is required."); process.exit(1); }
|
|
134
|
+
let parsed;
|
|
135
|
+
try { parsed = JSON.parse(await fs.readFile(path.resolve(process.cwd(), opts.file), "utf8")); }
|
|
136
|
+
catch (e) { console.error(`Error: could not read ${opts.file}: ${e.message}`); process.exit(1); }
|
|
137
|
+
const r = await post("save_inputs", orgId, month, { inputs: parsed }, "Save inputs");
|
|
138
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
139
|
+
console.log(`Inputs saved. Status: ${r.status}${r.missing?.length ? ` · needed: ${r.missing.join(" · ")}` : " · ready to approve"}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function print(orgId, month, opts) {
|
|
143
|
+
requireArgs(orgId, month);
|
|
144
|
+
const r = await post("print_url", orgId, month, {}, "Print URL");
|
|
145
|
+
if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
146
|
+
console.log(r.url);
|
|
147
|
+
console.error(`(valid ${Math.round((r.expires_in || 900) / 60)} min; this is the page headless Chrome prints, one 1600×900 page per slide)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function pull(orgId, month, opts) {
|
|
151
|
+
requireArgs(orgId, month);
|
|
152
|
+
let r;
|
|
153
|
+
try { r = await http.get("report", { organization_id: orgId, month, pull: 1 }); }
|
|
154
|
+
catch (e) { console.error(`Pull failed: ${e.message}`); process.exit(1); }
|
|
155
|
+
const slug = slugify(r.organization?.name, orgId.slice(0, 8));
|
|
156
|
+
const file = path.resolve(opts.out || path.join(REPORT_DIR, `${slug}-${month}.json`));
|
|
157
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
158
|
+
await fs.writeFile(file, JSON.stringify(r, null, 2));
|
|
159
|
+
const d = r.doc || {};
|
|
160
|
+
console.log(`Wrote ${path.relative(process.cwd(), file)}`);
|
|
161
|
+
console.log(` ${r.organization?.name} — ${month} · status ${r.workflow?.status ?? "draft"} · deck ${d.deck ? "built" : "not built"} · copy ${r.narrative?.fields ? `written by ${r.narrative.model}` : "not written"}`);
|
|
162
|
+
if (d.revenue?.influenced) console.log(` influenced ${d.revenue.influenced.revenue} · conversations ${d.conversations?.conversations} · sends ${d.deck?.broadcasts?.totals?.sends ?? "—"}`);
|
|
163
|
+
}
|
package/src/commands/segments.js
CHANGED
|
@@ -122,6 +122,13 @@ export async function plan(orgId, opts = {}) {
|
|
|
122
122
|
}
|
|
123
123
|
const splitMode = opts.split !== undefined;
|
|
124
124
|
const batchSize = Number(opts.batchSize ?? 75);
|
|
125
|
+
// --start-index continues an EXISTING batch series (f500-batch-13 after 12
|
|
126
|
+
// manual batches) instead of always restarting at 01 and colliding with it.
|
|
127
|
+
const startIndex = Number(opts.startIndex ?? 1);
|
|
128
|
+
if (!Number.isInteger(startIndex) || startIndex < 1 || startIndex > 999) {
|
|
129
|
+
console.error("Error: --start-index must be an integer 1..999.");
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
125
132
|
if (!splitMode) {
|
|
126
133
|
if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
|
|
127
134
|
if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
|
|
@@ -252,18 +259,35 @@ export async function plan(orgId, opts = {}) {
|
|
|
252
259
|
let idx = 0;
|
|
253
260
|
for (let g = 0; g < splitN; g++) {
|
|
254
261
|
const size = base + (g < rem ? 1 : 0);
|
|
255
|
-
const nn = String(
|
|
262
|
+
const nn = String(startIndex + g).padStart(2, "0");
|
|
256
263
|
batches.push({ tag: `${prefix}-batch-${nn}`, count: size, contact_ids: ids.slice(idx, idx + size) });
|
|
257
264
|
idx += size;
|
|
258
265
|
}
|
|
259
266
|
if (batches[0].count > 500) console.log(`⚠ each cohort is ~${batches[0].count} contacts — a single-cohort send is large (Meta tier risk); pace the sends.`);
|
|
260
267
|
} else {
|
|
261
268
|
for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
|
|
262
|
-
const nn = String(batches.length
|
|
269
|
+
const nn = String(startIndex + batches.length).padStart(2, "0");
|
|
263
270
|
batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
|
|
264
271
|
}
|
|
265
272
|
}
|
|
266
273
|
|
|
274
|
+
// A batch tag that ALREADY holds contacts means this plan would merge a new
|
|
275
|
+
// cohort into a past one (append-only apply = silent corruption of that
|
|
276
|
+
// batch's record). Refuse unless the caller says it is deliberate.
|
|
277
|
+
try {
|
|
278
|
+
const existing = await http.post("segments", { mode: "list", organization_id: orgId, prefix });
|
|
279
|
+
const held = new Map((existing?.tags || []).map((t) => [String(t.tag), Number(t.count) || 0]));
|
|
280
|
+
const clashes = batches.map((b) => b.tag).filter((t) => (held.get(t) || 0) > 0);
|
|
281
|
+
if (clashes.length && !opts.allowExistingTag) {
|
|
282
|
+
console.error(`Error: ${clashes.length} batch tag(s) already hold contacts on this org: ${clashes.slice(0, 5).join(", ")}${clashes.length > 5 ? ` …+${clashes.length - 5}` : ""}.`);
|
|
283
|
+
console.error(" apply is append-only, so this plan would MERGE a new cohort into an existing batch.");
|
|
284
|
+
console.error(` Use --start-index ${Math.max(...[...held.keys()].map((t) => Number(String(t).split("-batch-").pop())).filter(Number.isFinite), 0) + 1} to continue the series, or --allow-existing-tag if the merge is deliberate.`);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
} catch (e) {
|
|
288
|
+
console.log(`⚠ could not check existing batch tags (${e.message}) — verify with \`seg list --prefix ${prefix}\` before apply.`);
|
|
289
|
+
}
|
|
290
|
+
|
|
267
291
|
const slug = slugify(opts.segment || prefix, prefix);
|
|
268
292
|
const planDoc = {
|
|
269
293
|
schema_version: 1,
|
|
@@ -326,7 +350,10 @@ export async function apply(orgId, identifier, opts = {}) {
|
|
|
326
350
|
// V-11: warn when the batch tags already carry members (tag reuse merges counts).
|
|
327
351
|
try {
|
|
328
352
|
const before = await http.post("segments", { mode: "list", organization_id: orgId, prefix: planDoc.tag_prefix });
|
|
329
|
-
|
|
353
|
+
// Only THIS plan's batch tags matter — filtering on the prefix alone warned
|
|
354
|
+
// about every past batch of the campaign and prompted on a clean plan.
|
|
355
|
+
const planTags = new Set(planDoc.batches.map((b) => String(b.tag)));
|
|
356
|
+
const existing = (before.tags || []).filter((t) => Number(t.count) > 0 && planTags.has(String(t.tag)));
|
|
330
357
|
if (existing.length) {
|
|
331
358
|
console.log(`⚠ ${existing.length} of these tags already have members (counts will MERGE):`);
|
|
332
359
|
for (const t of existing.slice(0, 5)) console.log(` ${t.tag}: ${t.count}`);
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import * as flowmodCmd from "./commands/flowmod.js";
|
|
|
16
16
|
import * as groupsCmd from "./commands/groups.js";
|
|
17
17
|
import * as pinboardCmd from "./commands/pinboard.js";
|
|
18
18
|
import * as hoursCmd from "./commands/hours.js";
|
|
19
|
+
import * as reportCmd from "./commands/report.js";
|
|
19
20
|
import * as templatesCmd from "./commands/templates.js";
|
|
20
21
|
import * as orgCmd from "./commands/org.js";
|
|
21
22
|
import * as agentConfigCmd from "./commands/agent-config.js";
|
|
@@ -215,6 +216,56 @@ export function run(argv) {
|
|
|
215
216
|
.option("--json", "raw JSON output")
|
|
216
217
|
.action((opts) => hoursCmd.summary(opts));
|
|
217
218
|
|
|
219
|
+
// report deck — the monthly client deck (build / copy / approve / send)
|
|
220
|
+
const report = program.command("report").description("Client reporting: the monthly 10-slide client deck");
|
|
221
|
+
const deck = report.command("deck").description("The monthly client deck for one org and month (/reporting/deck)");
|
|
222
|
+
deck.command("status <organization_id> <month>")
|
|
223
|
+
.description("What exists for the month, the workflow status and what still blocks approval")
|
|
224
|
+
.option("--json", "raw JSON output")
|
|
225
|
+
.action((orgId, month, opts) => reportCmd.status(orgId, month, opts));
|
|
226
|
+
deck.command("build <organization_id> <month>")
|
|
227
|
+
.description("Build the deck metrics from the frozen month (leaderboard, timing, popup, carts, records, changes)")
|
|
228
|
+
.option("--refresh", "rebuild even if the metrics already exist")
|
|
229
|
+
.option("--json", "raw JSON output")
|
|
230
|
+
.action((orgId, month, opts) => reportCmd.build(orgId, month, opts));
|
|
231
|
+
deck.command("narrate <organization_id> <month>")
|
|
232
|
+
.description("Rewrite the ten slides' copy (LLM, under the report rules + numeric guard); hand edits are kept")
|
|
233
|
+
.option("--json", "raw JSON output")
|
|
234
|
+
.action((orgId, month, opts) => reportCmd.narrate(orgId, month, opts));
|
|
235
|
+
deck.command("generate <organization_id> <month>")
|
|
236
|
+
.description("Build the metrics (if missing, or --refresh) and write the copy")
|
|
237
|
+
.option("--refresh", "rebuild the metrics first")
|
|
238
|
+
.option("--force", "rewrite the copy even if it exists")
|
|
239
|
+
.option("--json", "raw JSON output")
|
|
240
|
+
.action((orgId, month, opts) => reportCmd.generate(orgId, month, opts));
|
|
241
|
+
deck.command("inputs <organization_id> <month>")
|
|
242
|
+
.description("Save the super-admin inputs from a JSON file (changes reviewed, action points, waiting-on-you, recipients confirmed)")
|
|
243
|
+
.option("--file <path>", "inputs JSON file")
|
|
244
|
+
.option("--json", "raw JSON output")
|
|
245
|
+
.action((orgId, month, opts) => reportCmd.inputs(orgId, month, opts));
|
|
246
|
+
deck.command("approve <organization_id> <month>")
|
|
247
|
+
.description("Approve the deck (refused until every required input is present)")
|
|
248
|
+
.option("--json", "raw JSON output")
|
|
249
|
+
.action((orgId, month, opts) => reportCmd.approve(orgId, month, opts));
|
|
250
|
+
deck.command("unapprove <organization_id> <month>")
|
|
251
|
+
.description("Take an approved deck back to needs input / ready")
|
|
252
|
+
.option("--json", "raw JSON output")
|
|
253
|
+
.action((orgId, month, opts) => reportCmd.unapprove(orgId, month, opts));
|
|
254
|
+
deck.command("send <organization_id> <month>")
|
|
255
|
+
.description("Email the PDF from flowiq@flowapt.com: --test-to for one test copy (status untouched), --client for the configured recipients (needs approval)")
|
|
256
|
+
.option("--test-to <email>", "send a test copy to this address only")
|
|
257
|
+
.option("--client", "send to the org's configured client recipients")
|
|
258
|
+
.option("--json", "raw JSON output")
|
|
259
|
+
.action((orgId, month, opts) => reportCmd.send(orgId, month, opts));
|
|
260
|
+
deck.command("print <organization_id> <month>")
|
|
261
|
+
.description("Mint a 15-minute print URL (the page headless Chrome renders to PDF)")
|
|
262
|
+
.option("--json", "raw JSON output")
|
|
263
|
+
.action((orgId, month, opts) => reportCmd.print(orgId, month, opts));
|
|
264
|
+
deck.command("pull <organization_id> <month>")
|
|
265
|
+
.description("Write the month's doc + deck metrics + copy + inputs to ./.flowiq/reports/<slug>-<month>.json")
|
|
266
|
+
.option("--out <path>", "write to this file instead")
|
|
267
|
+
.action((orgId, month, opts) => reportCmd.pull(orgId, month, opts));
|
|
268
|
+
|
|
218
269
|
// templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
|
|
219
270
|
const templates = program.command("templates")
|
|
220
271
|
.alias("tpl")
|
|
@@ -277,6 +328,8 @@ export function run(argv) {
|
|
|
277
328
|
.option("--discount [bool]", "agent.discount.enabled (true if bare)")
|
|
278
329
|
.option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
|
|
279
330
|
.option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
|
|
331
|
+
.option("--disable-base-tool <name>", "hide a disableable built-in tool from this agent (repeatable)", agentConfigCmd.collectToolName, [])
|
|
332
|
+
.option("--enable-base-tool <name>", "restore a previously disabled built-in tool (repeatable)", agentConfigCmd.collectToolName, [])
|
|
280
333
|
.action((orgId, opts) => agentConfigCmd.config(orgId, opts));
|
|
281
334
|
|
|
282
335
|
// agent-updates (pending client change-requests + chat context; pull + resolve)
|
|
@@ -544,6 +597,8 @@ export function run(argv) {
|
|
|
544
597
|
.option("--split <n>", "split the safe cohort into exactly N even cohorts (alternative to --batch-size; shuffles by default for balanced groups)")
|
|
545
598
|
.option("--seed <n>", "with --split: reproducible shuffle seed (omit = random each run)")
|
|
546
599
|
.option("--ordered", "with --split: keep server order instead of shuffling (deterministic, but skews groups by contact age)")
|
|
600
|
+
.option("--start-index <n>", "number the first batch tag from N instead of 01 — continues an existing series (e.g. --start-index 13 → <prefix>-batch-13)", "1")
|
|
601
|
+
.option("--allow-existing-tag", "allow a batch tag that already holds contacts (apply is append-only, so this MERGES cohorts)")
|
|
547
602
|
.option("--segment <name>", "plan file slug (default: the tag prefix)")
|
|
548
603
|
.option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
|
|
549
604
|
.action((orgId, opts) => segmentsCmd.plan(orgId, opts));
|