@audienti/cli 0.1.5 → 0.1.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/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
- import { readFile } from "node:fs/promises";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
3
4
  import { ApiError, AudientiClient, DEFAULT_HOST, normalizeHost } from "./api-client.js";
4
5
  import { configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
5
6
 
@@ -28,6 +29,28 @@ const PROSPECTS_ADD_NOTE_USAGE = "Usage: audienti prospects add-note <prsp_id> (
28
29
  const PROSPECTS_ADD_STEER_USAGE = "Usage: audienti prospects add-steer <prsp_id> (--message <text> [--engagement-type <key>] | --payload <file.json>) [--json] [--account <acct_id>]";
29
30
  const PROSPECTS_ADD_PROFILE_USAGE = "Usage: audienti prospects add-profile <prsp_id> --url <profile_url|email|phone> [--json] [--account <acct_id>]";
30
31
  const PROSPECTS_REPORT_BAD_PROFILE_USAGE = "Usage: audienti prospects report-bad-profile <prsp_id> <prof_id|citation_id> [--json] [--account <acct_id>]";
32
+ const WRITER_TEST_RUN_USAGE = "Usage: audienti writer test-run <prsp_id> [--json] [--mode <report|plan|step>] [--branch <both|no-accept|accepted>] [--step <step_key|row_number>] [--no-cache] [--clear-cache] [--account <acct_id>]";
33
+ const MOTIONS_ANALYTICS_USAGE = "Usage: audienti motions analytics <motn_id> [--window 30d] [--json] [--account <acct_id>]";
34
+ const ANALYTICS_PROSPECTS_USAGE = "Usage: audienti analytics prospects [--window 24h] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--provenance <source>] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]";
35
+ const ANALYTICS_PROSPECTS_COHORT_ANALYSIS_USAGE = "Usage: audienti analytics prospects cohort-analysis [--weeks <n>] [--window 24h] [--motion <motn_id>] [--provenance <source>] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]";
36
+ const ANALYTICS_USERS_USAGE = "Usage: audienti analytics users [--user <account_user_id|email|name|me>] [--window 30d | --start YYYY-MM-DD --end YYYY-MM-DD] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--provenance <source>] [--platform <linkedin|email|gmail>] [--json] [--account <acct_id>]";
37
+ const COHORT_STAGE_ORDER = [
38
+ "identified",
39
+ "pre_connect",
40
+ "connect_request",
41
+ "connected",
42
+ "engaged",
43
+ "meeting_requested",
44
+ "meeting_outcome_accepted",
45
+ "meeting_outcome_declined",
46
+ "nurture",
47
+ "non_responsive",
48
+ "delayed",
49
+ "rejected",
50
+ "cancel"
51
+ ];
52
+ const DAY_OF_WEEK_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
53
+ const WRITER_TEST_RUN_CACHE_VERSION = 1;
31
54
  const SEQUENCE_EXPORT_CSV_COLUMNS = [
32
55
  "prospect_id",
33
56
  "prospect_name",
@@ -56,7 +79,9 @@ const SEQUENCE_EXPORT_CSV_COLUMNS = [
56
79
  export async function run(argv = process.argv.slice(2), deps = {}) {
57
80
  const context = {
58
81
  env: deps.env || process.env,
82
+ cwd: deps.cwd || process.cwd(),
59
83
  fetchImpl: deps.fetch || globalThis.fetch,
84
+ now: deps.now || (() => new Date()),
60
85
  sleep: deps.sleep || sleep,
61
86
  stdout: deps.stdout || process.stdout,
62
87
  stderr: deps.stderr || process.stderr
@@ -106,6 +131,7 @@ async function dispatch(argv, context) {
106
131
  if (normalizedResource === "motions" && action === "list") return motionsList(rest, context, { accountOverride });
107
132
  if (normalizedResource === "motions" && action === "show") return motionsShow(rest, context, { accountOverride });
108
133
  if (normalizedResource === "motions" && action === "status") return motionsStatus(rest, context, { accountOverride });
134
+ if (normalizedResource === "motions" && action === "analytics") return motionsAnalytics(rest, context, { accountOverride });
109
135
  if (normalizedResource === "motions" && action === "prospects") return motionsProspects(rest, context, { accountOverride });
110
136
  if (normalizedResource === "motions" && action === "add-prospects") return motionsAddProspects(rest, context, { accountOverride });
111
137
  if (normalizedResource === "motions" && action === "create") return motionsCreate(rest, context, { accountOverride });
@@ -122,11 +148,13 @@ async function dispatch(argv, context) {
122
148
  if (normalizedResource === "prospects" && action === "sequence-export") return prospectsSequenceExport(rest, context, { accountOverride });
123
149
  if (normalizedResource === "prospects" && action === "import") return prospectsImport(rest, context, { accountOverride });
124
150
  if (normalizedResource === "prospects" && action === "import-status") return prospectsImportStatus(rest, context, { accountOverride });
151
+ if (normalizedResource === "writer" && action === "test-run") return writerTestRun(rest, context, { accountOverride });
125
152
  if (normalizedResource === "tools" && action === "get") return toolsGet(rest, context, { accountOverride });
126
153
  if (normalizedResource === "operator" && action === "queue") return operatorQueue(rest, context, { accountOverride });
127
154
  if (normalizedResource === "operator" && action === "next") return operatorNext(rest, context, { accountOverride });
128
155
  if (normalizedResource === "operator" && action === "outcome") return operatorOutcome(rest, context, { accountOverride });
129
156
  if (normalizedResource === "analytics" && ["prospects", "prospect"].includes(action)) return analyticsProspects(rest, context, { accountOverride });
157
+ if (normalizedResource === "analytics" && ["users", "user"].includes(action)) return analyticsUsers(rest, context, { accountOverride });
130
158
  if (normalizedResource === "analytics" && ["visibility", "visops"].includes(action)) return analyticsVisibility(rest, context, { accountOverride });
131
159
  if (normalizedResource === "analytics" && action === "content") return analyticsContent(rest, context, { accountOverride });
132
160
 
@@ -172,11 +200,13 @@ function helpTopicFromArgs(args) {
172
200
  function normalizeTopicParts(parts) {
173
201
  if (parts[0] === "plays") return ["motions", ...parts.slice(1)];
174
202
  if (parts[0] === "principals") return ["users", ...parts.slice(1)];
203
+ if (parts[0] === "writers") return ["writer", ...parts.slice(1)];
175
204
  return parts;
176
205
  }
177
206
 
178
207
  function normalizeResource(resource) {
179
208
  if (resource === "principals") return "users";
209
+ if (resource === "writers") return "writer";
180
210
  return resource === "plays" ? "motions" : resource;
181
211
  }
182
212
 
@@ -655,6 +685,23 @@ async function motionsStatus(args, context, { accountOverride } = {}) {
655
685
  renderMotionStatus(status, context);
656
686
  }
657
687
 
688
+ async function motionsAnalytics(args, context, { accountOverride } = {}) {
689
+ const { values, positionals } = parseCommandArgs(args, {
690
+ ...jsonOptions(),
691
+ window: { type: "string" }
692
+ });
693
+ if (positionals.length !== 1) throw new CommandError(MOTIONS_ANALYTICS_USAGE);
694
+
695
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
696
+ const payload = await client.analyticsProspects(accountId, {
697
+ motion_id: positionals[0],
698
+ window: values.window || "30d"
699
+ });
700
+ if (values.json) return writeJson(context.stdout, payload);
701
+
702
+ renderMotionAnalytics(payload, context);
703
+ }
704
+
658
705
  async function motionsProspects(args, context, { accountOverride } = {}) {
659
706
  const { values, positionals } = parseCommandArgs(args, {
660
707
  ...jsonOptions(),
@@ -914,13 +961,63 @@ async function prospectNoteCommand(args, context, { accountOverride, forcedType,
914
961
  }
915
962
 
916
963
  async function prospectsSequencePreview(args, context, { accountOverride } = {}) {
964
+ return runSequencePreviewCommand(args, context, {
965
+ accountOverride,
966
+ usageText: "Usage: audienti prospects sequence-preview <prsp_id> [--json] [--connection-state <state>] [--account <acct_id>]",
967
+ title: "Sequence preview"
968
+ });
969
+ }
970
+
971
+ async function writerTestRun(args, context, { accountOverride } = {}) {
972
+ const { values, positionals } = parseCommandArgs(args, {
973
+ ...jsonOptions(),
974
+ branch: { type: "string" },
975
+ branches: { type: "string" },
976
+ mode: { type: "string" },
977
+ step: { type: "string" },
978
+ "angle-index": { type: "string" },
979
+ "no-cache": { type: "boolean" },
980
+ "clear-cache": { type: "boolean" }
981
+ });
982
+ if (positionals.length !== 1) throw new CommandError(WRITER_TEST_RUN_USAGE);
983
+ if (values.branch && values.branches) throw new CommandError("Choose one branch filter: use either --branch or --branches.");
984
+ const draftMode = normalizeWriterTestRunMode(values.mode);
985
+ if (draftMode === "target" && !values.step) throw new CommandError("Step mode requires --step <step_key|row_number>.");
986
+ if (draftMode === "target" && !values.branch && !values.branches) throw new CommandError("Step mode requires --branch <no-accept|accepted>.");
987
+
988
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
989
+ const prospectId = positionals[0];
990
+ const branchFilter = values.branches || values.branch || "both";
991
+ const useCache = ["plan", "target"].includes(draftMode) && !values["no-cache"];
992
+ if (useCache && values["clear-cache"]) await clearWriterTestRunCache(context, { accountId, prospectId });
993
+ const cache = useCache ? await loadWriterTestRunCache(context, { accountId, prospectId }) : emptyWriterTestRunCache(context, { accountId, prospectId });
994
+ const cachedDrafts = useCache ? writerCachedDraftsForRequest(cache, branchFilter) : [];
995
+
996
+ const payload = await client.prospectSequenceExport(accountId, prospectId, compactObject({
997
+ branches: branchFilter,
998
+ angle_index: values["angle-index"],
999
+ draft_mode: draftMode,
1000
+ target_step: values.step,
1001
+ cached_drafts: cachedDrafts.length ? cachedDrafts : undefined
1002
+ }));
1003
+ if (useCache) {
1004
+ payload.meta ||= {};
1005
+ payload.meta.cache = writerCacheMeta(cache, cachedDrafts);
1006
+ await persistWriterDraftsFromPayload(context, { cache, payload });
1007
+ }
1008
+ if (values.json) return writeJson(context.stdout, payload);
1009
+
1010
+ renderWriterTestRun(payload, context);
1011
+ }
1012
+
1013
+ async function runSequencePreviewCommand(args, context, { accountOverride, usageText, title }) {
917
1014
  const { values, positionals } = parseCommandArgs(args, {
918
1015
  ...jsonOptions(),
919
1016
  "connection-state": { type: "string" }
920
1017
  });
921
1018
 
922
1019
  if (positionals.length !== 1) {
923
- throw new CommandError("Usage: audienti prospects sequence-preview <prsp_id> [--json] [--connection-state <state>] [--account <acct_id>]");
1020
+ throw new CommandError(usageText);
924
1021
  }
925
1022
 
926
1023
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
@@ -929,7 +1026,7 @@ async function prospectsSequencePreview(args, context, { accountOverride } = {})
929
1026
  }));
930
1027
  if (values.json) return writeJson(context.stdout, payload);
931
1028
 
932
- renderProspectSequencePreview(payload, context);
1029
+ renderProspectSequencePreview(payload, context, { title });
933
1030
  }
934
1031
 
935
1032
  async function prospectsSequenceExport(args, context, { accountOverride } = {}) {
@@ -938,10 +1035,12 @@ async function prospectsSequenceExport(args, context, { accountOverride } = {})
938
1035
  csv: { type: "boolean" },
939
1036
  branch: { type: "string" },
940
1037
  branches: { type: "string" },
1038
+ "draft-mode": { type: "string" },
1039
+ "target-step": { type: "string" },
941
1040
  "angle-index": { type: "string" }
942
1041
  });
943
1042
  if (positionals.length !== 1) {
944
- throw new CommandError("Usage: audienti prospects sequence-export <prsp_id> [--json|--csv] [--branch <both|no-accept|accepted>] [--account <acct_id>]");
1043
+ throw new CommandError("Usage: audienti prospects sequence-export <prsp_id> [--json|--csv] [--branch <both|no-accept|accepted>] [--draft-mode <all|plan|target>] [--target-step <step_key|row_number>] [--account <acct_id>]");
945
1044
  }
946
1045
  if (values.csv && values.json) throw new CommandError("Choose one output format: use either --csv or --json.");
947
1046
  if (values.branch && values.branches) throw new CommandError("Choose one branch filter: use either --branch or --branches.");
@@ -949,7 +1048,9 @@ async function prospectsSequenceExport(args, context, { accountOverride } = {})
949
1048
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
950
1049
  const payload = await client.prospectSequenceExport(accountId, positionals[0], compactObject({
951
1050
  branches: values.branches || values.branch,
952
- angle_index: values["angle-index"]
1051
+ angle_index: values["angle-index"],
1052
+ draft_mode: values["draft-mode"],
1053
+ target_step: values["target-step"]
953
1054
  }));
954
1055
  if (values.json) return writeJson(context.stdout, payload);
955
1056
  if (values.csv) return writeLine(context.stdout, sequenceExportRowsToCsv(payload?.rows || []));
@@ -957,6 +1058,160 @@ async function prospectsSequenceExport(args, context, { accountOverride } = {})
957
1058
  renderProspectSequenceExport(payload, context);
958
1059
  }
959
1060
 
1061
+ function normalizeWriterTestRunMode(value) {
1062
+ const normalized = String(value || "report").trim().toLowerCase();
1063
+ if (["report", "draft", "drafts", "all", "full"].includes(normalized)) return "all";
1064
+ if (normalized === "plan") return "plan";
1065
+ if (["step", "target"].includes(normalized)) return "target";
1066
+
1067
+ throw new CommandError("Unsupported writer test-run mode. Use report, plan, or step.");
1068
+ }
1069
+
1070
+ function emptyWriterTestRunCache(context, { accountId, prospectId }) {
1071
+ return {
1072
+ version: WRITER_TEST_RUN_CACHE_VERSION,
1073
+ account_id: accountId,
1074
+ prospect_id: prospectId,
1075
+ path: writerTestRunCachePath(context, { accountId, prospectId }),
1076
+ entries: {}
1077
+ };
1078
+ }
1079
+
1080
+ async function loadWriterTestRunCache(context, { accountId, prospectId }) {
1081
+ const cache = emptyWriterTestRunCache(context, { accountId, prospectId });
1082
+
1083
+ try {
1084
+ const parsed = JSON.parse(await readFile(cache.path, "utf8"));
1085
+ if (parsed?.version !== WRITER_TEST_RUN_CACHE_VERSION) return cache;
1086
+
1087
+ return {
1088
+ ...cache,
1089
+ entries: parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {}
1090
+ };
1091
+ } catch (error) {
1092
+ if (error.code === "ENOENT") return cache;
1093
+ if (error instanceof SyntaxError) return cache;
1094
+
1095
+ throw error;
1096
+ }
1097
+ }
1098
+
1099
+ async function clearWriterTestRunCache(context, { accountId, prospectId }) {
1100
+ await rm(writerTestRunCachePath(context, { accountId, prospectId }), { force: true });
1101
+ }
1102
+
1103
+ function writerTestRunCachePath(context, { accountId, prospectId }) {
1104
+ const dir = context.env.AUDIENTI_WRITER_TEST_RUN_CACHE_DIR || join(context.cwd, "tmp", "writer-test-run-cache");
1105
+ return join(dir, `${safeCacheSegment(accountId)}-${safeCacheSegment(prospectId)}.json`);
1106
+ }
1107
+
1108
+ function safeCacheSegment(value) {
1109
+ return String(value || "unknown").replace(/[^a-zA-Z0-9_.-]/g, "_");
1110
+ }
1111
+
1112
+ function writerCachedDraftsForRequest(cache, branchFilter) {
1113
+ const branchKeys = writerRequestedBranchKeys(branchFilter);
1114
+ return Object.values(cache.entries || {})
1115
+ .filter((entry) => branchKeys.includes(entry.branch))
1116
+ .filter((entry) => entry.key && (entry.body || entry.text || entry.subject))
1117
+ .map((entry) => compactObject({
1118
+ branch: entry.branch,
1119
+ key: entry.key,
1120
+ stage: entry.stage,
1121
+ channel: entry.channel,
1122
+ platform: entry.platform,
1123
+ message_mode: entry.message_mode,
1124
+ subject: entry.subject,
1125
+ body: entry.body,
1126
+ text: entry.text,
1127
+ status: entry.status,
1128
+ generated_at: entry.generated_at,
1129
+ writer_engine: entry.writer_engine,
1130
+ target: entry.target,
1131
+ metadata: entry.metadata
1132
+ }));
1133
+ }
1134
+
1135
+ function writerRequestedBranchKeys(branchFilter) {
1136
+ const values = String(branchFilter || "both").split(",").map((value) => value.trim()).filter(Boolean);
1137
+ if (values.length === 0 || values.includes("both")) return ["no_accept", "accepted"];
1138
+
1139
+ return values.map((value) => {
1140
+ const normalized = value.replaceAll("-", "_");
1141
+ if (["default", "no_accept", "not_connected"].includes(normalized)) return "no_accept";
1142
+ if (normalized === "accepted") return "accepted";
1143
+ return normalized;
1144
+ });
1145
+ }
1146
+
1147
+ function writerCacheMeta(cache, cachedDrafts) {
1148
+ return {
1149
+ enabled: true,
1150
+ path: cache.path,
1151
+ entry_count: Object.keys(cache.entries || {}).length,
1152
+ sent_draft_count: cachedDrafts.length
1153
+ };
1154
+ }
1155
+
1156
+ async function persistWriterDraftsFromPayload(context, { cache, payload }) {
1157
+ const entries = { ...(cache.entries || {}) };
1158
+ let changed = false;
1159
+
1160
+ for (const branch of Array.isArray(payload?.branches) ? payload.branches : []) {
1161
+ const branchKey = String(branch?.key || "").trim();
1162
+ if (!branchKey) continue;
1163
+
1164
+ for (const step of Array.isArray(branch?.steps) ? branch.steps : []) {
1165
+ const entry = writerCacheEntryFromStep(step, { branchKey, generatedAt: branch.generated_at || payload?.generated_at });
1166
+ if (!entry) continue;
1167
+
1168
+ entries[writerCacheEntryKey(entry)] = entry;
1169
+ changed = true;
1170
+ }
1171
+ }
1172
+
1173
+ if (!changed) return;
1174
+
1175
+ const nextCache = {
1176
+ version: WRITER_TEST_RUN_CACHE_VERSION,
1177
+ account_id: cache.account_id,
1178
+ prospect_id: cache.prospect_id,
1179
+ updated_at: new Date().toISOString(),
1180
+ entries
1181
+ };
1182
+ await mkdir(dirname(cache.path), { recursive: true });
1183
+ await writeFile(cache.path, `${JSON.stringify(nextCache, null, 2)}\n`, "utf8");
1184
+ cache.entries = entries;
1185
+ }
1186
+
1187
+ function writerCacheEntryFromStep(step, { branchKey, generatedAt }) {
1188
+ if (step?.kind !== "message") return null;
1189
+ if (!step.key) return null;
1190
+ if (!(step.body || step.text || step.subject)) return null;
1191
+ if (step.status === "planned" || step.status === "unavailable" || step.status === "error") return null;
1192
+
1193
+ return compactObject({
1194
+ branch: branchKey,
1195
+ key: step.key,
1196
+ stage: step.stage,
1197
+ channel: step.channel,
1198
+ platform: step.platform,
1199
+ message_mode: step.message_mode,
1200
+ subject: step.subject,
1201
+ body: step.body,
1202
+ text: step.text,
1203
+ status: step.status,
1204
+ generated_at: step.generated_at || generatedAt || new Date().toISOString(),
1205
+ writer_engine: step?.metadata?.writer_engine,
1206
+ target: step.target,
1207
+ metadata: step.metadata
1208
+ });
1209
+ }
1210
+
1211
+ function writerCacheEntryKey(entry) {
1212
+ return `${entry.branch}:${entry.key}`;
1213
+ }
1214
+
960
1215
  async function prospectsImport(args, context, { accountOverride } = {}) {
961
1216
  const { values, positionals } = parseCommandArgs(args, {
962
1217
  ...jsonOptions(),
@@ -1102,8 +1357,12 @@ async function operatorOutcome(args, context, { accountOverride } = {}) {
1102
1357
  }
1103
1358
 
1104
1359
  async function analyticsProspects(args, context, { accountOverride } = {}) {
1105
- const { values, positionals } = parseCommandArgs(args, analyticsOptions());
1106
- if (positionals.length > 0) throw new CommandError("Usage: audienti analytics prospects [--window 24h] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]");
1360
+ if (args[0] === "cohort-analysis") {
1361
+ return analyticsProspectsCohortAnalysis(args.slice(1), context, { accountOverride });
1362
+ }
1363
+
1364
+ const { values, positionals } = parseCommandArgs(args, analyticsProspectsOptions());
1365
+ if (positionals.length > 0) throw new CommandError(ANALYTICS_PROSPECTS_USAGE);
1107
1366
 
1108
1367
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
1109
1368
  const payload = await client.analyticsProspects(accountId, analyticsQuery(values));
@@ -1112,6 +1371,61 @@ async function analyticsProspects(args, context, { accountOverride } = {}) {
1112
1371
  renderAnalyticsProspects(payload, context);
1113
1372
  }
1114
1373
 
1374
+ async function analyticsProspectsCohortAnalysis(args, context, { accountOverride } = {}) {
1375
+ const { values, positionals } = parseCommandArgs(args, {
1376
+ ...jsonOptions(),
1377
+ weeks: { type: "string" },
1378
+ window: { type: "string" },
1379
+ motion: { type: "string" },
1380
+ provenance: { type: "string" },
1381
+ user: { type: "string" }
1382
+ });
1383
+ if (positionals.length > 0) throw new CommandError(ANALYTICS_PROSPECTS_COHORT_ANALYSIS_USAGE);
1384
+
1385
+ const weeks = normalizedCohortAnalysisWeeks(values.weeks);
1386
+ const cohorts = weeklyCohorts({ weeks, now: currentDate(context) });
1387
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
1388
+ const rows = [];
1389
+
1390
+ for (const cohort of cohorts) {
1391
+ const payload = await client.analyticsProspects(accountId, compactObject({
1392
+ window: values.window,
1393
+ account_user_id: values.user,
1394
+ motion_id: values.motion,
1395
+ provenance: values.provenance,
1396
+ cohort_start: cohort.start_date,
1397
+ cohort_end: cohort.end_date
1398
+ }));
1399
+ rows.push(cohortAnalysisRow(payload, cohort));
1400
+ }
1401
+
1402
+ const payload = {
1403
+ kind: "prospect_cohort_analysis",
1404
+ weeks,
1405
+ window: values.window || "24h",
1406
+ motion: rows.find((row) => row.motion)?.motion || motionPayload(values.motion),
1407
+ provenance: rows.find((row) => row.provenance)?.provenance || provenancePayload(values.provenance),
1408
+ account_user: rows.find((row) => row.account_user)?.account_user || null,
1409
+ cohorts: rows
1410
+ };
1411
+ if (values.json) return writeJson(context.stdout, payload);
1412
+
1413
+ renderAnalyticsProspectCohortAnalysis(payload, context);
1414
+ }
1415
+
1416
+ async function analyticsUsers(args, context, { accountOverride } = {}) {
1417
+ const { values, positionals } = parseCommandArgs(args, analyticsUsersOptions());
1418
+ if (positionals.length > 0) throw new CommandError(ANALYTICS_USERS_USAGE);
1419
+ validateDatePair(values.start, values.end, "--start", "--end");
1420
+ validateDatePair(values["cohort-start"], values["cohort-end"], "--cohort-start", "--cohort-end");
1421
+
1422
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
1423
+ const payload = await client.analyticsUsers(accountId, analyticsUsersQuery(values));
1424
+ if (values.json) return writeJson(context.stdout, payload);
1425
+
1426
+ renderAnalyticsUsers(payload, context);
1427
+ }
1428
+
1115
1429
  async function analyticsVisibility(args, context, { accountOverride } = {}) {
1116
1430
  const { values, positionals } = parseCommandArgs(args, analyticsOptions());
1117
1431
  if (positionals.length > 0) throw new CommandError("Usage: audienti analytics visibility [--window 24h] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]");
@@ -1260,13 +1574,161 @@ function analyticsOptions() {
1260
1574
  };
1261
1575
  }
1262
1576
 
1577
+ function analyticsProspectsOptions() {
1578
+ return {
1579
+ ...analyticsOptions(),
1580
+ "cohort-start": { type: "string" },
1581
+ "cohort-end": { type: "string" },
1582
+ motion: { type: "string" },
1583
+ provenance: { type: "string" }
1584
+ };
1585
+ }
1586
+
1587
+ function analyticsUsersOptions() {
1588
+ return {
1589
+ ...jsonOptions(),
1590
+ user: { type: "string" },
1591
+ window: { type: "string" },
1592
+ start: { type: "string" },
1593
+ end: { type: "string" },
1594
+ "cohort-start": { type: "string" },
1595
+ "cohort-end": { type: "string" },
1596
+ motion: { type: "string" },
1597
+ provenance: { type: "string" },
1598
+ platform: { type: "string" },
1599
+ channel: { type: "string" }
1600
+ };
1601
+ }
1602
+
1263
1603
  function analyticsQuery(values) {
1264
1604
  return compactObject({
1265
1605
  window: values.window,
1606
+ cohort_start: values["cohort-start"],
1607
+ cohort_end: values["cohort-end"],
1608
+ motion_id: values.motion,
1609
+ provenance: values.provenance,
1266
1610
  account_user_id: values.user
1267
1611
  });
1268
1612
  }
1269
1613
 
1614
+ function analyticsUsersQuery(values) {
1615
+ const hasDateRange = Boolean(values.start || values.end);
1616
+ return compactObject({
1617
+ account_user_id: values.user || "me",
1618
+ window: hasDateRange ? undefined : (values.window || "30d"),
1619
+ start_date: values.start,
1620
+ end_date: values.end,
1621
+ cohort_start: values["cohort-start"],
1622
+ cohort_end: values["cohort-end"],
1623
+ motion_id: values.motion,
1624
+ provenance: values.provenance,
1625
+ platform: values.platform || values.channel
1626
+ });
1627
+ }
1628
+
1629
+ function validateDatePair(start, end, startFlag, endFlag) {
1630
+ if ((start && !end) || (!start && end)) {
1631
+ throw new CommandError(`${startFlag} and ${endFlag} must be provided together.`);
1632
+ }
1633
+ }
1634
+
1635
+ function normalizedCohortAnalysisWeeks(rawValue) {
1636
+ const weeks = Number.parseInt(rawValue || "4", 10);
1637
+ if (!Number.isInteger(weeks) || weeks <= 0) {
1638
+ throw new CommandError("--weeks must be a positive integer.");
1639
+ }
1640
+ if (weeks > 26) {
1641
+ throw new CommandError("--weeks must be 26 or less.");
1642
+ }
1643
+
1644
+ return weeks;
1645
+ }
1646
+
1647
+ function currentDate(context) {
1648
+ const raw = typeof context.now === "function" ? context.now() : context.now;
1649
+ const date = raw instanceof Date ? raw : new Date(raw || Date.now());
1650
+ if (Number.isNaN(date.getTime())) return utcDateOnly(new Date());
1651
+
1652
+ return utcDateOnly(date);
1653
+ }
1654
+
1655
+ function weeklyCohorts({ weeks, now }) {
1656
+ const currentWeekStart = startOfUtcWeek(now);
1657
+ const rows = [];
1658
+
1659
+ for (let offset = weeks - 1; offset >= 0; offset -= 1) {
1660
+ const start = addUtcDays(currentWeekStart, offset * -7);
1661
+ const plannedEnd = addUtcDays(start, 6);
1662
+ const end = plannedEnd > now ? now : plannedEnd;
1663
+ rows.push({
1664
+ start_date: isoDate(start),
1665
+ end_date: isoDate(end)
1666
+ });
1667
+ }
1668
+
1669
+ return rows;
1670
+ }
1671
+
1672
+ function startOfUtcWeek(date) {
1673
+ const day = date.getUTCDay();
1674
+ const mondayOffset = (day + 6) % 7;
1675
+ return addUtcDays(date, -mondayOffset);
1676
+ }
1677
+
1678
+ function utcDateOnly(date) {
1679
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
1680
+ }
1681
+
1682
+ function addUtcDays(date, days) {
1683
+ const next = new Date(date.getTime());
1684
+ next.setUTCDate(next.getUTCDate() + days);
1685
+ return utcDateOnly(next);
1686
+ }
1687
+
1688
+ function isoDate(date) {
1689
+ return date.toISOString().slice(0, 10);
1690
+ }
1691
+
1692
+ function cohortAnalysisRow(payload, fallbackCohort) {
1693
+ const cohort = payload?.cohort || fallbackCohort;
1694
+ const stages = {};
1695
+ const stageLabels = {};
1696
+ for (const row of Array.isArray(payload?.queue_stages) ? payload.queue_stages : []) {
1697
+ const key = String(row?.key || "").trim();
1698
+ if (!key) continue;
1699
+
1700
+ stages[key] = row?.count || 0;
1701
+ stageLabels[key] = row?.label || key;
1702
+ }
1703
+
1704
+ return {
1705
+ cohort,
1706
+ label: `${display(cohort.start_date)} to ${display(cohort.end_date)}`,
1707
+ total_count: payload?.cohort_prospects_count ?? payload?.prospects_added_count ?? 0,
1708
+ motion: payload?.motion || null,
1709
+ provenance: payload?.provenance || null,
1710
+ account_user: payload?.account_user || null,
1711
+ stages,
1712
+ stage_labels: stageLabels
1713
+ };
1714
+ }
1715
+
1716
+ function motionPayload(motionId) {
1717
+ if (!motionId) return null;
1718
+
1719
+ return { prefix_id: motionId, name: motionId };
1720
+ }
1721
+
1722
+ function provenancePayload(provenance) {
1723
+ if (!provenance) return null;
1724
+
1725
+ return {
1726
+ key: provenance,
1727
+ label: humanize(provenance),
1728
+ field: "account_prospects.intake_source"
1729
+ };
1730
+ }
1731
+
1270
1732
  function compactObject(object) {
1271
1733
  return Object.fromEntries(
1272
1734
  Object.entries(object).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== "")
@@ -1585,10 +2047,16 @@ function renderBulkMutationResult(payload, context, { successLabel, zeroSuccessL
1585
2047
  function renderMotions(motions, context) {
1586
2048
  if (!Array.isArray(motions) || motions.length === 0) return writeLine(context.stdout, "No motions found.");
1587
2049
 
1588
- writeLine(context.stdout, "MOTION ID\tSTATUS\tKIND\tNAME");
1589
- for (const motion of motions) {
1590
- writeLine(context.stdout, `${display(motion.prefix_id)}\t${display(motion.status)}\t${display(motion.kind)}\t${display(motion.name)}`);
1591
- }
2050
+ writeAlignedTable(context, ["MOTION ID", "STATUS", "KIND", "NAME"], motions.map(motionTableRow));
2051
+ }
2052
+
2053
+ function motionTableRow(motion) {
2054
+ return [
2055
+ display(motion?.prefix_id),
2056
+ display(motion?.status),
2057
+ display(motion?.kind),
2058
+ display(motion?.name)
2059
+ ];
1592
2060
  }
1593
2061
 
1594
2062
  function renderMotion(motion, context) {
@@ -1773,7 +2241,7 @@ function renderProspectProfileMutation(payload, context, { action }) {
1773
2241
  if (profile.url) writeLine(context.stdout, `URL: ${profile.url}`);
1774
2242
  }
1775
2243
 
1776
- function renderProspectSequencePreview(payload, context) {
2244
+ function renderProspectSequencePreview(payload, context, { title = "Sequence preview" } = {}) {
1777
2245
  const prospect = payload?.prospect || {};
1778
2246
  const report = payload?.report || {};
1779
2247
  const preview = report?.last_preview || {};
@@ -1782,6 +2250,7 @@ function renderProspectSequencePreview(payload, context) {
1782
2250
  const steps = Array.isArray(report?.steps) ? report.steps : [];
1783
2251
  const contextInfo = payload?.context || {};
1784
2252
 
2253
+ writeLine(context.stdout, title);
1785
2254
  writeLine(context.stdout, `Prospect: ${display(prospect.display_name || selected.prospect_name)} (${display(prospect.prefix_id || selected.prospect_id)})`);
1786
2255
  if (contextInfo.source) writeLine(context.stdout, `Context: ${contextInfo.source}`);
1787
2256
  if (contextInfo.message) writeLine(context.stdout, contextInfo.message);
@@ -1802,21 +2271,176 @@ function renderProspectSequencePreview(payload, context) {
1802
2271
  writeLine(context.stdout, "");
1803
2272
  writeLine(context.stdout, "Sequence:");
1804
2273
 
1805
- steps.forEach((step, index) => {
1806
- const kind = display(step.kind).toUpperCase();
1807
- const stage = display(step.stage);
1808
- const channel = display(step.channel);
1809
- const timing = step?.timing?.mode === "scheduled" ? ` [scheduled ${display(step?.timing?.scheduled_for)}]` : "";
1810
- writeLine(context.stdout, `${index + 1}. ${kind} | ${stage} | ${channel}${timing}`);
1811
-
1812
- if (step.disposition) writeLine(context.stdout, ` Disposition: ${step.disposition}`);
1813
- if (step.transition_label) writeLine(context.stdout, ` Transition: ${step.transition_label}`);
1814
- if (step.rationale) writeLine(context.stdout, ` Why: ${step.rationale}`);
1815
- if (step.guidance) writeLine(context.stdout, ` Guidance: ${step.guidance}`);
1816
- if (step.body) writeLine(context.stdout, ` Body: ${step.body}`);
1817
- if (step.empty_body_reason) writeLine(context.stdout, ` Empty body reason: ${step.empty_body_reason}`);
1818
- if (step.missing_reason) writeLine(context.stdout, ` Missing reason: ${step.missing_reason}`);
1819
- });
2274
+ steps.forEach((step, index) => renderSequenceStep(step, index, context));
2275
+ }
2276
+
2277
+ function renderWriterTestRun(payload, context) {
2278
+ const prospect = payload?.prospect || {};
2279
+ const branches = Array.isArray(payload?.branches) ? payload.branches : [];
2280
+ const draftMode = payload?.meta?.draft_mode || "all";
2281
+ const targetStep = payload?.meta?.target_step;
2282
+
2283
+ writeLine(context.stdout, "Writer campaign simulator");
2284
+ writeLine(context.stdout, `Prospect: ${display(prospect.display_name || prospect.name)} (${display(prospect.prefix_id)})`);
2285
+ if (payload?.context?.source) writeLine(context.stdout, `Context: ${payload.context.source}`);
2286
+ if (payload?.context?.message) writeLine(context.stdout, payload.context.message);
2287
+ if (payload?.context?.motion_name) writeLine(context.stdout, `Motion: ${payload.context.motion_name}`);
2288
+ if (payload?.context?.agent_name) writeLine(context.stdout, `Agent: ${payload.context.agent_name}`);
2289
+ if (payload?.context?.offer_name) writeLine(context.stdout, `Offer: ${payload.context.offer_name}`);
2290
+ writeLine(context.stdout, `Mode: ${display(draftMode)}`);
2291
+ if (targetStep) writeLine(context.stdout, `Target step: ${display(targetStep)}`);
2292
+ if (payload?.meta?.cache?.enabled) {
2293
+ writeLine(context.stdout, `Cache: ${display(payload.meta.cache.path)}`);
2294
+ writeLine(context.stdout, `Cached drafts sent: ${display(payload.meta.cache.sent_draft_count || 0)}`);
2295
+ }
2296
+ writeLine(context.stdout, `Start: ${isoDate(currentDate(context))}`);
2297
+ writeLine(context.stdout, "DATE: step execution date; for WAIT rows, the wait clears on that date.");
2298
+ writeLine(context.stdout, "Scenario: simulate the full path if the prospect does not reply.");
2299
+ if (draftMode === "plan") {
2300
+ writeLine(context.stdout, "Drafts are skipped; this run only plans the path and context.");
2301
+ } else if (draftMode === "target") {
2302
+ writeLine(context.stdout, "Only the target step is drafted; later steps are omitted.");
2303
+ } else {
2304
+ writeLine(context.stdout, "This can take a while because the writer drafts every message step.");
2305
+ }
2306
+
2307
+ if (branches.length === 0) {
2308
+ writeLine(context.stdout, "No simulator branches were generated.");
2309
+ return;
2310
+ }
2311
+
2312
+ for (const branch of branches) {
2313
+ const steps = Array.isArray(branch.steps) ? branch.steps : [];
2314
+ const summary = branch.summary || {};
2315
+ writeLine(context.stdout, "");
2316
+ writeLine(context.stdout, `${display(branch.label)} (${display(branch.key)})`);
2317
+ if (summary.channel_sequence?.length) writeLine(context.stdout, `Channels: ${summary.channel_sequence.join(" -> ")}`);
2318
+ if (summary.total_duration_days !== undefined) writeLine(context.stdout, `Duration days: ${summary.total_duration_days}`);
2319
+ if (summary.terminal_disposition) writeLine(context.stdout, `Terminal disposition: ${summary.terminal_disposition}`);
2320
+
2321
+ if (steps.length === 0) {
2322
+ writeLine(context.stdout, "No steps.");
2323
+ continue;
2324
+ }
2325
+
2326
+ renderWriterStepTable(steps, context);
2327
+ if (draftMode === "target") renderWriterTargetDraft(branch, context);
2328
+ }
2329
+ }
2330
+
2331
+ function renderWriterStepTable(steps, context) {
2332
+ writeLine(context.stdout, "# DATE DOW TYPE ACTION CH STATUS");
2333
+ writeLine(context.stdout, "-- ---------- --- ---- ------------------------------------------ ------- ---------");
2334
+ steps.forEach((step, index) => renderWriterStepRow(step, index, context));
2335
+ }
2336
+
2337
+ function renderWriterStepRow(step, index, context) {
2338
+ const stepDate = writerStepDate(step, context);
2339
+ const row = [
2340
+ fixedWidth(index + 1, 2, { align: "right" }),
2341
+ fixedWidth(stepDate, 10),
2342
+ fixedWidth(writerStepDow(stepDate), 3),
2343
+ fixedWidth(compactKindLabel(step.kind), 4),
2344
+ fixedWidth(writerStepAction(step), 42),
2345
+ fixedWidth(compactChannelLabel(step.channel), 7),
2346
+ fixedWidth(display(step.status), 9)
2347
+ ].join(" ");
2348
+ writeLine(context.stdout, row);
2349
+ }
2350
+
2351
+ function renderWriterTargetDraft(branch, context) {
2352
+ const steps = Array.isArray(branch.steps) ? branch.steps : [];
2353
+ const resolvedTargetStep = String(branch.resolved_target_step || "").trim();
2354
+ const draftedStep = steps.find((step) => step?.kind === "message" && step?.key === resolvedTargetStep) ||
2355
+ [...steps].reverse().find((step) => step?.kind === "message" && (step.body || step.text || step.subject));
2356
+ if (!draftedStep) return;
2357
+
2358
+ writeLine(context.stdout, "");
2359
+ writeLine(context.stdout, `Drafted copy: ${display(draftedStep.stage)}${branch.resolved_target_step ? ` (${branch.resolved_target_step})` : ""}`);
2360
+ const targetUrl = writerDraftTargetUrl(draftedStep);
2361
+ if (targetUrl) writeLine(context.stdout, `Replying to: ${targetUrl}`);
2362
+ if (draftedStep.subject) writeLine(context.stdout, `Subject: ${draftedStep.subject}`);
2363
+ writeLine(context.stdout, display(draftedStep.body || draftedStep.text || draftedStep.empty_body_reason || ""));
2364
+ }
2365
+
2366
+ function writerDraftTargetUrl(step) {
2367
+ const target = step?.target || {};
2368
+ return target.post_url || target.comment_url || target.url || null;
2369
+ }
2370
+
2371
+ function writerStepDate(step, context) {
2372
+ if (step?.timing?.mode === "scheduled") return dateOnlyLabel(step?.timing?.scheduled_for);
2373
+ return String(display(step.kind)).toLowerCase() === "terminal" ? "after" : isoDate(currentDate(context));
2374
+ }
2375
+
2376
+ function writerStepDow(dateLabel) {
2377
+ const date = parseDateOnlyLabel(dateLabel);
2378
+ return date ? DAY_OF_WEEK_LABELS[date.getUTCDay()] : "";
2379
+ }
2380
+
2381
+ function dateOnlyLabel(value) {
2382
+ const raw = String(display(value)).trim();
2383
+ return raw.match(/^\d{4}-\d{2}-\d{2}/)?.[0] || raw;
2384
+ }
2385
+
2386
+ function parseDateOnlyLabel(value) {
2387
+ const match = String(display(value)).match(/^(\d{4})-(\d{2})-(\d{2})$/);
2388
+ if (!match) return null;
2389
+
2390
+ const [, year, month, day] = match;
2391
+ return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
2392
+ }
2393
+
2394
+ function writerStepAction(step) {
2395
+ return display(step.stage || step.key);
2396
+ }
2397
+
2398
+ function compactKindLabel(kind) {
2399
+ const value = String(display(kind)).toLowerCase();
2400
+ if (value === "message") return "MSG";
2401
+ if (value === "action") return "ACT";
2402
+ if (value === "event") return "EVT";
2403
+ if (value === "terminal") return "END";
2404
+ if (value === "wait") return "WAIT";
2405
+ return display(kind).toUpperCase();
2406
+ }
2407
+
2408
+ function compactChannelLabel(channel) {
2409
+ const value = String(display(channel));
2410
+ if (value === "LinkedIn") return "LI";
2411
+ if (value === "LinkedIn InMail") return "InMail";
2412
+ if (value === "Timeline") return "Wait";
2413
+ if (value === "Disposition") return "Done";
2414
+ return value;
2415
+ }
2416
+
2417
+ function fixedWidth(value, width, options = {}) {
2418
+ const text = truncateCliText(value, width);
2419
+ return options.align === "right" ? text.padStart(width) : text.padEnd(width);
2420
+ }
2421
+
2422
+ function truncateCliText(value, maxLength) {
2423
+ const text = String(display(value)).replace(/\s+/g, " ").trim();
2424
+ if (text.length <= maxLength) return text;
2425
+ return `${text.slice(0, Math.max(0, maxLength - 3))}...`;
2426
+ }
2427
+
2428
+ function renderSequenceStep(step, index, context) {
2429
+ const kind = display(step.kind).toUpperCase();
2430
+ const stage = display(step.stage);
2431
+ const channel = display(step.channel);
2432
+ const timing = step?.timing?.mode === "scheduled" ? ` [scheduled ${display(step?.timing?.scheduled_for)}]` : "";
2433
+ writeLine(context.stdout, `${index + 1}. ${kind} | ${stage} | ${channel}${timing}`);
2434
+
2435
+ if (step.disposition) writeLine(context.stdout, ` Disposition: ${step.disposition}`);
2436
+ if (step.status) writeLine(context.stdout, ` Status: ${step.status}`);
2437
+ if (step.transition_label) writeLine(context.stdout, ` Transition: ${step.transition_label}`);
2438
+ if (step.rationale) writeLine(context.stdout, ` Why: ${step.rationale}`);
2439
+ if (step.guidance) writeLine(context.stdout, ` Guidance: ${step.guidance}`);
2440
+ if (step.subject) writeLine(context.stdout, ` Subject: ${step.subject}`);
2441
+ if (step.body) writeLine(context.stdout, ` Body: ${step.body}`);
2442
+ if (step.empty_body_reason) writeLine(context.stdout, ` Empty body reason: ${step.empty_body_reason}`);
2443
+ if (step.missing_reason) writeLine(context.stdout, ` Missing reason: ${step.missing_reason}`);
1820
2444
  }
1821
2445
 
1822
2446
  function renderProspectSequenceExport(payload, context) {
@@ -1899,17 +2523,13 @@ function renderOperatorQueue(payload, context) {
1899
2523
  return;
1900
2524
  }
1901
2525
 
1902
- writeLine(context.stdout, "MOVE ID\tKIND\tPROSPECT\tMOTION\tNEXT ACTION");
1903
- for (const row of queue) {
1904
- writeLine(context.stdout, operatorRowLine(row));
1905
- }
2526
+ writeOperatorRows(context, queue);
1906
2527
  }
1907
2528
 
1908
2529
  function renderOperatorNext(row, context) {
1909
2530
  if (!row) return writeLine(context.stdout, "No operator moves found.");
1910
2531
 
1911
- writeLine(context.stdout, "MOVE ID\tKIND\tPROSPECT\tMOTION\tNEXT ACTION");
1912
- writeLine(context.stdout, operatorRowLine(row));
2532
+ writeOperatorRows(context, [row]);
1913
2533
  }
1914
2534
 
1915
2535
  function renderOperatorPlan(row, context) {
@@ -1981,11 +2601,72 @@ function renderOperatorOutcome(payload, context) {
1981
2601
 
1982
2602
  function renderAnalyticsProspects(payload, context) {
1983
2603
  writeLine(context.stdout, `Prospect analytics (${analyticsWindowLabel(payload)})`);
2604
+ writeAnalyticsCohort(payload, context);
1984
2605
  writeAnalyticsScope(payload, context);
1985
- writeLine(context.stdout, `Prospects added: ${display(payload?.prospects_added_count, 0)}`);
2606
+ if (payload?.cohort) {
2607
+ writeLine(context.stdout, `Cohort prospects: ${display(payload?.cohort_prospects_count, payload?.prospects_added_count || 0)}`);
2608
+ } else {
2609
+ writeLine(context.stdout, `Prospects added: ${display(payload?.prospects_added_count, 0)}`);
2610
+ }
1986
2611
  writeAnalyticsActionSummary(payload?.actions, context, "Actions");
1987
2612
  writeCountTable(context, "Action breakdown", payload?.actions?.breakdown, ["ACTION", "COUNT", "AUTOMATED", "AUTO %"], actionBreakdownRow);
1988
- writeCountTable(context, "Queue stages", payload?.queue_stages, ["STAGE", "COUNT"], countRow);
2613
+ writeCountTable(context, payload?.cohort ? "Current cohort stages" : "Queue stages", payload?.queue_stages, ["STAGE", "COUNT"], countRow);
2614
+ }
2615
+
2616
+ function renderMotionAnalytics(payload, context) {
2617
+ writeLine(context.stdout, `Motion analytics (${analyticsWindowLabel(payload)})`);
2618
+ writeAnalyticsMotion(payload, context);
2619
+ if (payload?.motion?.created_at) writeLine(context.stdout, `Created: ${payload.motion.created_at}`);
2620
+ writeLine(context.stdout, `Prospects produced: ${display(payload?.prospects_added_count, 0)}`);
2621
+ writeCountTable(context, "Prospect cohorts by produced day", payload?.prospects_by_day, ["DATE", "PRODUCED", "ACTIVE", "ACTIVE %", "INACTIVE", "STAGES"], dailyProspectRow);
2622
+ }
2623
+
2624
+ function renderAnalyticsProspectCohortAnalysis(payload, context) {
2625
+ const cohorts = Array.isArray(payload?.cohorts) ? payload.cohorts : [];
2626
+ writeLine(context.stdout, `Prospect cohort analysis (${display(payload?.weeks, cohorts.length)} weeks)`);
2627
+ writeLine(context.stdout, `Activity window: ${display(payload?.window, "24h")}`);
2628
+ writeLine(context.stdout, "Cohorts: account_prospects.created_at, calendar weeks, oldest first");
2629
+ writeAnalyticsMotion(payload, context);
2630
+ writeAnalyticsProvenance(payload, context);
2631
+ if (payload?.account_user) {
2632
+ writeLine(context.stdout, `User: ${entityLabel(payload.account_user)}`);
2633
+ } else {
2634
+ writeLine(context.stdout, "User: all account users");
2635
+ }
2636
+
2637
+ if (cohorts.length === 0) {
2638
+ writeLine(context.stdout, "No cohorts generated.");
2639
+ return;
2640
+ }
2641
+
2642
+ const stageColumns = cohortAnalysisStageColumns(cohorts);
2643
+ const headers = ["COHORT", "TOTAL", ...stageColumns.map((column) => column.label)];
2644
+ const rows = cohorts.map((row) => [
2645
+ row.label,
2646
+ row.total_count,
2647
+ ...stageColumns.map((column) => row.stages?.[column.key] || 0)
2648
+ ]);
2649
+
2650
+ writeLine(context.stdout, "");
2651
+ writeAlignedTable(context, headers, rows, {
2652
+ numericColumns: headers.map((_, index) => index > 0)
2653
+ });
2654
+ }
2655
+
2656
+ function renderAnalyticsUsers(payload, context) {
2657
+ writeLine(context.stdout, `User analytics (${analyticsActivityLabel(payload)})`);
2658
+ writeAnalyticsCohort(payload, context);
2659
+ writeAnalyticsScope(payload, context);
2660
+ writeAnalyticsPlatform(payload, context);
2661
+
2662
+ const summary = payload?.summary || {};
2663
+ writeLine(context.stdout, `Actions: ${display(summary.total_count, 0)}`);
2664
+ writeLine(context.stdout, `Performed by you: ${display(summary.performed_by_user_count, 0)} (${percentageLabel(summary.performed_by_user_percentage)})`);
2665
+ writeLine(context.stdout, `Other humans: ${display(summary.performed_by_others_count, 0)} (${percentageLabel(summary.performed_by_others_percentage)})`);
2666
+ writeLine(context.stdout, `Agent: ${display(summary.agentic_count, 0)} (${percentageLabel(summary.agentic_percentage)})`);
2667
+ writeAnalyticsDailyActions(payload, context);
2668
+ writeCountTable(context, "Action mix", payload?.action_mix, ["ACTION", "COUNT", "%"], mixRow);
2669
+ writeCountTable(context, "Platform mix", payload?.platform_mix, ["PLATFORM", "COUNT", "%"], mixRow);
1989
2670
  }
1990
2671
 
1991
2672
  function renderAnalyticsVisibility(payload, context) {
@@ -2005,6 +2686,8 @@ function renderAnalyticsContent(payload, context) {
2005
2686
  }
2006
2687
 
2007
2688
  function writeAnalyticsScope(payload, context) {
2689
+ writeAnalyticsMotion(payload, context);
2690
+ writeAnalyticsProvenance(payload, context);
2008
2691
  if (payload?.account_user) {
2009
2692
  writeLine(context.stdout, `User: ${entityLabel(payload.account_user)}`);
2010
2693
  } else {
@@ -2012,6 +2695,113 @@ function writeAnalyticsScope(payload, context) {
2012
2695
  }
2013
2696
  }
2014
2697
 
2698
+ function writeAnalyticsPlatform(payload, context) {
2699
+ if (!payload?.platform) return;
2700
+
2701
+ const label = display(payload.platform.label, payload.platform.key);
2702
+ const values = Array.isArray(payload.platform.values) ? payload.platform.values.filter(Boolean).join(", ") : display(payload.platform.key);
2703
+ writeLine(context.stdout, `Platform: ${label} (${display(payload.platform.field, "events.platform")}: ${values})`);
2704
+ }
2705
+
2706
+ function writeAnalyticsMotion(payload, context) {
2707
+ if (!payload?.motion) return;
2708
+
2709
+ writeLine(context.stdout, `Motion: ${entityLabel(payload.motion)}`);
2710
+ }
2711
+
2712
+ function writeAnalyticsProvenance(payload, context) {
2713
+ if (!payload?.provenance) return;
2714
+
2715
+ writeLine(context.stdout, `Provenance: ${display(payload.provenance.label, payload.provenance.key)} (${display(payload.provenance.field, "account_prospects.intake_source")})`);
2716
+ }
2717
+
2718
+ function writeAnalyticsCohort(payload, context) {
2719
+ const cohort = payload?.cohort;
2720
+ if (!cohort) return;
2721
+
2722
+ writeLine(context.stdout, `Cohort: ${display(cohort.start_date)} to ${display(cohort.end_date)} (${display(cohort.field, "account_prospects.created_at")})`);
2723
+ }
2724
+
2725
+ function writeAnalyticsDailyActions(payload, context) {
2726
+ const dailyRows = Array.isArray(payload?.daily_actions) ? payload.daily_actions : [];
2727
+ writeLine(context.stdout, "");
2728
+ writeLine(context.stdout, "Actions by day");
2729
+ if (dailyRows.length === 0) return writeLine(context.stdout, "None");
2730
+
2731
+ const actionColumns = dailyActionColumns(dailyRows, payload?.action_mix);
2732
+ const headers = ["DATE", "TOTAL", ...actionColumns.map((column) => column.label)];
2733
+ const rows = dailyRows.map((row) => [
2734
+ row?.date,
2735
+ row?.total_count || 0,
2736
+ ...actionColumns.map((column) => row?.actions?.[column.key] || 0)
2737
+ ]);
2738
+
2739
+ writeAlignedTable(context, headers, rows, {
2740
+ numericColumns: headers.map((_, index) => index > 0)
2741
+ });
2742
+ }
2743
+
2744
+ function dailyActionColumns(dailyRows, actionMix) {
2745
+ const labels = {};
2746
+ const keys = [];
2747
+ for (const row of Array.isArray(actionMix) ? actionMix : []) {
2748
+ const key = String(row?.key || "").trim();
2749
+ if (!key) continue;
2750
+ if (!keys.includes(key)) keys.push(key);
2751
+ labels[key] = row?.label || key;
2752
+ }
2753
+
2754
+ for (const row of dailyRows) {
2755
+ for (const key of Object.keys(row?.actions || {})) {
2756
+ if (!keys.includes(key)) keys.push(key);
2757
+ labels[key] ||= key;
2758
+ }
2759
+ }
2760
+
2761
+ return keys.slice(0, 6).map((key) => ({ key, label: compactActionLabel(key, labels[key] || key) }));
2762
+ }
2763
+
2764
+ function compactActionLabel(key, label) {
2765
+ const labels = {
2766
+ "action.profile.connect_request_sent": "Connect sent",
2767
+ "action.profile.withdraw_connection": "Withdraw",
2768
+ "action.profile.follow": "Follow",
2769
+ "action.profile.view": "View",
2770
+ "action.profile.in_mail_message": "InMail",
2771
+ "action.post.comment": "Comment",
2772
+ "action.post.like": "Like",
2773
+ "messaging.message_sent": "Message",
2774
+ "messaging.email_sent": "Email",
2775
+ "action.meeting.requested": "Meeting req",
2776
+ "action.prospect.nurtured": "Nurtured",
2777
+ "action.prospect.motion_completed_no_outcome": "No outcome"
2778
+ };
2779
+ if (labels[key]) return labels[key];
2780
+
2781
+ const words = String(label || key).split(/\s+/).filter(Boolean);
2782
+ return words.length <= 2 ? words.join(" ") : words.slice(0, 2).join(" ");
2783
+ }
2784
+
2785
+ function cohortAnalysisStageColumns(cohorts) {
2786
+ const labels = {};
2787
+ const keys = [];
2788
+ for (const cohort of cohorts) {
2789
+ for (const [key, label] of Object.entries(cohort.stage_labels || {})) {
2790
+ if (!keys.includes(key)) keys.push(key);
2791
+ labels[key] ||= label;
2792
+ }
2793
+ }
2794
+
2795
+ return keys
2796
+ .sort((left, right) => cohortStageRank(left) - cohortStageRank(right) || left.localeCompare(right))
2797
+ .map((key) => ({ key, label: labels[key] || key }));
2798
+ }
2799
+
2800
+ function cohortStageRank(key) {
2801
+ const index = COHORT_STAGE_ORDER.indexOf(String(key));
2802
+ return index === -1 ? COHORT_STAGE_ORDER.length : index;
2803
+ }
2804
+
2015
2805
  function writeAnalyticsActionSummary(actions, context, label) {
2016
2806
  const total = display(actions?.total_count, 0);
2017
2807
  const automated = display(actions?.automated_count, 0);
@@ -2025,8 +2815,32 @@ function writeCountTable(context, title, rows, headers, mapRow) {
2025
2815
  writeLine(context.stdout, title);
2026
2816
  if (list.length === 0) return writeLine(context.stdout, "None");
2027
2817
 
2028
- writeLine(context.stdout, headers.join("\t"));
2029
- for (const row of list) writeLine(context.stdout, mapRow(row).join("\t"));
2818
+ writeAlignedTable(context, headers, list.map(mapRow));
2819
+ }
2820
+
2821
+ function writeAlignedTable(context, headers, rows, options = {}) {
2822
+ const tableRows = [headers, ...rows].map((row) => row.map((value) => display(value)));
2823
+ const widths = headers.map((_, index) => Math.max(...tableRows.map((row) => visibleLength(row[index] || ""))));
2824
+ const numericColumns = options.numericColumns || headers.map((header, index) => index > 0 && numericHeader(header));
2825
+
2826
+ writeLine(context.stdout, formatAlignedRow(headers, widths, numericColumns));
2827
+ writeLine(context.stdout, widths.map((width) => "-".repeat(width)).join(" "));
2828
+ for (const row of rows) writeLine(context.stdout, formatAlignedRow(row, widths, numericColumns));
2829
+ }
2830
+
2831
+ function numericHeader(header) {
2832
+ return ["COUNT", "AUTOMATED", "AUTO %", "TOTAL", "%", "PRODUCED", "ACTIVE", "ACTIVE %", "INACTIVE"].includes(String(header || "").toUpperCase());
2833
+ }
2834
+
2835
+ function formatAlignedRow(row, widths, numericColumns) {
2836
+ return row.map((value, index) => {
2837
+ const text = String(display(value));
2838
+ return numericColumns[index] ? text.padStart(widths[index]) : text.padEnd(widths[index]);
2839
+ }).join(" ");
2840
+ }
2841
+
2842
+ function visibleLength(value) {
2843
+ return String(display(value)).length;
2030
2844
  }
2031
2845
 
2032
2846
  function actionBreakdownRow(row) {
@@ -2045,6 +2859,37 @@ function countRow(row) {
2045
2859
  ];
2046
2860
  }
2047
2861
 
2862
+ function dailyProspectRow(row) {
2863
+ const count = Number(row?.count || 0);
2864
+ return [
2865
+ display(row?.date),
2866
+ countDash(row?.count),
2867
+ countDash(row?.active_count),
2868
+ count > 0 ? percentageLabel(row?.active_percentage) : "-",
2869
+ countDash(row?.inactive_count),
2870
+ stageSummary(row?.queue_stages)
2871
+ ];
2872
+ }
2873
+
2874
+ function countDash(value) {
2875
+ return Number(value || 0) === 0 ? "-" : display(value, 0);
2876
+ }
2877
+
2878
+ function stageSummary(rows) {
2879
+ const stages = Array.isArray(rows) ? rows.filter((row) => Number(row?.count || 0) > 0) : [];
2880
+ if (stages.length === 0) return "-";
2881
+
2882
+ return stages.map((row) => `${display(row?.label || row?.key)} ${display(row?.count, 0)}`).join(" | ");
2883
+ }
2884
+
2885
+ function mixRow(row) {
2886
+ return [
2887
+ display(row?.label || row?.key),
2888
+ display(row?.count, 0),
2889
+ percentageLabel(row?.percentage)
2890
+ ];
2891
+ }
2892
+
2048
2893
  function analyticsWindowLabel(payload) {
2049
2894
  const window = payload?.window || {};
2050
2895
  const key = display(window.key, "24h");
@@ -2053,18 +2898,67 @@ function analyticsWindowLabel(payload) {
2053
2898
  return `${key}: ${window.started_at} to ${window.ended_at}`;
2054
2899
  }
2055
2900
 
2901
+ function analyticsActivityLabel(payload) {
2902
+ const range = payload?.date_range;
2903
+ if (range?.start_date && range?.end_date) {
2904
+ return `${range.start_date} to ${range.end_date}`;
2905
+ }
2906
+
2907
+ return analyticsWindowLabel(payload);
2908
+ }
2909
+
2056
2910
  function percentageLabel(value) {
2057
2911
  return value === undefined || value === null || value === "" ? "n/a" : `${value}%`;
2058
2912
  }
2059
2913
 
2060
- function operatorRowLine(row) {
2914
+ function humanize(value) {
2915
+ const words = String(value || "").trim().replaceAll("-", "_").split("_").filter(Boolean);
2916
+ if (words.length === 0) return "-";
2917
+
2918
+ return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
2919
+ }
2920
+
2921
+ function writeOperatorRows(context, rows) {
2922
+ writeAlignedTable(context, ["MOVE ID", "WORK TYPE", "SUBJECT", "MOTION", "NEXT ACTION"], rows.map(operatorTableRow));
2923
+ }
2924
+
2925
+ function operatorTableRow(row) {
2061
2926
  return [
2062
2927
  display(row?.id),
2063
- display(row?.opportunity_kind),
2064
- display(row?.prospect?.display_name || row?.prospect?.name || row?.profile?.display_name),
2065
- display(row?.motion?.name),
2928
+ operatorWorkTypeLabel(row),
2929
+ operatorSubjectLabel(row),
2930
+ operatorMotionLabel(row),
2066
2931
  display(nextActionLabel(row))
2067
- ].join("\t");
2932
+ ];
2933
+ }
2934
+
2935
+ function operatorWorkTypeLabel(row) {
2936
+ return humanize(row?.opportunity_kind || row?.source_kind);
2937
+ }
2938
+
2939
+ function operatorSubjectLabel(row) {
2940
+ return display(
2941
+ row?.prospect?.display_name ||
2942
+ row?.prospect?.name ||
2943
+ row?.profile?.display_name ||
2944
+ row?.profile?.username ||
2945
+ postLabel(row?.post) ||
2946
+ row?.display_name ||
2947
+ row?.name
2948
+ );
2949
+ }
2950
+
2951
+ function operatorMotionLabel(row) {
2952
+ return display(row?.motion?.name || row?.motion?.display_name || row?.motion?.prefix_id || row?.motion?.id);
2953
+ }
2954
+
2955
+ function postLabel(post) {
2956
+ if (!post) return "";
2957
+
2958
+ const body = String(post.body || "").trim().replace(/\s+/g, " ");
2959
+ if (body) return body.length > 48 ? `${body.slice(0, 45)}...` : body;
2960
+
2961
+ return post.url || (post.id ? `Post ${post.id}` : "");
2068
2962
  }
2069
2963
 
2070
2964
  function nextActionLabel(source) {
@@ -2329,65 +3223,77 @@ const HELP_TOPICS = new Map([
2329
3223
  "Usage:",
2330
3224
  " audienti <command> [options]",
2331
3225
  "",
2332
- "Start here for local agents:",
2333
- " audienti help agent-workflows",
2334
- "",
2335
- "Implemented commands:",
2336
- " audienti auth token <token> [--host <url>]",
2337
- " audienti auth status",
2338
- " audienti auth logout",
2339
- " audienti config list [--json]",
2340
- " audienti accounts list [--json]",
2341
- " audienti accounts select <acct_id>",
2342
- " audienti users list [--json]",
2343
- " audienti offers list [--json]",
2344
- " audienti offers create --name <text> [--json]",
2345
- " audienti icps list [--json]",
2346
- " audienti icps create (--name <text> | --payload <file.json>) [--json]",
2347
- " audienti companies search --query <text> [--json]",
2348
- " audienti lists list [--json]",
2349
- " audienti lists create --name <text> [--json]",
2350
- " audienti lists show <list_id> [--json]",
2351
- " audienti lists update <list_id> [--json]",
2352
- " audienti lists delete <list_id> --confirm <yes|true|Y|y> [--json]",
2353
- " audienti lists prospects <list_id> [--json]",
2354
- " audienti lists add-prospects <list_id> <prsp_id> [prsp_id...] [--json]",
2355
- " audienti lists remove-prospects <list_id> <prsp_id> [prsp_id...] [--json]",
2356
- " audienti motions list [--json]",
2357
- " audienti motions show <motn_id> [--json]",
2358
- " audienti motions status <motn_id> [--json]",
2359
- " audienti motions prospects <motn_id> [--json]",
2360
- " audienti motions add-prospects <motn_id> <prsp_id> [prsp_id...] [--json]",
2361
- " audienti motions create --payload <file.json> [--json]",
2362
- " audienti prospects list [--json]",
2363
- " audienti prospects show <prsp_id> [--json]",
2364
- " audienti prospects timeline <prsp_id> [--json]",
2365
- " audienti prospects message-types <prsp_id> [--json]",
2366
- " audienti prospects write <prsp_id> --type <surface_key> [--json]",
2367
- " audienti prospects add-note <prsp_id> --message <text> [--json]",
2368
- " audienti prospects add-steer <prsp_id> --message <text> [--json]",
2369
- " audienti prospects add-profile <prsp_id> --url <profile_url|email|phone> [--json]",
2370
- " audienti prospects report-bad-profile <prsp_id> <prof_id|citation_id> [--json]",
2371
- " audienti prospects sequence-preview <prsp_id> [--json]",
2372
- " audienti prospects sequence-export <prsp_id> [--csv]",
2373
- " audienti prospects import <linkedin_url> [--list <list_id>] [--motion <motn_id>] [--json]",
2374
- " audienti prospects import-status <primp_id> [--json]",
2375
- " audienti tools get <email|phone> --url <linkedin_url> [--json]",
2376
- " audienti operator next [--json|--plan|--done|--skip|--fail|--return]",
2377
- " audienti operator queue [--json]",
2378
- " audienti operator outcome <row_id> --payload <file.json> [--json]",
2379
- " audienti analytics prospects [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
2380
- " audienti analytics visibility [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
2381
- " audienti analytics content [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
2382
- "",
2383
- "Planned submit-shape help topics:",
2384
- " audienti prospects disposition help",
3226
+ "Start:",
3227
+ " audienti auth token <token> Save an API token",
3228
+ " audienti accounts list See accounts available to this token",
3229
+ " audienti accounts select <acct_id> Use one account by default",
3230
+ " audienti help agent-workflows Common agent/operator paths",
3231
+ "",
3232
+ "Work areas:",
3233
+ " Setup & identity",
3234
+ " audienti auth status",
3235
+ " audienti config list",
3236
+ " audienti users list",
3237
+ "",
3238
+ " Motions / plays",
3239
+ " audienti motions list",
3240
+ " audienti motions show <motn_id>",
3241
+ " audienti motions analytics <motn_id>",
3242
+ " audienti motions prospects <motn_id>",
3243
+ " audienti motions create --payload <file.json>",
3244
+ " Tip: `plays` is accepted anywhere `motions` is accepted.",
3245
+ "",
3246
+ " Prospects",
3247
+ " audienti prospects list [filters]",
3248
+ " audienti prospects show <prsp_id>",
3249
+ " audienti prospects timeline <prsp_id>",
3250
+ " audienti prospects import <linkedin_url> [--motion <motn_id>]",
3251
+ " audienti prospects add-note <prsp_id> --message <text>",
3252
+ " audienti prospects add-profile <prsp_id> --url <profile_url|email|phone>",
3253
+ "",
3254
+ " Lists & targeting inputs",
3255
+ " audienti lists list",
3256
+ " audienti lists prospects <list_id>",
3257
+ " audienti offers list",
3258
+ " audienti icps list",
3259
+ " audienti companies search --query <text>",
3260
+ "",
3261
+ " Writer",
3262
+ " audienti writer test-run <prsp_id>",
3263
+ " audienti prospects write <prsp_id> --type <surface_key>",
3264
+ " audienti prospects sequence-export <prsp_id>",
3265
+ "",
3266
+ " Operator queue",
3267
+ " audienti operator next --plan",
3268
+ " audienti operator next --done --note <text>",
3269
+ " audienti operator queue",
3270
+ "",
3271
+ " Analytics",
3272
+ " audienti analytics prospects --window 24h",
3273
+ " audienti analytics prospects cohort-analysis --weeks 4 --motion <motn_id>",
3274
+ " audienti analytics users --user me --window 30d",
3275
+ " audienti analytics visibility --window 24h --user me",
3276
+ " audienti analytics content --window week",
3277
+ "",
3278
+ " Utilities",
3279
+ " audienti tools get email --url <linkedin_url>",
3280
+ " audienti tools get phone --url <linkedin_url>",
3281
+ "",
3282
+ "Common flows:",
3283
+ " Work the next move: audienti operator next --plan",
3284
+ " Inspect a prospect: audienti prospects show <prsp_id> --json",
3285
+ " Preview a campaign: audienti writer test-run <prsp_id>",
3286
+ " Analyze one motion: audienti motions analytics <motn_id>",
3287
+ " Audit your work: audienti analytics users --user me --window 30d",
2385
3288
  "",
2386
3289
  "Global options:",
2387
3290
  " --account <acct_id> Use an account for one command without saving it",
2388
3291
  " --help, -h Show help",
2389
3292
  "",
2390
- "Run `audienti <command> help` for accepted options, examples, and payload shapes."
3293
+ "More help:",
3294
+ " audienti <area> help Example: audienti prospects help",
3295
+ " audienti <area> <command> help Example: audienti analytics prospects help",
3296
+ " Use --json when another program or agent will consume the output."
2391
3297
  ].join("\n")],
2392
3298
 
2393
3299
  ["auth", [
@@ -2903,6 +3809,7 @@ const HELP_TOPICS = new Map([
2903
3809
  " audienti motions list [--json]",
2904
3810
  " audienti motions show <motn_id> [--json]",
2905
3811
  " audienti motions status <motn_id> [--json]",
3812
+ " audienti motions analytics <motn_id> [--window 30d] [--json]",
2906
3813
  " audienti motions prospects <motn_id> [--json]",
2907
3814
  " audienti motions add-prospects <motn_id> <prsp_id> [prsp_id...] [--json]",
2908
3815
  " audienti motions create --payload <file.json> [--json]",
@@ -2971,6 +3878,27 @@ const HELP_TOPICS = new Map([
2971
3878
  " GET /api/v1/accounts/:account_id/motions/:id/status.json"
2972
3879
  ].join("\n")],
2973
3880
 
3881
+ ["motions analytics", [
3882
+ "Usage:",
3883
+ ` ${MOTIONS_ANALYTICS_USAGE.slice("Usage: ".length)}`,
3884
+ "",
3885
+ "Status: implemented",
3886
+ "",
3887
+ "Purpose:",
3888
+ " Show whether one motion is producing prospect output by day.",
3889
+ "",
3890
+ "Options:",
3891
+ " --window <w> AccountProspect.created_at window to inspect. Defaults to 30d. Maximum 90d.",
3892
+ "",
3893
+ "Output shape:",
3894
+ " motion: selected motion/play, including created_at",
3895
+ " prospects_added_count: prospects produced inside the window",
3896
+ " prospects_by_day[]: date, count, active/inactive counts, and current queue_stages for that produced-day cohort",
3897
+ "",
3898
+ "API:",
3899
+ " GET /api/v1/accounts/:account_id/analytics/prospects.json?motion_id=:motion_id"
3900
+ ].join("\n")],
3901
+
2974
3902
  ["motions prospects", [
2975
3903
  "Usage:",
2976
3904
  " audienti motions prospects <motn_id> [--json] [--account <acct_id>]",
@@ -3358,9 +4286,54 @@ const HELP_TOPICS = new Map([
3358
4286
  " POST /api/v1/accounts/:account_id/prospects/:id/sequence_preview.json"
3359
4287
  ].join("\n")],
3360
4288
 
4289
+ ["writer", [
4290
+ "Usage:",
4291
+ ` ${WRITER_TEST_RUN_USAGE.slice("Usage: ".length)}`,
4292
+ "",
4293
+ "Status: implemented",
4294
+ "",
4295
+ "Purpose:",
4296
+ " Run a writer campaign test for one prospect: resolve their current context, simulate the full no-reply path, and draft each message step.",
4297
+ "",
4298
+ "Commands:",
4299
+ " audienti writer test-run <prsp_id>",
4300
+ "",
4301
+ "Alias:",
4302
+ " audienti writers test-run <prsp_id>"
4303
+ ].join("\n")],
4304
+
4305
+ ["writer test-run", [
4306
+ "Usage:",
4307
+ ` ${WRITER_TEST_RUN_USAGE.slice("Usage: ".length)}`,
4308
+ "",
4309
+ "Status: implemented",
4310
+ "",
4311
+ "Purpose:",
4312
+ " Run the prospect-scoped writer test run for a single prospect.",
4313
+ "",
4314
+ "Behavior:",
4315
+ " Uses the same Prospects::SequencePreview simulator as the app, resolves the prospect's motion/agent/ICP/offer context, runs no-reply branches, and returns the full campaign path with actions, waits, channel changes, drafted message bodies, and terminal disposition.",
4316
+ "",
4317
+ "Options:",
4318
+ " --mode <mode> report drafts every message, plan skips drafting, step drafts one selected step",
4319
+ " --branch <branch> Optional branch filter: both | no-accept | accepted",
4320
+ " --step <step_key|row_number> Required with --mode step. Row numbers come from the # column.",
4321
+ " --no-cache Plan/step modes ignore locally cached simulator drafts",
4322
+ " --clear-cache Plan/step modes clear locally cached simulator drafts before running",
4323
+ "",
4324
+ "Output shape:",
4325
+ " branches[].key: no_accept | accepted",
4326
+ " branches[].steps[]: ordered wait/action/message/terminal steps for that simulated path",
4327
+ " branches[].steps[].body: draft copy for message steps when the writer can generate one",
4328
+ " branches[].summary: channel sequence, touch counts, duration, terminal disposition",
4329
+ "",
4330
+ "API:",
4331
+ " POST /api/v1/accounts/:account_id/prospects/:id/sequence_export.json"
4332
+ ].join("\n")],
4333
+
3361
4334
  ["prospects sequence-export", [
3362
4335
  "Usage:",
3363
- " audienti prospects sequence-export <prsp_id> [--json|--csv] [--branch <both|no-accept|accepted>] [--angle-index <n>] [--account <acct_id>]",
4336
+ " audienti prospects sequence-export <prsp_id> [--json|--csv] [--branch <both|no-accept|accepted>] [--draft-mode <all|plan|target>] [--target-step <step_key|row_number>] [--angle-index <n>] [--account <acct_id>]",
3364
4337
  "",
3365
4338
  "Status: implemented",
3366
4339
  "",
@@ -3372,6 +4345,11 @@ const HELP_TOPICS = new Map([
3372
4345
  " no-accept: no reply and no accepted connection request",
3373
4346
  " accepted: connection request accepted, then no reply otherwise",
3374
4347
  "",
4348
+ "Draft modes:",
4349
+ " all: default. Draft every message step",
4350
+ " plan: build the timeline without drafting message bodies",
4351
+ " target: draft only --target-step and return the branch prefix through that step. Row numbers use rows[].step_number.",
4352
+ "",
3375
4353
  "Output shape:",
3376
4354
  " rows[].prospect_id: prsp_",
3377
4355
  " rows[].branch: no_accept | accepted",
@@ -3590,7 +4568,9 @@ const HELP_TOPICS = new Map([
3590
4568
 
3591
4569
  ["analytics", [
3592
4570
  "Usage:",
3593
- " audienti analytics prospects [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
4571
+ " audienti analytics prospects [--window 24h] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--user <account_user_id|email|name|me>] [--json]",
4572
+ " audienti analytics prospects cohort-analysis [--weeks <n>] [--window 24h] [--motion <motn_id>] [--user <account_user_id|email|name|me>] [--json]",
4573
+ " audienti analytics users [--user <account_user_id|email|name|me>] [--window 30d | --start YYYY-MM-DD --end YYYY-MM-DD] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--platform <linkedin|email|gmail>] [--json]",
3594
4574
  " audienti analytics visibility [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
3595
4575
  " audienti analytics visops [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
3596
4576
  " audienti analytics content [--window 24h] [--user <account_user_id|email|name|me>] [--json]",
@@ -3599,23 +4579,56 @@ const HELP_TOPICS = new Map([
3599
4579
  "",
3600
4580
  "Window:",
3601
4581
  " --window <24h|7d|1w|day|week>",
3602
- " --user <account_user_id|email|name|me> Narrow analytics to one account user. Email/name partials are accepted when they match exactly one account user.",
4582
+ " --start <YYYY-MM-DD> --end <YYYY-MM-DD> For user analytics, select the events.created_at activity range instead of --window.",
4583
+ " --cohort-start <YYYY-MM-DD> --cohort-end <YYYY-MM-DD> Select the AccountProspect.created_at cohort while --window or --start/--end selects the activity period.",
4584
+ " --motion <motn_id> For prospect and user analytics, filter AccountProspect.motion_id to one motion/play.",
4585
+ " --provenance <source> Optional lower-level AccountProspect.intake_source filter.",
4586
+ " --platform <linkedin|email|gmail> For user analytics, filter events.platform. --channel is accepted as an alias.",
4587
+ " cohort-analysis loops over recent weekly AccountProspect.created_at cohorts and compares their current stages.",
4588
+ " --user <account_user_id|email|name|me> Narrow analytics to one account user. For prospect analytics, this means prospects assigned to that account user. Email/name partials are accepted when they match exactly one account user.",
3603
4589
  "",
3604
4590
  "Output:",
3605
- " Account-scoped analytics for prospects, visibility engagement, and ContentOps publishing."
4591
+ " Account-scoped analytics for prospects, users, visibility engagement, and ContentOps publishing."
3606
4592
  ].join("\n")],
3607
4593
 
3608
4594
  ["analytics prospects", [
3609
4595
  "Usage:",
3610
- " audienti analytics prospects [--window 24h] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]",
4596
+ " audienti analytics prospects [--window 24h] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--provenance <source>] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]",
4597
+ " audienti analytics prospects cohort-analysis [--weeks <n>] [--window 24h] [--motion <motn_id>] [--provenance <source>] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]",
3611
4598
  "",
3612
4599
  "Status: implemented",
3613
4600
  "",
3614
4601
  "Output shape:",
3615
- " prospects_added_count: account prospects added in the window",
4602
+ " window: activity/event period for actions",
4603
+ " cohort: selected AccountProspect.created_at cohort when cohort dates are provided",
4604
+ " motion: selected motion/play when --motion is provided",
4605
+ " provenance: selected AccountProspect.intake_source when --provenance is provided",
4606
+ " prospects_added_count: account prospects added in the window, or cohort size when cohort dates are provided",
4607
+ " cohort_prospects_count: selected AccountProspect.created_at cohort size when cohort dates are provided",
3616
4608
  " account_user: selected account user when --user is provided, otherwise null",
3617
- " actions: outbound action totals, type breakdown, and automated percentage",
3618
- " queue_stages[]: current account prospect stage counts",
4609
+ " --user filters AccountProspect.assigned_to_account_user_id, so `--user me` reports prospects assigned to you",
4610
+ " actions: outbound action totals in the window, narrowed to cohort prospects when cohort dates are provided",
4611
+ " queue_stages[]: current account prospect stage counts, narrowed to the selected cohort when cohort dates are provided",
4612
+ "",
4613
+ "API:",
4614
+ " GET /api/v1/accounts/:account_id/analytics/prospects.json"
4615
+ ].join("\n")],
4616
+
4617
+ ["analytics prospects cohort-analysis", [
4618
+ "Usage:",
4619
+ ` ${ANALYTICS_PROSPECTS_COHORT_ANALYSIS_USAGE.slice("Usage: ".length)}`,
4620
+ "",
4621
+ "Status: implemented",
4622
+ "",
4623
+ "Behavior:",
4624
+ " Calls the prospect analytics endpoint once per weekly AccountProspect.created_at cohort, then renders current pipeline-stage counts side by side so older cohorts can be compared against newer cohorts.",
4625
+ "",
4626
+ "Options:",
4627
+ " --weeks <n> Number of calendar-week cohorts to inspect. Defaults to 4. Maximum 26.",
4628
+ " --window <w> Activity window passed through to each analytics call. Defaults to 24h.",
4629
+ " --motion <motn_id> Optional motion/play filter.",
4630
+ " --provenance <source> Optional AccountProspect.intake_source filter.",
4631
+ " --user <id> Optional account-user filter.",
3619
4632
  "",
3620
4633
  "API:",
3621
4634
  " GET /api/v1/accounts/:account_id/analytics/prospects.json"
@@ -3628,6 +4641,43 @@ const HELP_TOPICS = new Map([
3628
4641
  "Alias for `audienti analytics prospects`."
3629
4642
  ].join("\n")],
3630
4643
 
4644
+ ["analytics users", [
4645
+ "Usage:",
4646
+ ` ${ANALYTICS_USERS_USAGE.slice("Usage: ".length)}`,
4647
+ "",
4648
+ "Status: implemented",
4649
+ "",
4650
+ "Purpose:",
4651
+ " Audit one account user's outbound action history with the same actor semantics used by the Operations user analytics page.",
4652
+ "",
4653
+ "Options:",
4654
+ " --user <account_user_id|email|name|me> Defaults to me.",
4655
+ " --window <w> Activity window. Defaults to 30d when --start/--end are not provided.",
4656
+ " --start <YYYY-MM-DD> --end <YYYY-MM-DD> Explicit events.created_at activity range.",
4657
+ " --cohort-start <YYYY-MM-DD> --cohort-end <YYYY-MM-DD> Optional AccountProspect.created_at cohort filter.",
4658
+ " --motion <motn_id> Optional motion/play filter.",
4659
+ " --provenance <source> Optional AccountProspect.intake_source filter.",
4660
+ " --platform <linkedin|email|gmail> Optional events.platform filter. `email` includes email and gmail rows; --channel is an alias.",
4661
+ "",
4662
+ "Output shape:",
4663
+ " account_user: selected account user",
4664
+ " summary: performed-by-user totals and performed-by-others comparison",
4665
+ " daily_actions[]: action counts by events.created_at date",
4666
+ " action_mix[]: action type counts and percentages",
4667
+ " platform: selected platform/channel filter when --platform or --channel is provided",
4668
+ " platform_mix[]: platform counts and percentages",
4669
+ "",
4670
+ "API:",
4671
+ " GET /api/v1/accounts/:account_id/analytics/users.json"
4672
+ ].join("\n")],
4673
+
4674
+ ["analytics user", [
4675
+ "Usage:",
4676
+ " audienti analytics user [--user <account_user_id|email|name|me>] [--window 30d | --start YYYY-MM-DD --end YYYY-MM-DD] [--cohort-start YYYY-MM-DD --cohort-end YYYY-MM-DD] [--motion <motn_id>] [--json] [--account <acct_id>]",
4677
+ "",
4678
+ "Alias for `audienti analytics users`."
4679
+ ].join("\n")],
4680
+
3631
4681
  ["analytics visibility", [
3632
4682
  "Usage:",
3633
4683
  " audienti analytics visibility [--window 24h] [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]",
@@ -3703,6 +4753,7 @@ const HELP_TOPICS = new Map([
3703
4753
  " audienti prospects report-bad-profile <prsp_id> <prof_id>",
3704
4754
  " audienti prospects add-note <prsp_id> --type steer --message \"Meeting will not happen\" --engagement-type action.meeting.canceled",
3705
4755
  " audienti prospects sequence-preview <prsp_id>",
4756
+ " audienti writer test-run <prsp_id>",
3706
4757
  " audienti prospects sequence-export <prsp_id> --csv",
3707
4758
  "",
3708
4759
  "5. Attach existing prospects without re-importing",
@@ -3717,6 +4768,7 @@ const HELP_TOPICS = new Map([
3717
4768
  "",
3718
4769
  "7. Inspect account analytics",
3719
4770
  " audienti analytics prospects --window 24h",
4771
+ " audienti analytics users --user me --window 30d",
3720
4772
  " audienti analytics visibility --window 24h --user me",
3721
4773
  " audienti analytics content --window week",
3722
4774
  "",