@audienti/cli 0.1.20 → 0.1.22

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to the Audienti CLI are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.22] - 2026-07-21
8
+
9
+ ### Added
10
+
11
+ - Add `related` mode to `audienti users activity` for inspecting human work performed through the selected user's connected accounts.
12
+
13
+ ## [0.1.21] - 2026-07-20
14
+
15
+ ### Changed
16
+
17
+ - Show scoped help when a CLI command prefix is entered without the required remaining command or arguments, accept `account` as an alias for `accounts`, make `writer test-run` build the timeline without drafting every message by default, back writer test runs with a reusable report session instead of a CLI-local cache, run writer report work through a queued job that the CLI polls instead of holding one long HTTP request open, add `--report <rprt_id>` to continue a writer session, add `--no-wait` for launching queued writer work and coming back later, add `writer test-run show <prsp_id> <rprt_id>` to retrieve queued writer output after it finishes, let the workspace `bin/cli` reuse the global account selection without carrying bearer tokens across hosts, and explain API network failures with the configured host.
18
+
7
19
  ## [0.1.20] - 2026-07-19
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -148,23 +148,45 @@ and one AccountProspect.created_at cohort:
148
148
  audienti analytics users --user me --start 2026-07-01 --end 2026-07-07 --cohort-start 2026-06-01 --cohort-end 2026-06-30 --motion <motn_id>
149
149
  ```
150
150
 
151
- To run a writer campaign test for one prospect, including the no-reply path,
152
- planned actions, channel changes, and drafted messages:
151
+ To start a report-backed writer session for one prospect, including the no-reply
152
+ path, planned actions, channel changes, and planned message rows:
153
153
 
154
154
  ```bash
155
155
  audienti writer test-run <prsp_id>
156
156
  ```
157
157
 
158
- For fast simulator work, plan the branches without drafting every message:
158
+ The output includes a `Report: rprt_...` id. Treat that report id as the writing
159
+ session handle while the generated report is retained.
160
+
161
+ To draft every message into that report-backed session, opt into full report
162
+ mode:
163
+
164
+ ```bash
165
+ audienti writer test-run <prsp_id> --mode report --report <rprt_id>
166
+ ```
167
+
168
+ For writer debugging, draft only one selected timeline row on one branch into
169
+ the same report:
170
+
171
+ ```bash
172
+ audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id>
173
+ ```
174
+
175
+ The CLI queues the report job on the API and polls until the report finishes, so
176
+ slower writer calls do not depend on a single long HTTP request. Use
177
+ `--timeout-seconds <n>` to adjust the CLI wait budget.
178
+
179
+ To queue work into the report and come back later:
159
180
 
160
181
  ```bash
161
- audienti writer test-run <prsp_id> --mode plan
182
+ audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id> --no-wait
162
183
  ```
163
184
 
164
- For writer debugging, draft only one selected timeline row on one branch:
185
+ If the CLI stops waiting before the server job finishes, fetch the completed
186
+ report later with:
165
187
 
166
188
  ```bash
167
- audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3
189
+ audienti writer test-run show <prsp_id> <rprt_id>
168
190
  ```
169
191
 
170
192
  To update a prospect's attached profile channels through the same paths used by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audienti/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Agent-first command-line client for Audienti.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -72,4 +72,18 @@ audienti tools linkedin-review --url https://www.linkedin.com/in/example --icp <
72
72
  audienti tools linkedin-review reports --json
73
73
  audienti tools linkedin-review show <rprt_id> --json
74
74
  audienti tools linkedin-review status <rprt_id> --json
75
+ audienti writer test-run <prsp_id>
76
+ audienti writer test-run <prsp_id> --mode report --report <rprt_id>
77
+ audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id>
78
+ audienti writer test-run <prsp_id> --mode step --branch no-accept --step 3 --report <rprt_id> --no-wait
79
+ audienti writer test-run show <prsp_id> <rprt_id>
75
80
  ```
81
+
82
+ `audienti writer test-run <prsp_id>` starts a report-backed writing session and
83
+ builds the campaign timeline without drafting every message. The printed
84
+ `Report: rprt_...` id is the session handle. Pass `--report <rprt_id>` when
85
+ drafting every message with `--mode report` or one selected row with
86
+ `--mode step`; the server report supplies prior drafted rows as context. Use
87
+ `--timeout-seconds <n>` for longer waits, or `--no-wait` to launch and return
88
+ immediately. Use `writer test-run show` with the report id to fetch the
89
+ completed report later.
package/src/api-client.js CHANGED
@@ -1,11 +1,12 @@
1
1
  export const DEFAULT_HOST = "https://app.audienti.com";
2
2
 
3
3
  export class ApiError extends Error {
4
- constructor(message, { status, body } = {}) {
4
+ constructor(message, { status, body, cause } = {}) {
5
5
  super(message);
6
6
  this.name = "ApiError";
7
7
  this.status = status;
8
8
  this.body = body;
9
+ this.cause = cause;
9
10
  }
10
11
  }
11
12
 
@@ -433,6 +434,17 @@ export class AudientiClient {
433
434
  });
434
435
  }
435
436
 
437
+ createProspectSequenceExportJob(accountId, prospectId, body = {}) {
438
+ return this.requestJson(accountPath(accountId, ["prospects", prospectId, "sequence_export_jobs"]), {
439
+ method: "POST",
440
+ body
441
+ });
442
+ }
443
+
444
+ prospectSequenceExportJob(accountId, prospectId, jobId) {
445
+ return this.requestJson(accountPath(accountId, ["prospects", prospectId, "sequence_export_jobs", jobId]));
446
+ }
447
+
436
448
  addProspectNote(accountId, prospectId, body) {
437
449
  return this.requestJson(accountPath(accountId, ["prospects", prospectId, "add_note"]), {
438
450
  method: "POST",
@@ -516,11 +528,18 @@ export class AudientiClient {
516
528
  }
517
529
 
518
530
  async requestJson(path, { method = "GET", body } = {}) {
519
- const response = await this.fetchImpl(new URL(path, `${this.host}/`), {
520
- method,
521
- headers: this.headers(body),
522
- body: body === undefined ? undefined : JSON.stringify(body)
523
- });
531
+ const url = new URL(path, `${this.host}/`);
532
+ let response;
533
+
534
+ try {
535
+ response = await this.fetchImpl(url, {
536
+ method,
537
+ headers: this.headers(body),
538
+ body: body === undefined ? undefined : JSON.stringify(body)
539
+ });
540
+ } catch (error) {
541
+ throw new ApiError(networkErrorMessage(url, error), { cause: error });
542
+ }
524
543
 
525
544
  const responseBody = await parseBody(response);
526
545
 
@@ -608,3 +627,13 @@ function errorMessage(status, body) {
608
627
 
609
628
  return `Audienti API request failed with HTTP ${status}.`;
610
629
  }
630
+
631
+ function networkErrorMessage(url, error) {
632
+ const detail = error?.message ? ` (${error.message})` : "";
633
+ return [
634
+ `Unable to reach Audienti API at ${url.origin}.`,
635
+ "Check that the app is running and the configured host is correct.",
636
+ "For workspace-local CLI auth, start the workspace app server first or pass `--host <url>` to `auth token`.",
637
+ detail
638
+ ].join(" ").trim();
639
+ }
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { basename, dirname, join } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
4
  import { ApiError, AudientiClient, DEFAULT_HOST, normalizeHost } from "./api-client.js";
5
5
  import { configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
6
6
 
@@ -17,6 +17,8 @@ const DEFAULT_LIST_LIMIT = 20;
17
17
  const API_MAX_LIST_LIMIT = 100;
18
18
  const DEFAULT_LOOKUP_TIMEOUT_SECONDS = 60;
19
19
  const DEFAULT_LOOKUP_POLL_INTERVAL_SECONDS = 2;
20
+ const DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS = 180;
21
+ const DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS = 2;
20
22
  const PACKAGE_NAME = "@audienti/cli";
21
23
  const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
22
24
  const DEFAULT_PROFILE_IDENTIFIERS = [
@@ -49,11 +51,12 @@ const COMPANY_RULES_CREATE_USAGE = "Usage: audienti company-rules create (--link
49
51
  const COMPANY_RULES_UPDATE_USAGE = "Usage: audienti company-rules update <rule_id> [--linkedin-url <url>] [--domain <domain>] [--disposition <monitor|nurture|not_fit|reject>] [--name <text>] [--user <account_user_id|email|me|none>] [--note <text>] [--json] [--account <acct_id>]";
50
52
  const COMPANY_RULES_REMOVE_USAGE = "Usage: audienti company-rules remove <rule_id> [--json] [--account <acct_id>]";
51
53
  const COMPANY_RULES_APPLY_USAGE = "Usage: audienti company-rules apply (<rule_id>|--all) [--json] [--account <acct_id>]";
52
- const USERS_ACTIVITY_USAGE = "Usage: audienti users activity [account_user_id|me] [--mode <actor|account_usage>] [--window <24h|7d|30d>] [--platform <linkedin|email|gmail>] [--query <text>] [--limit <n>] [--page <n>] [--json] [--account <acct_id>]";
54
+ const USERS_ACTIVITY_USAGE = "Usage: audienti users activity [account_user_id|me] [--mode <actor|account_usage|related>] [--window <24h|7d|30d>] [--platform <linkedin|email|gmail>] [--query <text>] [--limit <n>] [--page <n>] [--json] [--account <acct_id>]";
53
55
  const OFFERS_SHOW_USAGE = "Usage: audienti offers show <offr_id> [--json] [--account <acct_id>]";
54
56
  const OFFERS_UPDATE_USAGE = "Usage: audienti offers update <offr_id> [--name <text>] [--description <text>] [--url <url>] [--json] [--account <acct_id>]";
55
57
  const OFFERS_DELETE_USAGE = "Usage: audienti offers delete <offr_id> --confirm <yes|true|Y|y> [--json] [--account <acct_id>]";
56
- 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>]";
58
+ const WRITER_TEST_RUN_USAGE = "Usage: audienti writer test-run <prsp_id> [--json] [--mode <plan|report|step>] [--branch <both|no-accept|accepted>] [--step <step_key|row_number>] [--report <rprt_id>] [--no-wait] [--timeout-seconds <n>] [--poll-interval-seconds <n>] [--account <acct_id>]";
59
+ const WRITER_TEST_RUN_SHOW_USAGE = "Usage: audienti writer test-run show <prsp_id> <rprt_id> [--json] [--account <acct_id>]";
57
60
  const MOTIONS_ANALYTICS_USAGE = "Usage: audienti motions analytics <motn_id> [--window 30d] [--json] [--account <acct_id>]";
58
61
  const MOTIONS_UPDATE_USAGE = "Usage: audienti motions update <motn_id> [--status <draft|preparing|active|paused|archived>] [--tags <tag[,tag...]>] [--own-post-engagement <true|false>] [--json] [--account <acct_id>]";
59
62
  const CONTENT_PROGRAMS_USAGE = "Usage: audienti content programs [--user <account_user_id|email|name|me>] [--json] [--account <acct_id>]";
@@ -103,7 +106,6 @@ const COHORT_STAGE_ORDER = [
103
106
  "cancel"
104
107
  ];
105
108
  const DAY_OF_WEEK_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
106
- const WRITER_TEST_RUN_CACHE_VERSION = 1;
107
109
  const SEQUENCE_EXPORT_CSV_COLUMNS = [
108
110
  "prospect_id",
109
111
  "prospect_name",
@@ -158,6 +160,12 @@ async function dispatch(argv, context) {
158
160
  return 0;
159
161
  }
160
162
 
163
+ const implicitHelpTopic = incompleteHelpTopicFromArgs(args);
164
+ if (implicitHelpTopic) {
165
+ writeLine(context.stdout, helpFor(implicitHelpTopic));
166
+ return 0;
167
+ }
168
+
161
169
  const [resource, action, ...rest] = args;
162
170
  const normalizedResource = normalizeResource(resource);
163
171
 
@@ -303,7 +311,42 @@ function helpTopicFromArgs(args) {
303
311
  return normalizeTopicParts(args.slice(0, helpIndex));
304
312
  }
305
313
 
314
+ function incompleteHelpTopicFromArgs(args) {
315
+ if (args.length === 0 || args[0] === "help") return null;
316
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg.startsWith("-"))) return null;
317
+
318
+ const topicParts = normalizeTopicParts(args);
319
+ const topic = topicParts.join(" ").trim();
320
+ const helpText = HELP_TOPICS.get(topic);
321
+ if (!helpText) return null;
322
+
323
+ return usageAllowsExactCommand(helpText, topic) ? null : topicParts;
324
+ }
325
+
326
+ function usageAllowsExactCommand(helpText, topic) {
327
+ return usageCommandsFor(helpText).some((command) => usageCommandAllowsExactTopic(command, topic));
328
+ }
329
+
330
+ function usageCommandsFor(helpText) {
331
+ return helpText.split("\n").flatMap((line) => {
332
+ const trimmed = line.trim();
333
+ if (trimmed.startsWith("audienti ")) return [trimmed.slice("audienti ".length)];
334
+
335
+ const oneLineUsage = trimmed.match(/^Usage:\s+audienti\s+(.+)$/);
336
+ return oneLineUsage ? [oneLineUsage[1]] : [];
337
+ });
338
+ }
339
+
340
+ function usageCommandAllowsExactTopic(command, topic) {
341
+ if (command === topic) return true;
342
+ if (!command.startsWith(`${topic} `)) return false;
343
+
344
+ const remainder = command.slice(topic.length).trimStart();
345
+ return remainder.startsWith("[");
346
+ }
347
+
306
348
  function normalizeTopicParts(parts) {
349
+ if (parts[0] === "account") return ["accounts", ...parts.slice(1)];
307
350
  if (parts[0] === "plays") return ["motions", ...parts.slice(1)];
308
351
  if (parts[0] === "principals") return ["users", ...parts.slice(1)];
309
352
  if (parts[0] === "writers") return ["writer", ...parts.slice(1)];
@@ -311,6 +354,7 @@ function normalizeTopicParts(parts) {
311
354
  }
312
355
 
313
356
  function normalizeResource(resource) {
357
+ if (resource === "account") return "accounts";
314
358
  if (resource === "principals") return "users";
315
359
  if (resource === "writers") return "writer";
316
360
  if (resource === "company_rules" || resource === "company-rules") return "company-rules";
@@ -2000,6 +2044,10 @@ async function prospectsSequencePreview(args, context, { accountOverride } = {})
2000
2044
  }
2001
2045
 
2002
2046
  async function writerTestRun(args, context, { accountOverride } = {}) {
2047
+ if (["show", "status"].includes(args[0])) {
2048
+ return writerTestRunShow(args.slice(1), context, { accountOverride });
2049
+ }
2050
+
2003
2051
  const { values, positionals } = parseCommandArgs(args, {
2004
2052
  ...jsonOptions(),
2005
2053
  branch: { type: "string" },
@@ -2007,8 +2055,10 @@ async function writerTestRun(args, context, { accountOverride } = {}) {
2007
2055
  mode: { type: "string" },
2008
2056
  step: { type: "string" },
2009
2057
  "angle-index": { type: "string" },
2010
- "no-cache": { type: "boolean" },
2011
- "clear-cache": { type: "boolean" }
2058
+ report: { type: "string" },
2059
+ "timeout-seconds": { type: "string" },
2060
+ "poll-interval-seconds": { type: "string" },
2061
+ "no-wait": { type: "boolean" }
2012
2062
  });
2013
2063
  if (positionals.length !== 1) throw new CommandError(WRITER_TEST_RUN_USAGE);
2014
2064
  if (values.branch && values.branches) throw new CommandError("Choose one branch filter: use either --branch or --branches.");
@@ -2019,28 +2069,65 @@ async function writerTestRun(args, context, { accountOverride } = {}) {
2019
2069
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
2020
2070
  const prospectId = positionals[0];
2021
2071
  const branchFilter = values.branches || values.branch || "both";
2022
- const useCache = ["plan", "target"].includes(draftMode) && !values["no-cache"];
2023
- if (useCache && values["clear-cache"]) await clearWriterTestRunCache(context, { accountId, prospectId });
2024
- const cache = useCache ? await loadWriterTestRunCache(context, { accountId, prospectId }) : emptyWriterTestRunCache(context, { accountId, prospectId });
2025
- const cachedDrafts = useCache ? writerCachedDraftsForRequest(cache, branchFilter) : [];
2026
-
2027
- const payload = await client.prospectSequenceExport(accountId, prospectId, compactObject({
2072
+ const requestBody = compactObject({
2028
2073
  branches: branchFilter,
2029
2074
  angle_index: values["angle-index"],
2030
2075
  draft_mode: draftMode,
2031
2076
  target_step: values.step,
2032
- cached_drafts: cachedDrafts.length ? cachedDrafts : undefined
2033
- }));
2034
- if (useCache) {
2035
- payload.meta ||= {};
2036
- payload.meta.cache = writerCacheMeta(cache, cachedDrafts);
2037
- await persistWriterDraftsFromPayload(context, { cache, payload });
2077
+ session_report_id: values.report
2078
+ });
2079
+
2080
+ let payload;
2081
+ try {
2082
+ const startedPayload = await client.createProspectSequenceExportJob(accountId, prospectId, requestBody);
2083
+ if (values["no-wait"]) {
2084
+ if (values.json) return writeJson(context.stdout, startedPayload);
2085
+
2086
+ const completedPayload = sequenceExportJobResultPayload(startedPayload);
2087
+ if (completedPayload) return renderWriterTestRun(completedPayload, context);
2088
+
2089
+ return renderWriterTestRunJobStatus(startedPayload, context, {
2090
+ prospectId,
2091
+ reportId: startedPayload?.report?.prefix_id || startedPayload?.report?.id
2092
+ });
2093
+ }
2094
+
2095
+ payload = await waitForProspectSequenceExportJob(client, accountId, prospectId, startedPayload, {
2096
+ timeoutSeconds: normalizePositiveInteger(values["timeout-seconds"]) || DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS,
2097
+ pollIntervalSeconds: normalizePositiveInteger(values["poll-interval-seconds"]) || DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS,
2098
+ sleepImpl: context.sleep
2099
+ });
2100
+ } catch (error) {
2101
+ if (error instanceof ApiError && error.status === 404) {
2102
+ throw new CommandError(writerReportApiUnavailableMessage({ host: client.host }));
2103
+ }
2104
+
2105
+ if (error instanceof ApiError && error.status === 504 && draftMode === "target") {
2106
+ throw new CommandError(writerTestRunStepTimeoutMessage({ prospectId, branchFilter, step: values.step }));
2107
+ }
2108
+
2109
+ throw error;
2038
2110
  }
2039
2111
  if (values.json) return writeJson(context.stdout, payload);
2040
2112
 
2041
2113
  renderWriterTestRun(payload, context);
2042
2114
  }
2043
2115
 
2116
+ async function writerTestRunShow(args, context, { accountOverride } = {}) {
2117
+ const { values, positionals } = parseCommandArgs(args, jsonOptions());
2118
+ if (positionals.length !== 2) throw new CommandError(WRITER_TEST_RUN_SHOW_USAGE);
2119
+
2120
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
2121
+ const [prospectId, reportId] = positionals;
2122
+ const response = await client.prospectSequenceExportJob(accountId, prospectId, reportId);
2123
+ if (values.json) return writeJson(context.stdout, response);
2124
+
2125
+ const payload = sequenceExportJobResultPayload(response);
2126
+ if (payload) return renderWriterTestRun(payload, context);
2127
+
2128
+ renderWriterTestRunJobStatus(response, context, { prospectId, reportId });
2129
+ }
2130
+
2044
2131
  async function runSequencePreviewCommand(args, context, { accountOverride, usageText, title }) {
2045
2132
  const { values, positionals } = parseCommandArgs(args, {
2046
2133
  ...jsonOptions(),
@@ -2090,7 +2177,7 @@ async function prospectsSequenceExport(args, context, { accountOverride } = {})
2090
2177
  }
2091
2178
 
2092
2179
  function normalizeWriterTestRunMode(value) {
2093
- const normalized = String(value || "report").trim().toLowerCase();
2180
+ const normalized = String(value || "plan").trim().toLowerCase();
2094
2181
  if (["report", "draft", "drafts", "all", "full"].includes(normalized)) return "all";
2095
2182
  if (normalized === "plan") return "plan";
2096
2183
  if (["step", "target"].includes(normalized)) return "target";
@@ -2098,159 +2185,20 @@ function normalizeWriterTestRunMode(value) {
2098
2185
  throw new CommandError("Unsupported writer test-run mode. Use report, plan, or step.");
2099
2186
  }
2100
2187
 
2101
- function emptyWriterTestRunCache(context, { accountId, prospectId }) {
2102
- return {
2103
- version: WRITER_TEST_RUN_CACHE_VERSION,
2104
- account_id: accountId,
2105
- prospect_id: prospectId,
2106
- path: writerTestRunCachePath(context, { accountId, prospectId }),
2107
- entries: {}
2108
- };
2109
- }
2110
-
2111
- async function loadWriterTestRunCache(context, { accountId, prospectId }) {
2112
- const cache = emptyWriterTestRunCache(context, { accountId, prospectId });
2113
-
2114
- try {
2115
- const parsed = JSON.parse(await readFile(cache.path, "utf8"));
2116
- if (parsed?.version !== WRITER_TEST_RUN_CACHE_VERSION) return cache;
2117
-
2118
- return {
2119
- ...cache,
2120
- entries: parsed.entries && typeof parsed.entries === "object" ? parsed.entries : {}
2121
- };
2122
- } catch (error) {
2123
- if (error.code === "ENOENT") return cache;
2124
- if (error instanceof SyntaxError) return cache;
2125
-
2126
- throw error;
2127
- }
2128
- }
2129
-
2130
- async function clearWriterTestRunCache(context, { accountId, prospectId }) {
2131
- await rm(writerTestRunCachePath(context, { accountId, prospectId }), { force: true });
2132
- }
2133
-
2134
- function writerTestRunCachePath(context, { accountId, prospectId }) {
2135
- const dir = context.env.AUDIENTI_WRITER_TEST_RUN_CACHE_DIR || join(context.cwd, "tmp", "writer-test-run-cache");
2136
- return join(dir, `${safeCacheSegment(accountId)}-${safeCacheSegment(prospectId)}.json`);
2137
- }
2138
-
2139
- function safeCacheSegment(value) {
2140
- return String(value || "unknown").replace(/[^a-zA-Z0-9_.-]/g, "_");
2141
- }
2142
-
2143
- function writerCachedDraftsForRequest(cache, branchFilter) {
2144
- const branchKeys = writerRequestedBranchKeys(branchFilter);
2145
- return Object.values(cache.entries || {})
2146
- .filter((entry) => branchKeys.includes(entry.branch))
2147
- .filter((entry) => entry.key && (entry.body || entry.text || entry.subject || writerCacheQualityFailure(entry)))
2148
- .map((entry) => compactObject({
2149
- branch: entry.branch,
2150
- key: entry.key,
2151
- stage: entry.stage,
2152
- channel: entry.channel,
2153
- platform: entry.platform,
2154
- message_mode: entry.message_mode,
2155
- subject: entry.subject,
2156
- body: entry.body,
2157
- text: entry.text,
2158
- status: entry.status,
2159
- quality_codes: entry.quality_codes,
2160
- blank_reason: entry.blank_reason,
2161
- writer_path: entry.writer_path,
2162
- generated_at: entry.generated_at,
2163
- writer_engine: entry.writer_engine,
2164
- target: entry.target,
2165
- metadata: entry.metadata
2166
- }));
2167
- }
2168
-
2169
- function writerRequestedBranchKeys(branchFilter) {
2170
- const values = String(branchFilter || "both").split(",").map((value) => value.trim()).filter(Boolean);
2171
- if (values.length === 0 || values.includes("both")) return ["no_accept", "accepted"];
2172
-
2173
- return values.map((value) => {
2174
- const normalized = value.replaceAll("-", "_");
2175
- if (["default", "no_accept", "not_connected"].includes(normalized)) return "no_accept";
2176
- if (normalized === "accepted") return "accepted";
2177
- return normalized;
2178
- });
2179
- }
2180
-
2181
- function writerCacheMeta(cache, cachedDrafts) {
2182
- return {
2183
- enabled: true,
2184
- path: cache.path,
2185
- entry_count: Object.keys(cache.entries || {}).length,
2186
- sent_draft_count: cachedDrafts.length
2187
- };
2188
- }
2189
-
2190
- async function persistWriterDraftsFromPayload(context, { cache, payload }) {
2191
- const entries = { ...(cache.entries || {}) };
2192
- let changed = false;
2193
-
2194
- for (const branch of Array.isArray(payload?.branches) ? payload.branches : []) {
2195
- const branchKey = String(branch?.key || "").trim();
2196
- if (!branchKey) continue;
2197
-
2198
- for (const step of Array.isArray(branch?.steps) ? branch.steps : []) {
2199
- const entry = writerCacheEntryFromStep(step, { branchKey, generatedAt: branch.generated_at || payload?.generated_at });
2200
- if (!entry) continue;
2201
-
2202
- entries[writerCacheEntryKey(entry)] = entry;
2203
- changed = true;
2204
- }
2205
- }
2206
-
2207
- if (!changed) return;
2208
-
2209
- const nextCache = {
2210
- version: WRITER_TEST_RUN_CACHE_VERSION,
2211
- account_id: cache.account_id,
2212
- prospect_id: cache.prospect_id,
2213
- updated_at: new Date().toISOString(),
2214
- entries
2215
- };
2216
- await mkdir(dirname(cache.path), { recursive: true });
2217
- await writeFile(cache.path, `${JSON.stringify(nextCache, null, 2)}\n`, "utf8");
2218
- cache.entries = entries;
2219
- }
2220
-
2221
- function writerCacheEntryFromStep(step, { branchKey, generatedAt }) {
2222
- if (step?.kind !== "message") return null;
2223
- if (!step.key) return null;
2224
- if (!(step.body || step.text || step.subject || writerCacheQualityFailure(step))) return null;
2225
- if (step.status === "planned" || step.status === "unavailable" || step.status === "error") return null;
2226
-
2227
- return compactObject({
2228
- branch: branchKey,
2229
- key: step.key,
2230
- stage: step.stage,
2231
- channel: step.channel,
2232
- platform: step.platform,
2233
- message_mode: step.message_mode,
2234
- subject: step.subject,
2235
- body: step.body,
2236
- text: step.text,
2237
- status: step.status,
2238
- quality_codes: Array.isArray(step.quality_codes) ? step.quality_codes.filter(Boolean) : undefined,
2239
- blank_reason: step.blank_reason,
2240
- writer_path: step.writer_path,
2241
- generated_at: step.generated_at || generatedAt || new Date().toISOString(),
2242
- writer_engine: step.writer_engine || step?.metadata?.writer_engine,
2243
- target: step.target,
2244
- metadata: step.metadata
2245
- });
2246
- }
2247
-
2248
- function writerCacheEntryKey(entry) {
2249
- return `${entry.branch}:${entry.key}`;
2188
+ function writerTestRunStepTimeoutMessage({ prospectId, branchFilter, step }) {
2189
+ return [
2190
+ `Timed out while drafting writer step ${display(step)} on branch ${display(branchFilter)}.`,
2191
+ "The timeline command is working; this timeout happened during the selected writer generation, not account selection or API connectivity.",
2192
+ `Inspect the timeline with \`audienti writer test-run ${prospectId}\`, then retry the row or use its step key, for example \`--step connection_request\`.`
2193
+ ].join(" ");
2250
2194
  }
2251
2195
 
2252
- function writerCacheQualityFailure(entry) {
2253
- return String(entry?.status || "") === "quality_failure";
2196
+ function writerReportApiUnavailableMessage({ host }) {
2197
+ return [
2198
+ `The configured Audienti API at ${host} does not support report-backed writer sessions yet.`,
2199
+ "This CLI expects the /sequence_export_jobs writer report API.",
2200
+ "Use a local app server running this branch and re-auth with `audienti auth token <token> --host <url>`, or deploy this branch before pointing the CLI at production."
2201
+ ].join(" ");
2254
2202
  }
2255
2203
 
2256
2204
  async function prospectsImport(args, context, { accountOverride } = {}) {
@@ -3260,6 +3208,79 @@ function importFinished(payload) {
3260
3208
  return payload?.ready === true || payload?.status === "completed" || payload?.status === "failed";
3261
3209
  }
3262
3210
 
3211
+ async function waitForProspectSequenceExportJob(client, accountId, prospectId, startedPayload, {
3212
+ timeoutSeconds = DEFAULT_WRITER_TEST_RUN_TIMEOUT_SECONDS,
3213
+ pollIntervalSeconds = DEFAULT_WRITER_TEST_RUN_POLL_INTERVAL_SECONDS,
3214
+ sleepImpl = sleep
3215
+ } = {}) {
3216
+ const firstResult = sequenceExportJobResultPayload(startedPayload);
3217
+ if (firstResult) return firstResult;
3218
+
3219
+ const reportId = startedPayload?.report?.prefix_id || startedPayload?.report?.id;
3220
+ if (!reportId) {
3221
+ throw new CommandError("Writer test run did not return a sequence export job id.");
3222
+ }
3223
+ if (sequenceExportJobFailed(startedPayload)) {
3224
+ throw new CommandError(writerTestRunJobFailureMessage(startedPayload, { reportId }));
3225
+ }
3226
+
3227
+ const timeoutAt = Date.now() + (timeoutSeconds * 1000);
3228
+ let latest = startedPayload;
3229
+
3230
+ while (Date.now() < timeoutAt) {
3231
+ await sleepImpl(pollIntervalSeconds * 1000);
3232
+ latest = await client.prospectSequenceExportJob(accountId, prospectId, reportId);
3233
+ const result = sequenceExportJobResultPayload(latest);
3234
+ if (result) return result;
3235
+ if (sequenceExportJobFailed(latest)) {
3236
+ throw new CommandError(writerTestRunJobFailureMessage(latest, { reportId }));
3237
+ }
3238
+ }
3239
+
3240
+ throw new CommandError(`Timed out after ${timeoutSeconds} seconds waiting for writer test run ${reportId}. The server job may still finish. Check it with: audienti writer test-run show ${prospectId} ${reportId}`);
3241
+ }
3242
+
3243
+ function sequenceExportJobResultPayload(payload) {
3244
+ if (payload?.report?.status !== "completed") return null;
3245
+
3246
+ const result = payload?.content?.payload;
3247
+ if (!result || typeof result !== "object") return null;
3248
+
3249
+ result.meta ||= {};
3250
+ result.meta.report_id = payload?.report?.prefix_id || payload?.report?.id || result.meta.report_id;
3251
+ return result;
3252
+ }
3253
+
3254
+ function sequenceExportJobFailed(payload) {
3255
+ const status = payload?.report?.status || payload?.run?.status;
3256
+ return status === "failed" || status === "canceled";
3257
+ }
3258
+
3259
+ function writerTestRunJobFailureMessage(payload, { reportId }) {
3260
+ const reason = payload?.content?.flat_payload?.error || payload?.error || "The sequence export job failed.";
3261
+ return `Writer test run ${reportId} failed: ${reason}`;
3262
+ }
3263
+
3264
+ function renderWriterTestRunJobStatus(payload, context, { prospectId, reportId }) {
3265
+ const report = payload?.report || {};
3266
+ const run = payload?.run || {};
3267
+ const status = report.status || run.status || "unknown";
3268
+
3269
+ writeLine(context.stdout, "Writer test run job");
3270
+ writeLine(context.stdout, `Report: ${display(report.prefix_id || reportId)} (${display(status)})`);
3271
+ if (report.stage) writeLine(context.stdout, `Stage: ${display(report.stage)}`);
3272
+ if (run.status) writeLine(context.stdout, `Run: ${display(run.status)}`);
3273
+ if (report.updated_at) writeLine(context.stdout, `Updated: ${display(report.updated_at)}`);
3274
+
3275
+ const error = payload?.content?.flat_payload?.error || payload?.error;
3276
+ if (error) {
3277
+ writeLine(context.stdout, `Error: ${display(error)}`);
3278
+ return;
3279
+ }
3280
+
3281
+ writeLine(context.stdout, `Not complete yet. Check later: audienti writer test-run show ${prospectId} ${report.prefix_id || reportId}`);
3282
+ }
3283
+
3263
3284
  function parseProspectTotalLimit(value) {
3264
3285
  const parsed = normalizePositiveInteger(value);
3265
3286
  if (parsed === null) return MAX_ALL_PROSPECTS;
@@ -4035,6 +4056,7 @@ function renderWriterTestRun(payload, context) {
4035
4056
 
4036
4057
  writeLine(context.stdout, "Writer campaign simulator");
4037
4058
  writeLine(context.stdout, `Prospect: ${display(prospect.display_name || prospect.name)} (${display(prospect.prefix_id)})`);
4059
+ if (payload?.meta?.report_id) writeLine(context.stdout, `Report: ${display(payload.meta.report_id)}`);
4038
4060
  if (payload?.context?.source) writeLine(context.stdout, `Context: ${payload.context.source}`);
4039
4061
  if (payload?.context?.message) writeLine(context.stdout, payload.context.message);
4040
4062
  if (payload?.context?.motion_name) writeLine(context.stdout, `Motion: ${payload.context.motion_name}`);
@@ -4042,10 +4064,6 @@ function renderWriterTestRun(payload, context) {
4042
4064
  if (payload?.context?.offer_name) writeLine(context.stdout, `Offer: ${payload.context.offer_name}`);
4043
4065
  writeLine(context.stdout, `Mode: ${display(draftMode)}`);
4044
4066
  if (targetStep) writeLine(context.stdout, `Target step: ${display(targetStep)}`);
4045
- if (payload?.meta?.cache?.enabled) {
4046
- writeLine(context.stdout, `Cache: ${display(payload.meta.cache.path)}`);
4047
- writeLine(context.stdout, `Cached drafts sent: ${display(payload.meta.cache.sent_draft_count || 0)}`);
4048
- }
4049
4067
  writeLine(context.stdout, `Start: ${isoDate(currentDate(context))}`);
4050
4068
  writeLine(context.stdout, "DATE: step execution date; for WAIT rows, the wait clears on that date.");
4051
4069
  writeLine(context.stdout, "Scenario: simulate the full path if the prospect does not reply.");
@@ -5661,7 +5679,7 @@ const HELP_TOPICS = new Map([
5661
5679
  "",
5662
5680
  "Input shape:",
5663
5681
  " account_user_id: integer account user id, or me for the saved default account user when configured",
5664
- " mode: actor | account_usage",
5682
+ " mode: actor | account_usage | related",
5665
5683
  " window: 24h | 7d | 30d",
5666
5684
  " platform: linkedin | email | gmail",
5667
5685
  "",
@@ -7318,10 +7336,11 @@ const HELP_TOPICS = new Map([
7318
7336
  "Status: implemented",
7319
7337
  "",
7320
7338
  "Purpose:",
7321
- " Run a writer campaign test for one prospect: resolve their current context, simulate the full no-reply path, and draft each message step.",
7339
+ " Run a report-backed writer session for one prospect: resolve current context, build the no-reply timeline, and optionally draft selected rows.",
7322
7340
  "",
7323
7341
  "Commands:",
7324
7342
  " audienti writer test-run <prsp_id>",
7343
+ " audienti writer test-run show <prsp_id> <rprt_id>",
7325
7344
  "",
7326
7345
  "Alias:",
7327
7346
  " audienti writers test-run <prsp_id>"
@@ -7334,26 +7353,60 @@ const HELP_TOPICS = new Map([
7334
7353
  "Status: implemented",
7335
7354
  "",
7336
7355
  "Purpose:",
7337
- " Run the prospect-scoped writer test run for a single prospect.",
7356
+ " Run or continue the prospect-scoped writer session report for a single prospect.",
7357
+ "",
7358
+ "Commands:",
7359
+ " audienti writer test-run <prsp_id>",
7360
+ " audienti writer test-run show <prsp_id> <rprt_id>",
7338
7361
  "",
7339
7362
  "Behavior:",
7340
- " 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.",
7363
+ " Creates or updates a server report for the writing session. The default plan mode writes the timeline to that report without drafting every message. Step mode drafts one selected row against the same report when --report <rprt_id> is passed. Drafting every message requires --mode report.",
7364
+ "",
7365
+ "Local workflow:",
7366
+ " 1. Start the local app server for the workspace, for example: direnv exec . bin/dev",
7367
+ " 2. Confirm bin/cli is pointed at the local API: bin/cli config list --json",
7368
+ " 3. Start or reopen the timeline report: bin/cli writer test-run <prsp_id>",
7369
+ " 4. Save the printed Report id, then draft one row into that same session:",
7370
+ " bin/cli writer test-run <prsp_id> --mode step --branch no-accept --step <row_number|step_key> --report <rprt_id>",
7371
+ " 5. To launch work and come back later, add --no-wait, then inspect it with:",
7372
+ " bin/cli writer test-run show <prsp_id> <rprt_id>",
7373
+ "",
7374
+ "Report workflow:",
7375
+ " The report is the session cache. Plan mode writes the timeline to the report. Step mode reads prior drafted rows from the same report and writes the selected row back to it. Reports expire after the server retention window.",
7341
7376
  "",
7342
7377
  "Options:",
7343
- " --mode <mode> report drafts every message, plan skips drafting, step drafts one selected step",
7378
+ " --mode <mode> plan skips drafting, report drafts every message, step drafts one selected step",
7344
7379
  " --branch <branch> Optional branch filter: both | no-accept | accepted",
7345
7380
  " --step <step_key|row_number> Required with --mode step. Row numbers come from the # column.",
7346
- " --no-cache Plan/step modes ignore locally cached simulator drafts",
7347
- " --clear-cache Plan/step modes clear locally cached simulator drafts before running",
7381
+ " --report <rprt_id> Continue an existing writer session report",
7382
+ " --no-wait Queue the writer report job, print the report id, and exit",
7383
+ " --timeout-seconds <n> Wait budget before the command fails. Default: 180",
7384
+ " --poll-interval-seconds <n> Status poll interval. Default: 2",
7348
7385
  "",
7349
7386
  "Output shape:",
7350
7387
  " branches[].key: no_accept | accepted",
7351
7388
  " branches[].steps[]: ordered wait/action/message/terminal steps for that simulated path",
7352
- " branches[].steps[].body: draft copy for message steps when the writer can generate one",
7389
+ " branches[].steps[].body: draft copy for message steps only in report mode, step mode, or when already present in the report",
7353
7390
  " branches[].summary: channel sequence, touch counts, duration, terminal disposition",
7354
7391
  "",
7355
7392
  "API:",
7356
- " POST /api/v1/accounts/:account_id/prospects/:id/sequence_export.json"
7393
+ " POST /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs.json, then poll GET /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs/:job_id.json"
7394
+ ].join("\n")],
7395
+
7396
+ ["writer test-run show", [
7397
+ "Usage:",
7398
+ ` ${WRITER_TEST_RUN_SHOW_USAGE.slice("Usage: ".length)}`,
7399
+ "",
7400
+ "Status: implemented",
7401
+ "",
7402
+ "Purpose:",
7403
+ " Fetch a writer session report by report id and render the saved output when it is complete.",
7404
+ "",
7405
+ "Behavior:",
7406
+ " Completed jobs render the same campaign simulator output as the original test-run command. Pending or processing jobs print report status and the check-later command. Failed jobs print the persisted failure reason.",
7407
+ "",
7408
+ "API:",
7409
+ " GET /api/v1/accounts/:account_id/prospects/:id/sequence_export_jobs/:job_id.json"
7357
7410
  ].join("\n")],
7358
7411
 
7359
7412
  ["prospects sequence-export", [
package/src/config.js CHANGED
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
 
5
5
  const CONFIG_FILENAME = "config.json";
6
+ const ACCOUNT_CONFIG_KEYS = ["accountId", "accountName", "accountUserId", "accountUserName", "accountUserEmail"];
6
7
 
7
8
  export class ConfigError extends Error {
8
9
  constructor(message) {
@@ -19,8 +20,25 @@ export function configPath(env = process.env) {
19
20
  return join(configDirectory(env), CONFIG_FILENAME);
20
21
  }
21
22
 
23
+ export function fallbackConfigDirectory(env = process.env) {
24
+ return env.AUDIENTI_CONFIG_FALLBACK_HOME || null;
25
+ }
26
+
27
+ export function fallbackConfigPath(env = process.env) {
28
+ const directory = fallbackConfigDirectory(env);
29
+ return directory ? join(directory, CONFIG_FILENAME) : null;
30
+ }
31
+
22
32
  export async function readConfig({ env = process.env } = {}) {
23
- const filePath = configPath(env);
33
+ const config = await readConfigFile(configPath(env));
34
+ const fallbackPath = fallbackConfigPath(env);
35
+ if (!fallbackPath || fallbackPath === configPath(env)) return applyConfigOverrides(config, env);
36
+
37
+ const fallbackConfig = await readConfigFile(fallbackPath);
38
+ return applyConfigOverrides(mergeFallbackConfig(config, fallbackConfig), env);
39
+ }
40
+
41
+ async function readConfigFile(filePath) {
24
42
 
25
43
  try {
26
44
  return JSON.parse(await readFile(filePath, "utf8"));
@@ -34,6 +52,49 @@ export async function readConfig({ env = process.env } = {}) {
34
52
  }
35
53
  }
36
54
 
55
+ function mergeFallbackConfig(config, fallbackConfig) {
56
+ const merged = { ...config };
57
+
58
+ if (!merged.host && !merged.token && fallbackConfig.host && fallbackConfig.token) {
59
+ merged.host = fallbackConfig.host;
60
+ merged.token = fallbackConfig.token;
61
+ }
62
+
63
+ if (!merged.accountId && fallbackConfig.accountId) {
64
+ for (const key of ACCOUNT_CONFIG_KEYS) {
65
+ if (fallbackConfig[key]) merged[key] = fallbackConfig[key];
66
+ }
67
+ } else if (merged.accountId && merged.accountId === fallbackConfig.accountId) {
68
+ for (const key of ACCOUNT_CONFIG_KEYS) {
69
+ if (!merged[key] && fallbackConfig[key]) merged[key] = fallbackConfig[key];
70
+ }
71
+ }
72
+
73
+ return merged;
74
+ }
75
+
76
+ function applyConfigOverrides(config, env) {
77
+ if (!env.AUDIENTI_CONFIG_HOST_OVERRIDE) return config;
78
+
79
+ const overridden = {
80
+ ...config,
81
+ host: env.AUDIENTI_CONFIG_HOST_OVERRIDE
82
+ };
83
+ if (config.token && !sameCredentialOrigin(config.host, overridden.host)) delete overridden.token;
84
+
85
+ return overridden;
86
+ }
87
+
88
+ function sameCredentialOrigin(left, right) {
89
+ if (!left || !right) return false;
90
+
91
+ try {
92
+ return new URL(left).origin === new URL(right).origin;
93
+ } catch {
94
+ return String(left).replace(/\/+$/, "") === String(right).replace(/\/+$/, "");
95
+ }
96
+ }
97
+
37
98
  export async function writeConfig(config, { env = process.env } = {}) {
38
99
  const filePath = configPath(env);
39
100
  const dir = dirname(filePath);