fullstackgtm 0.55.1 → 0.56.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.56.0] — 2026-07-13
11
+
12
+ ### Added
13
+
14
+ - Portable ICP derivation, Clay normalization, filter-routing, and fit-scoring
15
+ package exports let browser and Worker surfaces execute the same contracts as
16
+ the CLI instead of maintaining behavioral copies.
17
+ - Human-paired CLIs now mirror reviewed ICP derivations and explicitly saved
18
+ signal runs into tenant-scoped hosted artifact records for later analysis;
19
+ local files remain authoritative and the mirror is not used as a cache.
20
+
21
+ ### Changed
22
+
23
+ - Published agent-skill guidance now documents Clay acquisition, targeted
24
+ company discovery, Exa-backed behavioral evidence, and hosted artifact
25
+ mirroring.
26
+
27
+ ## [0.55.2] — 2026-07-13
28
+
29
+ ### Changed
30
+
31
+ - The OSS repository now publishes a representative contributor test suite
32
+ covering CLI contracts, planning, configuration, HTTP safety, and security,
33
+ while hosted, live-integration, and adversarial release tests remain private.
34
+
35
+ ### Fixed
36
+
37
+ - OSS mirror generation and `npm test` now fail when zero public tests are
38
+ discovered, preventing false-green contributor and release checks.
39
+
10
40
  ## [0.55.1] — 2026-07-13
11
41
 
12
42
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -64,6 +64,12 @@ Linux, macOS, and Windows; source-level tests remain on Node 22.6+.
64
64
  > (a `pretest` guard) rather than silently passing with zero tests. On the
65
65
  > published mirror, `tests/` is present and `npm test` works there.
66
66
 
67
+ The public mirror includes a representative suite covering supported CLI,
68
+ planning, configuration, HTTP, and security behavior. Additional hosted-product,
69
+ live-integration, and adversarial release tests remain in the private monorepo;
70
+ both suites gate releases. The public test selection is reviewed explicitly in
71
+ `oss/public-tests.txt`, and `npm test` fails if the mirror contains zero tests.
72
+
67
73
  **Node:** the published runtime supports Node ≥ 20 (`engines`), but the test
68
74
  runner needs Node **≥ 22.6** for `--experimental-strip-types`. Develop on 22.6+.
69
75
 
@@ -7,7 +7,7 @@ import { patchPlanToMarkdown } from "../format.js";
7
7
  import { createFilePlanStore } from "../planStore.js";
8
8
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor } from "../enrich.js";
9
9
  import { loadMeter, remaining } from "../acquireMeter.js";
10
- import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
10
+ import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveProfileContacts, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
11
11
  import { createClaySearch, discoverClayInvestmentProspects, runClayPeopleSearchPage } from "../connectors/clay.js";
12
12
  import { runContactWaterfall } from "../contactProviders.js";
13
13
  import { loadSeen, recordSeen } from "../acquireSeen.js";
@@ -44,7 +44,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
44
44
  enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
45
45
  [source options] [--run-label <label>] [--json]
46
46
  enrich ingest <file.csv|payload.json|spool.jsonl|spool-dir> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
47
- enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
47
+ enrich acquire [--source <id>] [--max <new-leads>] [--scan-limit <candidates>] [--company-domain <domain>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
48
48
  enrich status [--runs] [--source <id>] [--config <path>] [--json]
49
49
 
50
50
  acquire creates NET-NEW leads from a staged prospect list (ingest first):
@@ -515,8 +515,14 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
515
515
  : disc.provider === "clay"
516
516
  ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
517
517
  : {};
518
+ const targetCompanyDomain = option(rest, "--company-domain")?.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
519
+ if (targetCompanyDomain) {
520
+ if (disc.provider !== "clay")
521
+ throw new Error("enrich acquire: --company-domain currently requires --source clay");
522
+ filters = { ...filters, company_identifier: [targetCompanyDomain] };
523
+ }
518
524
  const queryFingerprint = createHash("sha256")
519
- .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
525
+ .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null, targetCompanyDomain: targetCompanyDomain ?? null }))
520
526
  .digest("hex");
521
527
  const checkpointKey = {
522
528
  provider: disc.provider,
@@ -599,7 +605,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
599
605
  if (!cursor) {
600
606
  const route = clayRoutes[clayRouteIndex];
601
607
  if (route)
602
- filters = route.filters;
608
+ filters = { ...route.filters, ...(targetCompanyDomain ? { company_identifier: [targetCompanyDomain] } : {}) };
603
609
  progress.note(`creating Clay people-search iterator · ${route?.id ?? "configured"}`);
604
610
  cursor = await createClaySearch({ apiKey: providerKey("clay"), sourceType: "people", filters });
605
611
  }
@@ -643,6 +649,8 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
643
649
  }
644
650
  discovered += page.length;
645
651
  progress.items(discovered, scanLimit);
652
+ if (targetCompanyDomain)
653
+ page = page.filter((prospect) => normalizedCompanyDomain(prospect.companyDomain) === targetCompanyDomain);
646
654
  if (page.length === 0) {
647
655
  exhausted = true;
648
656
  break;
@@ -716,6 +724,13 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
716
724
  progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
717
725
  resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
718
726
  }
727
+ const profileFields = fields
728
+ .filter((field) => field === "work_email" || field === "mobile")
729
+ .filter((field) => resolved.some((prospect) => prospect.linkedin && (field === "mobile" ? !prospect.mobile : !prospect.email)));
730
+ if (profileFields.length) {
731
+ progress.note(`resolving LinkedIn contact details with pipe0 for ${resolved.length} candidate(s)`);
732
+ resolved = await pipe0ResolveProfileContacts({ apiKey: providerKey("pipe0"), prospects: resolved, fields: profileFields });
733
+ }
719
734
  return resolved;
720
735
  },
721
736
  },
@@ -885,6 +900,9 @@ function printAcquireOutput(options) {
885
900
  console.log("The meter is charged only when a create lands at apply.");
886
901
  }
887
902
  }
903
+ function normalizedCompanyDomain(value) {
904
+ return (value ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "");
905
+ }
888
906
  function hostedRunsUrl() {
889
907
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
890
908
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/dist/cli/help.js CHANGED
@@ -630,7 +630,7 @@ export const COMMAND_FLAGS = {
630
630
  dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
631
631
  reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--verbose", "--json", "--out"],
632
632
  backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--verbose", "--json"],
633
- enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
633
+ enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--company-domain", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--verbose", "--json", "--out"],
634
634
  call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--verbose", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
635
635
  suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
636
636
  plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--verbose", "--json"],
@@ -645,7 +645,7 @@ export const COMMAND_FLAGS = {
645
645
  schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--verbose", "--json"],
646
646
  };
647
647
  export const FLAGS_WITH_VALUES = new Set([
648
- "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
648
+ "--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--company-domain", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-accounts", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--max-results", "--max-searches", "--max-usd", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
649
649
  ]);
650
650
  // Lifecycle-grouped front door. One line per verb, organized by the
651
651
  // Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
package/dist/cli/icp.js CHANGED
@@ -14,6 +14,7 @@ import { box, colorEnabled, paint, truncateToWidth } from "./ui.js";
14
14
  import { getCredential, storeCredential } from "../credentials.js";
15
15
  import { deriveWebsiteIcp, icpReviewSegments, OPENROUTER_API_BASE } from "../icpDerive.js";
16
16
  import { unknownSubcommandError } from "./suggest.js";
17
+ import { writeHostedArtifact } from "../hostedArtifacts.js";
17
18
  function renderJudgeDecisions(decisions) {
18
19
  if (decisions.length === 0)
19
20
  return "No accounts cleared the score threshold.";
@@ -242,6 +243,14 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
242
243
  writeFileSync(path, `${JSON.stringify(derived.icp, null, 2)}\n`);
243
244
  console.error(`Wrote reviewed ICP to ${path}. Next: fullstackgtm enrich acquire --source clay --icp ${path}`);
244
245
  }
246
+ const mirrored = await writeHostedArtifact({
247
+ kind: "icp", key: `icp:${derived.company.domain}`, label: derived.icp.name,
248
+ domain: derived.company.domain, document: derived,
249
+ });
250
+ if (mirrored.status === "saved")
251
+ console.error("Mirrored the reviewed ICP to the paired hosted workspace.");
252
+ else if (mirrored.status === "unavailable")
253
+ console.error(`Warning: ${mirrored.reason}. The local ICP is still authoritative.`);
245
254
  if (rest.includes("--json"))
246
255
  console.log(JSON.stringify(derived, null, 2));
247
256
  else if (!reviewedInteractively)
@@ -10,6 +10,7 @@ import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
10
10
  import { loadIcp, option, readSnapshot, repeatedOption, saveRequested } from "./shared.js";
11
11
  import { createStatusLine } from "./ui.js";
12
12
  import { unknownSubcommandError } from "./suggest.js";
13
+ import { writeHostedArtifact } from "../hostedArtifacts.js";
13
14
  /**
14
15
  * Resolve a signals config: explicit --config, else signals.config.json in cwd,
15
16
  * else the zero-config DEFAULT_SIGNALS_CONFIG (preset-first, like enrich).
@@ -169,6 +170,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
169
170
  await store.appendRun({ id: signalRunId(runLabel), runLabel, startedAt: now.toISOString(), completedAt: new Date().toISOString(),
170
171
  buckets: ["job"], counts: { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, signals: ranked });
171
172
  console.error(`Saved signal run "${runLabel}". Next: \`fullstackgtm icp judge --signals-from ${runLabel} --save\`.`);
173
+ await mirrorSignalRun(runLabel, now, ["job"], { fetched: discovered.summary.rawResults, new: ranked.length, deduped: deduped.length }, ranked);
172
174
  }
173
175
  else {
174
176
  console.error("(not saved — re-run with --save to persist this evidence to the signal ledger)");
@@ -297,6 +299,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
297
299
  signals: ranked,
298
300
  });
299
301
  console.error(`Saved signal run "${runLabel}" (${fresh.length} fresh). Next: \`fullstackgtm icp judge --save\`.`);
302
+ await mirrorSignalRun(runLabel, now, buckets, { fetched, new: fresh.length, deduped: deduped.length }, ranked);
300
303
  }
301
304
  else {
302
305
  console.error("(not saved — re-run with --save to persist this run to the signal ledger)");
@@ -385,6 +388,16 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
385
388
  }
386
389
  throw unknownSubcommandError("signals", sub, ["fetch", "discover", "list", "outcome", "weights"]);
387
390
  }
391
+ async function mirrorSignalRun(runLabel, startedAt, buckets, counts, signals) {
392
+ const mirrored = await writeHostedArtifact({
393
+ kind: "signal_run", key: `signals:${signalRunId(runLabel)}`, label: runLabel,
394
+ document: { runLabel, startedAt: startedAt.toISOString(), completedAt: new Date().toISOString(), buckets, counts, signals },
395
+ });
396
+ if (mirrored.status === "saved")
397
+ console.error("Mirrored the signal run to the paired hosted workspace.");
398
+ else if (mirrored.status === "unavailable")
399
+ console.error(`Warning: ${mirrored.reason}. The local signal ledger is still authoritative.`);
400
+ }
388
401
  function positiveIntegerOption(args, flag, fallback) {
389
402
  const raw = option(args, flag);
390
403
  if (raw == null)
@@ -1,4 +1,6 @@
1
1
  import type { Prospect } from "./prospectSources.ts";
2
+ import { type ClayCompany } from "../portable/clay.ts";
3
+ export { normalizeClayCompany, normalizeClayPerson, type ClayCompany } from "../portable/clay.ts";
2
4
  export declare const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
3
5
  export type ClayKeyValidation = {
4
6
  ok: boolean;
@@ -10,16 +12,6 @@ export type ClayPeopleSearchPage = {
10
12
  prospects: Prospect[];
11
13
  hasMore: boolean;
12
14
  };
13
- export type ClayCompany = {
14
- name?: string;
15
- domain?: string;
16
- linkedin?: string;
17
- description?: string;
18
- industry?: string;
19
- size?: string;
20
- location?: string;
21
- fundingAmountRange?: string;
22
- };
23
15
  export type ClayCompanySearchPage = {
24
16
  companies: ClayCompany[];
25
17
  hasMore: boolean;
@@ -48,7 +40,6 @@ export declare function runClayCompanySearchPage(opts: {
48
40
  fetchImpl?: typeof fetch;
49
41
  apiBaseUrl?: string;
50
42
  }): Promise<ClayCompanySearchPage>;
51
- export declare function normalizeClayCompany(value: unknown): ClayCompany;
52
43
  /** Account-first investment discovery: find thesis-shaped companies, then
53
44
  * resolve founders/operators only inside those accounts. */
54
45
  export declare function discoverClayInvestmentProspects(opts: {
@@ -64,7 +55,6 @@ export declare function discoverClayInvestmentProspects(opts: {
64
55
  companiesScanned: number;
65
56
  companiesMatched: ClayCompany[];
66
57
  }>;
67
- export declare function normalizeClayPerson(value: unknown): Prospect;
68
58
  /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
69
59
  export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
70
60
  /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
@@ -1,5 +1,7 @@
1
1
  import { getCredential } from "../credentials.js";
2
2
  import { ProviderHttpError } from "../providerError.js";
3
+ import { normalizeClayCompany, normalizeClayPerson, } from "../portable/clay.js";
4
+ export { normalizeClayCompany, normalizeClayPerson } from "../portable/clay.js";
3
5
  export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
4
6
  function clayHeaders(apiKey) {
5
7
  return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
@@ -59,15 +61,6 @@ export async function runClayCompanySearchPage(opts) {
59
61
  throw new Error("Clay company search returned no has_more flag.");
60
62
  return { companies: body.data.map(normalizeClayCompany), hasMore };
61
63
  }
62
- export function normalizeClayCompany(value) {
63
- const row = value && typeof value === "object" ? value : {};
64
- return {
65
- name: stringValue(row.name), domain: bareDomain(stringValue(row.domain)),
66
- linkedin: normalizeLinkedin(stringValue(row.linkedin_url)), description: stringValue(row.description),
67
- industry: stringValue(row.industry), size: stringValue(row.size), location: stringValue(row.location),
68
- fundingAmountRange: stringValue(row.total_funding_amount_range_usd),
69
- };
70
- }
71
64
  /** Account-first investment discovery: find thesis-shaped companies, then
72
65
  * resolve founders/operators only inside those accounts. */
73
66
  export async function discoverClayInvestmentProspects(opts) {
@@ -99,43 +92,6 @@ export async function discoverClayInvestmentProspects(opts) {
99
92
  }
100
93
  return { prospects, companiesScanned: companyPage.companies.length, companiesMatched: companyPage.companies };
101
94
  }
102
- export function normalizeClayPerson(value) {
103
- const row = value && typeof value === "object" ? value : {};
104
- const location = row.structured_location && typeof row.structured_location === "object"
105
- ? row.structured_location
106
- : {};
107
- const fullName = stringValue(row.name);
108
- return {
109
- firstName: stringValue(row.first_name),
110
- lastName: stringValue(row.last_name),
111
- fullName,
112
- jobTitle: stringValue(row.latest_experience_title),
113
- companyName: stringValue(row.latest_experience_company),
114
- companyDomain: bareDomain(stringValue(row.domain)),
115
- linkedin: normalizeLinkedin(stringValue(row.url)),
116
- headline: undefined,
117
- sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
118
- location: {
119
- city: stringValue(location.city),
120
- state: stringValue(location.state),
121
- region: stringValue(location.region),
122
- country: stringValue(location.country),
123
- countryCode: stringValue(location.country_iso),
124
- },
125
- };
126
- }
127
- function stringValue(value) {
128
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
129
- }
130
- function bareDomain(value) {
131
- return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
132
- }
133
- function normalizeLinkedin(value) {
134
- if (!value)
135
- return undefined;
136
- const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
137
- return normalized.replace(/\/$/, "");
138
- }
139
95
  /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
140
96
  export async function validateClayApiKey(apiKey, fetchImpl = fetch, apiBaseUrl = CLAY_PUBLIC_API_BASE) {
141
97
  let response;
@@ -128,6 +128,14 @@ export declare function pipe0ResolveWorkEmails(opts: {
128
128
  * at scale). */
129
129
  concurrency?: number;
130
130
  }): Promise<Prospect[]>;
131
+ /** Resolve work email and mobile from a known LinkedIn profile URL. */
132
+ export declare function pipe0ResolveProfileContacts(opts: {
133
+ apiKey: string;
134
+ prospects: Prospect[];
135
+ fields: Array<"work_email" | "mobile">;
136
+ apiBaseUrl?: string;
137
+ fetchImpl?: FetchImpl;
138
+ }): Promise<Prospect[]>;
131
139
  /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
132
140
  export declare function hostFromUrl(url: string | undefined): string | undefined;
133
141
  /**
@@ -245,6 +245,38 @@ export async function pipe0ResolveWorkEmails(opts) {
245
245
  function personKey(name, company) {
246
246
  return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
247
247
  }
248
+ /** Resolve work email and mobile from a known LinkedIn profile URL. */
249
+ export async function pipe0ResolveProfileContacts(opts) {
250
+ const fetchImpl = opts.fetchImpl ?? fetch;
251
+ const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
252
+ const resolvable = opts.prospects.filter((prospect) => prospect.linkedin);
253
+ if (!resolvable.length || !opts.fields.length)
254
+ return opts.prospects;
255
+ const pipes = [
256
+ ...(opts.fields.includes("work_email") ? [{ pipe_id: "person:workemail:profileurl:waterfall@1" }] : []),
257
+ ...(opts.fields.includes("mobile") ? [{ pipe_id: "person:mobile:profileurl:waterfall@1" }] : []),
258
+ ];
259
+ const body = await pipe0Post(fetchImpl, base, opts.apiKey, {
260
+ pipes,
261
+ input: resolvable.map((prospect) => ({ profile_url: prospect.linkedin })),
262
+ });
263
+ const details = new Map();
264
+ for (const recordId of body?.order ?? []) {
265
+ const fields = body?.records?.[recordId]?.fields ?? {};
266
+ const profile = fieldValue(fields.profile_url)?.trim().toLowerCase().replace(/\/+$/, "");
267
+ if (!profile)
268
+ continue;
269
+ details.set(profile, {
270
+ email: fields.work_email?.status === "completed" ? fieldValue(fields.work_email) : undefined,
271
+ mobile: fields.mobile?.status === "completed" ? fieldValue(fields.mobile) : undefined,
272
+ });
273
+ }
274
+ return opts.prospects.map((prospect) => {
275
+ const key = prospect.linkedin?.trim().toLowerCase().replace(/\/+$/, "");
276
+ const match = key ? details.get(key) : undefined;
277
+ return match ? { ...prospect, email: prospect.email ?? match.email, mobile: prospect.mobile ?? match.mobile } : prospect;
278
+ });
279
+ }
248
280
  /** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
249
281
  export function hostFromUrl(url) {
250
282
  if (!url || !url.trim())
@@ -4,7 +4,7 @@ export const CONTACT_PROVIDER_CAPABILITIES = {
4
4
  provider: "pipe0",
5
5
  operation: "person:workemail:waterfall@1",
6
6
  inputShapes: ["name_domain"],
7
- outputFields: ["work_email"],
7
+ outputFields: ["work_email", "mobile"],
8
8
  execution: "batch",
9
9
  billing: "per_attempt",
10
10
  chargesOn: ["request"],
@@ -0,0 +1,22 @@
1
+ export type HostedArtifact = {
2
+ kind: "icp" | "signal_run";
3
+ key: string;
4
+ label: string;
5
+ domain?: string;
6
+ document: unknown;
7
+ sourceVersion?: string;
8
+ };
9
+ export type HostedArtifactResult = {
10
+ status: "unpaired";
11
+ } | {
12
+ status: "saved";
13
+ created: boolean;
14
+ updatedAt: number;
15
+ } | {
16
+ status: "unavailable";
17
+ reason: string;
18
+ };
19
+ export declare function writeHostedArtifact(artifact: HostedArtifact, options?: {
20
+ fetchImpl?: typeof fetch;
21
+ timeoutMs?: number;
22
+ }): Promise<HostedArtifactResult>;
@@ -0,0 +1,45 @@
1
+ /** Best-effort artifact mirroring for a human-paired CLI profile. */
2
+ import { getCredential } from "./credentials.js";
3
+ const ENDPOINT = "/api/cli/artifact";
4
+ const DEFAULT_TIMEOUT_MS = 4000;
5
+ function broker() {
6
+ const credential = getCredential("broker");
7
+ if (!credential?.baseUrl || !credential.accessToken)
8
+ return null;
9
+ try {
10
+ const url = new URL(credential.baseUrl);
11
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
12
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
13
+ return null;
14
+ return { baseUrl: url.href.replace(/\/+$/, ""), accessToken: credential.accessToken };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export async function writeHostedArtifact(artifact, options = {}) {
21
+ if (!artifact.key || artifact.key.length > 256 || !artifact.label || artifact.label.length > 200) {
22
+ throw new Error("Hosted artifact key/label is invalid.");
23
+ }
24
+ const paired = broker();
25
+ if (!paired)
26
+ return { status: "unpaired" };
27
+ try {
28
+ const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
29
+ method: "POST",
30
+ headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
31
+ body: JSON.stringify(artifact),
32
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
33
+ });
34
+ if (!response.ok)
35
+ return { status: "unavailable", reason: `hosted artifact request failed (HTTP ${response.status})` };
36
+ const body = await response.json();
37
+ if (typeof body.created !== "boolean" || typeof body.updatedAt !== "number") {
38
+ return { status: "unavailable", reason: "hosted artifact response was invalid" };
39
+ }
40
+ return { status: "saved", created: body.created, updatedAt: body.updatedAt };
41
+ }
42
+ catch {
43
+ return { status: "unavailable", reason: "hosted artifact request was unavailable" };
44
+ }
45
+ }
@@ -1,26 +1,9 @@
1
1
  import { type LlmCallOptions } from "./llm.ts";
2
- import { type Icp } from "./icp.ts";
2
+ import type { Icp } from "./icp.ts";
3
+ import { type WebsiteIcpDerivation } from "./portable/icpDeriveContract.ts";
4
+ export type { WebsiteIcpDerivation, WebsiteIcpEvidence } from "./portable/icpDeriveContract.ts";
3
5
  export declare const DEFAULT_ICP_DERIVATION_MODEL = "z-ai/glm-5.2";
4
6
  export declare const OPENROUTER_API_BASE = "https://openrouter.ai/api";
5
- export type WebsiteIcpEvidence = {
6
- label: string;
7
- excerpt: string;
8
- sourceUrl: string;
9
- };
10
- export type WebsiteIcpDerivation = {
11
- company: {
12
- name: string;
13
- domain: string;
14
- summary: string;
15
- };
16
- icp: Icp;
17
- evidence: WebsiteIcpEvidence[];
18
- confidence: number;
19
- derivation: {
20
- mode: "model";
21
- model: string;
22
- };
23
- };
24
7
  export type IcpDerivationProgress = {
25
8
  stage: "fetch" | "model" | "verify";
26
9
  message: string;