@synap-core/workspace-templates 0.33.0 → 0.34.0
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.
|
@@ -321,6 +321,19 @@
|
|
|
321
321
|
"company": "string",
|
|
322
322
|
"accountId": "string"
|
|
323
323
|
},
|
|
324
|
+
"inputMap": {
|
|
325
|
+
"params": {
|
|
326
|
+
"fullName": "$.name",
|
|
327
|
+
"company": "$.company",
|
|
328
|
+
"accountId": "$account.id"
|
|
329
|
+
},
|
|
330
|
+
"accounts": {
|
|
331
|
+
"verb": "unipile_list_accounts",
|
|
332
|
+
"id": "id",
|
|
333
|
+
"label": "name",
|
|
334
|
+
"provider": "provider"
|
|
335
|
+
}
|
|
336
|
+
},
|
|
324
337
|
"code": "// Find ONE person's LinkedIn profile by their full name and company: a STRICT\n// match or none (CRM wave 2, S4b). A pure read: it writes nothing, so it runs at\n// once. Writing the match onto the person (linkedinUrl, linkedinProviderId) is\n// the caller's write, proposed for a person to approve (founder decision 7).\n// STRICT: the result's name equals the full name (case- and accent-insensitive,\n// spaces collapsed), the company is in its headline as whole words (a v2\n// PeopleSearchResult has no company field), and exactly one result is both.\n// None, several, or a third page left unread (so one more could exist): no\n// match, with up to 3 candidates (the closest first) for a person to choose.\n// v2: POST /{account_id}/linkedin/search/people, paged by `cursor` (the reply's\n// next_cursor); the company also narrows the search (advanced_keywords).\nconst accountId = String(args?.accountId ?? '').trim();\nconst fullName = String(args?.fullName ?? '').trim();\nconst company = String(args?.company ?? '').trim();\nif (!accountId) throw new Error('linkedin_find_profile: accountId is required — call unipile_list_accounts first to find yours.');\nif (!fullName) throw new Error('linkedin_find_profile: fullName (the person\\'s first and last name) is required');\nif (!company) throw new Error('linkedin_find_profile: company is required: without it a name alone is never a strict match');\n\nconst fold = (s) => String(s ?? '').normalize('NFD').replace(/\\p{M}+/gu, '').toLowerCase();\nconst nameKey = (s) => fold(s).replace(/\\s+/g, ' ').trim();\nconst words = (s) => fold(s).split(/[^\\p{L}\\p{N}]+/u).filter(Boolean);\nconst hasWords = (text, part) => {\n if (!part.length) return false;\n for (let i = 0; i + part.length <= text.length; i++) {\n if (part.every((w, j) => text[i + j] === w)) return true;\n }\n return false;\n};\nconst wanted = nameKey(fullName);\nconst companyWords = words(company);\nconst nameMatches = (p) => nameKey(p?.display_name) === wanted;\nconst companyInHeadline = (p) => hasWords(words(p?.headline), companyWords);\n\nconst MAX_PAGES = 2;\nconst account = encodeURIComponent(accountId);\nconst body = { keywords: fullName, advanced_keywords: { company } };\nconst byId = new Map();\nlet cursor = null;\nlet pages = 0;\ndo {\n const qs = cursor ? `?${new URLSearchParams({ cursor }).toString()}` : '';\n const res = await callProvider('unipile_linkedin', 'POST', `/${account}/linkedin/search/people${qs}`, body);\n if (res?.proposed === true) {\n return { matched: false, reason: 'proposed', proposed: true, proposalId: res?.proposalId ?? null, candidates: [] };\n }\n if (res?.status && (res.status < 200 || res.status >= 300)) throw new Error(`linkedin_find_profile: the LinkedIn search failed (status ${res.status}).`);\n const payload = res?.body ?? res;\n const items = Array.isArray(payload?.data) ? payload.data : [];\n for (const p of items) {\n const key = String(p?.id ?? p?.public_identifier ?? `row-${byId.size}`);\n if (!byId.has(key)) byId.set(key, p);\n }\n pages += 1;\n const next = typeof payload?.next_cursor === 'string' && payload.next_cursor ? payload.next_cursor : null;\n cursor = next && next !== cursor ? next : null;\n} while (cursor && pages < MAX_PAGES);\nconst moreUnread = Boolean(cursor);\n\nconst view = (p) => {\n const publicIdentifier = p?.public_identifier ?? null;\n return {\n name: String(p?.display_name ?? '').trim() || null,\n headline: p?.headline ?? null,\n location: p?.location ?? null,\n providerId: p?.id ?? null,\n publicIdentifier,\n profileUrl: p?.profile_url ?? (publicIdentifier ? `https://www.linkedin.com/in/${publicIdentifier}` : null),\n networkDistance: p?.network_distance ?? null,\n nameMatches: nameMatches(p),\n companyInHeadline: companyInHeadline(p),\n };\n};\nconst rows = [...byId.values()].map(view);\nconst strict = rows.filter((r) => r.nameMatches && r.companyInHeadline);\nconst searched = { fullName, company, results: rows.length, pages, moreUnread };\n\nif (strict.length === 1 && !moreUnread) {\n const profile = strict[0];\n return {\n matched: true,\n profile,\n properties: { linkedinUrl: profile.profileUrl, linkedinProviderId: profile.providerId },\n candidates: [],\n searched,\n };\n}\nconst rank = (r) => (r.nameMatches ? 2 : 0) + (r.companyInHeadline ? 1 : 0);\nconst candidates = rows\n .map((r, i) => ({ r, i }))\n .sort((a, b) => rank(b.r) - rank(a.r) || a.i - b.i)\n .slice(0, 3)\n .map(({ r }) => r);\nconst reason = strict.length > 1 ? 'several' : strict.length === 1 ? 'more_results' : 'none';\nreturn { matched: false, reason, candidates, searched };\n"
|
|
325
338
|
},
|
|
326
339
|
{
|
package/package.json
CHANGED
package/src/crm.yaml
CHANGED
|
@@ -1503,9 +1503,11 @@ playbooks:
|
|
|
1503
1503
|
# ── Find their LinkedIn — one lead's LinkedIn profile ───────────────────
|
|
1504
1504
|
# CRM Messages contract (decision b603d75e, build map ca310f27): the first of
|
|
1505
1505
|
# the steps a LinkedIn lead walks (Find their LinkedIn, Ask to connect,
|
|
1506
|
-
# Waiting, Send).
|
|
1507
|
-
# actions door
|
|
1508
|
-
# for
|
|
1506
|
+
# Waiting, Send). The CRM app runs linkedin_find_profile itself through the
|
|
1507
|
+
# actions door (enrich_entity, input { name, company }; founder 7 Nov 2026:
|
|
1508
|
+
# "no need for ai" for a tool with a parameter), and offers this process
|
|
1509
|
+
# only as "Ask Synap AI to search" when the lookup finds nothing, for one
|
|
1510
|
+
# lead (`params.lead`, as "Draft a first message"). On a strict match
|
|
1509
1511
|
# it PROPOSES linkedinUrl and linkedinProviderId on the person; on none or
|
|
1510
1512
|
# several it hands the person up to 3 candidates. The reach fields stay the
|
|
1511
1513
|
# pod's (backend#786). A lifecycle READER of leadStage: no stage moves.
|
|
@@ -79,6 +79,22 @@ describe("linkedin_find_profile — a strict match or none", () => {
|
|
|
79
79
|
expect(verb.code).not.toMatch(/propose\.entity|callProvider\([^)]*'(PATCH|PUT|DELETE)'/);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// A pod app runs the lookup itself (CRM "Find their LinkedIn", founder 7 Nov 2026: "no need for ai"):
|
|
83
|
+
// the enrich_entity input (backend migration 0409: { name, company? }) feeds every parameter of the
|
|
84
|
+
// verb, one action per Unipile account. Without a map the actions door offers the verb to no app.
|
|
85
|
+
it("serves enrich_entity for a pod app: { name, company } and the account feed every parameter", () => {
|
|
86
|
+
expect(verb.inputMap).toEqual({
|
|
87
|
+
params: { fullName: "$.name", company: "$.company", accountId: "$account.id" },
|
|
88
|
+
accounts: { verb: "unipile_list_accounts", id: "id", label: "name", provider: "provider" },
|
|
89
|
+
});
|
|
90
|
+
expect(Object.keys(verb.inputMap.params).sort()).toEqual(Object.keys(verb.parameters).sort());
|
|
91
|
+
// Only the fields the intent's input has (0409), never a `$to` address: the app names no recipient.
|
|
92
|
+
const read = Object.values(verb.inputMap.params as Record<string, string>).filter((s) => !s.startsWith("$account."));
|
|
93
|
+
expect(read.sort()).toEqual(["$.company", "$.name"]);
|
|
94
|
+
// The account source is the capability's own declared read.
|
|
95
|
+
expect(skill("unipile_list_accounts").metadata?.readOnly).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
82
98
|
it("one result with the same name (case and accents aside) and the company in its headline: the match", async () => {
|
|
83
99
|
const other = row("u-2", "Ada Byron", "Engineer at Analytical Engines");
|
|
84
100
|
const { result, calls, proposals } = await run("linkedin_find_profile", ARGS, [{ data: [other, ADA], next_cursor: null }]);
|