@sellable/mcp 0.1.272 → 0.1.274
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/index-dev.js +0 -0
- package/dist/index.js +0 -0
- package/dist/server.js +7 -0
- package/dist/tools/harvest-jobs.d.ts +182 -0
- package/dist/tools/harvest-jobs.js +429 -0
- package/dist/tools/leads.js +1 -1
- package/dist/tools/registry.d.ts +47 -0
- package/dist/tools/registry.js +2 -0
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +10 -0
- package/skills/create-campaign/core/providers/prospeo.json +5 -2
- package/skills/create-post/SKILL.md +44 -0
- package/skills/create-post/references/post-file-contract.md +4 -0
- package/skills/create-post/references/post-validation.md +52 -1
- package/skills/generate-messages/SKILL.md +10 -0
- package/skills/providers/prospeo.md +21 -0
- package/skills/research/config.json +0 -9
package/dist/index-dev.js
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
File without changes
|
package/dist/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { copySenderConfigTool, getEngageMemoryTool, migrateFlatConfigsTool, reco
|
|
|
18
18
|
import { getEngageStateTool, setEngageStateTool, } from "./tools/engage-state.js";
|
|
19
19
|
import { bulkEnrichWithProspeo, enrichWithProspeo, getProspeoCredits, } from "./tools/enrichment.js";
|
|
20
20
|
import { getCampaignFramework } from "./tools/framework.js";
|
|
21
|
+
import { confirmHarvestJobCompanies, searchHarvestJobs, } from "./tools/harvest-jobs.js";
|
|
21
22
|
import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
|
|
22
23
|
import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
|
|
23
24
|
import { getCampaignNavigationState } from "./tools/navigation.js";
|
|
@@ -372,6 +373,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
372
373
|
case "confirm_prospeo_company_accounts":
|
|
373
374
|
result = await confirmProspeoCompanyAccounts(args);
|
|
374
375
|
break;
|
|
376
|
+
case "search_harvest_jobs":
|
|
377
|
+
result = await searchHarvestJobs(args);
|
|
378
|
+
break;
|
|
379
|
+
case "confirm_harvest_job_companies":
|
|
380
|
+
result = await confirmHarvestJobCompanies(args);
|
|
381
|
+
break;
|
|
375
382
|
case "load_csv_domains":
|
|
376
383
|
result = await loadCsvDomains(args);
|
|
377
384
|
break;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
type ArtifactFormat = "markdown" | "csv" | "both";
|
|
2
|
+
export interface SearchHarvestJobsInput {
|
|
3
|
+
search?: string;
|
|
4
|
+
searches?: string[];
|
|
5
|
+
location?: string;
|
|
6
|
+
geoId?: string;
|
|
7
|
+
postedLimit?: "24h" | "week" | "month";
|
|
8
|
+
sortBy?: "relevance" | "date";
|
|
9
|
+
workplaceType?: string | string[];
|
|
10
|
+
employmentType?: string | string[];
|
|
11
|
+
experienceLevel?: string | string[];
|
|
12
|
+
easyApply?: boolean;
|
|
13
|
+
under10Applicants?: boolean;
|
|
14
|
+
salary?: string;
|
|
15
|
+
pages?: number;
|
|
16
|
+
maxRows?: number;
|
|
17
|
+
artifactFormat?: ArtifactFormat;
|
|
18
|
+
outputDir?: string;
|
|
19
|
+
fileBaseName?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ConfirmHarvestJobCompaniesInput {
|
|
22
|
+
searchToken?: string;
|
|
23
|
+
selectedJobIds?: string[];
|
|
24
|
+
name?: string;
|
|
25
|
+
outputDir?: string;
|
|
26
|
+
fileBaseName?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
export declare const harvestJobToolDefinitions: ({
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: string;
|
|
34
|
+
properties: {
|
|
35
|
+
search: {
|
|
36
|
+
type: string;
|
|
37
|
+
};
|
|
38
|
+
searches: {
|
|
39
|
+
type: string;
|
|
40
|
+
items: {
|
|
41
|
+
type: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
location: {
|
|
45
|
+
type: string;
|
|
46
|
+
};
|
|
47
|
+
geoId: {
|
|
48
|
+
type: string;
|
|
49
|
+
};
|
|
50
|
+
postedLimit: {
|
|
51
|
+
type: string;
|
|
52
|
+
enum: string[];
|
|
53
|
+
};
|
|
54
|
+
sortBy: {
|
|
55
|
+
type: string;
|
|
56
|
+
enum: string[];
|
|
57
|
+
};
|
|
58
|
+
workplaceType: {
|
|
59
|
+
oneOf: ({
|
|
60
|
+
type: string;
|
|
61
|
+
items?: undefined;
|
|
62
|
+
} | {
|
|
63
|
+
type: string;
|
|
64
|
+
items: {
|
|
65
|
+
type: string;
|
|
66
|
+
};
|
|
67
|
+
})[];
|
|
68
|
+
};
|
|
69
|
+
employmentType: {
|
|
70
|
+
oneOf: ({
|
|
71
|
+
type: string;
|
|
72
|
+
items?: undefined;
|
|
73
|
+
} | {
|
|
74
|
+
type: string;
|
|
75
|
+
items: {
|
|
76
|
+
type: string;
|
|
77
|
+
};
|
|
78
|
+
})[];
|
|
79
|
+
};
|
|
80
|
+
experienceLevel: {
|
|
81
|
+
oneOf: ({
|
|
82
|
+
type: string;
|
|
83
|
+
items?: undefined;
|
|
84
|
+
} | {
|
|
85
|
+
type: string;
|
|
86
|
+
items: {
|
|
87
|
+
type: string;
|
|
88
|
+
};
|
|
89
|
+
})[];
|
|
90
|
+
};
|
|
91
|
+
easyApply: {
|
|
92
|
+
type: string;
|
|
93
|
+
};
|
|
94
|
+
under10Applicants: {
|
|
95
|
+
type: string;
|
|
96
|
+
};
|
|
97
|
+
salary: {
|
|
98
|
+
type: string;
|
|
99
|
+
};
|
|
100
|
+
pages: {
|
|
101
|
+
type: string;
|
|
102
|
+
description: string;
|
|
103
|
+
};
|
|
104
|
+
maxRows: {
|
|
105
|
+
type: string;
|
|
106
|
+
description: string;
|
|
107
|
+
};
|
|
108
|
+
artifactFormat: {
|
|
109
|
+
type: string;
|
|
110
|
+
enum: string[];
|
|
111
|
+
description: string;
|
|
112
|
+
};
|
|
113
|
+
outputDir: {
|
|
114
|
+
type: string;
|
|
115
|
+
description: string;
|
|
116
|
+
};
|
|
117
|
+
fileBaseName: {
|
|
118
|
+
type: string;
|
|
119
|
+
description: string;
|
|
120
|
+
};
|
|
121
|
+
searchToken?: undefined;
|
|
122
|
+
selectedJobIds?: undefined;
|
|
123
|
+
name?: undefined;
|
|
124
|
+
};
|
|
125
|
+
required: never[];
|
|
126
|
+
additionalProperties: boolean;
|
|
127
|
+
};
|
|
128
|
+
} | {
|
|
129
|
+
name: string;
|
|
130
|
+
description: string;
|
|
131
|
+
inputSchema: {
|
|
132
|
+
type: string;
|
|
133
|
+
properties: {
|
|
134
|
+
searchToken: {
|
|
135
|
+
type: string;
|
|
136
|
+
description: string;
|
|
137
|
+
};
|
|
138
|
+
selectedJobIds: {
|
|
139
|
+
type: string;
|
|
140
|
+
items: {
|
|
141
|
+
type: string;
|
|
142
|
+
};
|
|
143
|
+
description: string;
|
|
144
|
+
};
|
|
145
|
+
name: {
|
|
146
|
+
type: string;
|
|
147
|
+
};
|
|
148
|
+
outputDir: {
|
|
149
|
+
type: string;
|
|
150
|
+
description: string;
|
|
151
|
+
};
|
|
152
|
+
fileBaseName: {
|
|
153
|
+
type: string;
|
|
154
|
+
description: string;
|
|
155
|
+
};
|
|
156
|
+
search?: undefined;
|
|
157
|
+
searches?: undefined;
|
|
158
|
+
location?: undefined;
|
|
159
|
+
geoId?: undefined;
|
|
160
|
+
postedLimit?: undefined;
|
|
161
|
+
sortBy?: undefined;
|
|
162
|
+
workplaceType?: undefined;
|
|
163
|
+
employmentType?: undefined;
|
|
164
|
+
experienceLevel?: undefined;
|
|
165
|
+
easyApply?: undefined;
|
|
166
|
+
under10Applicants?: undefined;
|
|
167
|
+
salary?: undefined;
|
|
168
|
+
pages?: undefined;
|
|
169
|
+
maxRows?: undefined;
|
|
170
|
+
artifactFormat?: undefined;
|
|
171
|
+
};
|
|
172
|
+
required: string[];
|
|
173
|
+
additionalProperties: boolean;
|
|
174
|
+
};
|
|
175
|
+
})[];
|
|
176
|
+
export declare function searchHarvestJobs(input: SearchHarvestJobsInput): Promise<{
|
|
177
|
+
[k: string]: unknown;
|
|
178
|
+
}>;
|
|
179
|
+
export declare function confirmHarvestJobCompanies(input: ConfirmHarvestJobCompaniesInput): Promise<{
|
|
180
|
+
[k: string]: unknown;
|
|
181
|
+
}>;
|
|
182
|
+
export {};
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getApi } from "../api.js";
|
|
5
|
+
import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
|
|
6
|
+
const entryPath = process.argv[1] ? path.resolve(process.argv[1]) : process.cwd();
|
|
7
|
+
const workspaceRoot = resolveWorkspaceRoot(path.dirname(entryPath));
|
|
8
|
+
const HARVEST_JOB_SEARCH_TOKEN_REF_PREFIX = "mcp-harvest-job-search-token:";
|
|
9
|
+
const MAX_HARVEST_JOB_SEARCH_TOKEN_REFS = 200;
|
|
10
|
+
const tokenRefs = new Map();
|
|
11
|
+
let tokenRefCounter = 0;
|
|
12
|
+
export const harvestJobToolDefinitions = [
|
|
13
|
+
{
|
|
14
|
+
name: "search_harvest_jobs",
|
|
15
|
+
description: "Search current LinkedIn jobs through Sellable's Harvest-backed v3 API, write markdown/CSV review artifacts, and return a token reference for selected-company confirmation. Use when account-source intent is current LinkedIn job posts. Does not call Prospeo or confirm companies.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
search: { type: "string" },
|
|
20
|
+
searches: { type: "array", items: { type: "string" } },
|
|
21
|
+
location: { type: "string" },
|
|
22
|
+
geoId: { type: "string" },
|
|
23
|
+
postedLimit: { type: "string", enum: ["24h", "week", "month"] },
|
|
24
|
+
sortBy: { type: "string", enum: ["relevance", "date"] },
|
|
25
|
+
workplaceType: {
|
|
26
|
+
oneOf: [
|
|
27
|
+
{ type: "string" },
|
|
28
|
+
{ type: "array", items: { type: "string" } },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
employmentType: {
|
|
32
|
+
oneOf: [
|
|
33
|
+
{ type: "string" },
|
|
34
|
+
{ type: "array", items: { type: "string" } },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
experienceLevel: {
|
|
38
|
+
oneOf: [
|
|
39
|
+
{ type: "string" },
|
|
40
|
+
{ type: "array", items: { type: "string" } },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
easyApply: { type: "boolean" },
|
|
44
|
+
under10Applicants: { type: "boolean" },
|
|
45
|
+
salary: { type: "string" },
|
|
46
|
+
pages: {
|
|
47
|
+
type: "number",
|
|
48
|
+
description: "Max pages per search term. Backend cap is 2.",
|
|
49
|
+
},
|
|
50
|
+
maxRows: {
|
|
51
|
+
type: "number",
|
|
52
|
+
description: "Max returned rows across searches. Backend cap is 100.",
|
|
53
|
+
},
|
|
54
|
+
artifactFormat: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["markdown", "csv", "both"],
|
|
57
|
+
description: "Review artifact format. Defaults to markdown.",
|
|
58
|
+
},
|
|
59
|
+
outputDir: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Optional safe output directory under the workspace, home, or temp root.",
|
|
62
|
+
},
|
|
63
|
+
fileBaseName: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Optional safe artifact filename base without extension.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: [],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "confirm_harvest_job_companies",
|
|
74
|
+
description: "Confirm selected Harvest LinkedIn job IDs into a backend-resolved Prospeo domainFilterId, then guide the follow-on search_prospeo people search. Requires searchToken or token reference from search_harvest_jobs. Does not accept raw domains.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
searchToken: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Signed search token or mcp-harvest-job-search-token:* reference from search_harvest_jobs.",
|
|
81
|
+
},
|
|
82
|
+
selectedJobIds: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: { type: "string" },
|
|
85
|
+
description: "Harvest job IDs selected from the search artifact.",
|
|
86
|
+
},
|
|
87
|
+
name: { type: "string" },
|
|
88
|
+
outputDir: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "Optional safe output directory under the workspace, home, or temp root.",
|
|
91
|
+
},
|
|
92
|
+
fileBaseName: {
|
|
93
|
+
type: "string",
|
|
94
|
+
description: "Optional safe artifact filename base without extension.",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ["searchToken", "selectedJobIds"],
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
function removeUndefinedValues(input) {
|
|
103
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
104
|
+
}
|
|
105
|
+
function storeTokenRef(token) {
|
|
106
|
+
if (typeof token !== "string" || token.length === 0)
|
|
107
|
+
return token;
|
|
108
|
+
if (token.startsWith(HARVEST_JOB_SEARCH_TOKEN_REF_PREFIX))
|
|
109
|
+
return token;
|
|
110
|
+
const ref = `${HARVEST_JOB_SEARCH_TOKEN_REF_PREFIX}${Date.now().toString(36)}-${(++tokenRefCounter).toString(36)}`;
|
|
111
|
+
tokenRefs.set(ref, token);
|
|
112
|
+
while (tokenRefs.size > MAX_HARVEST_JOB_SEARCH_TOKEN_REFS) {
|
|
113
|
+
const oldest = tokenRefs.keys().next().value;
|
|
114
|
+
if (!oldest)
|
|
115
|
+
break;
|
|
116
|
+
tokenRefs.delete(oldest);
|
|
117
|
+
}
|
|
118
|
+
return ref;
|
|
119
|
+
}
|
|
120
|
+
function resolveTokenRef(token) {
|
|
121
|
+
if (!token.startsWith(HARVEST_JOB_SEARCH_TOKEN_REF_PREFIX))
|
|
122
|
+
return token;
|
|
123
|
+
const resolved = tokenRefs.get(token);
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
throw new Error("confirm_harvest_job_companies received a stale Harvest job search token reference. Re-run search_harvest_jobs.");
|
|
126
|
+
}
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
function validateSearchInput(input) {
|
|
130
|
+
if (!input?.search && (!input?.searches || input.searches.length === 0)) {
|
|
131
|
+
throw new Error("search_harvest_jobs requires search or searches.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function validateConfirmInput(input) {
|
|
135
|
+
if (!input?.searchToken) {
|
|
136
|
+
throw new Error("confirm_harvest_job_companies requires searchToken from search_harvest_jobs.");
|
|
137
|
+
}
|
|
138
|
+
if (!input.selectedJobIds || input.selectedJobIds.length === 0) {
|
|
139
|
+
throw new Error("confirm_harvest_job_companies requires selectedJobIds from search_harvest_jobs results.");
|
|
140
|
+
}
|
|
141
|
+
if ("domain" in input ||
|
|
142
|
+
"domains" in input ||
|
|
143
|
+
"includeDomains" in input ||
|
|
144
|
+
"companyDomains" in input) {
|
|
145
|
+
throw new Error("confirm_harvest_job_companies does not accept raw domains. Use searchToken and selectedJobIds.");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function decodeMaybe(value) {
|
|
149
|
+
try {
|
|
150
|
+
return decodeURIComponent(value);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isPathInside(candidate, root) {
|
|
157
|
+
const relative = path.relative(root, candidate);
|
|
158
|
+
return (relative === "" ||
|
|
159
|
+
(!relative.startsWith("..") && !path.isAbsolute(relative)));
|
|
160
|
+
}
|
|
161
|
+
function safeBaseName(value, fallback) {
|
|
162
|
+
const raw = (value ?? fallback).trim();
|
|
163
|
+
const decoded = decodeMaybe(raw);
|
|
164
|
+
const cleaned = decoded
|
|
165
|
+
.replace(/\.[A-Za-z0-9]+$/, "")
|
|
166
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
167
|
+
.replace(/^-+|-+$/g, "")
|
|
168
|
+
.slice(0, 80);
|
|
169
|
+
if (!cleaned || cleaned.includes("..")) {
|
|
170
|
+
throw new Error("fileBaseName must be a safe filename.");
|
|
171
|
+
}
|
|
172
|
+
return cleaned;
|
|
173
|
+
}
|
|
174
|
+
function allowedRoots() {
|
|
175
|
+
return [workspaceRoot, os.homedir(), os.tmpdir()]
|
|
176
|
+
.map((root) => {
|
|
177
|
+
const resolved = path.resolve(root);
|
|
178
|
+
try {
|
|
179
|
+
return fs.realpathSync(resolved);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return resolved;
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
function resolveOutputDir(outputDir) {
|
|
188
|
+
const target = outputDir
|
|
189
|
+
? path.isAbsolute(outputDir)
|
|
190
|
+
? path.resolve(outputDir)
|
|
191
|
+
: path.resolve(workspaceRoot, outputDir)
|
|
192
|
+
: path.resolve(workspaceRoot, ".sellable", "artifacts", "harvest-jobs");
|
|
193
|
+
if (target.includes("\0")) {
|
|
194
|
+
throw new Error("outputDir contains invalid characters.");
|
|
195
|
+
}
|
|
196
|
+
fs.mkdirSync(target, { recursive: true });
|
|
197
|
+
const real = fs.realpathSync(target);
|
|
198
|
+
if (!allowedRoots().some((root) => isPathInside(real, root))) {
|
|
199
|
+
throw new Error("outputDir must be inside the workspace, home directory, or temp directory.");
|
|
200
|
+
}
|
|
201
|
+
return real;
|
|
202
|
+
}
|
|
203
|
+
function collisionSafePath(outputDir, fileBaseName, ext) {
|
|
204
|
+
const extension = ext.startsWith(".") ? ext : `.${ext}`;
|
|
205
|
+
if (![".md", ".csv"].includes(extension)) {
|
|
206
|
+
throw new Error(`Unsupported artifact extension: ${extension}`);
|
|
207
|
+
}
|
|
208
|
+
let candidate = path.join(outputDir, `${fileBaseName}${extension}`);
|
|
209
|
+
let counter = 1;
|
|
210
|
+
while (fs.existsSync(candidate)) {
|
|
211
|
+
candidate = path.join(outputDir, `${fileBaseName}-${counter}${extension}`);
|
|
212
|
+
counter += 1;
|
|
213
|
+
}
|
|
214
|
+
const resolved = path.resolve(candidate);
|
|
215
|
+
if (!isPathInside(resolved, outputDir)) {
|
|
216
|
+
throw new Error("Artifact path escapes outputDir.");
|
|
217
|
+
}
|
|
218
|
+
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
|
|
219
|
+
throw new Error("Refusing to write through symlink artifact path.");
|
|
220
|
+
}
|
|
221
|
+
return resolved;
|
|
222
|
+
}
|
|
223
|
+
function csvEscape(value) {
|
|
224
|
+
const text = value === null || value === undefined ? "" : String(value);
|
|
225
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
226
|
+
}
|
|
227
|
+
function formatValue(value) {
|
|
228
|
+
if (value === null || value === undefined)
|
|
229
|
+
return "";
|
|
230
|
+
if (typeof value === "object")
|
|
231
|
+
return JSON.stringify(value);
|
|
232
|
+
return String(value);
|
|
233
|
+
}
|
|
234
|
+
function renderSearchMarkdown(response) {
|
|
235
|
+
const rows = Array.isArray(response.rows) ? response.rows : [];
|
|
236
|
+
const lines = [
|
|
237
|
+
"# Harvest LinkedIn Job Search",
|
|
238
|
+
"",
|
|
239
|
+
`Row count: ${response.rowCount ?? rows.length}`,
|
|
240
|
+
`Cost summary: ${JSON.stringify(response.costSummary ?? {})}`,
|
|
241
|
+
`Search summary: ${JSON.stringify(response.summary ?? {})}`,
|
|
242
|
+
"",
|
|
243
|
+
"Domains are resolved only during confirmation. Select stable Harvest job IDs, not LinkedIn company URLs.",
|
|
244
|
+
"",
|
|
245
|
+
"| Job ID | Company | Title | Posted | Location | Easy Apply | Job URL | Company LinkedIn | Request ID |",
|
|
246
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
247
|
+
...rows.map((row) => [
|
|
248
|
+
row.jobId,
|
|
249
|
+
row.companyName,
|
|
250
|
+
row.title,
|
|
251
|
+
row.postedDate,
|
|
252
|
+
row.locationText,
|
|
253
|
+
row.easyApply,
|
|
254
|
+
row.jobUrl,
|
|
255
|
+
row.companyLinkedinUrl,
|
|
256
|
+
row.harvestRequestId,
|
|
257
|
+
]
|
|
258
|
+
.map((value) => String(value ?? "").replace(/\|/g, "\\|"))
|
|
259
|
+
.join(" | ")
|
|
260
|
+
.replace(/^/, "| ")
|
|
261
|
+
.replace(/$/, " |")),
|
|
262
|
+
"",
|
|
263
|
+
"Next step: confirm_harvest_job_companies({ searchToken, selectedJobIds: [...] })",
|
|
264
|
+
];
|
|
265
|
+
return `${lines.join("\n")}\n`;
|
|
266
|
+
}
|
|
267
|
+
function renderSearchCsv(response) {
|
|
268
|
+
const rows = Array.isArray(response.rows) ? response.rows : [];
|
|
269
|
+
const columns = [
|
|
270
|
+
"rowId",
|
|
271
|
+
"jobId",
|
|
272
|
+
"companyName",
|
|
273
|
+
"title",
|
|
274
|
+
"jobUrl",
|
|
275
|
+
"postedDate",
|
|
276
|
+
"locationText",
|
|
277
|
+
"easyApply",
|
|
278
|
+
"companyLinkedinUrl",
|
|
279
|
+
"companyUniversalName",
|
|
280
|
+
"harvestRequestId",
|
|
281
|
+
"search",
|
|
282
|
+
"page",
|
|
283
|
+
];
|
|
284
|
+
return [
|
|
285
|
+
columns.join(","),
|
|
286
|
+
...rows.map((row) => columns.map((column) => csvEscape(row[column])).join(",")),
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
function renderConfirmMarkdown(response) {
|
|
290
|
+
const unresolved = Array.isArray(response.unresolvedJobs)
|
|
291
|
+
? response.unresolvedJobs
|
|
292
|
+
: [];
|
|
293
|
+
const skipped = Array.isArray(response.skippedJobs) ? response.skippedJobs : [];
|
|
294
|
+
const lines = [
|
|
295
|
+
"# Harvest LinkedIn Job Confirmation",
|
|
296
|
+
"",
|
|
297
|
+
`Domain filter ID: ${response.domainFilterId ?? ""}`,
|
|
298
|
+
`Resolved: ${response.resolvedCount ?? 0}`,
|
|
299
|
+
`Unresolved: ${response.unresolvedCount ?? 0}`,
|
|
300
|
+
`Duplicates: ${response.duplicateCount ?? 0}`,
|
|
301
|
+
`Skipped: ${response.skippedCount ?? 0}`,
|
|
302
|
+
`Cost summary: ${JSON.stringify(response.costSummary ?? {})}`,
|
|
303
|
+
"",
|
|
304
|
+
"## Resolved Domains",
|
|
305
|
+
"",
|
|
306
|
+
...(response.includeDomains ?? []).map((domain) => `- ${domain}`),
|
|
307
|
+
"",
|
|
308
|
+
"## Unresolved Jobs",
|
|
309
|
+
"",
|
|
310
|
+
...unresolved.map((row) => `- ${formatValue(row.jobId)}: ${formatValue(row.reason)}`),
|
|
311
|
+
"",
|
|
312
|
+
"## Skipped Jobs",
|
|
313
|
+
"",
|
|
314
|
+
...skipped.map((row) => `- ${formatValue(row.jobId)}: ${formatValue(row.reason)}`),
|
|
315
|
+
"",
|
|
316
|
+
"Next step: search_prospeo with the returned domainFilterId.",
|
|
317
|
+
];
|
|
318
|
+
return `${lines.join("\n")}\n`;
|
|
319
|
+
}
|
|
320
|
+
function writeSearchArtifacts(response, input) {
|
|
321
|
+
const format = input.artifactFormat ?? "markdown";
|
|
322
|
+
const outputDir = resolveOutputDir(input.outputDir);
|
|
323
|
+
const baseName = safeBaseName(input.fileBaseName, `harvest-jobs-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
324
|
+
const artifacts = {};
|
|
325
|
+
if (format === "markdown" || format === "both") {
|
|
326
|
+
const markdownPath = collisionSafePath(outputDir, baseName, ".md");
|
|
327
|
+
fs.writeFileSync(markdownPath, renderSearchMarkdown(response), "utf8");
|
|
328
|
+
artifacts.markdown = markdownPath;
|
|
329
|
+
}
|
|
330
|
+
if (format === "csv" || format === "both") {
|
|
331
|
+
const csvPath = collisionSafePath(outputDir, baseName, ".csv");
|
|
332
|
+
fs.writeFileSync(csvPath, renderSearchCsv(response), "utf8");
|
|
333
|
+
artifacts.csv = csvPath;
|
|
334
|
+
}
|
|
335
|
+
return artifacts;
|
|
336
|
+
}
|
|
337
|
+
function writeConfirmArtifact(response, input) {
|
|
338
|
+
const outputDir = resolveOutputDir(input.outputDir);
|
|
339
|
+
const baseName = safeBaseName(input.fileBaseName, `harvest-jobs-confirm-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
340
|
+
const artifactPath = collisionSafePath(outputDir, baseName, ".md");
|
|
341
|
+
fs.writeFileSync(artifactPath, renderConfirmMarkdown(response), "utf8");
|
|
342
|
+
return artifactPath;
|
|
343
|
+
}
|
|
344
|
+
function compactSearchResponse(response, artifacts) {
|
|
345
|
+
const rows = Array.isArray(response.rows) ? response.rows : [];
|
|
346
|
+
const token = storeTokenRef(response.searchToken);
|
|
347
|
+
return removeUndefinedValues({
|
|
348
|
+
success: response.success ?? true,
|
|
349
|
+
rowCount: typeof response.rowCount === "number" ? response.rowCount : rows.length,
|
|
350
|
+
costSummary: response.costSummary ?? {},
|
|
351
|
+
artifacts,
|
|
352
|
+
sampleJobs: rows.slice(0, 10).map((row) => removeUndefinedValues({
|
|
353
|
+
rowId: row.rowId,
|
|
354
|
+
jobId: row.jobId,
|
|
355
|
+
company: row.companyName,
|
|
356
|
+
title: row.title,
|
|
357
|
+
jobUrl: row.jobUrl,
|
|
358
|
+
postedDate: row.postedDate,
|
|
359
|
+
location: row.locationText,
|
|
360
|
+
easyApply: row.easyApply,
|
|
361
|
+
companyLinkedinUrl: row.companyLinkedinUrl,
|
|
362
|
+
companyUniversalName: row.companyUniversalName,
|
|
363
|
+
})),
|
|
364
|
+
searchToken: token,
|
|
365
|
+
nextStep: "Review the artifact, then call confirm_harvest_job_companies with searchToken and selectedJobIds. Harvest jobs are account-source evidence; Prospeo is the follow-on people search.",
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function compactConfirmResponse(response, artifactPath) {
|
|
369
|
+
return removeUndefinedValues({
|
|
370
|
+
success: response.success ?? true,
|
|
371
|
+
domainFilterId: response.domainFilterId,
|
|
372
|
+
includeDomains: response.includeDomains,
|
|
373
|
+
resolvedCount: response.resolvedCount,
|
|
374
|
+
unresolvedCount: response.unresolvedCount,
|
|
375
|
+
duplicateCount: response.duplicateCount,
|
|
376
|
+
skippedCount: response.skippedCount,
|
|
377
|
+
costSummary: response.costSummary,
|
|
378
|
+
unresolvedJobs: response.unresolvedJobs ?? [],
|
|
379
|
+
skippedJobs: response.skippedJobs ?? [],
|
|
380
|
+
carryoverSummary: response.carryoverSummary,
|
|
381
|
+
confirmArtifactPath: artifactPath,
|
|
382
|
+
suggestedNextCall: {
|
|
383
|
+
tool: "search_prospeo",
|
|
384
|
+
arguments: response.nextSearchProspeoCall ?? {
|
|
385
|
+
domainFilterId: response.domainFilterId,
|
|
386
|
+
filters: { max_person_per_company: 1 },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
export async function searchHarvestJobs(input) {
|
|
392
|
+
validateSearchInput(input);
|
|
393
|
+
const api = getApi();
|
|
394
|
+
const response = await api.post("/api/v3/harvest/linkedin-jobs/search", removeUndefinedValues({
|
|
395
|
+
search: input.search,
|
|
396
|
+
searches: input.searches,
|
|
397
|
+
location: input.location,
|
|
398
|
+
geoId: input.geoId,
|
|
399
|
+
postedLimit: input.postedLimit,
|
|
400
|
+
sortBy: input.sortBy,
|
|
401
|
+
workplaceType: input.workplaceType,
|
|
402
|
+
employmentType: input.employmentType,
|
|
403
|
+
experienceLevel: input.experienceLevel,
|
|
404
|
+
easyApply: input.easyApply,
|
|
405
|
+
under10Applicants: input.under10Applicants,
|
|
406
|
+
salary: input.salary,
|
|
407
|
+
pages: input.pages,
|
|
408
|
+
maxRows: input.maxRows,
|
|
409
|
+
}));
|
|
410
|
+
if (!response || typeof response !== "object" || !response.searchToken) {
|
|
411
|
+
throw new Error("search_harvest_jobs API response did not include a searchToken.");
|
|
412
|
+
}
|
|
413
|
+
const artifacts = writeSearchArtifacts(response, input);
|
|
414
|
+
return compactSearchResponse(response, artifacts);
|
|
415
|
+
}
|
|
416
|
+
export async function confirmHarvestJobCompanies(input) {
|
|
417
|
+
validateConfirmInput(input);
|
|
418
|
+
const api = getApi();
|
|
419
|
+
const response = await api.post("/api/v3/harvest/linkedin-jobs/confirm-domain-filter", removeUndefinedValues({
|
|
420
|
+
searchToken: resolveTokenRef(input.searchToken),
|
|
421
|
+
selectedJobIds: input.selectedJobIds,
|
|
422
|
+
name: input.name,
|
|
423
|
+
}));
|
|
424
|
+
if (!response || typeof response !== "object" || !response.domainFilterId) {
|
|
425
|
+
throw new Error("confirm_harvest_job_companies API response did not include a domainFilterId.");
|
|
426
|
+
}
|
|
427
|
+
const artifactPath = writeConfirmArtifact(response, input);
|
|
428
|
+
return compactConfirmResponse(response, artifactPath);
|
|
429
|
+
}
|
package/dist/tools/leads.js
CHANGED
|
@@ -1813,7 +1813,7 @@ export const leadToolDefinitions = [
|
|
|
1813
1813
|
},
|
|
1814
1814
|
{
|
|
1815
1815
|
name: "search_prospeo",
|
|
1816
|
-
description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. Use Prospeo first for hiring-led targeting because it supports company_job_posting_hiring_for and company_job_posting_quantity; Sales Nav does not filter companies by hiring role. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. For company lookalike/account asks, call search_prospeo_companies first, review accounts, then confirm_prospeo_company_accounts to create a domainFilterId before using search_prospeo for people. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), add job-posting filters for hiring-led campaigns, then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. If output includes warnings/fallback, report that the MCP retried a rejected precise title request with a safer domain-filter people search instead of claiming the original precise filter succeeded. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Post-mint create-campaign-v2 watch runs MUST pass currentStep: "prospeo". Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
|
|
1816
|
+
description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. Use Prospeo first for hiring-led targeting because it supports company_job_posting_hiring_for and company_job_posting_quantity; Sales Nav does not filter companies by hiring role. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. For current LinkedIn job-post account-source asks, call search_harvest_jobs first, review the artifact, then confirm_harvest_job_companies to create a domainFilterId before using search_prospeo for people. For company lookalike/account asks, call search_prospeo_companies first, review accounts, then confirm_prospeo_company_accounts to create a domainFilterId before using search_prospeo for people. Raw domain inputs, LinkedIn company URLs as domains, and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), add job-posting filters for hiring-led campaigns, then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. If output includes warnings/fallback, report that the MCP retried a rejected precise title request with a safer domain-filter people search instead of claiming the original precise filter succeeded. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Post-mint create-campaign-v2 watch runs MUST pass currentStep: "prospeo". Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
|
|
1817
1817
|
inputSchema: {
|
|
1818
1818
|
type: "object",
|
|
1819
1819
|
properties: {
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -1956,6 +1956,53 @@ export declare const allTools: ({
|
|
|
1956
1956
|
};
|
|
1957
1957
|
required: never[];
|
|
1958
1958
|
};
|
|
1959
|
+
} | {
|
|
1960
|
+
name: string;
|
|
1961
|
+
description: string;
|
|
1962
|
+
inputSchema: {
|
|
1963
|
+
type: string;
|
|
1964
|
+
properties: {
|
|
1965
|
+
searchToken: {
|
|
1966
|
+
type: string;
|
|
1967
|
+
description: string;
|
|
1968
|
+
};
|
|
1969
|
+
selectedJobIds: {
|
|
1970
|
+
type: string;
|
|
1971
|
+
items: {
|
|
1972
|
+
type: string;
|
|
1973
|
+
};
|
|
1974
|
+
description: string;
|
|
1975
|
+
};
|
|
1976
|
+
name: {
|
|
1977
|
+
type: string;
|
|
1978
|
+
};
|
|
1979
|
+
outputDir: {
|
|
1980
|
+
type: string;
|
|
1981
|
+
description: string;
|
|
1982
|
+
};
|
|
1983
|
+
fileBaseName: {
|
|
1984
|
+
type: string;
|
|
1985
|
+
description: string;
|
|
1986
|
+
};
|
|
1987
|
+
search?: undefined;
|
|
1988
|
+
searches?: undefined;
|
|
1989
|
+
location?: undefined;
|
|
1990
|
+
geoId?: undefined;
|
|
1991
|
+
postedLimit?: undefined;
|
|
1992
|
+
sortBy?: undefined;
|
|
1993
|
+
workplaceType?: undefined;
|
|
1994
|
+
employmentType?: undefined;
|
|
1995
|
+
experienceLevel?: undefined;
|
|
1996
|
+
easyApply?: undefined;
|
|
1997
|
+
under10Applicants?: undefined;
|
|
1998
|
+
salary?: undefined;
|
|
1999
|
+
pages?: undefined;
|
|
2000
|
+
maxRows?: undefined;
|
|
2001
|
+
artifactFormat?: undefined;
|
|
2002
|
+
};
|
|
2003
|
+
required: string[];
|
|
2004
|
+
additionalProperties: boolean;
|
|
2005
|
+
};
|
|
1959
2006
|
} | {
|
|
1960
2007
|
name: string;
|
|
1961
2008
|
description: string;
|
package/dist/tools/registry.js
CHANGED
|
@@ -14,6 +14,7 @@ import { engageMemoryToolDefinitions } from "./engage-memory.js";
|
|
|
14
14
|
import { engageStateToolDefinitions } from "./engage-state.js";
|
|
15
15
|
import { enrichmentToolDefinitions } from "./enrichment.js";
|
|
16
16
|
import { frameworkToolDefinitions } from "./framework.js";
|
|
17
|
+
import { harvestJobToolDefinitions } from "./harvest-jobs.js";
|
|
17
18
|
import { leadToolDefinitions } from "./leads.js";
|
|
18
19
|
import { linkedinToolDefinitions } from "./linkedin.js";
|
|
19
20
|
import { navigationToolDefinitions } from "./navigation.js";
|
|
@@ -43,6 +44,7 @@ export const allTools = [
|
|
|
43
44
|
...contentPostToolDefinitions,
|
|
44
45
|
...navigationToolDefinitions,
|
|
45
46
|
...leadToolDefinitions,
|
|
47
|
+
...harvestJobToolDefinitions,
|
|
46
48
|
...enrichmentToolDefinitions,
|
|
47
49
|
...processingToolDefinitions,
|
|
48
50
|
...rubricToolDefinitions,
|
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ allowed-tools:
|
|
|
29
29
|
- mcp__sellable__search_prospeo
|
|
30
30
|
- mcp__sellable__search_prospeo_companies
|
|
31
31
|
- mcp__sellable__confirm_prospeo_company_accounts
|
|
32
|
+
- mcp__sellable__search_harvest_jobs
|
|
33
|
+
- mcp__sellable__confirm_harvest_job_companies
|
|
32
34
|
- mcp__sellable__search_signals
|
|
33
35
|
- mcp__sellable__fetch_post_engagers
|
|
34
36
|
- mcp__sellable__enrich_with_prospeo
|
|
@@ -227,6 +229,14 @@ are likely. Sales Nav is useful for recent LinkedIn activity, role/title
|
|
|
227
229
|
precision, and referral paths, but it does not provide hiring-by-role filters;
|
|
228
230
|
say that distinction plainly in the source-plan gate.
|
|
229
231
|
|
|
232
|
+
When the brief asks for current LinkedIn job-post intent, such as companies
|
|
233
|
+
hiring Power BI developers this month, use Harvest jobs as the account source:
|
|
234
|
+
`search_harvest_jobs -> confirm_harvest_job_companies -> search_prospeo`.
|
|
235
|
+
First write and review the Harvest job artifact. Then confirm selected Harvest
|
|
236
|
+
job IDs into a `domainFilterId`; Prospeo remains the people-search provider.
|
|
237
|
+
Do not paste LinkedIn company URLs as domains. Do not fetch full job details for
|
|
238
|
+
every search row by default; selected batches only.
|
|
239
|
+
|
|
230
240
|
For company lookalikes, best-customer lookalikes, "companies like X",
|
|
231
241
|
lookalike accounts, companies that use AI, companies with API/SSO/Chrome
|
|
232
242
|
extension, news/award/integration/key-customer filters, or account discovery
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
"requiredTools": [
|
|
8
8
|
"update_campaign",
|
|
9
9
|
"get_provider_prompt",
|
|
10
|
+
"search_harvest_jobs",
|
|
11
|
+
"confirm_harvest_job_companies",
|
|
10
12
|
"search_prospeo_companies",
|
|
11
13
|
"confirm_prospeo_company_accounts",
|
|
12
14
|
"search_prospeo",
|
|
@@ -24,13 +26,14 @@
|
|
|
24
26
|
"useWhen": [
|
|
25
27
|
"You want Prospeo filters or domain-based search",
|
|
26
28
|
"You need company lookalike account discovery before finding people",
|
|
29
|
+
"You need current LinkedIn job-post evidence before finding hiring stakeholders",
|
|
27
30
|
"You want companies like X, target-domain, best-customer/top-customer, or job-search employer lookalikes, or accounts using AI/API/SSO/Chrome extensions",
|
|
28
31
|
"You need companies hiring for specific roles using job-posting filters",
|
|
29
32
|
"You need high deliverability from Prospeo"
|
|
30
33
|
],
|
|
31
34
|
"avoidWhen": ["You need LinkedIn activity filters"],
|
|
32
|
-
"reason": "Strong for hiring-led search, company/account lookalikes, Prospeo-specific filters, verified contacts, and domain lists.",
|
|
33
|
-
"prose": "Since you're targeting **{icp}**, I'd recommend **Prospeo**.\n\nHere's why:\n- Prospeo can discover lookalike accounts first, then turn approved accounts into a domainFilterId for people search\n- Prospeo can filter for companies hiring specific roles with job-posting filters\n- We can pair those company signals with buyer/referrer titles and verified-contact coverage\n\nFor lookalike accounts, I will show an account sample first; those account rows are not people leads yet. After approval, I will use the companySearchToken to confirm the accounts, then search people at the returned domainFilterId. For outbound lookalikes, I will use target/account/customer domains or verified past-customer accounts, not the sender's company. For job-search/application lookalikes, I can use current or past employers as existing-company seeds.\n\nShould I search Prospeo for {icp}?"
|
|
35
|
+
"reason": "Strong for hiring-led search, current LinkedIn job-post account sourcing, company/account lookalikes, Prospeo-specific filters, verified contacts, and domain lists.",
|
|
36
|
+
"prose": "Since you're targeting **{icp}**, I'd recommend **Prospeo**.\n\nHere's why:\n- Prospeo can discover lookalike accounts first, then turn approved accounts into a domainFilterId for people search\n- Harvest job search can source companies from current LinkedIn job posts, then confirm selected jobs into a domainFilterId for Prospeo people search\n- Prospeo can filter for companies hiring specific roles with job-posting filters\n- We can pair those company signals with buyer/referrer titles and verified-contact coverage\n\nFor current LinkedIn job-post evidence, I will use search_harvest_jobs -> confirm_harvest_job_companies -> search_prospeo. I will not paste LinkedIn company URLs as domains, and I will only fetch job details for selected batches. For lookalike accounts, I will show an account sample first; those account rows are not people leads yet. After approval, I will use the companySearchToken to confirm the accounts, then search people at the returned domainFilterId. For outbound lookalikes, I will use target/account/customer domains or verified past-customer accounts, not the sender's company. For job-search/application lookalikes, I can use current or past employers as existing-company seeds.\n\nShould I search Prospeo for {icp}?"
|
|
34
37
|
},
|
|
35
38
|
"askOption": {
|
|
36
39
|
"label": "Prospeo",
|
|
@@ -606,6 +606,7 @@ Visible Flow Trace
|
|
|
606
606
|
- worker status per person:
|
|
607
607
|
- recent posts sampled:
|
|
608
608
|
- voice model:
|
|
609
|
+
- mechanics receipts:
|
|
609
610
|
- complete voice variants:
|
|
610
611
|
- follower-adjusted signal when available:
|
|
611
612
|
- hook/body moves worth stealing:
|
|
@@ -623,6 +624,11 @@ Visible Flow Trace
|
|
|
623
624
|
- proof gate:
|
|
624
625
|
- voice gate:
|
|
625
626
|
- anti-AI gate:
|
|
627
|
+
- local finalizer pass:
|
|
628
|
+
- turn separators added:
|
|
629
|
+
- contractions applied:
|
|
630
|
+
- lowercase pass:
|
|
631
|
+
- abstract-to-concrete rewrites:
|
|
626
632
|
- mobile preview gate:
|
|
627
633
|
- template adaptation gate:
|
|
628
634
|
- hook-to-body repayment:
|
|
@@ -1242,6 +1248,42 @@ either completed for every active configured influencer or the validation
|
|
|
1242
1248
|
receipt has an explicit user opt-out reason. A light source-research summary
|
|
1243
1249
|
does not satisfy this gate.
|
|
1244
1250
|
|
|
1251
|
+
## Step 3.5: Local Finalizer Pass
|
|
1252
|
+
|
|
1253
|
+
Run this pass after the canonical draft exists and before validation and save.
|
|
1254
|
+
|
|
1255
|
+
1. Add `--` between each major turn in the post so the story is easier to read.
|
|
1256
|
+
Use it to separate turns such as hook, context, problem, mechanism, product
|
|
1257
|
+
proof, concrete example, and CTA. Do not add `--` inside a tight list where
|
|
1258
|
+
it would make the post harder to scan.
|
|
1259
|
+
2. Use contractions in the draft body by default: `don't`, `isn't`, `that's`,
|
|
1260
|
+
`it's`, `can't`, `we're`, `you're`, and similar natural phrasing. Keep the
|
|
1261
|
+
non-contracted form only when the user explicitly wrote it that way, when a
|
|
1262
|
+
quoted line must stay exact, or when the contraction would sound unnatural.
|
|
1263
|
+
3. Keep optional headings, section labels, and line starts lowercase by default
|
|
1264
|
+
when the post still reads naturally. Preserve proper nouns, acronyms, product
|
|
1265
|
+
names, and required capitalization such as `Claude`, `LinkedIn`, `Codex`,
|
|
1266
|
+
`Sellable.dev`, `Clay`, `HeyReach`, and `ICP`.
|
|
1267
|
+
4. Run a Harry Dry-style concrete-language audit:
|
|
1268
|
+
- highlight every abstract or vague word/phrase in the validation receipt
|
|
1269
|
+
before saving
|
|
1270
|
+
- replace each one in the draft with a concrete object, action, number,
|
|
1271
|
+
example, screen, list, person, place, or visible workflow step
|
|
1272
|
+
- prefer words a reader can picture, such as `lead list`, `send settings`,
|
|
1273
|
+
`reply inbox`, `follow-up`, `team post`, `warm LinkedIn prospect`,
|
|
1274
|
+
`reply handling`, or `one sentence in Claude Code`
|
|
1275
|
+
- do not make a phrase concrete by inventing proof, metrics, customers, or
|
|
1276
|
+
product behavior the user did not provide
|
|
1277
|
+
5. Record this pass in the validation receipt as `localFinalizerPass` with:
|
|
1278
|
+
- `turnSeparatorsAdded: yes | no` and a short reason
|
|
1279
|
+
- `contractionsApplied: yes | no`
|
|
1280
|
+
- `lowercasePass: yes | no` with preserved proper nouns listed
|
|
1281
|
+
- `abstractToConcreteRewrites`, listing each abstract phrase, why it was
|
|
1282
|
+
weak, and the concrete replacement used
|
|
1283
|
+
|
|
1284
|
+
If this pass cannot be completed, save the draft as `needs_revision` instead of
|
|
1285
|
+
`ready`, and explain which finalizer requirement failed.
|
|
1286
|
+
|
|
1245
1287
|
## Step 4: Validation
|
|
1246
1288
|
|
|
1247
1289
|
Use `references/post-validation.md`.
|
|
@@ -1280,6 +1322,8 @@ Every saved draft needs a validation receipt with:
|
|
|
1280
1322
|
- outbound AI-tell audit findings
|
|
1281
1323
|
- hook preview pass/warn/fail status and compact fallback when warned
|
|
1282
1324
|
- finalizer changes
|
|
1325
|
+
- local finalizer pass with turn separators, contractions, lowercase pass, and
|
|
1326
|
+
abstract-to-concrete rewrites
|
|
1283
1327
|
- blocked/retry-needed reasons, if any
|
|
1284
1328
|
|
|
1285
1329
|
If validation fails, do not save the draft as `ready`. Save as `needs_revision` or return blocked/retry-needed.
|
|
@@ -118,6 +118,10 @@ Draft files must preserve:
|
|
|
118
118
|
promise, concrete examples, abstractions to remove, and draft risks
|
|
119
119
|
- validation receipt, including LinkedIn preview pass/warn/fail status and
|
|
120
120
|
compact fallback when the selected hook carries a warning
|
|
121
|
+
- local finalizer pass: whether `--` turn separators were added, whether
|
|
122
|
+
contractions were applied, whether lowercase style was applied where safe,
|
|
123
|
+
preserved proper nouns/acronyms, and abstract-to-concrete rewrites with the
|
|
124
|
+
before/after phrases used in the final draft
|
|
121
125
|
- visible flow trace when the user asked for whole-flow, debug, or step-by-step
|
|
122
126
|
mode, including checkpoint statuses, quality break, downstream effect, and
|
|
123
127
|
whether the trace was shown before saving
|
|
@@ -125,6 +125,41 @@ After the first draft:
|
|
|
125
125
|
9. preserve the user's actual story and point
|
|
126
126
|
10. remove AI tells
|
|
127
127
|
11. re-check LinkedIn preview fit after edits
|
|
128
|
+
12. add `--` between major story turns when it improves scanability
|
|
129
|
+
13. use natural contractions by default
|
|
130
|
+
14. keep optional headings, section labels, and line starts lowercase when safe
|
|
131
|
+
15. run the concrete-language audit below and replace abstract phrasing before
|
|
132
|
+
saving
|
|
133
|
+
|
|
134
|
+
### Local Finalizer Pass
|
|
135
|
+
|
|
136
|
+
Before a draft can be saved as `ready`, record a local finalizer pass. This is
|
|
137
|
+
the last copy pass after the draft body exists and before `save_post_draft`.
|
|
138
|
+
|
|
139
|
+
Record:
|
|
140
|
+
|
|
141
|
+
- `turnSeparatorsAdded`: yes | no
|
|
142
|
+
- `turnSeparatorReason`: where `--` was inserted or why it was skipped
|
|
143
|
+
- `contractionsApplied`: yes | no
|
|
144
|
+
- `contractionsChanged`: examples such as `do not -> don't`, `is not -> isn't`,
|
|
145
|
+
`that is -> that's`, or `it is -> it's`
|
|
146
|
+
- `lowercasePass`: yes | no
|
|
147
|
+
- `preservedCapitalization`: proper nouns, acronyms, product names, and required
|
|
148
|
+
capitalization such as `Claude`, `LinkedIn`, `Codex`, `Sellable.dev`, `Clay`,
|
|
149
|
+
`HeyReach`, and `ICP`
|
|
150
|
+
- `abstractToConcreteRewrites`: every abstract phrase flagged, why it was weak,
|
|
151
|
+
and the concrete replacement used
|
|
152
|
+
|
|
153
|
+
Save as `needs_revision` when:
|
|
154
|
+
|
|
155
|
+
- major story turns are visually hard to follow and no `--` separators or other
|
|
156
|
+
clear turn markers were added
|
|
157
|
+
- the draft keeps stiff non-contracted phrasing without a user/source reason
|
|
158
|
+
- optional headings or labels stay title-cased in a way that clashes with the
|
|
159
|
+
user's lowercase post style
|
|
160
|
+
- meaningful abstract phrases remain in the draft after the concrete-language
|
|
161
|
+
audit
|
|
162
|
+
- the validation receipt does not show what changed during this finalizer pass
|
|
128
163
|
|
|
129
164
|
## Visible Flow Trace Audit
|
|
130
165
|
|
|
@@ -260,7 +295,9 @@ or practical next action.
|
|
|
260
295
|
## Abstraction-To-Concrete Rewrite Audit
|
|
261
296
|
|
|
262
297
|
Record every meaningful abstraction found in the draft and the concrete
|
|
263
|
-
replacement. Do not merely say "made concrete."
|
|
298
|
+
replacement. Do not merely say "made concrete." Use a Harry Dry-style standard:
|
|
299
|
+
if the reader cannot picture the word on a screen, in a list, in a sentence they
|
|
300
|
+
would type, or as a workflow step someone could perform, make it more concrete.
|
|
264
301
|
|
|
265
302
|
Use this format:
|
|
266
303
|
|
|
@@ -279,11 +316,25 @@ Common abstractions to flag:
|
|
|
279
316
|
- vague nouns such as `signal`, `market`, `attention`, `system`, `quality`,
|
|
280
317
|
`context`, `machine`, or `workflow` when the sentence does not name the actual
|
|
281
318
|
source, action, number, or object
|
|
319
|
+
- vague product words such as `orchestrator`, `campaign object`, `GTM
|
|
320
|
+
superpowers`, `agentic`, `AI-native`, or `platform` when the sentence does not
|
|
321
|
+
name the visible work being done
|
|
282
322
|
- broad persona labels used as lead sources
|
|
283
323
|
- "warm" without explaining what made the source warm
|
|
284
324
|
- "offer" without explaining why the recipient would believe or want it
|
|
285
325
|
- "learning" without naming what metric changes the next decision
|
|
286
326
|
|
|
327
|
+
Concrete replacements should name visible things, for example:
|
|
328
|
+
|
|
329
|
+
- `lead list`
|
|
330
|
+
- `send settings`
|
|
331
|
+
- `reply inbox`
|
|
332
|
+
- `follow-up`
|
|
333
|
+
- `team post`
|
|
334
|
+
- `warm LinkedIn prospect`
|
|
335
|
+
- `reply handling`
|
|
336
|
+
- `one sentence in Claude Code`
|
|
337
|
+
|
|
287
338
|
Do not make language concrete by inventing facts. If the concrete replacement
|
|
288
339
|
requires missing proof, ask the user or save as `needs_revision`.
|
|
289
340
|
|
|
@@ -761,6 +761,15 @@ help...` or `built for [buyer] to...`. Do not overuse generic solved-state
|
|
|
761
761
|
bridges like `there's a way to...`; they read informercial when they imply
|
|
762
762
|
the buyer's problem is now solved before the product or artifact has earned
|
|
763
763
|
that claim.
|
|
764
|
+
- run a contraction naturalness pass on the buyer-facing copy before selecting
|
|
765
|
+
the winner. Use common contractions when they make the message sound like a
|
|
766
|
+
human LinkedIn note: `it is` -> `it's`, `does not` -> `doesn't`, `do not` ->
|
|
767
|
+
`don't`, `cannot` -> `can't`, `we are` -> `we're`, `I am` -> `I'm`, and
|
|
768
|
+
`you are` -> `you're`. Keep the uncontracted form only when emphasis,
|
|
769
|
+
grammar, token boundaries, legal/technical clarity, or a deliberate formal
|
|
770
|
+
voice requires it. Do not create awkward noun contractions like
|
|
771
|
+
`outbound's becoming`. If selected copy contains stiff helper-verb phrasing
|
|
772
|
+
such as `It is knowing...`, rewrite it before approval.
|
|
764
773
|
- do not let the first substantive line become a generic problem-solved
|
|
765
774
|
promise. Openers shaped like `there's a way to stop/fix/solve [problem]`
|
|
766
775
|
are BLOCKED when they sound like an ad, imply the problem is already
|
|
@@ -2633,6 +2642,7 @@ hiring for`
|
|
|
2633
2642
|
- Best PS / no-PS decision: Candidate [A|B|C] — reason
|
|
2634
2643
|
- Element edits made by finalizer: ...
|
|
2635
2644
|
- Parallel action formatting: bullets used | paragraphs kept | not applicable — reason
|
|
2645
|
+
- Contraction naturalness: PASS | BLOCKED — reason
|
|
2636
2646
|
- Style consistency check: PASS | BLOCKED — reason
|
|
2637
2647
|
- Assembly note: clean combination or one candidate swept
|
|
2638
2648
|
|
|
@@ -9,10 +9,31 @@ Prospeo supports search + import in the campaign builder flow.
|
|
|
9
9
|
## Provider Decision Tree
|
|
10
10
|
|
|
11
11
|
- Known account list or CSV: use `load_csv_domains` or `save_domain_filters`, then `search_prospeo`.
|
|
12
|
+
- Current LinkedIn job-post intent: use `search_harvest_jobs`, review the job artifact, then use `confirm_harvest_job_companies` with selected Harvest job IDs to create a `domainFilterId`; only then call `search_prospeo` for hiring stakeholders at those companies. Harvest jobs are the account source, not a people-search replacement.
|
|
12
13
|
- Company/account lookalikes: use `search_prospeo_companies`, review the account sample, then use `confirm_prospeo_company_accounts` with the returned `companySearchToken` to create a `domainFilterId`; only then call `search_prospeo` for people at those accounts. Account rows are not people leads yet.
|
|
13
14
|
- Person search with known filters: use `search_prospeo` directly.
|
|
14
15
|
- LinkedIn activity, content-source, or post-engagement intent: stay on Signal Discovery or Sales Nav, not Prospeo.
|
|
15
16
|
|
|
17
|
+
Current LinkedIn job-post account sourcing must follow:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
search_harvest_jobs -> confirm_harvest_job_companies -> search_prospeo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Use `search_harvest_jobs` when the user's account-source intent is current
|
|
24
|
+
LinkedIn job posts, for example "companies hiring Power BI developers in the US
|
|
25
|
+
this month." The search tool writes review artifacts and returns a token or
|
|
26
|
+
`mcp-harvest-job-search-token:*` reference. After reviewing selected jobs, call
|
|
27
|
+
`confirm_harvest_job_companies` with selected Harvest job IDs only. It resolves
|
|
28
|
+
real company website domains from selected job details and bounded
|
|
29
|
+
`/linkedin/company` fallback, then returns a `domainFilterId` for
|
|
30
|
+
`search_prospeo`.
|
|
31
|
+
|
|
32
|
+
Do not paste LinkedIn company URLs as domains. Do not detail-fetch every job row
|
|
33
|
+
by default; fetch details only for selected batches during confirmation. Use
|
|
34
|
+
Prospeo directly when the user wants broader volume, company attributes,
|
|
35
|
+
lookalikes, or people search without needing current LinkedIn job evidence.
|
|
36
|
+
|
|
16
37
|
Company/account lookalikes must follow:
|
|
17
38
|
|
|
18
39
|
```text
|