@sellable/mcp 0.1.368 → 0.1.370
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/dist/api.d.ts +0 -7
- package/dist/api.js +0 -32
- package/dist/index-dev.js +0 -0
- package/dist/index.js +0 -0
- package/dist/server.js +2 -8
- package/dist/tools/cells.js +2 -2
- package/dist/tools/prompts.js +1 -1
- package/dist/tools/registry.js +0 -2
- package/dist/tools/rows.js +1 -1
- package/dist/tools/setup-evergreen-campaigns.d.ts +1 -1
- package/dist/tools/setup-evergreen-campaigns.js +2 -2
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +8 -0
- package/skills/create-campaign-v2/references/final-handoff-contract.md +14 -1
- package/skills/create-campaign-v2-tail/SKILL.md +16 -1
- package/skills/create-evergreen-campaigns/SKILL.md +536 -11
- package/skills/refill-sends/SKILL.md +8 -0
- package/skills/refill-sends-workflow/SKILL.md +61 -0
- package/dist/tools/workspace-export.d.ts +0 -110
- package/dist/tools/workspace-export.js +0 -465
|
@@ -7,6 +7,9 @@ allowed-tools:
|
|
|
7
7
|
- mcp__sellable__get_campaign_refill_state
|
|
8
8
|
- mcp__sellable__fill_campaign_horizon
|
|
9
9
|
- mcp__sellable__get_campaign
|
|
10
|
+
- mcp__sellable__get_campaign_context
|
|
11
|
+
- mcp__sellable__update_campaign
|
|
12
|
+
- mcp__sellable__get_provider_prompt
|
|
10
13
|
- mcp__sellable__get_campaign_message_preparation_status
|
|
11
14
|
- mcp__sellable__start_campaign_message_preparation
|
|
12
15
|
- mcp__sellable__cancel_campaign_message_preparation
|
|
@@ -14,6 +17,7 @@ allowed-tools:
|
|
|
14
17
|
- mcp__sellable__wait_for_lead_list_ready
|
|
15
18
|
- mcp__sellable__confirm_lead_list
|
|
16
19
|
- mcp__sellable__search_signals
|
|
20
|
+
- mcp__sellable__select_promising_posts
|
|
17
21
|
- mcp__sellable__fetch_post_engagers
|
|
18
22
|
- mcp__sellable__search_sales_nav
|
|
19
23
|
- mcp__sellable__lookup_sales_nav_filter
|
|
@@ -27,6 +31,10 @@ allowed-tools:
|
|
|
27
31
|
- mcp__sellable__get_rows
|
|
28
32
|
- mcp__sellable__get_rows_minimal
|
|
29
33
|
- mcp__sellable__get_table_rows
|
|
34
|
+
- mcp__sellable__get_campaign_table_schema
|
|
35
|
+
- mcp__sellable__select_campaign_cells
|
|
36
|
+
- mcp__sellable__wait_for_campaign_processing
|
|
37
|
+
- mcp__sellable__get_sender
|
|
30
38
|
---
|
|
31
39
|
|
|
32
40
|
# Refill Sends Workflow
|
|
@@ -101,6 +109,42 @@ Source replenishment choices:
|
|
|
101
109
|
source prospects into the same campaign/source-list path, then confirm
|
|
102
110
|
readiness.
|
|
103
111
|
|
|
112
|
+
### Running Campaign Signal Discovery Recovery
|
|
113
|
+
|
|
114
|
+
For an already-running regular campaign that needs same-campaign Signal
|
|
115
|
+
Discovery replenishment, treat the watch/current-step transition as a guarded
|
|
116
|
+
repair, not as normal create-campaign navigation.
|
|
117
|
+
|
|
118
|
+
Do not call `search_signals` with `currentStep:"signal-discovery"` directly from `currentStep:"running"`. The campaign step guard intentionally rejects that
|
|
119
|
+
backwards transition. After exact approval and a fresh refill-state reread, clear `currentStep` only if it still exactly matches `running`:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
update_campaign({ campaignId, currentStep:null, clearCurrentStepIfMatches:"running" })
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Then register provider preflight and run a campaign-scoped `search_signals` so
|
|
126
|
+
post rows exist in the campaign's Signal Discovery state:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
get_provider_prompt({ provider:"signal-discovery", campaignOfferId, confirmed:true })
|
|
130
|
+
search_signals({ campaignOfferId, currentStep:"signal-discovery", confirmed:true, ... })
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Use `select_promising_posts` before importing. For a fresh source refill, prefer
|
|
134
|
+
`selectionMode:"replace"` and `scrapePlanMode:"capacity-target"` with the
|
|
135
|
+
approved `targetEngagerCount` and `maxPostsToScrape`. If the approved post set cannot cover the target, do not knowingly run an under-capacity scrape; ask for approval to expand the selected post set or switch source.
|
|
136
|
+
|
|
137
|
+
If `import_leads` returns `reusedExistingSourceList` but the user explicitly
|
|
138
|
+
approved a different selected-post scrape, retry `import_leads` with the existing `sourceLeadListId` from that response or from refill state. For Signal
|
|
139
|
+
Discovery this bypasses the stale reuse guard and materializes the newly
|
|
140
|
+
selected campaign-scoped posts into a fresh source list.
|
|
141
|
+
|
|
142
|
+
After `wait_for_lead_list_ready`, call `confirm_lead_list` with the new
|
|
143
|
+
source-list id and same campaign id. Pass `currentStep:"running"` to `confirm_lead_list` for an ACTIVE already-running campaign so the watched state
|
|
144
|
+
returns to Running after same-campaign source rows are copied. Do not call
|
|
145
|
+
`update_campaign` to fake this after the copy; `confirm_lead_list` owns the
|
|
146
|
+
post-copy state transition.
|
|
147
|
+
|
|
104
148
|
Before any source mutation, show exact approval evidence: workspace id,
|
|
105
149
|
campaign id, workflow table id, source/provider id, source-list id when known,
|
|
106
150
|
import caps, target dates/caps, dedupe/DNC/provider exclusion expectations, and
|
|
@@ -135,11 +179,28 @@ Before prep or approval mutation, reread `get_campaign_refill_state` and compare
|
|
|
135
179
|
If they changed, stop. A second concurrent refill run must see the first run's
|
|
136
180
|
import/prep state on reread and avoid duplicate work.
|
|
137
181
|
|
|
182
|
+
After same-campaign source copy, inspect the actual campaign-table position of
|
|
183
|
+
the copied rows before starting prep. Newly copied rows often land after the first 100 table rows. Use `get_campaign_table_schema` to read the `reviewBatch`,
|
|
184
|
+
then use `select_campaign_cells` diagnostics against
|
|
185
|
+
`rowSelector:{ type:"reviewBatch" }` for `enrich`, `generateMessage`, and
|
|
186
|
+
`approved` roles when needed.
|
|
187
|
+
|
|
188
|
+
If review-batch enrich cells are pending or Generate Message cells are
|
|
189
|
+
dependency-blocked, omit `maxRowsToCheck` so the adaptive prep job can cover the
|
|
190
|
+
bounded table scan, or set a bounded cap high enough to include appended rows
|
|
191
|
+
when row count evidence requires it. Do not run a fixed `maxRowsToCheck:100` prep pass after appending rows to a larger existing campaign table; it can spend
|
|
192
|
+
the whole budget on older rows and miss the refill batch.
|
|
193
|
+
|
|
138
194
|
Scheduled success requires a post-action reread proving scheduler-owned cells
|
|
139
195
|
with non-null `scheduledFor`. Prepared, approved, and ready-to-schedule rows are
|
|
140
196
|
intermediate states; report them as awaiting scheduler unless scheduled cells
|
|
141
197
|
are present.
|
|
142
198
|
|
|
199
|
+
preparedMessages can remain 0 while scheduler-ready state changes downstream.
|
|
200
|
+
After a prep job reaches a terminal state, run `wait_for_campaign_processing`
|
|
201
|
+
when generated/pass counts are still settling, then poll `get_campaign_refill_state` until `readyToSchedule` drops, scheduled counts increase, or the state clearly
|
|
202
|
+
remains awaiting scheduler. Do not force scheduler fields directly.
|
|
203
|
+
|
|
143
204
|
## Evergreen Campaign Discipline
|
|
144
205
|
|
|
145
206
|
`fill_campaign_horizon` is a legacy evergreen-only lower-level primitive. Call it
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
export type WorkspaceExportDataset = "people" | "events";
|
|
2
|
-
export type WorkspaceExportIntent = "reached_out_people" | "people_and_events" | "outreach_event_log" | "dashboard_messages" | "connections_sent";
|
|
3
|
-
type WorkspaceExportActionType = "INVITE" | "DM" | "INMAIL_OPEN" | "INMAIL_CLOSED" | "VIEW_PROFILE" | "COMMENT";
|
|
4
|
-
export interface ExportWorkspaceCsvInput {
|
|
5
|
-
exportType?: "outreach";
|
|
6
|
-
exportIntent?: WorkspaceExportIntent;
|
|
7
|
-
datasets?: WorkspaceExportDataset[];
|
|
8
|
-
actionTypes?: WorkspaceExportActionType[];
|
|
9
|
-
outputDir?: string;
|
|
10
|
-
fromSentAt?: string;
|
|
11
|
-
toSentAt?: string;
|
|
12
|
-
campaignIds?: string[];
|
|
13
|
-
tableIds?: string[];
|
|
14
|
-
}
|
|
15
|
-
export declare const workspaceExportToolDefinitions: {
|
|
16
|
-
name: string;
|
|
17
|
-
description: string;
|
|
18
|
-
inputSchema: {
|
|
19
|
-
type: string;
|
|
20
|
-
properties: {
|
|
21
|
-
exportType: {
|
|
22
|
-
type: string;
|
|
23
|
-
enum: string[];
|
|
24
|
-
description: string;
|
|
25
|
-
};
|
|
26
|
-
exportIntent: {
|
|
27
|
-
type: string;
|
|
28
|
-
enum: string[];
|
|
29
|
-
description: string;
|
|
30
|
-
};
|
|
31
|
-
datasets: {
|
|
32
|
-
type: string;
|
|
33
|
-
items: {
|
|
34
|
-
type: string;
|
|
35
|
-
enum: string[];
|
|
36
|
-
};
|
|
37
|
-
description: string;
|
|
38
|
-
};
|
|
39
|
-
actionTypes: {
|
|
40
|
-
type: string;
|
|
41
|
-
items: {
|
|
42
|
-
type: string;
|
|
43
|
-
enum: string[];
|
|
44
|
-
};
|
|
45
|
-
description: string;
|
|
46
|
-
};
|
|
47
|
-
outputDir: {
|
|
48
|
-
type: string;
|
|
49
|
-
description: string;
|
|
50
|
-
};
|
|
51
|
-
fromSentAt: {
|
|
52
|
-
type: string;
|
|
53
|
-
description: string;
|
|
54
|
-
};
|
|
55
|
-
toSentAt: {
|
|
56
|
-
type: string;
|
|
57
|
-
description: string;
|
|
58
|
-
};
|
|
59
|
-
campaignIds: {
|
|
60
|
-
type: string;
|
|
61
|
-
items: {
|
|
62
|
-
type: string;
|
|
63
|
-
};
|
|
64
|
-
description: string;
|
|
65
|
-
};
|
|
66
|
-
tableIds: {
|
|
67
|
-
type: string;
|
|
68
|
-
items: {
|
|
69
|
-
type: string;
|
|
70
|
-
};
|
|
71
|
-
description: string;
|
|
72
|
-
};
|
|
73
|
-
};
|
|
74
|
-
required: never[];
|
|
75
|
-
additionalProperties: boolean;
|
|
76
|
-
};
|
|
77
|
-
}[];
|
|
78
|
-
export declare function exportWorkspaceCsv(input?: ExportWorkspaceCsvInput): Promise<{
|
|
79
|
-
status: string;
|
|
80
|
-
outputDir: string;
|
|
81
|
-
manifestPath: string;
|
|
82
|
-
datasets: {
|
|
83
|
-
dataset: WorkspaceExportDataset;
|
|
84
|
-
path: string;
|
|
85
|
-
rows: number;
|
|
86
|
-
bytes: number;
|
|
87
|
-
status: "complete" | "failed";
|
|
88
|
-
}[];
|
|
89
|
-
exportCutoff: string;
|
|
90
|
-
exportIntent: WorkspaceExportIntent | null;
|
|
91
|
-
intentDescription: string | null;
|
|
92
|
-
countDefinitions: {
|
|
93
|
-
reachedOutPeople: string;
|
|
94
|
-
outreachEvents: string;
|
|
95
|
-
dashboardMessagesSent: string;
|
|
96
|
-
connectionsSent: string;
|
|
97
|
-
profileViews: string;
|
|
98
|
-
};
|
|
99
|
-
counts: Record<string, unknown>;
|
|
100
|
-
workspace: {
|
|
101
|
-
id?: string;
|
|
102
|
-
name?: string | null;
|
|
103
|
-
};
|
|
104
|
-
skipped: string[];
|
|
105
|
-
failures: {
|
|
106
|
-
dataset: WorkspaceExportDataset;
|
|
107
|
-
error: string;
|
|
108
|
-
}[];
|
|
109
|
-
}>;
|
|
110
|
-
export {};
|
|
@@ -1,465 +0,0 @@
|
|
|
1
|
-
import { parse } from "csv-parse";
|
|
2
|
-
import { createReadStream } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { getApi } from "../api.js";
|
|
7
|
-
import { getConfig } from "../auth.js";
|
|
8
|
-
const MAX_FILTER_IDS = 100;
|
|
9
|
-
const MAX_QUERY_LENGTH = 8000;
|
|
10
|
-
export const workspaceExportToolDefinitions = [
|
|
11
|
-
{
|
|
12
|
-
name: "export_workspace_csv",
|
|
13
|
-
description: "Export active-workspace outreach CSVs to local files on the MCP host. " +
|
|
14
|
-
"Use exportIntent to disambiguate counts before exporting: " +
|
|
15
|
-
"`reached_out_people` means one deduped row per unique prospect who received an actual contact attempt (invite, DM, open InMail, or paid/closed InMail) and is the right choice for 'everyone we reached out to'; " +
|
|
16
|
-
"`dashboard_messages` means message events only (DMs + InMails), matching the dashboard Messages Sent card; " +
|
|
17
|
-
"`connections_sent` means invite events only; " +
|
|
18
|
-
"`outreach_event_log` means one row per action event for audit/reconciliation; " +
|
|
19
|
-
"`people_and_events` exports the reached-out people CSV plus matching contact event rows. " +
|
|
20
|
-
"If the user asks for 'sent', 'reached out', or cites conflicting dashboard/export numbers, ask which intent they want before calling the tool. " +
|
|
21
|
-
"Requires a valid Sellable API key plus active workspace; run list_workspaces and set_active_workspace first if needed. " +
|
|
22
|
-
"The manifest includes file paths, counts, filters, package/runtime metadata, and skipped/deferred scope notes. " +
|
|
23
|
-
"CSV files may contain prospect and outreach metadata, so store them appropriately.",
|
|
24
|
-
inputSchema: {
|
|
25
|
-
type: "object",
|
|
26
|
-
properties: {
|
|
27
|
-
exportType: {
|
|
28
|
-
type: "string",
|
|
29
|
-
enum: ["outreach"],
|
|
30
|
-
description: "Export family. Phase 70 supports outreach only.",
|
|
31
|
-
},
|
|
32
|
-
exportIntent: {
|
|
33
|
-
type: "string",
|
|
34
|
-
enum: [
|
|
35
|
-
"reached_out_people",
|
|
36
|
-
"people_and_events",
|
|
37
|
-
"outreach_event_log",
|
|
38
|
-
"dashboard_messages",
|
|
39
|
-
"connections_sent",
|
|
40
|
-
],
|
|
41
|
-
description: "Recommended semantic export. Use reached_out_people for 'everyone we reached out to'; dashboard_messages for the dashboard Messages Sent card; connections_sent for invites; outreach_event_log for audit rows; people_and_events for both reached people and matching contact events.",
|
|
42
|
-
},
|
|
43
|
-
datasets: {
|
|
44
|
-
type: "array",
|
|
45
|
-
items: { type: "string", enum: ["people", "events"] },
|
|
46
|
-
description: "Low-level datasets to export. people is one deduped prospect row; events is one action row. Omit this and use exportIntent for clearer behavior.",
|
|
47
|
-
},
|
|
48
|
-
actionTypes: {
|
|
49
|
-
type: "array",
|
|
50
|
-
items: {
|
|
51
|
-
type: "string",
|
|
52
|
-
enum: [
|
|
53
|
-
"INVITE",
|
|
54
|
-
"DM",
|
|
55
|
-
"INMAIL_OPEN",
|
|
56
|
-
"INMAIL_CLOSED",
|
|
57
|
-
"VIEW_PROFILE",
|
|
58
|
-
"COMMENT",
|
|
59
|
-
],
|
|
60
|
-
},
|
|
61
|
-
description: "Optional low-level action filter. Usually omit and use exportIntent. Dashboard messages are DM + INMAIL_OPEN + INMAIL_CLOSED; reached-out/contact events are INVITE + DM + INMAIL_OPEN + INMAIL_CLOSED.",
|
|
62
|
-
},
|
|
63
|
-
outputDir: {
|
|
64
|
-
type: "string",
|
|
65
|
-
description: "Optional base directory. A unique run subdirectory is created inside it.",
|
|
66
|
-
},
|
|
67
|
-
fromSentAt: {
|
|
68
|
-
type: "string",
|
|
69
|
-
description: "Optional ISO lower bound for outreach sentAt.",
|
|
70
|
-
},
|
|
71
|
-
toSentAt: {
|
|
72
|
-
type: "string",
|
|
73
|
-
description: "Optional ISO upper bound for outreach sentAt.",
|
|
74
|
-
},
|
|
75
|
-
campaignIds: {
|
|
76
|
-
type: "array",
|
|
77
|
-
items: { type: "string" },
|
|
78
|
-
description: "Optional campaign ids to include, max 100.",
|
|
79
|
-
},
|
|
80
|
-
tableIds: {
|
|
81
|
-
type: "array",
|
|
82
|
-
items: { type: "string" },
|
|
83
|
-
description: "Optional workflow table ids to include, max 100.",
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
required: [],
|
|
87
|
-
additionalProperties: false,
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
];
|
|
91
|
-
const CONTACT_ACTION_TYPES = [
|
|
92
|
-
"INVITE",
|
|
93
|
-
"DM",
|
|
94
|
-
"INMAIL_OPEN",
|
|
95
|
-
"INMAIL_CLOSED",
|
|
96
|
-
];
|
|
97
|
-
const DASHBOARD_MESSAGE_ACTION_TYPES = [
|
|
98
|
-
"DM",
|
|
99
|
-
"INMAIL_OPEN",
|
|
100
|
-
"INMAIL_CLOSED",
|
|
101
|
-
];
|
|
102
|
-
const ALL_ACTION_TYPES = [
|
|
103
|
-
"INVITE",
|
|
104
|
-
"DM",
|
|
105
|
-
"INMAIL_OPEN",
|
|
106
|
-
"INMAIL_CLOSED",
|
|
107
|
-
"VIEW_PROFILE",
|
|
108
|
-
"COMMENT",
|
|
109
|
-
];
|
|
110
|
-
const EXPORT_INTENT_CONFIG = {
|
|
111
|
-
reached_out_people: {
|
|
112
|
-
datasets: ["people"],
|
|
113
|
-
actionTypes: CONTACT_ACTION_TYPES,
|
|
114
|
-
description: "Unique prospects who received at least one actual contact attempt: invite, DM, open InMail, or paid/closed InMail.",
|
|
115
|
-
},
|
|
116
|
-
people_and_events: {
|
|
117
|
-
datasets: ["people", "events"],
|
|
118
|
-
actionTypes: CONTACT_ACTION_TYPES,
|
|
119
|
-
description: "Reached-out people plus one matching contact event row per invite, DM, open InMail, or paid/closed InMail.",
|
|
120
|
-
},
|
|
121
|
-
outreach_event_log: {
|
|
122
|
-
datasets: ["events"],
|
|
123
|
-
actionTypes: ALL_ACTION_TYPES,
|
|
124
|
-
description: "Raw workflow-table-backed action event log for audit/reconciliation, including profile views and comments when present.",
|
|
125
|
-
},
|
|
126
|
-
dashboard_messages: {
|
|
127
|
-
datasets: ["events"],
|
|
128
|
-
actionTypes: DASHBOARD_MESSAGE_ACTION_TYPES,
|
|
129
|
-
description: "Message events only, matching the dashboard Messages Sent card: DMs + open InMails + paid/closed InMails.",
|
|
130
|
-
},
|
|
131
|
-
connections_sent: {
|
|
132
|
-
datasets: ["events"],
|
|
133
|
-
actionTypes: ["INVITE"],
|
|
134
|
-
description: "Connection invite events only, matching the dashboard Connections Sent sub-count.",
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
function assertIsoDate(value, field) {
|
|
138
|
-
if (value === undefined || value === null || value === "")
|
|
139
|
-
return undefined;
|
|
140
|
-
if (typeof value !== "string")
|
|
141
|
-
throw new Error(`${field} must be a string`);
|
|
142
|
-
const date = new Date(value);
|
|
143
|
-
if (!Number.isFinite(date.getTime())) {
|
|
144
|
-
throw new Error(`${field} must be a valid ISO date`);
|
|
145
|
-
}
|
|
146
|
-
return date.toISOString();
|
|
147
|
-
}
|
|
148
|
-
function assertIdList(value, field) {
|
|
149
|
-
if (value === undefined || value === null)
|
|
150
|
-
return [];
|
|
151
|
-
if (!Array.isArray(value))
|
|
152
|
-
throw new Error(`${field} must be an array`);
|
|
153
|
-
if (value.length > MAX_FILTER_IDS) {
|
|
154
|
-
throw new Error(`${field} supports at most ${MAX_FILTER_IDS} ids`);
|
|
155
|
-
}
|
|
156
|
-
return value.map((id) => {
|
|
157
|
-
if (typeof id !== "string" || !/^[A-Za-z0-9_.:-]+$/.test(id)) {
|
|
158
|
-
throw new Error(`${field} contains an invalid id`);
|
|
159
|
-
}
|
|
160
|
-
return id;
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
function normalizeDatasets(value) {
|
|
164
|
-
if (value === undefined || value === null)
|
|
165
|
-
return ["people", "events"];
|
|
166
|
-
if (!Array.isArray(value))
|
|
167
|
-
throw new Error("datasets must be an array");
|
|
168
|
-
if (value.length === 0)
|
|
169
|
-
throw new Error("datasets cannot be empty");
|
|
170
|
-
const unique = Array.from(new Set(value));
|
|
171
|
-
return unique.map((dataset) => {
|
|
172
|
-
if (dataset !== "people" && dataset !== "events") {
|
|
173
|
-
throw new Error("datasets may only contain people or events");
|
|
174
|
-
}
|
|
175
|
-
return dataset;
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
function normalizeExportIntent(value) {
|
|
179
|
-
if (value === undefined || value === null || value === "")
|
|
180
|
-
return undefined;
|
|
181
|
-
if (value !== "reached_out_people" &&
|
|
182
|
-
value !== "people_and_events" &&
|
|
183
|
-
value !== "outreach_event_log" &&
|
|
184
|
-
value !== "dashboard_messages" &&
|
|
185
|
-
value !== "connections_sent") {
|
|
186
|
-
throw new Error("exportIntent is not supported");
|
|
187
|
-
}
|
|
188
|
-
return value;
|
|
189
|
-
}
|
|
190
|
-
function normalizeActionTypes(value) {
|
|
191
|
-
if (value === undefined || value === null)
|
|
192
|
-
return [];
|
|
193
|
-
if (!Array.isArray(value))
|
|
194
|
-
throw new Error("actionTypes must be an array");
|
|
195
|
-
if (value.length === 0)
|
|
196
|
-
throw new Error("actionTypes cannot be empty");
|
|
197
|
-
const unique = Array.from(new Set(value));
|
|
198
|
-
return unique.map((actionType) => {
|
|
199
|
-
if (actionType !== "INVITE" &&
|
|
200
|
-
actionType !== "DM" &&
|
|
201
|
-
actionType !== "INMAIL_OPEN" &&
|
|
202
|
-
actionType !== "INMAIL_CLOSED" &&
|
|
203
|
-
actionType !== "VIEW_PROFILE" &&
|
|
204
|
-
actionType !== "COMMENT") {
|
|
205
|
-
throw new Error("actionTypes contains an unsupported action type");
|
|
206
|
-
}
|
|
207
|
-
return actionType;
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
function validateInput(input) {
|
|
211
|
-
if (input.exportType && input.exportType !== "outreach") {
|
|
212
|
-
throw new Error("exportType must be outreach");
|
|
213
|
-
}
|
|
214
|
-
const exportIntent = normalizeExportIntent(input.exportIntent);
|
|
215
|
-
const intentConfig = exportIntent ? EXPORT_INTENT_CONFIG[exportIntent] : null;
|
|
216
|
-
const fromSentAt = assertIsoDate(input.fromSentAt, "fromSentAt");
|
|
217
|
-
const toSentAt = assertIsoDate(input.toSentAt, "toSentAt");
|
|
218
|
-
if (fromSentAt && toSentAt && new Date(fromSentAt) > new Date(toSentAt)) {
|
|
219
|
-
throw new Error("fromSentAt must be before toSentAt");
|
|
220
|
-
}
|
|
221
|
-
const actionTypes = normalizeActionTypes(input.actionTypes);
|
|
222
|
-
return {
|
|
223
|
-
exportIntent,
|
|
224
|
-
intentDescription: intentConfig?.description ?? null,
|
|
225
|
-
datasets: input.datasets === undefined || input.datasets === null
|
|
226
|
-
? (intentConfig?.datasets ?? ["people", "events"])
|
|
227
|
-
: normalizeDatasets(input.datasets),
|
|
228
|
-
actionTypes: actionTypes.length > 0 ? actionTypes : (intentConfig?.actionTypes ?? []),
|
|
229
|
-
outputDir: input.outputDir,
|
|
230
|
-
fromSentAt,
|
|
231
|
-
toSentAt,
|
|
232
|
-
campaignIds: assertIdList(input.campaignIds, "campaignIds"),
|
|
233
|
-
tableIds: assertIdList(input.tableIds, "tableIds"),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
function countDefinitions() {
|
|
237
|
-
return {
|
|
238
|
-
reachedOutPeople: "Deduped prospects with at least one actual contact attempt: INVITE, DM, INMAIL_OPEN, or INMAIL_CLOSED. This is what 'everyone we reached out to' should mean.",
|
|
239
|
-
outreachEvents: "One row per exported LinkedInOutreach action event after workspace, date, campaign/table, and action-type filters.",
|
|
240
|
-
dashboardMessagesSent: "Dashboard Messages Sent equals DM + INMAIL_OPEN + INMAIL_CLOSED events. It excludes connection invites.",
|
|
241
|
-
connectionsSent: "Dashboard Connections Sent equals INVITE events. Accepted invites show separately as Connections Made.",
|
|
242
|
-
profileViews: "VIEW_PROFILE is an action event for audit logs, but it is not a contact attempt and should not define reached-out people.",
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
async function packageVersion() {
|
|
246
|
-
const entryDir = process.argv[1]
|
|
247
|
-
? path.dirname(path.resolve(process.argv[1]))
|
|
248
|
-
: process.cwd();
|
|
249
|
-
const candidates = [
|
|
250
|
-
path.resolve(process.cwd(), "mcp/sellable/package.json"),
|
|
251
|
-
path.resolve(entryDir, "../package.json"),
|
|
252
|
-
path.resolve(entryDir, "../../package.json"),
|
|
253
|
-
];
|
|
254
|
-
for (const candidate of candidates) {
|
|
255
|
-
try {
|
|
256
|
-
const raw = await readFile(candidate, "utf8");
|
|
257
|
-
const parsed = JSON.parse(raw);
|
|
258
|
-
if (typeof parsed.version === "string")
|
|
259
|
-
return parsed.version;
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
// Try the next runtime/package layout.
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
async function ensureSafeBaseDir(outputDir) {
|
|
268
|
-
const baseDir = path.resolve(outputDir || path.join(tmpdir(), "sellable-mcp-exports"));
|
|
269
|
-
await mkdir(baseDir, { recursive: true });
|
|
270
|
-
const baseStat = await lstat(baseDir);
|
|
271
|
-
if (baseStat.isSymbolicLink()) {
|
|
272
|
-
throw new Error("outputDir must not be a symlink");
|
|
273
|
-
}
|
|
274
|
-
if (!baseStat.isDirectory()) {
|
|
275
|
-
throw new Error("outputDir must be a directory");
|
|
276
|
-
}
|
|
277
|
-
return baseDir;
|
|
278
|
-
}
|
|
279
|
-
async function createRunDir(outputDir) {
|
|
280
|
-
const baseDir = await ensureSafeBaseDir(outputDir);
|
|
281
|
-
for (let attempt = 0; attempt < 10; attempt++) {
|
|
282
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
283
|
-
const runDir = path.join(baseDir, `workspace-outreach-${stamp}-${process.pid}-${attempt}`);
|
|
284
|
-
try {
|
|
285
|
-
await mkdir(runDir, { recursive: false });
|
|
286
|
-
return runDir;
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
if (error.code !== "EEXIST")
|
|
290
|
-
throw error;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
throw new Error("Failed to create unique export directory");
|
|
294
|
-
}
|
|
295
|
-
function buildQuery(params) {
|
|
296
|
-
const searchParams = new URLSearchParams();
|
|
297
|
-
for (const [key, value] of Object.entries(params)) {
|
|
298
|
-
if (Array.isArray(value)) {
|
|
299
|
-
if (value.length > 0)
|
|
300
|
-
searchParams.set(key, value.join(","));
|
|
301
|
-
}
|
|
302
|
-
else if (value) {
|
|
303
|
-
searchParams.set(key, value);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
const query = searchParams.toString();
|
|
307
|
-
if (query.length > MAX_QUERY_LENGTH) {
|
|
308
|
-
throw new Error("Export query string is too long");
|
|
309
|
-
}
|
|
310
|
-
return query ? `?${query}` : "";
|
|
311
|
-
}
|
|
312
|
-
async function countCsvRows(filePath) {
|
|
313
|
-
const parser = createReadStream(filePath).pipe(parse({ bom: true, relax_column_count: true }));
|
|
314
|
-
let records = 0;
|
|
315
|
-
for await (const _record of parser) {
|
|
316
|
-
records += 1;
|
|
317
|
-
}
|
|
318
|
-
return Math.max(0, records - 1);
|
|
319
|
-
}
|
|
320
|
-
function redactManifest(value) {
|
|
321
|
-
if (Array.isArray(value))
|
|
322
|
-
return value.map(redactManifest);
|
|
323
|
-
if (!value || typeof value !== "object")
|
|
324
|
-
return value;
|
|
325
|
-
const entries = Object.entries(value).map(([key, entry]) => {
|
|
326
|
-
if (/token|secret|authorization|api[_-]?key/i.test(key)) {
|
|
327
|
-
return [key, "[redacted]"];
|
|
328
|
-
}
|
|
329
|
-
return [key, redactManifest(entry)];
|
|
330
|
-
});
|
|
331
|
-
return Object.fromEntries(entries);
|
|
332
|
-
}
|
|
333
|
-
async function writeJsonAtomic(filePath, value) {
|
|
334
|
-
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
335
|
-
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
336
|
-
encoding: "utf8",
|
|
337
|
-
flag: "wx",
|
|
338
|
-
});
|
|
339
|
-
await rename(tempPath, filePath);
|
|
340
|
-
}
|
|
341
|
-
export async function exportWorkspaceCsv(input = {}) {
|
|
342
|
-
const config = getConfig();
|
|
343
|
-
const workspaceId = config.activeWorkspaceId || config.workspaceId || null;
|
|
344
|
-
if (!workspaceId) {
|
|
345
|
-
throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace before export_workspace_csv.");
|
|
346
|
-
}
|
|
347
|
-
const validated = validateInput(input);
|
|
348
|
-
const startedAt = new Date().toISOString();
|
|
349
|
-
const runDir = await createRunDir(validated.outputDir);
|
|
350
|
-
const api = getApi();
|
|
351
|
-
const metadataQuery = buildQuery({
|
|
352
|
-
fromSentAt: validated.fromSentAt,
|
|
353
|
-
toSentAt: validated.toSentAt,
|
|
354
|
-
campaignIds: validated.campaignIds,
|
|
355
|
-
tableIds: validated.tableIds,
|
|
356
|
-
actionTypes: validated.actionTypes,
|
|
357
|
-
});
|
|
358
|
-
const metadataEndpoint = `/api/v3/mcp/workspace-export/outreach/metadata${metadataQuery}`;
|
|
359
|
-
const backendMetadata = await api.get(metadataEndpoint);
|
|
360
|
-
const exportCutoff = backendMetadata.exportCutoff;
|
|
361
|
-
if (!exportCutoff) {
|
|
362
|
-
throw new Error("Export metadata did not include exportCutoff");
|
|
363
|
-
}
|
|
364
|
-
const files = [];
|
|
365
|
-
const failures = [];
|
|
366
|
-
for (const dataset of validated.datasets) {
|
|
367
|
-
const query = buildQuery({
|
|
368
|
-
dataset,
|
|
369
|
-
exportCutoff,
|
|
370
|
-
fromSentAt: validated.fromSentAt,
|
|
371
|
-
toSentAt: validated.toSentAt,
|
|
372
|
-
campaignIds: validated.campaignIds,
|
|
373
|
-
tableIds: validated.tableIds,
|
|
374
|
-
actionTypes: validated.actionTypes,
|
|
375
|
-
});
|
|
376
|
-
const endpoint = `/api/v3/mcp/workspace-export/outreach${query}`;
|
|
377
|
-
const filePath = path.join(runDir, `outreach-${dataset}.csv`);
|
|
378
|
-
try {
|
|
379
|
-
const download = await api.downloadToFile(endpoint, filePath);
|
|
380
|
-
files.push({
|
|
381
|
-
dataset,
|
|
382
|
-
path: download.path,
|
|
383
|
-
rows: await countCsvRows(download.path),
|
|
384
|
-
bytes: download.bytes,
|
|
385
|
-
endpoint,
|
|
386
|
-
contentType: download.contentType,
|
|
387
|
-
status: "complete",
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
catch (error) {
|
|
391
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
392
|
-
failures.push({ dataset, error: message });
|
|
393
|
-
files.push({
|
|
394
|
-
dataset,
|
|
395
|
-
path: filePath,
|
|
396
|
-
rows: 0,
|
|
397
|
-
bytes: 0,
|
|
398
|
-
endpoint,
|
|
399
|
-
contentType: null,
|
|
400
|
-
status: "failed",
|
|
401
|
-
error: message,
|
|
402
|
-
});
|
|
403
|
-
await rm(filePath, { force: true }).catch(() => undefined);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
const manifest = redactManifest({
|
|
407
|
-
status: failures.length > 0 ? "partial" : "success",
|
|
408
|
-
tool: "export_workspace_csv",
|
|
409
|
-
exportType: "outreach",
|
|
410
|
-
exportIntent: validated.exportIntent ?? "legacy_datasets",
|
|
411
|
-
intentDescription: validated.intentDescription,
|
|
412
|
-
countDefinitions: countDefinitions(),
|
|
413
|
-
startedAt,
|
|
414
|
-
completedAt: new Date().toISOString(),
|
|
415
|
-
package: {
|
|
416
|
-
name: "@sellable/mcp",
|
|
417
|
-
version: await packageVersion(),
|
|
418
|
-
},
|
|
419
|
-
config: {
|
|
420
|
-
apiUrl: config.apiUrl,
|
|
421
|
-
activeWorkspaceId: workspaceId,
|
|
422
|
-
activeWorkspaceName: config.activeWorkspaceName || config.workspaceName,
|
|
423
|
-
},
|
|
424
|
-
backend: {
|
|
425
|
-
metadataEndpoint,
|
|
426
|
-
metadata: backendMetadata,
|
|
427
|
-
},
|
|
428
|
-
filters: {
|
|
429
|
-
fromSentAt: validated.fromSentAt ?? null,
|
|
430
|
-
toSentAt: validated.toSentAt ?? null,
|
|
431
|
-
campaignIds: validated.campaignIds,
|
|
432
|
-
tableIds: validated.tableIds,
|
|
433
|
-
actionTypes: validated.actionTypes,
|
|
434
|
-
exportCutoff,
|
|
435
|
-
},
|
|
436
|
-
outputDir: runDir,
|
|
437
|
-
files,
|
|
438
|
-
failures,
|
|
439
|
-
});
|
|
440
|
-
const manifestPath = path.join(runDir, "manifest.json");
|
|
441
|
-
await writeJsonAtomic(manifestPath, manifest);
|
|
442
|
-
return {
|
|
443
|
-
status: failures.length > 0 ? "partial" : "success",
|
|
444
|
-
outputDir: runDir,
|
|
445
|
-
manifestPath,
|
|
446
|
-
datasets: files.map((file) => ({
|
|
447
|
-
dataset: file.dataset,
|
|
448
|
-
path: file.path,
|
|
449
|
-
rows: file.rows,
|
|
450
|
-
bytes: file.bytes,
|
|
451
|
-
status: file.status,
|
|
452
|
-
})),
|
|
453
|
-
exportCutoff,
|
|
454
|
-
exportIntent: validated.exportIntent ?? null,
|
|
455
|
-
intentDescription: validated.intentDescription,
|
|
456
|
-
countDefinitions: countDefinitions(),
|
|
457
|
-
counts: backendMetadata.counts ?? {},
|
|
458
|
-
workspace: backendMetadata.workspace ?? {
|
|
459
|
-
id: workspaceId,
|
|
460
|
-
name: config.activeWorkspaceName || config.workspaceName || null,
|
|
461
|
-
},
|
|
462
|
-
skipped: backendMetadata.skipped ?? [],
|
|
463
|
-
failures,
|
|
464
|
-
};
|
|
465
|
-
}
|