@flowapt/flowiq-cli 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,32 @@ Notes:
88
88
  single "Main" section from `settings.system_prompt` and returns
89
89
  `bootstrapped: true`. The CLI prints a heads-up before push.
90
90
 
91
+ #### Prompt version history — `flowiq prompts history|restore` (v0.3.8)
92
+
93
+ A push overwrites `prompt_sections` in place, so the previous prompt used to
94
+ be gone for good. Since v0.3.8 every push **snapshots the prompt it replaced**
95
+ into the audit log, making every past version retrievable.
96
+
97
+ ```bash
98
+ flowiq prompts history <organization_id> # every recorded push: when / who / size
99
+ flowiq prompts restore <organization_id> <audit_id> # DRY-RUN (writes a working copy)
100
+ flowiq prompts restore <organization_id> <audit_id> --commit # make it live
101
+ flowiq prompts restore <organization_id> <audit_id> --after --commit
102
+ ```
103
+
104
+ - `history` lists each push as `<prior size> → <new size>` with the audit id
105
+ to feed `restore`. `--agent <id>` scopes to a non-active agent.
106
+ - `restore` **defaults to the version the push REPLACED** (i.e. "undo this
107
+ push"). `--after` restores the version that push made live — use it to jump
108
+ back to a specific older prompt rather than undoing one step.
109
+ - **Dry-run by default.** It always writes
110
+ `.flowiq/prompts/<org>-restore-<audit8>.json` so you can read or edit the
111
+ recovered version first; `--commit` pushes it.
112
+ - A restore goes through the normal push path, so it gets the same validation
113
+ **and its own audit row** — a rollback is never an untracked edit.
114
+ - History starts at the first push after v0.3.8 shipped. Versions overwritten
115
+ before that were never snapshotted and cannot be recovered.
116
+
91
117
  ### Questionnaires — `flowiq questionnaires pull|push <slug>` (alias `q`)
92
118
 
93
119
  Round-trips `agents.questionnaire` JSONB.
@@ -160,7 +186,7 @@ flowiq ct list
160
186
  - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
161
187
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
162
188
 
163
- ### Broadcast — `flowiq broadcast map|preview|send|resume|status|list` (alias `bc`)
189
+ ### Broadcast — `flowiq broadcast map|preview|send|resume|status|retry|list` (alias `bc`)
164
190
 
165
191
  Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
166
192
  template's variables **per row** from the CSV's own columns. The
@@ -198,7 +224,15 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
198
224
  by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
199
225
  `helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
200
226
  returns a `broadcastId` but has no CLI status log), but works for any broadcast
201
- id (dashboard sends included). `flowiq bc status <org> <broadcastId>`.
227
+ id (dashboard sends included). **`--failures` (v0.3.7)** additionally lists each
228
+ failed recipient + the Meta error reason (`error_code` / `error_title`) with a
229
+ by-reason rollup — makes a fire-and-forget send fully auditable.
230
+ - **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
231
+ failed recipients** — reconstructs the send from the `broadcasts` row (template +
232
+ params + header) and re-fires via the python engine, creating a NEW broadcast.
233
+ DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
234
+ Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
235
+ retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
202
236
  - **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
203
237
  recipients **always uses the python engine** (a `>2000` tag routes there too).
204
238
  Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
@@ -242,8 +276,12 @@ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
242
276
  ```
243
277
 
244
278
  Values are shared across the tag (use `{{first_name}}` etc. for per-contact
245
- personalization — resolved server-side). Opted-out / archived / blocked
246
- contacts under the tag are excluded automatically. Tags with >2000 contacts
279
+ personalization — resolved server-side). Supported per-contact tokens:
280
+ `{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
281
+ `{{whatsapp_id}}` `{{contact_id}}`, plus `{{attributes.<key>}}` for any
282
+ top-level key of the contact's `attributes` JSON (missing / object-valued
283
+ keys render as empty — both engines, since 21 Jul 2026). Opted-out /
284
+ archived / blocked contacts under the tag are excluded automatically. Tags with >2000 contacts
247
285
  are refused — slice them with `flowiq segments` first.
248
286
 
249
287
  ### Segments — `flowiq segments plan|apply|list|untag` (alias `seg`)
@@ -378,6 +416,19 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
378
416
  collapses to 1 at runtime — rejected).
379
417
  - `field:"attributes"` actions are warned (full jsonb replace; constant
380
418
  values only) but applied — this CLI is their only safe editing surface.
419
+ - **Action types accepted** (all seven the runtime implements):
420
+ `send_message` · `update_contact_field` · `add_contact_tag` ·
421
+ `remove_contact_tag` · `set_agent` · `delay` · `combined`.
422
+ Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
423
+ string) or the runtime writes no tag at all; `set_agent.agent_id` must be
424
+ an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
425
+ clearing is easy to do by accident); `delay.ms` is a number ≥ 0 and is
426
+ **clamped to 5000ms** at runtime (Meta's webhook ack window) — a larger
427
+ value is accepted with a warning that the extra wait never happens;
428
+ `combined.actions[]` sub-types are checked and warned, not blocked.
429
+ *(Before v0.3.9 only the first two + `combined` were accepted, so a
430
+ zero-edit pull→push failed for any org using tag / set_agent / delay
431
+ actions.)*
381
432
  - `action_config.link_preview: false` disables WhatsApp's link-preview card
382
433
  on that action's text send (absent/`true` = preview on, the default).
383
434
  Passed through verbatim; also toggleable per action in the dashboard.
@@ -398,6 +449,59 @@ flowiq m pull <contact_id> --count 25
398
449
 
399
450
  `--count` defaults to 25, max 200.
400
451
 
452
+ ### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
453
+
454
+ **Who changed what, when — with the full before/after content.** Every
455
+ mutating CLI action writes a `staff_cli_audit` row server-side: the staff user
456
+ + key, the endpoint + action, the org / agent / target, the CLI version and
457
+ IP, and the complete prior + new state of whatever it changed. It's what makes
458
+ `prompts history` / `restore` possible, and it's the answer to "who pushed
459
+ that?" and "what did this look like last week?".
460
+
461
+ ```bash
462
+ flowiq audit <organization_id> # recent activity on an org
463
+ flowiq audit # across all orgs
464
+ flowiq audit <org> --endpoint prompts --action push # one surface
465
+ flowiq audit <org> --user matt --since 2026-07-01 # one person, one window
466
+ flowiq audit <org> --status blocked # attempts that were REFUSED
467
+ flowiq audit show <audit_id> # one entry in full
468
+ flowiq audit show <audit_id> --content # + the before/after payloads
469
+ flowiq audit show <audit_id> --out entry.json # dump the whole entry
470
+ ```
471
+
472
+ Filters: `--endpoint --action --status --agent --target --user --since --until
473
+ --limit` (default 50, max 200) `--json`.
474
+
475
+ **What's captured, and how much:**
476
+
477
+ | Surface | Action | Content stored |
478
+ |---|---|---|
479
+ | `prompts` / `questionnaires` / `fine-tuning` / `knowledge` / `custom-tools` / `keywords` / `flowmod` | push | **full before + after** |
480
+ | `custom-tools` | enable / disable, **blocked** destructive push | full before + after |
481
+ | `agent-config` | config | before + after snapshot + the changed keys |
482
+ | `agents` / `org` / `meta-templates` | create | the created record / submitted request |
483
+ | `messaging-webhooks` / `webhooks` | push, reconnect | full before + after |
484
+ | `pinboard` | push | full prior row + the new one |
485
+ | `agent-updates` | resolve | prior status + the client-facing note |
486
+ | `broadcast` | **send (committed)**, retry | the exact payload sent + the resulting broadcastId |
487
+ | `segments` | apply / untag | the batch tags + their contact ids |
488
+ | `tag` | field/cohort/segment/attributes/messages/remove | the tag + the exact selector + counts (not per-contact) |
489
+ | `contacts-upsert` | upsert | the upserted rows |
490
+ | `agent-stress` | cleanup | the deleted test contacts |
491
+ | `messages` / `export-chats` | read / export | **metadata only** — who read whose chat, never a second copy of it |
492
+
493
+ Notes:
494
+ - **Dry-runs are not audited** (they change nothing) — but a **blocked**
495
+ destructive push IS, with `status=blocked`.
496
+ - Listings never include content, so they stay small over 95k-char prompts;
497
+ `audit show` fetches it.
498
+ - Payloads over 2MB are stored truncated with a marker rather than dropped —
499
+ an oversized push is never failed by the audit layer.
500
+ - An audit write can never break your action: it's best-effort and swallows
501
+ its own errors.
502
+ - **Read-only, and there is no `audit delete`.** An audit log you can edit
503
+ isn't an audit log.
504
+
401
505
  ### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
402
506
 
403
507
  Round-trips the **inbound** Shopify or WooCommerce platform webhooks (the
package/TEAM-GUIDE.md CHANGED
@@ -77,7 +77,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
77
77
  | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
78
78
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
79
79
  | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
80
- | Edit keyword auto-replies (incl. competition entry keywords) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug>` |
80
+ | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
81
81
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
82
82
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
83
83
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -90,10 +90,15 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
90
90
  | 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) |
91
91
  | 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/…`) |
92
92
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
93
- | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
93
+ | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`) |
94
94
  | Send a broadcast whose template has an IMAGE header | Same as above — the image is automatic (the template's own header image). Override with `--header-media <public-image-url>` if needed. |
95
95
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
96
96
  | Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
97
+ | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
98
+ | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
99
+ | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
100
+ | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
101
+ | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
97
102
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
98
103
  | Export an org's full chat history | `flowiq export chats <org_id>` |
99
104
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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,170 @@
1
+ // `flowiq audit` — read the staff-CLI audit trail.
2
+ //
3
+ // Every mutating CLI action writes a `staff_cli_audit` row server-side
4
+ // (api/cli/_audit.js) carrying who / what / when / where PLUS the full
5
+ // before+after content. This command is the read side:
6
+ //
7
+ // flowiq audit <org> — recent activity on an org
8
+ // flowiq audit --endpoint prompts — filter by surface
9
+ // flowiq audit --user matt --since 2026-07-01
10
+ // flowiq audit show <audit_id> — one entry, full detail
11
+ // flowiq audit show <audit_id> --content — + the before/after payloads
12
+ // flowiq audit show <audit_id> --out f.json — dump the entry to a file
13
+ //
14
+ // Read-only by design: there is no `audit delete`. An audit log you can edit
15
+ // isn't an audit log.
16
+
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { http } from "../http.js";
20
+
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ /** 2026-07-27 14:32 SAST — the log is read by humans in SAST. */
24
+ export function fmtSast(iso) {
25
+ if (!iso) return "—";
26
+ try {
27
+ const d = new Date(iso);
28
+ const s = new Intl.DateTimeFormat("en-ZA", {
29
+ timeZone: "Africa/Johannesburg",
30
+ year: "numeric", month: "2-digit", day: "2-digit",
31
+ hour: "2-digit", minute: "2-digit", hour12: false,
32
+ }).format(d);
33
+ // en-ZA gives "2026/07/27, 14:32"
34
+ return s.replace(/\//g, "-").replace(",", "");
35
+ } catch {
36
+ return iso;
37
+ }
38
+ }
39
+
40
+ function pad(s, n) {
41
+ const str = String(s ?? "");
42
+ return str.length >= n ? str.slice(0, n) : str + " ".repeat(n - str.length);
43
+ }
44
+
45
+ const STATUS_MARK = { ok: " ", blocked: "⛔", failed: "✗" };
46
+
47
+ export async function list(orgId, opts = {}) {
48
+ if (orgId && !UUID_RE.test(orgId)) {
49
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
50
+ process.exit(1);
51
+ }
52
+ let resp;
53
+ try {
54
+ resp = await http.get("audit", {
55
+ organization_id: orgId,
56
+ agent_id: opts.agent,
57
+ endpoint: opts.endpoint,
58
+ action: opts.action,
59
+ status: opts.status,
60
+ target_id: opts.target,
61
+ user: opts.user,
62
+ since: opts.since,
63
+ until: opts.until,
64
+ limit: opts.limit,
65
+ });
66
+ } catch (e) {
67
+ console.error(`Audit query failed: ${e.message}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ if (opts.json) {
72
+ console.log(JSON.stringify(resp, null, 2));
73
+ return;
74
+ }
75
+
76
+ const entries = resp.entries || [];
77
+ if (!entries.length) {
78
+ console.log("No audit entries match those filters.");
79
+ return;
80
+ }
81
+
82
+ console.log("");
83
+ console.log(`${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
+ console.log("");
85
+ console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
86
+ console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
87
+ for (const e of entries) {
88
+ const who = (e.user_email || "—").replace(/@.*$/, "");
89
+ const what = `${e.endpoint} ${e.action}`;
90
+ const mark = STATUS_MARK[e.status] ?? " ";
91
+ console.log(`${mark} ${pad(fmtSast(e.created_at), 17)} ${pad(who, 24)} ${pad(what, 26)} ${e.summary ?? ""}`);
92
+ if (!orgId && e.organization_name) {
93
+ console.log(` ${" ".repeat(17)} ${" ".repeat(24)} ${" ".repeat(26)} └ org: ${e.organization_name}`);
94
+ }
95
+ }
96
+ console.log("");
97
+ console.log(` ids: ${entries.map((e) => e.id.slice(0, 8)).join(" ")}`);
98
+ console.log(` detail: flowiq audit show <audit_id> (full id, or the 8-char prefix won't work — copy from --json)`);
99
+ console.log("");
100
+ }
101
+
102
+ export async function show(auditId, opts = {}) {
103
+ if (!UUID_RE.test(auditId)) {
104
+ console.error(`Error: "${auditId}" is not a valid audit entry id (uuid).`);
105
+ process.exit(1);
106
+ }
107
+ let resp;
108
+ try {
109
+ resp = await http.get("audit", { id: auditId });
110
+ } catch (e) {
111
+ console.error(`Audit lookup failed: ${e.message}`);
112
+ process.exit(1);
113
+ }
114
+ const e = resp.entry;
115
+
116
+ if (opts.out) {
117
+ const outPath = path.resolve(opts.out);
118
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
119
+ await fs.writeFile(outPath, JSON.stringify(e, null, 2) + "\n", "utf8");
120
+ console.log(`Wrote ${outPath}`);
121
+ return;
122
+ }
123
+ if (opts.json) {
124
+ console.log(JSON.stringify(e, null, 2));
125
+ return;
126
+ }
127
+
128
+ console.log("");
129
+ console.log(` ${e.endpoint} ${e.action}${e.status !== "ok" ? ` [${e.status.toUpperCase()}]` : ""}`);
130
+ console.log(` ${e.summary ?? ""}`);
131
+ console.log("");
132
+ console.log(` when: ${fmtSast(e.created_at)} SAST`);
133
+ console.log(` who: ${e.user_email ?? "—"}`);
134
+ if (e.organization_name || e.organization_id) {
135
+ console.log(` org: ${e.organization_name ?? "—"} (${e.organization_id ?? "—"})`);
136
+ }
137
+ if (e.agent_id) console.log(` agent: ${e.agent_name ?? "—"} (${e.agent_id})`);
138
+ if (e.target_id) console.log(` target: ${e.target_id}`);
139
+ if (e.cli_version) console.log(` cli: v${e.cli_version}`);
140
+ if (e.ip) console.log(` ip: ${e.ip}`);
141
+ if (e.error) console.log(` error: ${e.error}`);
142
+ console.log(` id: ${e.id}`);
143
+
144
+ if (e.metadata && Object.keys(e.metadata).length) {
145
+ console.log("");
146
+ console.log(" metadata:");
147
+ for (const line of JSON.stringify(e.metadata, null, 2).split("\n")) console.log(` ${line}`);
148
+ }
149
+
150
+ const beforeChars = e.before_content ? JSON.stringify(e.before_content).length : 0;
151
+ const afterChars = e.after_content ? JSON.stringify(e.after_content).length : 0;
152
+ console.log("");
153
+ console.log(` content: before ${beforeChars.toLocaleString()} chars · after ${afterChars.toLocaleString()} chars`);
154
+
155
+ if (opts.content) {
156
+ if (e.before_content) {
157
+ console.log("");
158
+ console.log(" ── BEFORE ──────────────────────────────────────────────");
159
+ console.log(JSON.stringify(e.before_content, null, 2));
160
+ }
161
+ if (e.after_content) {
162
+ console.log("");
163
+ console.log(" ── AFTER ───────────────────────────────────────────────");
164
+ console.log(JSON.stringify(e.after_content, null, 2));
165
+ }
166
+ } else if (beforeChars || afterChars) {
167
+ console.log(` (use --content to print them, or --out <file>.json to dump the whole entry)`);
168
+ }
169
+ console.log("");
170
+ }
@@ -962,7 +962,7 @@ export async function status(orgId, broadcastId, opts = {}) {
962
962
  if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
963
963
  if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
964
964
  let resp;
965
- try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId }); }
965
+ try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId, include_failures: !!opts.failures }); }
966
966
  catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
967
967
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
968
968
  const b = resp.broadcast, d = resp.delivery;
@@ -976,6 +976,47 @@ export async function status(orgId, broadcastId, opts = {}) {
976
976
  if (b.total_recipients) {
977
977
  console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
978
978
  }
979
+ if (opts.failures && resp.failures) {
980
+ console.log("");
981
+ console.log(` Failures by reason:`);
982
+ for (const [reason, n] of Object.entries(resp.failure_reasons || {}).sort((a, b2) => b2[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
983
+ const cap = opts.limit ? Number(opts.limit) : 40;
984
+ console.log("");
985
+ console.log(` Failed recipients${resp.failures.length > cap ? ` (showing ${cap} of ${resp.failures.length}; --json for all)` : ` (${resp.failures.length})`}:`);
986
+ for (const f of resp.failures.slice(0, cap)) {
987
+ console.log(` ${(f.number || "—").padEnd(14)} ${(f.name || "").slice(0, 22).padEnd(22)} ${f.error_code ?? "?"} ${f.error_title || ""}`);
988
+ }
989
+ }
990
+ }
991
+
992
+ /** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
993
+ * Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
994
+ * shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
995
+ export async function retry(orgId, broadcastId, opts = {}) {
996
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
997
+ if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId to retry)."); process.exit(1); }
998
+ let dry;
999
+ try { dry = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: true }); }
1000
+ catch (e) { console.error(`Retry check failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1001
+ console.log(`Broadcast ${broadcastId} (${dry.broadcast?.template_name ?? "?"}) — ${dry.failed_count} failed recipient(s).`);
1002
+ for (const [reason, n] of Object.entries(dry.failure_reasons || {}).sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
1003
+ if (!dry.failed_count) { console.log("Nothing to retry."); return; }
1004
+ console.log("");
1005
+ console.log("Note: permanent failures (131026 undeliverable / opt-outs) just fail again — retry helps transient (throttle/throughput) failures.");
1006
+ if (!opts.commit) {
1007
+ console.log(`DRY RUN — would re-send to ${dry.failed_count} failed recipient(s) via the python engine. Add --commit to retry.`);
1008
+ return;
1009
+ }
1010
+ if (!opts.yes) {
1011
+ const a = await ask(`Type "retry" to re-send to ${dry.failed_count} failed recipient(s): `);
1012
+ if (a !== "retry") { console.log("Aborted — nothing re-sent."); process.exit(0); }
1013
+ }
1014
+ let out;
1015
+ try { out = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: false }); }
1016
+ catch (e) { console.error(`Retry failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1017
+ console.log("");
1018
+ console.log(`✅ ${out.message || `Retry fired for ${out.retried} recipient(s)`}${out.broadcastId ? ` · new broadcastId ${out.broadcastId}` : ""}`);
1019
+ if (out.broadcastId) console.log(` Watch it: flowiq bc status ${orgId} ${out.broadcastId} --failures`);
979
1020
  }
980
1021
 
981
1022
  export async function list() {
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { http } from "../http.js";
9
+ import { fmtSast } from "./audit.js";
9
10
 
10
11
  const PROMPTS_DIR = path.resolve(process.cwd(), ".flowiq", "prompts");
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;
@@ -125,3 +126,175 @@ export async function push(identifier) {
125
126
  console.log(` content: ${resp.total_chars.toLocaleString()} chars`);
126
127
  console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
127
128
  }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // history / restore — powered by the staff-CLI audit log (staff_cli_audit).
132
+ // Every `prompts push` snapshots the prompt it replaced, so every past version
133
+ // is retrievable even though the agents row itself only holds the latest.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /** `flowiq prompts history <org>` — every recorded push, newest first. */
137
+ export async function history(orgId, opts = {}) {
138
+ if (!UUID_RE.test(orgId)) {
139
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
140
+ process.exit(1);
141
+ }
142
+ let resp;
143
+ try {
144
+ resp = await http.get("audit", {
145
+ organization_id: orgId,
146
+ agent_id: opts.agent,
147
+ endpoint: "prompts",
148
+ action: "push",
149
+ limit: opts.limit,
150
+ });
151
+ } catch (e) {
152
+ console.error(`History lookup failed: ${e.message}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ if (opts.json) {
157
+ console.log(JSON.stringify(resp, null, 2));
158
+ return;
159
+ }
160
+
161
+ const entries = resp.entries || [];
162
+ if (!entries.length) {
163
+ console.log("");
164
+ console.log("No prompt-push history recorded for this org yet.");
165
+ console.log("(History starts from the first push after the audit log shipped —");
166
+ console.log(" earlier versions were never snapshotted and can't be recovered.)");
167
+ console.log("");
168
+ return;
169
+ }
170
+
171
+ console.log("");
172
+ console.log(`Prompt history — ${entries[0].organization_name ?? orgId} (${entries.length} push${entries.length === 1 ? "" : "es"}, newest first)`);
173
+ console.log("");
174
+ for (const e of entries) {
175
+ const m = e.metadata || {};
176
+ const from = `${m.prior_sections ?? "?"} sec / ${(m.prior_total_chars ?? 0).toLocaleString()} chars`;
177
+ const to = `${m.sections ?? "?"} sec / ${(m.total_chars ?? 0).toLocaleString()} chars`;
178
+ console.log(` ${fmtSast(e.created_at)} SAST ${e.user_email ?? "—"}`);
179
+ console.log(` ${from} → ${to}${e.agent_name ? ` [agent: ${e.agent_name}]` : ""}`);
180
+ console.log(` ${e.id}`);
181
+ console.log("");
182
+ }
183
+ console.log(" Restore the prompt as it was BEFORE one of these pushes:");
184
+ console.log(` flowiq prompts restore ${orgId} <id>`);
185
+ console.log(" …or re-install the version a push made live:");
186
+ console.log(` flowiq prompts restore ${orgId} <id> --after`);
187
+ console.log("");
188
+ }
189
+
190
+ /**
191
+ * `flowiq prompts restore <org> <audit_id>` — re-push a past version.
192
+ *
193
+ * Default restores `before_content` ("undo this push"); `--after` restores the
194
+ * version that push made live. The restore itself goes through the ordinary
195
+ * /cli/prompts POST, so it gets the same validation AND its own audit row —
196
+ * a rollback is never an untracked edit.
197
+ */
198
+ export async function restore(orgId, auditId, opts = {}) {
199
+ if (!UUID_RE.test(orgId)) {
200
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
201
+ process.exit(1);
202
+ }
203
+ if (!UUID_RE.test(auditId)) {
204
+ console.error(`Error: "${auditId}" is not a valid audit entry id (get one from \`flowiq prompts history\`).`);
205
+ process.exit(1);
206
+ }
207
+
208
+ let entry;
209
+ try {
210
+ entry = (await http.get("audit", { id: auditId })).entry;
211
+ } catch (e) {
212
+ console.error(`Audit lookup failed: ${e.message}`);
213
+ process.exit(1);
214
+ }
215
+
216
+ if (entry.endpoint !== "prompts") {
217
+ console.error(`Error: audit entry ${auditId} is a "${entry.endpoint} ${entry.action}", not a prompt push.`);
218
+ process.exit(1);
219
+ }
220
+ if (entry.organization_id !== orgId) {
221
+ console.error(`Error: that audit entry belongs to a different org (${entry.organization_name ?? entry.organization_id}).`);
222
+ process.exit(1);
223
+ }
224
+
225
+ const side = opts.after ? "after_content" : "before_content";
226
+ const label = opts.after ? "the version this push MADE LIVE" : "the version this push REPLACED";
227
+ const snapshot = entry[side];
228
+ const sections = snapshot?.prompt_sections;
229
+ if (!Array.isArray(sections) || sections.length === 0) {
230
+ console.error(`Error: that entry has no ${opts.after ? "after" : "before"} prompt to restore.`);
231
+ if (!opts.after) console.error("(If this was the very first recorded push, there was no prior version — try --after on an older entry.)");
232
+ process.exit(1);
233
+ }
234
+ if (snapshot._truncated) {
235
+ console.error("Error: that snapshot was truncated when stored (oversized payload) and can't be restored safely.");
236
+ process.exit(1);
237
+ }
238
+
239
+ const totalChars = sections.reduce((a, s) => a + (s.content?.length || 0), 0);
240
+ console.log("");
241
+ console.log(`Restoring ${label}:`);
242
+ console.log(` from push: ${fmtSast(entry.created_at)} SAST by ${entry.user_email ?? "—"}`);
243
+ console.log(` org: ${entry.organization_name ?? orgId}`);
244
+ console.log(` agent: ${entry.agent_name ?? "—"} (${entry.agent_id})`);
245
+ console.log(` sections: ${sections.length}`);
246
+ console.log(` content: ${totalChars.toLocaleString()} chars`);
247
+ console.log("");
248
+
249
+ // Always write the working copy — even on a dry run, so the restored version
250
+ // can be inspected/edited before it goes live.
251
+ await fs.mkdir(PROMPTS_DIR, { recursive: true });
252
+ const orgSlug = slugify(entry.organization_name, orgId);
253
+ const filePath = path.join(PROMPTS_DIR, `${orgSlug}-restore-${auditId.slice(0, 8)}.json`);
254
+ await fs.writeFile(
255
+ filePath,
256
+ JSON.stringify(
257
+ {
258
+ organization_id: orgId,
259
+ organization_name: entry.organization_name,
260
+ agent_id: entry.agent_id,
261
+ agent_name: entry.agent_name,
262
+ restored_from_audit_id: auditId,
263
+ restored_side: opts.after ? "after" : "before",
264
+ prompt_sections: sections,
265
+ },
266
+ null,
267
+ 2
268
+ ) + "\n",
269
+ "utf8"
270
+ );
271
+ console.log(` working copy: ${filePath}`);
272
+
273
+ if (!opts.commit) {
274
+ console.log("");
275
+ console.log(" DRY RUN — nothing was pushed. Re-run with --commit to make this live:");
276
+ console.log(` flowiq prompts restore ${orgId} ${auditId}${opts.after ? " --after" : ""} --commit`);
277
+ console.log(` …or edit the file above and \`flowiq prompts push ${path.basename(filePath, ".json")}\`.`);
278
+ console.log("");
279
+ return;
280
+ }
281
+
282
+ let resp;
283
+ try {
284
+ resp = await http.post("prompts", {
285
+ organization_id: orgId,
286
+ prompt_sections: sections,
287
+ agent_id: entry.agent_id,
288
+ });
289
+ } catch (e) {
290
+ console.error(`Restore push failed: ${e.message}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ console.log("");
295
+ console.log(` ✓ Restored — ${resp.agent_name} (${resp.agent_id})`);
296
+ console.log(` sections: ${resp.sections}`);
297
+ console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
298
+ console.log(` (this restore is itself recorded in the audit log)`);
299
+ console.log("");
300
+ }
package/src/http.js CHANGED
@@ -3,8 +3,20 @@
3
3
  // - carry Authorization: Bearer <token>
4
4
  // - parse JSON, surfacing { error, message } shape with a readable error.
5
5
 
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import path from "node:path";
6
9
  import { loadConfig } from "./config.js";
7
10
 
11
+ // Sent on every request as X-Flowiq-Cli-Version so the server-side audit log
12
+ // records WHICH CLI version made a change (a stale install is a real source of
13
+ // surprising pushes).
14
+ let CLI_VERSION = "unknown";
15
+ try {
16
+ const dir = path.dirname(fileURLToPath(import.meta.url));
17
+ CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
18
+ } catch { /* version is a nice-to-have; never block a request on it */ }
19
+
8
20
  async function call(method, endpoint, { query, body } = {}) {
9
21
  const cfg = await loadConfig();
10
22
  if (!cfg.token) {
@@ -27,6 +39,7 @@ async function call(method, endpoint, { query, body } = {}) {
27
39
  headers: {
28
40
  Authorization: `Bearer ${cfg.token}`,
29
41
  Accept: "application/json",
42
+ "X-Flowiq-Cli-Version": CLI_VERSION,
30
43
  },
31
44
  };
32
45
  if (body !== undefined) {
@@ -36,8 +49,8 @@ async function call(method, endpoint, { query, body } = {}) {
36
49
 
37
50
  const resp = await fetch(url, init);
38
51
  const text = await resp.text();
39
- let parsed;
40
- try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
52
+ let parsed, isJson = true;
53
+ try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
41
54
  if (!resp.ok) {
42
55
  const err = new Error(
43
56
  parsed.message || parsed.error || `${method} ${endpoint} → HTTP ${resp.status}`
@@ -46,6 +59,28 @@ async function call(method, endpoint, { query, body } = {}) {
46
59
  err.body = parsed;
47
60
  throw err;
48
61
  }
62
+ // A 2xx that is not JSON is never a real API response — it is an HTML page
63
+ // (Vercel SSO / login / error page, or the SPA's index.html served for an
64
+ // endpoint that doesn't exist on that deployment). Returning it let callers
65
+ // "succeed" on garbage: a pull wrote `.flowiq/<topic>/undefined.json` with the
66
+ // HTML stuffed in `_raw`, masking an auth or deploy failure as a good pull.
67
+ if (!isJson) {
68
+ const ctype = resp.headers.get("content-type") || "none";
69
+ const looksHtml = /^\s*<(?:!doctype|html)\b/i.test(text);
70
+ const err = new Error(
71
+ `${method} ${endpoint} → HTTP ${resp.status} returned ${looksHtml ? "an HTML page" : "a non-JSON body"} ` +
72
+ `(content-type: ${ctype}), not an API response.\n` +
73
+ ` URL: ${url}\n` +
74
+ ` Likely: an SSO / login / error page at that host, a wrong api_url, or this endpoint ` +
75
+ `isn't deployed there.\n` +
76
+ ` Check: flowiq auth whoami · api_url in ~/.config/flowiq/auth.json\n` +
77
+ ` Body: ${text.slice(0, 140).replace(/\s+/g, " ").trim()}…`
78
+ );
79
+ err.status = resp.status;
80
+ err.code = "ENONJSON";
81
+ err.body = parsed;
82
+ throw err;
83
+ }
49
84
  return parsed;
50
85
  }
51
86
 
package/src/index.js CHANGED
@@ -29,6 +29,7 @@ import * as segmentsCmd from "./commands/segments.js";
29
29
  import * as tagCmd from "./commands/tag.js";
30
30
  import * as keywordsCmd from "./commands/keywords.js";
31
31
  import * as guideCmd from "./commands/guide.js";
32
+ import * as auditCmd from "./commands/audit.js";
32
33
  import { maybeNotifyUpdate } from "./update-check.js";
33
34
 
34
35
  // Read version from package.json so it stays in sync with the published npm
@@ -67,6 +68,17 @@ export function run(argv) {
67
68
  .description("Fetch an agent's prompt_sections into a local JSON file (default: active agent)")
68
69
  .option("--agent <id>", "target a specific agent instead of the org's active one")
69
70
  .action((orgId, opts) => promptsCmd.pull(orgId, opts));
71
+ prompts.command("history <organization_id>")
72
+ .description("Every recorded prompt push (who / when / size) — the version list for `restore`")
73
+ .option("--agent <id>", "history for a specific (non-active) agent")
74
+ .option("--limit <n>", "max entries (default 50, max 200)")
75
+ .option("--json", "raw JSON")
76
+ .action((orgId, opts) => promptsCmd.history(orgId, opts));
77
+ prompts.command("restore <organization_id> <audit_id>")
78
+ .description("Re-push a past prompt version from the audit log (DRY-RUN unless --commit)")
79
+ .option("--after", "restore the version that push MADE LIVE (default: the one it REPLACED)")
80
+ .option("--commit", "actually push it (default writes a working copy only)")
81
+ .action((orgId, auditId, opts) => promptsCmd.restore(orgId, auditId, opts));
70
82
  prompts.command("push <slug-or-path>")
71
83
  .description("Apply a local JSON file's prompt_sections back to the agent it was pulled from (regenerates system_prompt server-side)")
72
84
  .action((id) => promptsCmd.push(id));
@@ -261,6 +273,28 @@ export function run(argv) {
261
273
  .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
262
274
  .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
263
275
 
276
+ // audit (who did what, when — with the full before/after content)
277
+ const audit = program.command("audit")
278
+ .description("Staff-CLI audit trail: who changed what, when — with full before/after content")
279
+ .argument("[organization_id]", "scope to one org (omit for all orgs)")
280
+ .option("--endpoint <name>", "prompts | questionnaires | fine-tuning | knowledge | custom-tools | keywords | agent-config | broadcast | tag | …")
281
+ .option("--action <name>", "push | create | config | resolve | send | apply | read | …")
282
+ .option("--status <s>", "ok | blocked | failed")
283
+ .option("--agent <id>", "scope to one agent")
284
+ .option("--target <id>", "scope to one target (task id, tool name, tag, broadcast id)")
285
+ .option("--user <substr>", "filter by staff email (substring)")
286
+ .option("--since <date>", "YYYY-MM-DD or ISO")
287
+ .option("--until <date>", "YYYY-MM-DD or ISO")
288
+ .option("--limit <n>", "max entries (default 50, max 200)")
289
+ .option("--json", "raw JSON")
290
+ .action((orgId, opts) => auditCmd.list(orgId, opts));
291
+ audit.command("show <audit_id>")
292
+ .description("One audit entry in full")
293
+ .option("--content", "also print the before/after payloads")
294
+ .option("--out <path>", "write the whole entry (incl. content) to a JSON file")
295
+ .option("--json", "raw JSON")
296
+ .action((auditId, opts) => auditCmd.show(auditId, opts));
297
+
264
298
  // export (full chat history → TXT, byte-identical to the in-app export)
265
299
  const exp = program.command("export").description("Export data to local files");
266
300
  exp.command("chats <organization_id>")
@@ -413,8 +447,15 @@ export function run(argv) {
413
447
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
414
448
  broadcast.command("status <organization_id> <broadcast_id>")
415
449
  .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
450
+ .option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")
451
+ .option("--limit <n>", "with --failures: how many failed rows to print", "40")
416
452
  .option("--json", "raw JSON")
417
453
  .action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
454
+ broadcast.command("retry <organization_id> <broadcast_id>")
455
+ .description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
456
+ .option("--commit", "actually re-send (omit to see the failed count + reasons)")
457
+ .option("--yes", "skip the type-'retry' confirm gate")
458
+ .action((orgId, broadcastId, opts) => broadcastCmd.retry(orgId, broadcastId, opts));
418
459
  broadcast.command("list")
419
460
  .description("List local campaigns + sent counts")
420
461
  .action(() => broadcastCmd.list());