@audienti/cli 0.1.60 → 0.1.62

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,24 @@ All notable changes to the Audienti CLI are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.62] - 2026-09-10
8
+
9
+ ### Added
10
+
11
+ - `inbox-ops queue` now follows every page, numbers each row, saves the numbered list locally per account, and offers `--group-by domain`.
12
+ - Add bulk `inbox-ops ignore`, `filter-sender`, `filter-domain`, `allow-sender`, and `allow-domain` verbs that select rows by number, range, row id, `--domain`, or `--sender`, print a manifest, validate with `--dry-run`, and apply with `--yes` through the new `POST inbox_ops/actions` API with per-row results.
13
+ - Add `inbox_ops.queue`, `inbox_ops.filters`, `inbox_ops.actions`, `inbox_ops.rules.set`, and `inbox_ops.rules.remove` MCP tools.
14
+
15
+ ## [0.1.61] - 2026-09-08
16
+
17
+ ### Added
18
+
19
+ - Add owner-scoped `network-ops queue`, `accept`, and `decline` commands for pending inbound LinkedIn connection requests, with `reject` as a decline alias and explicit queued-versus-provider-confirmed output.
20
+
21
+ ### Fixed
22
+
23
+ - Preserve exact invitation-to-message binding in Network Ops output instead of assigning unrelated neighboring payload text.
24
+
7
25
  ## [0.1.60] - 2026-09-08
8
26
 
9
27
  ### Changed
package/README.md CHANGED
@@ -218,7 +218,8 @@ audienti operator next --plan
218
218
  audienti operator next --done --note "Connection request sent."
219
219
  ```
220
220
 
221
- `operator queue`, `operator failed-drafts`, and `inbox-ops queue` print a
221
+ `operator queue`, `operator failed-drafts`, `network-ops queue`, and
222
+ `inbox-ops queue` print a
222
223
  continuation command when more rows remain. Read-only `operator next` (including
223
224
  `--plan`) also prints one when its page has no focal move but more rows remain;
224
225
  when a move is present, inspect or act on it and rerun `operator next`.
@@ -229,22 +230,57 @@ with an offset. If the scan limit is reached, narrow the filters; an incomplete
229
230
  scan does not mean the queue is empty. JSON output preserves the API response.
230
231
  Pagination flags are not accepted when recording outcomes or requeueing drafts.
231
232
 
232
- Inbox Ops has its own owner-scoped CLI surface. Queue rows expose the stable row
233
- id used by the row-based rule command. Rules can also be set or removed directly
234
- by sender or domain; the server normalizes and validates every supplied key:
233
+ Network Ops has its own owner-scoped CLI surface for pending inbound LinkedIn
234
+ connection requests. It shows the exact invitation-bound message when one was
235
+ captured and queues the same canonical provider actions as the product UI:
235
236
 
236
237
  ```bash
237
- audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>]
238
+ audienti network-ops queue [--page <n>] [--offset <n>|--cursor <token>]
239
+ audienti network-ops accept <row_id>
240
+ audienti network-ops decline <row_id>
241
+ ```
242
+
243
+ `reject` is accepted as an alias for `decline`. An accepted API response means
244
+ the provider action was queued; it is not confirmation that LinkedIn completed
245
+ the action. The same queue can be read with
246
+ `audienti operator queue --opportunity-kind network`.
247
+
248
+ Inbox Ops has its own owner-scoped CLI surface built for clearing a backlog in
249
+ bulk. `inbox-ops queue` follows every page, numbers each row, and saves the
250
+ numbered list locally for the selected account. The bulk verbs select rows by
251
+ number, range, row id, `--domain`, or `--sender`, print a manifest, and apply
252
+ only with `--yes` (or an interactive confirmation). `--dry-run` validates every
253
+ row on the server and reports planned, skipped, and rejected rows without
254
+ changing anything:
255
+
256
+ ```bash
257
+ audienti inbox-ops queue
258
+ audienti inbox-ops queue --group-by domain
259
+ audienti inbox-ops ignore 1-25 --dry-run
260
+ audienti inbox-ops ignore 1-25 --yes
261
+ audienti inbox-ops filter-domain 3,7 --yes
262
+ audienti inbox-ops filter-domain --domain alerts.example.com --yes
263
+ audienti inbox-ops filter-sender 12 --yes
264
+ audienti inbox-ops allow-sender 4 --yes
265
+ audienti inbox-ops allow-domain --domain customer.example --yes
238
266
  audienti inbox-ops filters
239
267
  audienti inbox-ops rule <row_id> --scope sender --disposition filter
240
- audienti inbox-ops rule <row_id> --scope domain --disposition allow
241
268
  audienti inbox-ops rule set --scope sender --key news@example.com --disposition filter
242
269
  audienti inbox-ops rule remove --scope domain --key example.com
243
270
  ```
244
271
 
245
- The rule commands mirror the Operator card actions. They update only
246
- the authenticated owner's personal/global Inbox Ops preferences and do not
247
- archive mail, call the provider, or add a DNC entry.
272
+ Numbers stay valid until `inbox-ops queue` runs again. `ignore` hides one
273
+ thread until a newer reply arrives; `filter-*` and `allow-*` write durable rules
274
+ that apply to every current and future matching message across the owner's
275
+ accounts. Rule verbs given only `--domain` or `--sender` write the keyed rule
276
+ directly, even when the matching rows are no longer listed. Every row is
277
+ authorized individually on the server: rows another owner controls, stale rows,
278
+ and malformed ids are rejected one by one while the rest still apply. Pass
279
+ `--page`, `--offset`, or `--cursor` to read exactly one page instead.
280
+
281
+ All Inbox Ops commands update only the authenticated owner's personal/global
282
+ Inbox Ops preferences or private ignore receipts. They do not archive mail, call
283
+ the provider, or add a DNC entry.
248
284
 
249
285
  To manage simple reminders for yourself:
250
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audienti/cli",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "Agent-first command-line client for Audienti.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audienti
3
- description: Use when the user wants to operate Audienti through the production CLI or app-hosted MCP endpoint, including account selection, plays, prospect imports, lists and routing rules, message previews, analytics, or supported operator outcomes.
3
+ description: Use when the user wants to operate Audienti through the production CLI or app-hosted MCP endpoint, including account selection, plays, prospect imports, lists and routing rules, message previews, analytics, Inbox Ops, Network Ops, or supported operator outcomes.
4
4
  ---
5
5
 
6
6
  # Audienti CLI and MCP
@@ -12,6 +12,8 @@ undocumented API endpoints.
12
12
 
13
13
  Set `approach` in a Motion create/update payload, or use `motions update <id> --approach "..."`. A nonblank Approach automatically guides adaptive planning and writing; blank, nil, or whitespace keeps the existing sequence. The simple `--approach ""` flag explicitly clears the guidance and restores that sequence. There is no separate planning-mode selector, and existing sending controls still apply.
14
14
 
15
+ To clear an Inbox Ops backlog, run `inbox-ops queue` first: it follows every page, numbers each row, and saves the numbered list locally for the selected account. Then use `inbox-ops ignore`, `filter-domain`, `filter-sender`, `allow-domain`, or `allow-sender` with numbers, ranges (`1-25,30`), row ids, `--domain`, or `--sender`. Always run `--dry-run` first when acting for a person, read the per-row planned/skipped/rejected results back, and only then re-run with `--yes`. Numbers stay valid until `inbox-ops queue` runs again; ignore hides one thread until a new reply, while filter and allow rules are durable across all of the owner's accounts. Rule verbs given only `--domain` or `--sender` write the keyed rule directly. Through MCP use `inbox_ops.queue`, then `inbox_ops.actions` with `{operation, row_ids, dry_run}`.
16
+
15
17
  When Operator exposes `answer_planner_question`, read its prompt and candidate options with `operator queue` or `operator next`. Use `operator answer <row_id> --choice <id>` or `--answer "..."`, exactly one input. This command refetches the exact row and submits the current row and decision fingerprints. Do not substitute a generic outcome, a reusable steer note, or a guessed send. A 409 means refresh the question; 202 means the answer was recorded and planning continues. Answers apply only to that decision.
16
18
 
17
19
  ## Setup
@@ -136,7 +138,15 @@ audienti motions activate <motn_id> --json
136
138
  audienti motions delete <motn_id> --confirm yes --json
137
139
  audienti operator next --json
138
140
  audienti operator next --plan
141
+ audienti network-ops queue --json
142
+ audienti network-ops accept <row_id> --account <acct_id> --json
143
+ audienti network-ops decline <row_id> --account <acct_id> --json
139
144
  audienti inbox-ops queue --json
145
+ audienti inbox-ops queue --group-by domain
146
+ audienti inbox-ops ignore 1-25 --dry-run --json
147
+ audienti inbox-ops ignore 1-25 --yes --json
148
+ audienti inbox-ops filter-domain --domain alerts.example.com --yes --json
149
+ audienti inbox-ops filter-sender 3,7 --yes --json
140
150
  audienti inbox-ops filters --json
141
151
  audienti inbox-ops rule <row_id> --scope <sender|domain> --disposition <allow|filter> --json
142
152
  audienti analytics motions --json
package/src/api-client.js CHANGED
@@ -657,6 +657,12 @@ export class AudientiClient {
657
657
  return this.requestJson(accountPath(accountId, ["operator"], query));
658
658
  }
659
659
 
660
+ networkOpsAction(accountId, rowId, action) {
661
+ return this.requestJson(accountPath(accountId, ["network_ops", rowId, action]), {
662
+ method: "POST"
663
+ });
664
+ }
665
+
660
666
  inboxOpsFilters(accountId) {
661
667
  return this.requestJson(accountPath(accountId, ["inbox_ops", "filters"]));
662
668
  }
@@ -668,6 +674,13 @@ export class AudientiClient {
668
674
  });
669
675
  }
670
676
 
677
+ inboxOpsActions(accountId, body) {
678
+ return this.requestJson(accountPath(accountId, ["inbox_ops", "actions"]), {
679
+ method: "POST",
680
+ body
681
+ });
682
+ }
683
+
671
684
  setInboxOpsRule(accountId, body) {
672
685
  return this.requestJson(accountPath(accountId, ["inbox_ops", "rules"]), {
673
686
  method: "PATCH",
package/src/cli.js CHANGED
@@ -2,10 +2,11 @@ import { parseArgs } from "node:util";
2
2
  import { createServer } from "node:http";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomBytes } from "node:crypto";
5
- import { readFile } from "node:fs/promises";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { basename, join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { ApiError, AudientiClient, DEFAULT_HOST, normalizeHost } from "./api-client.js";
8
- import { configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
9
+ import { configDirectory, configPath, deleteConfig, maskToken, readConfig, writeConfig } from "./config.js";
9
10
 
10
11
  class CommandError extends Error {
11
12
  constructor(message, { exitCode = 1 } = {}) {
@@ -65,7 +66,27 @@ const PROSPECTS_CHECK_USAGE = "Usage: audienti prospects check [--json|--csv] [f
65
66
  const PROSPECTS_IMPORT_BATCH_USAGE = "Usage: audienti prospects import-batch --file <csv|jsonl|json> [--list <list_id>] [--motion <motn_id>] [--assigned-user <id|me>] [--json] [--account <acct_id>]";
66
67
  const OPERATOR_FAILED_DRAFTS_USAGE = "Usage: audienti operator failed-drafts [--json] [filters] [--account <acct_id>]";
67
68
  const OPERATOR_FAILED_DRAFTS_REQUEUE_USAGE = "Usage: audienti operator failed-drafts requeue (--all | <row_id> [row_id...]) [--limit <n>] [--json] [filters] [--account <acct_id>]";
68
- const INBOX_OPS_QUEUE_USAGE = "Usage: audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--json] [--account <acct_id>]";
69
+ const INBOX_OPS_QUEUE_USAGE = "Usage: audienti inbox-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--group-by domain] [--json] [--account <acct_id>]";
70
+ const INBOX_OPS_BULK_VERBS = {
71
+ ignore: "ignore",
72
+ "filter-sender": "filter_sender",
73
+ "filter-domain": "filter_domain",
74
+ "allow-sender": "allow_sender",
75
+ "allow-domain": "allow_domain"
76
+ };
77
+ const INBOX_OPS_BULK_LABELS = {
78
+ ignore: "Ignore",
79
+ "filter-sender": "Always filter the sender of",
80
+ "filter-domain": "Always filter the domain of",
81
+ "allow-sender": "Always show the sender of",
82
+ "allow-domain": "Always show the domain of"
83
+ };
84
+ const INBOX_OPS_SNAPSHOT_FILENAME = "inbox-ops-snapshot.json";
85
+ const INBOX_OPS_MAX_PAGES = 50;
86
+ const INBOX_OPS_BATCH_SIZE = 50;
87
+ const NETWORK_OPS_QUEUE_USAGE = "Usage: audienti network-ops queue [--page <n>] [--offset <n>|--cursor <token>] [--json] [--account <acct_id>]";
88
+ const NETWORK_OPS_ACCEPT_USAGE = "Usage: audienti network-ops accept <row_id> [--json] [--account <acct_id>]";
89
+ const NETWORK_OPS_DECLINE_USAGE = "Usage: audienti network-ops decline <row_id> [--json] [--account <acct_id>]";
69
90
  const OPERATOR_PAGINATION_HELP = [
70
91
  "Read pagination:",
71
92
  " --page <n> Positive page number",
@@ -80,6 +101,7 @@ const INBOX_OPS_RULE_USAGE = "Usage: audienti inbox-ops rule <row_id> --scope <s
80
101
  const INBOX_OPS_RULE_SET_USAGE = "Usage: audienti inbox-ops rule set --scope <sender|domain> --key <email|domain> --disposition <allow|filter> [--json] [--account <acct_id>]";
81
102
  const INBOX_OPS_RULE_REMOVE_USAGE = "Usage: audienti inbox-ops rule remove --scope <sender|domain> --key <email|domain> [--json] [--account <acct_id>]";
82
103
  const INBOX_OPS_ROW_ID_PATTERN = /^inbox_ops_message_[1-9]\d*$/;
104
+ const NETWORK_OPS_ROW_ID_PATTERN = /^network_ops_event_[1-9]\d*$/;
83
105
  const DNC_ADD_USAGE = "Usage: audienti dnc add <email|citation_id|profile_url> [--json] [--account <acct_id>]";
84
106
  const DNC_IMPORT_USAGE = "Usage: audienti dnc import --file <txt|csv> [--json] [--account <acct_id>]";
85
107
  const DNC_REMOVE_USAGE = "Usage: audienti dnc remove <dnc_entry_id> [--json] [--account <acct_id>]";
@@ -200,7 +222,8 @@ export async function run(argv = process.argv.slice(2), deps = {}) {
200
222
  now: deps.now || (() => new Date()),
201
223
  sleep: deps.sleep || sleep,
202
224
  stdout: deps.stdout || process.stdout,
203
- stderr: deps.stderr || process.stderr
225
+ stderr: deps.stderr || process.stderr,
226
+ stdin: deps.stdin || process.stdin
204
227
  };
205
228
 
206
229
  try {
@@ -346,9 +369,14 @@ async function dispatch(argv, context) {
346
369
  if (normalizedResource === "operator" && action === "next") return operatorNext(rest, context, { accountOverride });
347
370
  if (normalizedResource === "operator" && action === "outcome") return operatorOutcome(rest, context, { accountOverride });
348
371
  if (normalizedResource === "operator" && action === "answer") return operatorAnswer(rest, context, { accountOverride });
372
+ if (normalizedResource === "network-ops" && action === "queue") return networkOpsQueue(rest, context, { accountOverride });
373
+ if (normalizedResource === "network-ops" && ["accept", "decline", "reject"].includes(action)) {
374
+ return networkOpsAction(action, rest, context, { accountOverride });
375
+ }
349
376
  if (normalizedResource === "inbox-ops" && action === "queue") return inboxOpsQueue(rest, context, { accountOverride });
350
377
  if (normalizedResource === "inbox-ops" && action === "filters") return inboxOpsFilters(rest, context, { accountOverride });
351
378
  if (normalizedResource === "inbox-ops" && action === "rule") return inboxOpsRule(rest, context, { accountOverride });
379
+ if (normalizedResource === "inbox-ops" && Object.hasOwn(INBOX_OPS_BULK_VERBS, action)) return inboxOpsBulk(action, rest, context, { accountOverride });
352
380
  if (normalizedResource === "analytics" && ["prospects", "prospect"].includes(action)) return analyticsProspects(rest, context, { accountOverride });
353
381
  if (normalizedResource === "analytics" && ["users", "user"].includes(action)) return analyticsUsers(rest, context, { accountOverride });
354
382
  if (normalizedResource === "analytics" && ["visibility", "visops"].includes(action)) return analyticsVisibility(rest, context, { accountOverride });
@@ -3263,16 +3291,376 @@ async function operatorQueue(args, context, { accountOverride } = {}) {
3263
3291
  async function inboxOpsQueue(args, context, { accountOverride } = {}) {
3264
3292
  const { values, positionals } = parseCommandArgs(args, {
3265
3293
  ...jsonOptions(),
3266
- ...operatorPaginationOptions()
3294
+ ...operatorPaginationOptions(),
3295
+ "group-by": { type: "string" }
3267
3296
  });
3268
3297
  if (positionals.length > 0) throw new CommandError(INBOX_OPS_QUEUE_USAGE);
3298
+ const groupBy = values["group-by"];
3299
+ if (groupBy !== undefined && groupBy !== "domain") throw new CommandError("--group-by only supports domain.");
3269
3300
 
3270
3301
  const query = { opportunity_kind: "inbox", ...operatorPaginationQuery(values) };
3302
+ const singlePage = [values.page, values.offset, values.cursor].some((value) => value !== undefined);
3303
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
3304
+
3305
+ if (singlePage) {
3306
+ const payload = await client.operatorQueue(accountId, query);
3307
+ const rows = numberInboxOpsRows(inboxOpsPayloadRows(payload));
3308
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3309
+ if (values.json) return writeJson(context.stdout, payload);
3310
+
3311
+ renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(rows, context, { groupBy }));
3312
+ return;
3313
+ }
3314
+
3315
+ const { rows, pages, truncated } = await fetchAllInboxOpsRows(client, accountId, query);
3316
+ await writeInboxOpsSnapshot(context, { accountId, rows });
3317
+ if (values.json) {
3318
+ return writeJson(context.stdout, {
3319
+ kind: "inbox_ops_queue",
3320
+ account_id: accountId,
3321
+ row_count: rows.length,
3322
+ pages_fetched: pages,
3323
+ truncated,
3324
+ decision_queue: rows
3325
+ });
3326
+ }
3327
+
3328
+ renderInboxOpsQueue(rows, context, { groupBy });
3329
+ if (truncated) {
3330
+ writeLine(context.stdout, `Stopped after ${pages} pages; clear some rows and re-run to list the rest.`);
3331
+ }
3332
+ }
3333
+
3334
+ async function fetchAllInboxOpsRows(client, accountId, baseQuery) {
3335
+ const rows = [];
3336
+ const seen = new Set();
3337
+ let query = { ...baseQuery };
3338
+ let pages = 0;
3339
+ let truncated = false;
3340
+
3341
+ for (;;) {
3342
+ const payload = await client.operatorQueue(accountId, query);
3343
+ pages += 1;
3344
+ for (const row of inboxOpsPayloadRows(payload)) {
3345
+ if (seen.has(row.id)) continue;
3346
+ seen.add(row.id);
3347
+ rows.push(row);
3348
+ }
3349
+ if (payload?.has_more !== true) break;
3350
+ if (pages >= INBOX_OPS_MAX_PAGES) {
3351
+ truncated = true;
3352
+ break;
3353
+ }
3354
+
3355
+ const cursor = payload?.metrics?.next_cursor;
3356
+ const nextOffset = payload?.metrics?.next_offset;
3357
+ if (!cursor && !payload?.next_page) break;
3358
+ query = compactObject({
3359
+ ...baseQuery,
3360
+ operator_page: payload?.next_page,
3361
+ operator_cursor: cursor || undefined,
3362
+ operator_offset: cursor ? undefined : (nextOffset ?? undefined)
3363
+ });
3364
+ }
3365
+
3366
+ return { rows: numberInboxOpsRows(rows), pages, truncated };
3367
+ }
3368
+
3369
+ function inboxOpsPayloadRows(payload) {
3370
+ const decisionQueue = Array.isArray(payload?.decision_queue) ? payload.decision_queue : [];
3371
+ const rows = decisionQueue.length > 0 ? decisionQueue : [payload?.next_move].filter(Boolean);
3372
+ return rows.filter((row) => row?.id).map((row) => ({
3373
+ id: String(row.id),
3374
+ display_name: row.display_name,
3375
+ sender: row?.inbox_ops?.sender,
3376
+ domain: row?.inbox_ops?.domain,
3377
+ subject: row?.inbox_ops?.subject,
3378
+ channel: row?.inbox_ops?.channel,
3379
+ connected_account: row?.inbox_ops?.connected_account,
3380
+ state: row?.inbox_ops?.state
3381
+ }));
3382
+ }
3383
+
3384
+ function numberInboxOpsRows(rows) {
3385
+ return rows.map((row, index) => ({ number: index + 1, ...row }));
3386
+ }
3387
+
3388
+ function inboxOpsSnapshotPath(context) {
3389
+ return join(configDirectory(context.env), INBOX_OPS_SNAPSHOT_FILENAME);
3390
+ }
3391
+
3392
+ async function writeInboxOpsSnapshot(context, { accountId, rows }) {
3393
+ await mkdir(configDirectory(context.env), { recursive: true });
3394
+ await writeFile(inboxOpsSnapshotPath(context), JSON.stringify({
3395
+ version: 1,
3396
+ account_id: accountId,
3397
+ listed_at: context.now().toISOString(),
3398
+ rows
3399
+ }, null, 2));
3400
+ }
3401
+
3402
+ async function readInboxOpsSnapshot(context, accountId) {
3403
+ let snapshot;
3404
+ try {
3405
+ snapshot = JSON.parse(await readFile(inboxOpsSnapshotPath(context), "utf8"));
3406
+ } catch (error) {
3407
+ if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
3408
+ throw error;
3409
+ }
3410
+ if (!Array.isArray(snapshot?.rows)) return null;
3411
+ if (String(snapshot.account_id) !== String(accountId)) {
3412
+ throw new CommandError(`The current Inbox Ops list belongs to account ${snapshot.account_id}. Run \`audienti inbox-ops queue --account ${accountId}\` first.`);
3413
+ }
3414
+ return snapshot;
3415
+ }
3416
+
3417
+ function parseInboxOpsSelectors(positionals) {
3418
+ const numbers = new Set();
3419
+ const rowIds = [];
3420
+ const tokens = positionals.flatMap((value) => String(value).split(",")).map((token) => token.trim()).filter(Boolean);
3421
+
3422
+ for (const token of tokens) {
3423
+ if (/^\d+$/.test(token)) {
3424
+ numbers.add(Number(token));
3425
+ continue;
3426
+ }
3427
+ const range = /^(\d+)-(\d+)$/.exec(token);
3428
+ if (range) {
3429
+ const [start, end] = [Number(range[1]), Number(range[2])];
3430
+ if (start < 1 || end < start) throw new CommandError(`Invalid range "${token}". Use ascending ranges like 1-25.`);
3431
+ for (let number = start; number <= end; number += 1) numbers.add(number);
3432
+ continue;
3433
+ }
3434
+ if (INBOX_OPS_ROW_ID_PATTERN.test(token)) {
3435
+ rowIds.push(token);
3436
+ continue;
3437
+ }
3438
+ throw new CommandError(`Unrecognized selector "${token}". Use numbers, ranges like 1-25, or row ids like inbox_ops_message_123.`);
3439
+ }
3440
+ if (numbers.has(0)) throw new CommandError("Row numbers start at 1.");
3441
+
3442
+ return { numbers: [...numbers].sort((left, right) => left - right), rowIds };
3443
+ }
3444
+
3445
+ function inboxOpsDomainMatches(rowDomain, domain) {
3446
+ const candidate = String(rowDomain || "").trim().toLowerCase();
3447
+ const wanted = String(domain || "").trim().toLowerCase().replace(/^@/, "").replace(/\.$/, "");
3448
+ if (!candidate || !wanted) return false;
3449
+ return candidate === wanted || candidate.endsWith(`.${wanted}`);
3450
+ }
3451
+
3452
+ function inboxOpsSenderMatches(rowSender, sender) {
3453
+ const candidate = String(rowSender || "").trim().toLowerCase();
3454
+ const wanted = String(sender || "").trim().toLowerCase();
3455
+ return Boolean(candidate) && candidate === wanted;
3456
+ }
3457
+
3458
+ async function resolveInboxOpsSelection(context, accountId, { positionals, domain, sender, usage }) {
3459
+ const { numbers, rowIds } = parseInboxOpsSelectors(positionals);
3460
+ const needsSnapshot = numbers.length > 0 || domain !== undefined || sender !== undefined;
3461
+ const snapshot = needsSnapshot ? await readInboxOpsSnapshot(context, accountId) : null;
3462
+ if (needsSnapshot && !snapshot) {
3463
+ throw new CommandError("No Inbox Ops list for this account yet. Run `audienti inbox-ops queue` first, then select rows by number.");
3464
+ }
3465
+
3466
+ const selected = [];
3467
+ const seen = new Set();
3468
+ const add = (row) => {
3469
+ if (seen.has(row.id)) return;
3470
+ seen.add(row.id);
3471
+ selected.push(row);
3472
+ };
3473
+
3474
+ for (const number of numbers) {
3475
+ const row = snapshot.rows.find((candidate) => candidate.number === number);
3476
+ if (!row) {
3477
+ throw new CommandError(`Row ${number} is not in the current Inbox Ops list (1-${snapshot.rows.length}). Re-run \`audienti inbox-ops queue\` to refresh the numbers.`);
3478
+ }
3479
+ add(row);
3480
+ }
3481
+ if (domain !== undefined) {
3482
+ const matches = snapshot.rows.filter((row) => inboxOpsDomainMatches(row.domain, domain));
3483
+ if (matches.length === 0) throw new CommandError(`No listed rows match domain ${domain}.`);
3484
+ matches.forEach(add);
3485
+ }
3486
+ if (sender !== undefined) {
3487
+ const matches = snapshot.rows.filter((row) => inboxOpsSenderMatches(row.sender, sender));
3488
+ if (matches.length === 0) throw new CommandError(`No listed rows match sender ${sender}.`);
3489
+ matches.forEach(add);
3490
+ }
3491
+ for (const rowId of rowIds) add(snapshot?.rows.find((row) => row.id === rowId) || { id: rowId });
3492
+ if (selected.length === 0) throw new CommandError(usage);
3493
+
3494
+ return selected;
3495
+ }
3496
+
3497
+ function inboxOpsBulkUsage(verb) {
3498
+ return `Usage: audienti inbox-ops ${verb} <numbers|ranges|row_ids...> [--domain <domain>] [--sender <email>] [--dry-run] [--yes] [--json] [--account <acct_id>]`;
3499
+ }
3500
+
3501
+ async function inboxOpsBulk(verb, args, context, { accountOverride } = {}) {
3502
+ const operation = INBOX_OPS_BULK_VERBS[verb];
3503
+ const usage = inboxOpsBulkUsage(verb);
3504
+ const { values, positionals } = parseCommandArgs(args, {
3505
+ ...jsonOptions(),
3506
+ domain: { type: "string" },
3507
+ sender: { type: "string" },
3508
+ "dry-run": { type: "boolean" },
3509
+ yes: { type: "boolean" }
3510
+ });
3511
+ if (positionals.length === 0 && values.domain === undefined && values.sender === undefined) throw new CommandError(usage);
3512
+ if (values["dry-run"] && values.yes) throw new CommandError("Choose either --dry-run or --yes, not both.");
3513
+ if (values.domain !== undefined && !String(values.domain).trim()) throw new CommandError("--domain must not be blank.");
3514
+ if (values.sender !== undefined && !String(values.sender).trim()) throw new CommandError("--sender must not be blank.");
3515
+
3516
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
3517
+ const keyedRule = inboxOpsKeyedRule(operation, values, positionals);
3518
+ if (keyedRule) return inboxOpsBulkKeyedRule(verb, keyedRule, values, context, { client, accountId });
3519
+
3520
+ const selected = await resolveInboxOpsSelection(context, accountId, {
3521
+ positionals, domain: values.domain, sender: values.sender, usage
3522
+ });
3523
+ if (!values.json) renderInboxOpsManifest(context, verb, selected);
3524
+
3525
+ if (values["dry-run"]) {
3526
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: true });
3527
+ if (values.json) return writeJson(context.stdout, payload);
3528
+ return renderInboxOpsBulkResults(context, { selected, payload });
3529
+ }
3530
+
3531
+ if (!values.yes) {
3532
+ const confirmed = await confirmInboxOpsAction(context);
3533
+ if (!confirmed) {
3534
+ if (values.json) return writeJson(context.stdout, { applied: false, operation, row_ids: selected.map((row) => row.id), message: "Re-run with --yes to apply." });
3535
+ writeLine(context.stdout, "");
3536
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply, or --dry-run to validate on the server.");
3537
+ return;
3538
+ }
3539
+ }
3540
+
3541
+ const payload = await inboxOpsActionsInBatches(client, accountId, { operation, rows: selected, dryRun: false });
3542
+ if (values.json) return writeJson(context.stdout, payload);
3543
+ renderInboxOpsBulkResults(context, { selected, payload });
3544
+ }
3545
+
3546
+ function inboxOpsKeyedRule(operation, values, positionals) {
3547
+ if (operation === "ignore" || positionals.length > 0) return null;
3548
+ const [disposition, scope] = operation.split("_");
3549
+ const key = scope === "domain" ? values.domain : values.sender;
3550
+ const otherKey = scope === "domain" ? values.sender : values.domain;
3551
+ if (key === undefined || otherKey !== undefined) return null;
3552
+ return { scope, disposition, key: String(key).trim() };
3553
+ }
3554
+
3555
+ async function inboxOpsBulkKeyedRule(verb, { scope, disposition, key }, values, context, { client, accountId }) {
3556
+ const snapshot = await readInboxOpsSnapshot(context, accountId).catch(() => null);
3557
+ const matches = (snapshot?.rows || []).filter((row) => (scope === "domain" ? inboxOpsDomainMatches(row.domain, key) : inboxOpsSenderMatches(row.sender, key)));
3558
+ const action = disposition === "allow" ? "Always show" : "Always filter";
3559
+ if (!values.json) {
3560
+ const listed = matches.length > 0 ? ` Listed rows affected: ${compressNumbers(matches.map((row) => row.number))}.` : "";
3561
+ writeLine(context.stdout, `${action} ${scope} ${key}.${listed}`);
3562
+ writeLine(context.stdout, "This rule is durable and applies to every current and future matching message across your accounts.");
3563
+ }
3564
+ if (values["dry-run"]) {
3565
+ if (values.json) return writeJson(context.stdout, { dry_run: true, rule: { scope, disposition, key }, listed_row_ids: matches.map((row) => row.id) });
3566
+ return writeLine(context.stdout, "Dry run: no rule was written.");
3567
+ }
3568
+ if (!values.yes) {
3569
+ const confirmed = await confirmInboxOpsAction(context);
3570
+ if (!confirmed) {
3571
+ if (values.json) return writeJson(context.stdout, { applied: false, rule: { scope, disposition, key }, message: "Re-run with --yes to apply." });
3572
+ writeLine(context.stdout, "Nothing applied. Re-run with --yes to apply.");
3573
+ return;
3574
+ }
3575
+ }
3576
+
3577
+ const payload = await client.setInboxOpsRule(accountId, { scope, key, disposition });
3578
+ if (values.json) return writeJson(context.stdout, payload);
3579
+ renderInboxOpsRule(payload, context);
3580
+ }
3581
+
3582
+ async function inboxOpsActionsInBatches(client, accountId, { operation, rows, dryRun }) {
3583
+ const results = [];
3584
+ let last = null;
3585
+ for (let index = 0; index < rows.length; index += INBOX_OPS_BATCH_SIZE) {
3586
+ const batch = rows.slice(index, index + INBOX_OPS_BATCH_SIZE);
3587
+ last = await client.inboxOpsActions(accountId, compactObject({
3588
+ operation,
3589
+ row_ids: batch.map((row) => row.id),
3590
+ dry_run: dryRun || undefined
3591
+ }));
3592
+ results.push(...(Array.isArray(last?.results) ? last.results : []));
3593
+ }
3594
+
3595
+ const counts = {};
3596
+ for (const result of results) counts[result.status] = (counts[result.status] || 0) + 1;
3597
+ return { ...(last || {}), operation, dry_run: dryRun, counts, results };
3598
+ }
3599
+
3600
+ async function confirmInboxOpsAction(context) {
3601
+ const input = context.stdin;
3602
+ if (!input || !input.isTTY) return false;
3603
+ const prompt = createInterface({ input, output: context.stdout });
3604
+ try {
3605
+ const answer = await prompt.question("Apply? [y/N] ");
3606
+ return /^y(es)?$/i.test(String(answer).trim());
3607
+ } finally {
3608
+ prompt.close();
3609
+ }
3610
+ }
3611
+
3612
+ function compressNumbers(numbers) {
3613
+ const sorted = [...new Set(numbers.filter((value) => Number.isInteger(value)))].sort((left, right) => left - right);
3614
+ const parts = [];
3615
+ let start = null;
3616
+ let previous = null;
3617
+ for (const number of sorted) {
3618
+ if (start === null) {
3619
+ start = previous = number;
3620
+ continue;
3621
+ }
3622
+ if (number === previous + 1) {
3623
+ previous = number;
3624
+ continue;
3625
+ }
3626
+ parts.push(start === previous ? String(start) : `${start}-${previous}`);
3627
+ start = previous = number;
3628
+ }
3629
+ if (start !== null) parts.push(start === previous ? String(start) : `${start}-${previous}`);
3630
+ return parts.join(",");
3631
+ }
3632
+
3633
+ async function networkOpsQueue(args, context, { accountOverride } = {}) {
3634
+ const { values, positionals } = parseCommandArgs(args, {
3635
+ ...jsonOptions(),
3636
+ ...operatorPaginationOptions()
3637
+ });
3638
+ if (positionals.length > 0) throw new CommandError(NETWORK_OPS_QUEUE_USAGE);
3639
+
3640
+ const query = { opportunity_kind: "network", ...operatorPaginationQuery(values) };
3271
3641
  const { client, accountId } = await requireAccountContext(context, { accountOverride });
3272
3642
  const payload = await client.operatorQueue(accountId, query);
3273
3643
  if (values.json) return writeJson(context.stdout, payload);
3274
3644
 
3275
- renderOperatorRead(payload, context, { command: "inbox-ops queue", accountId, query }, () => renderInboxOpsQueue(payload, context));
3645
+ renderOperatorRead(payload, context, { command: "network-ops queue", accountId, query }, () => renderNetworkOpsQueue(payload, context, { accountId }));
3646
+ }
3647
+
3648
+ async function networkOpsAction(action, args, context, { accountOverride } = {}) {
3649
+ const { values, positionals } = parseCommandArgs(args, jsonOptions());
3650
+ const canonicalAction = action === "reject" ? "decline" : action;
3651
+ const usage = canonicalAction === "accept" ? NETWORK_OPS_ACCEPT_USAGE : NETWORK_OPS_DECLINE_USAGE;
3652
+ if (positionals.length !== 1) throw new CommandError(usage);
3653
+
3654
+ const rowId = positionals[0];
3655
+ if (!NETWORK_OPS_ROW_ID_PATTERN.test(rowId)) {
3656
+ throw new CommandError("<row_id> must match network_ops_event_<positive integer>.");
3657
+ }
3658
+
3659
+ const { client, accountId } = await requireAccountContext(context, { accountOverride });
3660
+ const payload = await client.networkOpsAction(accountId, rowId, canonicalAction);
3661
+ if (values.json) return writeJson(context.stdout, payload);
3662
+
3663
+ renderNetworkOpsAction(payload, context);
3276
3664
  }
3277
3665
 
3278
3666
  async function inboxOpsFilters(args, context, { accountOverride } = {}) {
@@ -6405,7 +6793,7 @@ function operatorContinuationCommand(payload, { command, accountId, query, plan
6405
6793
  }
6406
6794
 
6407
6795
  function operatorContinuationFilters(command, query, resolvedFilters = {}) {
6408
- if (command === "inbox-ops queue") return [];
6796
+ if (["inbox-ops queue", "network-ops queue"].includes(command)) return [];
6409
6797
 
6410
6798
  const filters = { ...resolvedFilters, ...query };
6411
6799
  const flags = {
@@ -6434,18 +6822,109 @@ function operatorCommandArgument(value) {
6434
6822
  return /^[a-zA-Z0-9_./:@=-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`;
6435
6823
  }
6436
6824
 
6437
- function renderInboxOpsQueue(payload, context) {
6825
+ function renderInboxOpsQueue(rows, context, { groupBy } = {}) {
6826
+ if (rows.length === 0) return writeLine(context.stdout, "No Inbox Ops rows found.");
6827
+
6828
+ if (groupBy === "domain") {
6829
+ const groups = new Map();
6830
+ for (const row of rows) {
6831
+ const domain = row.domain || "-";
6832
+ if (!groups.has(domain)) groups.set(domain, []);
6833
+ groups.get(domain).push(row.number);
6834
+ }
6835
+ const sorted = [...groups.entries()].sort(([leftDomain, left], [rightDomain, right]) => right.length - left.length || leftDomain.localeCompare(rightDomain));
6836
+ writeAlignedTable(context, ["DOMAIN", "COUNT", "ROWS"], sorted.map(([domain, numbers]) => [domain, numbers.length, compressNumbers(numbers)]));
6837
+ writeLine(context.stdout, "");
6838
+ writeLine(context.stdout, `${rows.length} rows across ${groups.size} domains.`);
6839
+ writeLine(context.stdout, "Filter a whole domain: audienti inbox-ops filter-domain --domain <domain> --yes");
6840
+ writeLine(context.stdout, "Ignore listed rows: audienti inbox-ops ignore 1-25 --yes");
6841
+ return;
6842
+ }
6843
+
6844
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT", "CONNECTED INBOX"], rows.map((row) => [
6845
+ row.number,
6846
+ display(row.id),
6847
+ display(row.sender),
6848
+ display(row.domain),
6849
+ display(row.subject),
6850
+ display(row.connected_account)
6851
+ ]));
6852
+ writeLine(context.stdout, "");
6853
+ writeLine(context.stdout, `${rows.length} rows. Numbers stay valid until you re-run this command.`);
6854
+ writeLine(context.stdout, "Act by number: audienti inbox-ops ignore 1-25 | filter-domain 3,7 | filter-sender 12 | allow-sender 4 (add --yes to apply, --dry-run to validate)");
6855
+ writeLine(context.stdout, "Group first: audienti inbox-ops queue --group-by domain");
6856
+ }
6857
+
6858
+ function renderInboxOpsManifest(context, verb, selected) {
6859
+ writeLine(context.stdout, `${INBOX_OPS_BULK_LABELS[verb]} ${selected.length} message${selected.length === 1 ? "" : "s"}:`);
6860
+ writeAlignedTable(context, ["#", "ROW ID", "SENDER", "DOMAIN", "SUBJECT"], selected.map((row) => [
6861
+ row.number ?? "-",
6862
+ display(row.id),
6863
+ display(row.sender),
6864
+ display(row.domain),
6865
+ display(row.subject)
6866
+ ]));
6867
+ }
6868
+
6869
+ function renderInboxOpsBulkResults(context, { selected, payload }) {
6870
+ const numbers = new Map(selected.map((row) => [row.id, row.number]));
6871
+ const results = Array.isArray(payload?.results) ? payload.results : [];
6872
+ writeLine(context.stdout, "");
6873
+ writeAlignedTable(context, ["#", "ROW ID", "RESULT", "DETAIL"], results.map((result) => [
6874
+ numbers.get(result.row_id) ?? "-",
6875
+ display(result.row_id),
6876
+ humanize(result.status),
6877
+ inboxOpsResultDetail(result)
6878
+ ]));
6879
+ const counts = payload?.counts || {};
6880
+ const summary = ["applied", "planned", "skipped", "rejected"]
6881
+ .filter((status) => counts[status])
6882
+ .map((status) => `${humanize(status)} ${counts[status]}`)
6883
+ .join(", ");
6884
+ writeLine(context.stdout, "");
6885
+ writeLine(context.stdout, payload?.dry_run ? `Dry run: ${summary || "nothing to do"}. No changes were made.` : `${summary || "Nothing changed"}.`);
6886
+ if (!payload?.dry_run && counts.applied) {
6887
+ writeLine(context.stdout, "Row numbers stay valid until you re-run `audienti inbox-ops queue`.");
6888
+ }
6889
+ }
6890
+
6891
+ function inboxOpsResultDetail(result) {
6892
+ const parts = [];
6893
+ if (result.reason) parts.push(humanize(result.reason));
6894
+ if (result.scope && result.key) parts.push(`${result.disposition || ""} ${result.scope} ${result.key}`.trim());
6895
+ if (result.message) parts.push(result.message);
6896
+ return parts.join(" | ") || "-";
6897
+ }
6898
+
6899
+ function renderNetworkOpsQueue(payload, context, { accountId }) {
6438
6900
  const decisionQueue = Array.isArray(payload?.decision_queue) ? payload.decision_queue : [];
6439
6901
  const rows = decisionQueue.length > 0 ? decisionQueue : [payload?.next_move].filter(Boolean);
6440
- if (rows.length === 0) return writeLine(context.stdout, "No Inbox Ops rows found.");
6902
+ if (rows.length === 0) return writeLine(context.stdout, "No Network Ops rows found.");
6441
6903
 
6442
- writeAlignedTable(context, ["ROW ID", "SENDER", "DOMAIN", "SUBJECT", "CONNECTED INBOX"], rows.map((row) => [
6904
+ writeAlignedTable(context, ["ROW ID", "PERSON", "MESSAGE", "STATE"], rows.map((row) => [
6443
6905
  display(row?.id),
6444
- display(row?.inbox_ops?.sender),
6445
- display(row?.inbox_ops?.domain),
6446
- display(row?.inbox_ops?.subject),
6447
- display(row?.inbox_ops?.connected_account)
6906
+ operatorSubjectLabel(row),
6907
+ row?.network_ops?.note || "-",
6908
+ display(row?.network_ops?.state)
6448
6909
  ]));
6910
+
6911
+ for (const row of rows) {
6912
+ const accountOption = ` --account ${operatorCommandArgument(accountId)}`;
6913
+ writeLine(context.stdout, "");
6914
+ writeLine(context.stdout, `Accept: audienti network-ops accept ${row.id}${accountOption}`);
6915
+ writeLine(context.stdout, `Decline: audienti network-ops decline ${row.id}${accountOption}`);
6916
+ }
6917
+ }
6918
+
6919
+ function renderNetworkOpsAction(payload, context) {
6920
+ const actionKey = String(payload?.action?.key || "");
6921
+ const label = actionKey.endsWith("_decline") ? "Decline" : "Acceptance";
6922
+ const person = payload?.request?.display_name || payload?.request?.id || payload?.row_id;
6923
+ writeLine(context.stdout, `${label} queued for ${display(person)} (${display(payload?.row_id)}).`);
6924
+ if (payload?.action?.prefix_id) {
6925
+ writeLine(context.stdout, `Action: ${payload.action.prefix_id} (${display(payload.action.state)})`);
6926
+ }
6927
+ writeLine(context.stdout, "Queued means provider execution was requested; it is not confirmation that LinkedIn completed the action.");
6449
6928
  }
6450
6929
 
6451
6930
  function renderInboxOpsFilters(payload, context) {
@@ -7900,9 +8379,17 @@ const HELP_TOPICS = new Map([
7900
8379
  " audienti operator failed-drafts requeue <row_id>",
7901
8380
  "",
7902
8381
  " Inbox Ops",
7903
- " audienti inbox-ops queue",
8382
+ " audienti inbox-ops queue [--group-by domain]",
8383
+ " audienti inbox-ops ignore <numbers|ranges> --yes",
8384
+ " audienti inbox-ops filter-domain <numbers|ranges> | --domain <domain> --yes",
8385
+ " audienti inbox-ops filter-sender|allow-sender|allow-domain <numbers|ranges> --yes",
7904
8386
  " audienti inbox-ops filters",
7905
8387
  " audienti inbox-ops rule <row_id> --scope <sender|domain> --disposition <allow|filter>",
8388
+ "",
8389
+ " Network Ops",
8390
+ " audienti network-ops queue",
8391
+ " audienti network-ops accept <row_id>",
8392
+ " audienti network-ops decline <row_id>",
7906
8393
  "",
7907
8394
  " Analytics",
7908
8395
  " audienti analytics motions",
@@ -10732,6 +11219,7 @@ const HELP_TOPICS = new Map([
10732
11219
  "Usage:",
10733
11220
  ` ${INBOX_OPS_QUEUE_USAGE.slice("Usage: ".length)}`,
10734
11221
  ` ${INBOX_OPS_FILTERS_USAGE.slice("Usage: ".length)}`,
11222
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => ` ${inboxOpsBulkUsage(verb).slice("Usage: ".length)}`),
10735
11223
  ` ${INBOX_OPS_RULE_USAGE.slice("Usage: ".length)}`,
10736
11224
  ` ${INBOX_OPS_RULE_SET_USAGE.slice("Usage: ".length)}`,
10737
11225
  ` ${INBOX_OPS_RULE_REMOVE_USAGE.slice("Usage: ".length)}`,
@@ -10739,10 +11227,16 @@ const HELP_TOPICS = new Map([
10739
11227
  "Status: implemented",
10740
11228
  "",
10741
11229
  "Purpose:",
10742
- " Inspect the authenticated owner's private Inbox Ops queue and personal/global email rules, then set or remove sender and domain rules.",
11230
+ " Inspect the authenticated owner's private Inbox Ops queue and personal/global email rules, then clear rows in bulk by number or set and remove sender and domain rules.",
11231
+ "",
11232
+ "Workflow:",
11233
+ " 1. audienti inbox-ops queue (numbers every row and saves the list locally)",
11234
+ " 2. audienti inbox-ops queue --group-by domain (see which domains dominate)",
11235
+ " 3. audienti inbox-ops ignore 1-25 --yes (or filter-domain / filter-sender / allow-sender / allow-domain)",
10743
11236
  "",
10744
11237
  "Safety:",
10745
11238
  " Rules always apply to the token owner. Row-based updates derive identity from an authorized current Inbox Ops row.",
11239
+ " Bulk verbs print a manifest and apply only with --yes or an interactive confirmation; --dry-run validates every row on the server without changing anything.",
10746
11240
  " Key-based set/remove commands normalize and validate the supplied sender or domain on the server.",
10747
11241
  "",
10748
11242
  "Rule values:",
@@ -10752,6 +11246,7 @@ const HELP_TOPICS = new Map([
10752
11246
  "API:",
10753
11247
  " queue: GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox",
10754
11248
  " filters: GET /api/v1/accounts/:account_id/inbox_ops/filters.json",
11249
+ " bulk: POST /api/v1/accounts/:account_id/inbox_ops/actions.json",
10755
11250
  " rule: PATCH /api/v1/accounts/:account_id/inbox_ops/:row_id/rule.json",
10756
11251
  " keyed rules: PATCH|DELETE /api/v1/accounts/:account_id/inbox_ops/rules.json"
10757
11252
  ].join("\n")],
@@ -10761,15 +11256,47 @@ const HELP_TOPICS = new Map([
10761
11256
  "",
10762
11257
  "Status: implemented",
10763
11258
  "",
10764
- ...OPERATOR_PAGINATION_HELP,
10765
11259
  "Purpose:",
10766
- " List one page of the authenticated owner's current private Inbox Ops rows with the authoritative sender/domain rule identity, subject, and connected inbox.",
10767
- " When more rows exist, the plain output prints the exact continuation command with the same account.",
11260
+ " List every current private Inbox Ops row for the authenticated owner with a short number, the authoritative sender/domain rule identity, subject, and connected inbox.",
11261
+ " The numbered list is saved locally per account so the bulk verbs can select rows by number until you re-run this command.",
11262
+ " --group-by domain summarizes the same list by sender domain with the row numbers in each group.",
11263
+ "",
11264
+ "Paging:",
11265
+ " Without paging flags the command follows every page itself. Pass --page, --offset, or --cursor to read exactly one page instead;",
11266
+ " that page is numbered and saved the same way and prints the exact continuation command when more rows remain.",
10768
11267
  "",
10769
11268
  "API:",
10770
11269
  " GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=inbox"
10771
11270
  ].join("\n")],
10772
11271
 
11272
+ ...Object.keys(INBOX_OPS_BULK_VERBS).map((verb) => [`inbox-ops ${verb}`, [
11273
+ inboxOpsBulkUsage(verb),
11274
+ "",
11275
+ "Status: implemented",
11276
+ "",
11277
+ "Purpose:",
11278
+ verb === "ignore"
11279
+ ? " Hide the selected private inbound threads from Inbox Ops. A newer reply in the same thread reopens it."
11280
+ : ` ${INBOX_OPS_BULK_LABELS[verb]} the selected rows by writing one durable ${verb.endsWith("domain") ? "domain" : "sender"} rule per distinct key. Matching current and future messages ${verb.startsWith("allow") ? "always stay visible" : "leave the queue"}.`,
11281
+ "",
11282
+ "Selectors:",
11283
+ " Numbers and ranges from the last `audienti inbox-ops queue` list: 1 4 7-12 or 1-25,30",
11284
+ " Row ids: inbox_ops_message_<id>",
11285
+ " --domain <domain> selects every listed row whose sender domain matches (subdomains included)",
11286
+ " --sender <email> selects every listed row from that sender",
11287
+ ...(verb === "ignore" ? [] : [
11288
+ ` With only --${verb.endsWith("domain") ? "domain" : "sender"} and no row selectors, the rule is written directly by key even when the rows are no longer listed.`
11289
+ ]),
11290
+ "",
11291
+ "Safety:",
11292
+ " Prints the manifest first. Applies only with --yes or an interactive confirmation.",
11293
+ " --dry-run sends the batch to the server with dry_run and reports planned/skipped/rejected per row without changing anything.",
11294
+ " Rows another owner controls, stale rows, and malformed ids are rejected individually; the rest still apply.",
11295
+ "",
11296
+ "API:",
11297
+ " POST /api/v1/accounts/:account_id/inbox_ops/actions.json {operation, row_ids, dry_run}"
11298
+ ].join("\n")]),
11299
+
10773
11300
  ["inbox-ops filters", [
10774
11301
  INBOX_OPS_FILTERS_USAGE,
10775
11302
  "",
@@ -10806,6 +11333,66 @@ const HELP_TOPICS = new Map([
10806
11333
  " key: PATCH|DELETE /api/v1/accounts/:account_id/inbox_ops/rules.json"
10807
11334
  ].join("\n")],
10808
11335
 
11336
+ ["network-ops", [
11337
+ "Usage:",
11338
+ ` ${NETWORK_OPS_QUEUE_USAGE.slice("Usage: ".length)}`,
11339
+ ` ${NETWORK_OPS_ACCEPT_USAGE.slice("Usage: ".length)}`,
11340
+ ` ${NETWORK_OPS_DECLINE_USAGE.slice("Usage: ".length)}`,
11341
+ " audienti network-ops reject <row_id> [--json] [--account <acct_id>]",
11342
+ "",
11343
+ "Status: implemented",
11344
+ "",
11345
+ "Purpose:",
11346
+ " Inspect the authenticated owner's private inbound LinkedIn connection requests and queue accept or decline provider actions.",
11347
+ " `reject` is accepted as an alias for `decline`.",
11348
+ "",
11349
+ "Safety:",
11350
+ " Rows always resolve through the current owner-private Network Ops scope. Arbitrary event ids and another user's requests fail closed.",
11351
+ " A successful response means the provider action was queued, not that LinkedIn has confirmed it.",
11352
+ "",
11353
+ "API:",
11354
+ " queue: GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=network",
11355
+ " action: POST /api/v1/accounts/:account_id/network_ops/:row_id/:accept_or_decline.json"
11356
+ ].join("\n")],
11357
+
11358
+ ["network-ops queue", [
11359
+ NETWORK_OPS_QUEUE_USAGE,
11360
+ "",
11361
+ "Status: implemented",
11362
+ "",
11363
+ ...OPERATOR_PAGINATION_HELP,
11364
+ "Purpose:",
11365
+ " List one page of the authenticated owner's pending inbound LinkedIn connection requests, including the exact invitation-bound message when available.",
11366
+ " The general equivalent is `audienti operator queue --opportunity-kind network`.",
11367
+ "",
11368
+ "API:",
11369
+ " GET /api/v1/accounts/:account_id/operator.json?opportunity_kind=network"
11370
+ ].join("\n")],
11371
+
11372
+ ["network-ops accept", [
11373
+ NETWORK_OPS_ACCEPT_USAGE,
11374
+ "",
11375
+ "Status: implemented",
11376
+ "",
11377
+ "Purpose:",
11378
+ " Queue the canonical LinkedIn accept action for one authorized pending Network Ops row.",
11379
+ "",
11380
+ "API:",
11381
+ " POST /api/v1/accounts/:account_id/network_ops/:row_id/accept.json"
11382
+ ].join("\n")],
11383
+
11384
+ ["network-ops decline", [
11385
+ NETWORK_OPS_DECLINE_USAGE,
11386
+ "",
11387
+ "Status: implemented",
11388
+ "",
11389
+ "Purpose:",
11390
+ " Queue the canonical LinkedIn decline action for one authorized pending Network Ops row. `reject` is an alias.",
11391
+ "",
11392
+ "API:",
11393
+ " POST /api/v1/accounts/:account_id/network_ops/:row_id/decline.json"
11394
+ ].join("\n")],
11395
+
10809
11396
  ["operator", [
10810
11397
  "Usage:",
10811
11398
  " audienti operator next [--json|--plan|--done|--skip|--fail|--return]",
@@ -10822,7 +11409,7 @@ const HELP_TOPICS = new Map([
10822
11409
  " --motion <motn_id>",
10823
11410
  " --list <list_id>",
10824
11411
  " --stage <stage>",
10825
- " --opportunity-kind prospect|visibility|inbox",
11412
+ " --opportunity-kind prospect|visibility|inbox|network|content",
10826
11413
  " --writing-status ready|drafting|draft_failed"
10827
11414
  ].join("\n")],
10828
11415
 
@@ -11357,7 +11944,14 @@ const HELP_TOPICS = new Map([
11357
11944
  " audienti operator failed-drafts",
11358
11945
  " audienti operator failed-drafts requeue <row_id>",
11359
11946
  " audienti operator outcome <row_id> --payload <file.json>",
11947
+ " audienti network-ops queue",
11948
+ " audienti network-ops accept <row_id>",
11949
+ " audienti network-ops decline <row_id>",
11360
11950
  " audienti inbox-ops queue",
11951
+ " audienti inbox-ops queue --group-by domain",
11952
+ " audienti inbox-ops ignore 1-25 --dry-run",
11953
+ " audienti inbox-ops ignore 1-25 --yes",
11954
+ " audienti inbox-ops filter-domain --domain alerts.example.com --yes",
11361
11955
  " audienti inbox-ops filters",
11362
11956
  " audienti inbox-ops rule <row_id> --scope sender --disposition filter",
11363
11957
  " audienti inbox-ops rule set --scope sender --key news@example.com --disposition filter",