orangeslice 2.5.0 → 2.6.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/dist/api.d.ts CHANGED
@@ -3,4 +3,13 @@ export interface OrangesliceConfig {
3
3
  baseUrl?: string;
4
4
  }
5
5
  export declare function configure(opts: OrangesliceConfig): void;
6
+ /**
7
+ * Run `fn` with a per-call apiKey scoped via AsyncLocalStorage. Inside `fn`,
8
+ * `resolveApiKey()` returns this apiKey instead of falling back to the module
9
+ * singleton/env/config-file. Useful for server-side consumers (like the MCP
10
+ * route) that handle multiple users concurrently within one process.
11
+ *
12
+ * No-op on non-Node runtimes (falls back to the standard resolution chain).
13
+ */
14
+ export declare function withApiKey<T>(apiKey: string, fn: () => T | Promise<T>): Promise<T>;
6
15
  export declare function post<T>(endpoint: string, payload: Record<string, unknown>): Promise<T>;
package/dist/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.configure = configure;
4
+ exports.withApiKey = withApiKey;
4
5
  exports.post = post;
5
6
  /**
6
7
  * Transport layer for orangeslice SDK.
@@ -14,16 +15,58 @@ const DEFAULT_INLINE_WAIT_MS = 5000;
14
15
  const USER_CONFIG_PATH = ".config/orangeslice/config.json";
15
16
  const _config = {};
16
17
  function configure(opts) {
17
- if (opts.apiKey !== undefined)
18
- _config.apiKey = opts.apiKey;
19
- if (opts.baseUrl !== undefined)
20
- _config.baseUrl = opts.baseUrl;
18
+ if (!opts || typeof opts !== "object" || Array.isArray(opts)) {
19
+ throw new Error("[orangeslice] configure() expects an object, e.g. configure({ apiKey: 'osk_...' })");
20
+ }
21
+ _config.apiKey = opts.apiKey;
22
+ _config.baseUrl = opts.baseUrl;
23
+ }
24
+ // AsyncLocalStorage is Node-only. Browser-style runtimes get a no-op shim so
25
+ // that `withApiKey` doesn't crash; calls outside Node fall back to the
26
+ // existing singleton/env/file resolution chain.
27
+ let _callContextStore = null;
28
+ function getCallContextStore() {
29
+ if (_callContextStore)
30
+ return _callContextStore;
31
+ if (!process?.versions?.node) {
32
+ _callContextStore = {
33
+ getStore: () => undefined,
34
+ run: (_ctx, fn) => fn()
35
+ };
36
+ return _callContextStore;
37
+ }
38
+ try {
39
+ const { AsyncLocalStorage } = require("node:async_hooks");
40
+ _callContextStore = new AsyncLocalStorage();
41
+ return _callContextStore;
42
+ }
43
+ catch {
44
+ _callContextStore = {
45
+ getStore: () => undefined,
46
+ run: (_ctx, fn) => fn()
47
+ };
48
+ return _callContextStore;
49
+ }
50
+ }
51
+ /**
52
+ * Run `fn` with a per-call apiKey scoped via AsyncLocalStorage. Inside `fn`,
53
+ * `resolveApiKey()` returns this apiKey instead of falling back to the module
54
+ * singleton/env/config-file. Useful for server-side consumers (like the MCP
55
+ * route) that handle multiple users concurrently within one process.
56
+ *
57
+ * No-op on non-Node runtimes (falls back to the standard resolution chain).
58
+ */
59
+ function withApiKey(apiKey, fn) {
60
+ const store = getCallContextStore();
61
+ return Promise.resolve().then(() => store.run({ apiKey }, fn));
21
62
  }
22
63
  function resolveBaseUrl() {
23
- return (_config.baseUrl || process.env.ORANGESLICE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
64
+ const ctx = getCallContextStore().getStore();
65
+ return (ctx?.baseUrl || _config.baseUrl || process.env.ORANGESLICE_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
24
66
  }
25
67
  function resolveApiKey() {
26
- return _config.apiKey || process.env.ORANGESLICE_API_KEY || readApiKeyFromConfigFile() || "";
68
+ const ctx = getCallContextStore().getStore();
69
+ return ctx?.apiKey || _config.apiKey || process.env.ORANGESLICE_API_KEY || readApiKeyFromConfigFile() || "";
27
70
  }
28
71
  function readApiKeyFromConfigFile() {
29
72
  // Guard for browser-like runtimes.
@@ -95,16 +138,42 @@ function resolvePollUrl(baseUrl, pending) {
95
138
  }
96
139
  return `${baseUrl}/function/result/${pending.requestId}`;
97
140
  }
141
+ function sameOrigin(a, b) {
142
+ try {
143
+ const ua = new URL(a);
144
+ const ub = new URL(b);
145
+ return ua.protocol === ub.protocol && ua.host === ub.host;
146
+ }
147
+ catch {
148
+ // If either URL is unparseable, treat as cross-origin (fail closed).
149
+ return false;
150
+ }
151
+ }
98
152
  async function fetchWithRedirect(url, init) {
99
153
  let res = await fetch(url, { ...init, redirect: "manual" });
100
154
  if (res.status >= 300 && res.status < 400) {
101
155
  const location = res.headers.get("location");
102
156
  if (location) {
103
- res = await fetch(location, { ...init });
157
+ // Resolve the redirect target against the original URL so a relative
158
+ // `Location` header is handled correctly.
159
+ const resolved = new URL(location, url).toString();
160
+ // Cross-origin redirects must not receive the `Authorization` header
161
+ // (or any other auth-bearing headers) — otherwise a misconfigured /
162
+ // malicious redirect could exfiltrate the user's `osk_` API key to an
163
+ // arbitrary host.
164
+ const nextInit = sameOrigin(url, resolved) ? { ...init } : stripAuthHeaders(init);
165
+ res = await fetch(resolved, nextInit);
104
166
  }
105
167
  }
106
168
  return res;
107
169
  }
170
+ function stripAuthHeaders(init) {
171
+ const headers = new Headers(init.headers ?? {});
172
+ headers.delete("authorization");
173
+ headers.delete("cookie");
174
+ headers.delete("proxy-authorization");
175
+ return { ...init, headers };
176
+ }
108
177
  function formatRequestError(endpoint, status, message) {
109
178
  // 4xx → caller-fixable problem: surface just the server's message so it reads
110
179
  // like a normal validation error (e.g. `Cannot resolve sheet "leads"`).
package/dist/cli.js CHANGED
File without changes
package/dist/expansion.js CHANGED
@@ -32,6 +32,9 @@ async function personLinkedinEnrich(params) {
32
32
  if (!url && !username) {
33
33
  throw new Error("[orangeslice] person.linkedin.enrich: provide url or username");
34
34
  }
35
+ if (url && !username) {
36
+ throw new Error(`[orangeslice] person.linkedin.enrich: URL must be a personal LinkedIn profile (linkedin.com/in/<username>), got: ${url}`);
37
+ }
35
38
  const sql = extended
36
39
  ? `SELECT lkd.*
37
40
  FROM linkedin_profile_slug ps
@@ -21,9 +21,14 @@ const api_1 = require("./api");
21
21
  * // { object: { company: "Apple Inc", year: 1976, founder: "Steve Jobs" } }
22
22
  */
23
23
  async function generateObject(options) {
24
- return (0, api_1.post)("/execute/llm", {
24
+ const payload = {
25
25
  mode: "object",
26
26
  prompt: options.prompt,
27
27
  schemaJson: options.schema
28
- });
28
+ };
29
+ if (options.system !== undefined)
30
+ payload.system = options.system;
31
+ if (options.model !== undefined)
32
+ payload.model = options.model;
33
+ return (0, api_1.post)("/execute/llm", payload);
29
34
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { configure } from "./api";
1
+ export { configure, withApiKey } from "./api";
2
2
  export type { OrangesliceConfig } from "./api";
3
3
  export { integrations } from "./integrations";
4
4
  export type { Integration, IntegrationProvider, IntegrationListParams, IntegrationCreateParams, IntegrationUpdateParams, IntegrationConnectOptions, IntegrationExecuteOptions } from "./integrations";
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.services = exports.builtWithSearchByTech = exports.builtWithRelationships = exports.builtWithLookupDomain = exports.geoParseAddress = exports.companyGetEmployeesFromLinkedin = exports.companyLinkedinFindUrl = exports.companyLinkedinEnrich = exports.personContactGet = exports.personLinkedinFindUrl = exports.personLinkedinEnrich = exports.PREDICT_LEADS_OPERATION_IDS = exports.predictLeads = exports.executePredictLeads = exports.googleMapsScrape = exports.runApifyActor = exports.browserExecute = exports.scrapeWebsite = exports.generateObject = exports.webBatchSearch = exports.webSearch = exports.OCEAN_OPERATION_IDS = exports.oceanSearchPeople = exports.oceanSearchCompanies = exports.executeOcean = exports.crunchbaseSearch = exports.linkedinSearch = exports.ctx = exports.scrapeCareersPage = exports.findCareersPage = exports.skills = exports.integrations = exports.configure = void 0;
3
+ exports.services = exports.builtWithSearchByTech = exports.builtWithRelationships = exports.builtWithLookupDomain = exports.geoParseAddress = exports.companyGetEmployeesFromLinkedin = exports.companyLinkedinFindUrl = exports.companyLinkedinEnrich = exports.personContactGet = exports.personLinkedinFindUrl = exports.personLinkedinEnrich = exports.PREDICT_LEADS_OPERATION_IDS = exports.predictLeads = exports.executePredictLeads = exports.googleMapsScrape = exports.runApifyActor = exports.browserExecute = exports.scrapeWebsite = exports.generateObject = exports.webBatchSearch = exports.webSearch = exports.OCEAN_OPERATION_IDS = exports.oceanSearchPeople = exports.oceanSearchCompanies = exports.executeOcean = exports.crunchbaseSearch = exports.linkedinSearch = exports.ctx = exports.scrapeCareersPage = exports.findCareersPage = exports.skills = exports.integrations = exports.withApiKey = exports.configure = void 0;
4
4
  var api_1 = require("./api");
5
5
  Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return api_1.configure; } });
6
+ Object.defineProperty(exports, "withApiKey", { enumerable: true, get: function () { return api_1.withApiKey; } });
6
7
  var integrations_1 = require("./integrations");
7
8
  Object.defineProperty(exports, "integrations", { enumerable: true, get: function () { return integrations_1.integrations; } });
8
9
  var skills_1 = require("./skills");
@@ -255,15 +255,35 @@ const { script } = await integrations.websiteTracking.setupTracking({
255
255
  sheetName: "Website Visitors"
256
256
  });
257
257
 
258
- // Inspect / remove later
258
+ // Inspect later
259
259
  const tracked = await integrations.websiteTracking.listTrackedDomains();
260
+
261
+ // "Stop on THIS sheet only" — keeps the upstream allow-list up and
262
+ // any other Orange Slice spreadsheets that enrolled the same domain
263
+ // keep receiving visitor rows.
264
+ await integrations.websiteTracking.detachTracking("acme.com");
265
+
266
+ // "Stop tracking this domain on all my spreadsheets" — deletes every
267
+ // Website Visitors trigger + mapping for the domain across all of
268
+ // this account's spreadsheets. Other accounts that enrolled the same
269
+ // domain are unaffected; the upstream allow-list is only dropped when
270
+ // no other account still needs it.
260
271
  await integrations.websiteTracking.removeTracking("acme.com");
272
+
273
+ // After a customer tops up, re-enable any paused mappings:
274
+ const { resumed, alreadyActive, failures } =
275
+ await integrations.websiteTracking.resumeTracking();
261
276
  ```
262
277
 
263
278
  Pricing: **80 credits per identified visitor** (charged at the master
264
- webhook). Repeat visits and company-only pings are forwarded to the sheet
265
- but don't cost credits. When the account runs out of credits the domain
266
- is automatically paused; the next top-up resumes it.
279
+ webhook). Charging happens ONCE per ping even when the same account
280
+ enrolled the domain on multiple spreadsheets — the row fans out to
281
+ every matching trigger under a single credit reservation. Repeat
282
+ visits and company-only pings are forwarded to the sheet but don't
283
+ cost credits. When the account runs out of credits the domain is
284
+ automatically paused. **Resume is not automatic** — call
285
+ `resumeTracking()` (optionally scoped to one domain) once the customer
286
+ has topped up, otherwise the next top-up won't bring tracking back online.
267
287
 
268
288
  See [websiteTracking/](./websiteTracking/) for the full recipe,
269
289
  canonical visitor shape, and trigger code template.
@@ -10,24 +10,48 @@ website. The customer pastes a single `<script>` tag on their site;
10
10
  every identified visit lands as a row on the spreadsheet that owns the
11
11
  trigger.
12
12
 
13
- - **One shared tracking script** is pasted on the customer's site. No
14
- per-customer external account, no logins, no separate billing.
13
+ - **Per-account tracking script** (`https://www.orangeslice.ai/wt.js?accountId=…`)
14
+ is pasted on the customer's site. The `accountId` round-trips back to
15
+ us as RB2B's `customer_id` on every ping, so even when multiple
16
+ Orange Slice accounts have enrolled the same domain, the ping is
17
+ routed only to the account that actually installed the script on
18
+ that page. No per-customer external account, no logins, no separate
19
+ billing. The script is provider-agnostic — if Orange Slice ever
20
+ swaps the underlying identification provider, the customer's pasted
21
+ snippet keeps working without any change.
15
22
  - Every visitor ping fans out to a **master webhook** we own. The
16
- master webhook looks up the captured domain, charges the customer's
17
- Orange Slice credit balance, then forwards the visitor record to a
18
- trigger on the customer's spreadsheet which writes it as a row.
23
+ master webhook looks up `(captured domain, customer_id)`, charges the
24
+ customer's Orange Slice credit balance ONCE, then forwards the
25
+ visitor record to every trigger that this account enrolled for the
26
+ domain (one ping → one charge → N sheet rows when the same account
27
+ enrolled the domain on N spreadsheets).
19
28
 
20
29
  ## Pricing
21
30
 
22
- **80 credits per identified visitor.** A visitor counts as "identified"
23
- when we resolve both a LinkedIn URL and a first name on a non-repeat
24
- visit. Company-only pings and repeat visitor pings are recorded for
25
- context but NEVER charge credits.
31
+ **80 credits per non-repeat visitor.** Every distinct visit Orange
32
+ Slice's tracking partner reports is charged, including:
33
+
34
+ - Full identifications (LinkedIn URL + first name resolved), AND
35
+ - Company-only matches (the visiting company is resolved but no
36
+ specific person).
37
+
38
+ Repeat visits from the same person/device are forwarded to the sheet
39
+ for context but NEVER charge credits.
40
+
41
+ Every row written to the sheet still includes whatever fields the
42
+ upstream resolved — column code can branch on `v.linkedinUrl !== null`
43
+ if the customer wants to filter out company-only rows downstream.
26
44
 
27
45
  When the customer's balance hits zero, the master webhook automatically
28
- pauses their domain so they stop incurring charges. The next top-up
29
- resumes the domain automatically the customer doesn't have to re-paste
30
- the script.
46
+ pauses their domain so they stop incurring charges. **Resume is not
47
+ automatic** — after the customer tops up, the agent must explicitly call
48
+ `integrations.websiteTracking.resumeTracking()` to re-enable tracking.
49
+ The customer never has to re-paste the script.
50
+
51
+ If a customer mentions topping up credits or wanting their tracking
52
+ turned back on, check `listTrackedDomains()` — any mapping with
53
+ `status: "disabled_no_credits"` needs `resumeTracking()` to come back to
54
+ life.
31
55
 
32
56
  ## The Recipe (call once per domain)
33
57
 
@@ -42,15 +66,23 @@ const { script, triggerId } = await integrations.websiteTracking.setupTracking({
42
66
 
43
67
  `setupTracking` does all of the following atomically:
44
68
 
45
- 1. Creates a sheet-aware trigger that ingests visitor payloads into the
46
- chosen sheet (uses `addRows` with `createMissingColumns: true` so the
47
- user doesn't have to pre-create columns).
48
- 2. Inserts the domain trigger mapping into our master routing table.
49
- 3. Calls the upstream allow-list API to register the domain.
50
- 4. Returns the canonical `<script>` tag the user pastes on their site.
51
-
52
- If the same domain is re-enrolled it is idempotent — the existing
53
- trigger and mapping are reused.
69
+ 1. Creates the destination sheet on the spreadsheet if it doesn't exist
70
+ yet (matched case-insensitively by name).
71
+ 2. Creates a sheet-aware trigger that ingests visitor payloads into that
72
+ sheet (uses `addRows` with `createMissingColumns: true` so the user
73
+ doesn't have to pre-create columns either).
74
+ 3. Inserts the domain trigger mapping into our master routing table.
75
+ 4. Calls the upstream allow-list API to register the domain.
76
+ 5. Returns the per-account
77
+ `<script src="https://www.orangeslice.ai/wt.js?accountId=…">`
78
+ wrapper for the user to paste on their site. The `accountId` query
79
+ param is what scopes inbound pings to this account so a squatter
80
+ who also enrolled the domain cannot steal traffic from pages that
81
+ load this exact tag.
82
+
83
+ If the same (domain, spreadsheet) is re-enrolled it is idempotent —
84
+ the existing trigger and mapping are reused. If the user previously
85
+ deleted the sheet, the helper re-creates it.
54
86
 
55
87
  ## After setupTracking — what to tell the user
56
88
 
@@ -63,24 +95,87 @@ trigger and mapping are reused.
63
95
  columns) for tasks like "compute company size" or "draft an outreach
64
96
  email" so per-row work shows in the UI and is retryable.
65
97
 
66
- ## Multiple sites
98
+ ## Multiple sites, multiple spreadsheets
67
99
 
68
100
  A single account can track many domains, each with its own trigger and
69
- sheet. Just call `setupTracking` once per domain. A domain can only be
70
- tracked by ONE Orange Slice account at a time — re-enrolling from another
71
- account throws unless the original account previously called
72
- `removeTracking` (then the row is a tombstone and the new account can
73
- claim it).
101
+ sheet call `setupTracking` once per domain.
102
+
103
+ A single account can ALSO track the same domain on multiple
104
+ spreadsheets (for example, sales-ops on one sheet and a marketing
105
+ analyst on another). Each `setupTracking` call on a different
106
+ spreadsheet creates its own trigger + mapping. When a visitor ping
107
+ arrives, **80 credits are charged once** and the visitor row is fanned
108
+ out to every matching trigger.
109
+
110
+ Multiple Orange Slice accounts can also enroll the same domain — there
111
+ is no cross-account block. Routing is disambiguated by the `accountId`
112
+ baked into the `<script>` tag (RB2B receives it as `customer_id` and
113
+ echoes it back on every ping), so pings only land on the account that
114
+ actually installed the pixel.
74
115
 
75
116
  ## Inspecting / removing
76
117
 
77
118
  ```typescript
78
- // What's tracked on this spreadsheet?
119
+ // What's tracked on this spreadsheet? Returns active AND paused
120
+ // (`disabled_no_credits`) mappings — check the `status` field.
79
121
  const mappings = await integrations.websiteTracking.listTrackedDomains();
80
122
 
81
- // Stop tracking a domain.
123
+ // "Stop tracking on THIS sheet only" — keep the upstream allow-list
124
+ // up so other spreadsheets / accounts that enrolled the same domain
125
+ // keep receiving visitor rows. The destination sheet keeps the
126
+ // customer's existing visitor rows.
127
+ await integrations.websiteTracking.detachTracking("acme.com");
128
+
129
+ // "Stop tracking this domain on all my spreadsheets" — deletes every
130
+ // Website Visitors trigger + mapping for that domain across ALL of
131
+ // this account's spreadsheets. Other Orange Slice accounts that
132
+ // co-enrolled the same domain are unaffected. The upstream allow-list
133
+ // is only dropped if no other account still has the domain active.
82
134
  await integrations.websiteTracking.removeTracking("acme.com");
83
135
  ```
84
136
 
137
+ ### Picking between `detachTracking` and `removeTracking`
138
+
139
+ Both are scoped to the calling account — no caller can affect another
140
+ account's mappings, so picking the wrong one is recoverable.
141
+
142
+ - "Remove tracking from this sheet but keep my other sheet running" /
143
+ "I don't want visitors landing here anymore" / "delete this sheet's
144
+ Website Visitors trigger" → **`detachTracking(domain)`** (this
145
+ spreadsheet only).
146
+ - "Turn off tracking for acme.com entirely" / "we sold this domain" /
147
+ "stop all visitor identification for acme.com" →
148
+ **`removeTracking(domain)`** (every spreadsheet under this account).
149
+ - If the user has the same domain on multiple of THEIR spreadsheets
150
+ and the intent is ambiguous, ASK — `removeTracking` will tear down
151
+ all of them, whereas `detachTracking` is per-sheet surgical.
152
+
153
+ ## Resuming a paused domain (after a top-up)
154
+
155
+ ```typescript
156
+ // Re-enable every paused mapping on this spreadsheet:
157
+ const result = await integrations.websiteTracking.resumeTracking();
158
+
159
+ // Or scope to one domain:
160
+ const result = await integrations.websiteTracking.resumeTracking("acme.com");
161
+
162
+ // result shape:
163
+ // {
164
+ // resumed: [{ domain, mappingId }], // flipped back to active
165
+ // alreadyActive: [{ domain, mappingId }], // already running, no-op
166
+ // failures: [{ domain, mappingId, error }], // upstream rejected
167
+ // }
168
+ ```
169
+
170
+ Only mappings currently in `status: "disabled_no_credits"` get resumed.
171
+ Already-active mappings are reported in `alreadyActive` and not touched.
172
+ The upstream allow-list call is idempotent — calling resume on a domain
173
+ the provider already considers active is safe and ends up in
174
+ `alreadyActive` after the conditional DB update.
175
+
176
+ Tell the user how many domains came back online (`result.resumed.length`)
177
+ and surface any `result.failures[].error` messages — those usually mean
178
+ the upstream provider rejected the re-enrollment and need a human look.
179
+
85
180
  See [setupTracking.md](./setupTracking.md) for the full input/output
86
181
  contract.
@@ -25,23 +25,42 @@ both store as `acme.com`.
25
25
 
26
26
  ```typescript
27
27
  {
28
- script: string; // <script> tag for the user to paste
29
- domain: string; // normalized domain
30
- triggerId: string;
31
- mappingId: string;
32
- sheetName: string;
33
- triggerName: string;
34
- alreadyOwned: boolean; // true if mapping existed before this call
35
- alreadyOnAllowList: boolean; // true if the upstream allow-list already had the domain
28
+ script: string; // <script> tag for the user to paste (see below)
29
+ domain: string; // normalized domain
30
+ triggerId: string;
31
+ mappingId: string;
32
+ sheetName: string;
33
+ triggerName: string;
34
+ alreadyOwned: boolean; // true if mapping existed before this call
35
+ alreadyOnAllowList: boolean; // true if the upstream allow-list already had the domain
36
36
  }
37
37
  ```
38
38
 
39
+ The returned `script` is the per-account wrapper:
40
+
41
+ ```html
42
+ <script src="https://www.orangeslice.ai/wt.js?accountId=<uuid>" async></script>
43
+ ```
44
+
45
+ `/wt.js` is an Orange Slice-owned route that reads the active tracking
46
+ provider at request time and injects the appropriate pixel. The
47
+ `accountId` query param is what scopes inbound pings to the calling
48
+ account (round-trips as RB2B's `customer_id`), so multiple Orange Slice
49
+ accounts can safely co-enroll the same domain without stealing each
50
+ other's traffic. If we ever swap the underlying provider, the
51
+ customer's pasted snippet keeps working without any action on their
52
+ end.
53
+
39
54
  ## Errors
40
55
 
41
56
  - `Invalid domain` — `domain` couldn't be normalized to a host with a TLD.
42
- - `Domain "<x>" is already claimed by another account` every domain
43
- belongs to exactly one Orange Slice account (a `removed` tombstone from
44
- a prior teardown does NOT lock the domain).
57
+ - `Concurrent setupTracking for "<x>" on this spreadsheet retry.`
58
+ a parallel `setupTracking` for the same (domain, spreadsheet) raced
59
+ ahead; re-run the call to pick up the now-live row idempotently.
60
+
61
+ `setupTracking` no longer blocks cross-account enrollment: any account
62
+ can enroll any domain, and a single account can enroll the same domain
63
+ on multiple spreadsheets.
45
64
 
46
65
  ## What the trigger does
47
66
 
@@ -52,6 +71,11 @@ into the user's chosen sheet with `createMissingColumns: true` and
52
71
  `run: true`. Any enrichment / scoring belongs in column code on that
53
72
  sheet.
54
73
 
74
+ The destination sheet is **created automatically** if it doesn't already
75
+ exist on the spreadsheet — there's no pre-flight "make the sheet first"
76
+ step the agent has to do. Existing sheets are matched case-insensitively
77
+ by name and reused.
78
+
55
79
  ## Canonical visitor shape
56
80
 
57
81
  Every identified visit is delivered to the trigger as a record with
@@ -92,19 +116,27 @@ const result = await integrations.websiteTracking.setupTracking({
92
116
 
93
117
  // Then tell the user:
94
118
  // 1. Paste this snippet (`result.script`) into the <head> of acme.com.
95
- // 2. Identified visitors will land in the "Website Visitors" sheet.
96
- // 3. Each identified visitor costs 80 credits. Repeat / company-only
97
- // visits are free.
98
- // 4. If credits run out, tracking pauses automatically and resumes on
99
- // the next top-up.
119
+ // 2. Visitor rows will land in the "Website Visitors" sheet within
120
+ // ~5 minutes of installing.
121
+ // 3. Each non-repeat visit costs 80 credits. This includes both
122
+ // full-person and company-only matches. Repeat visits from the
123
+ // same person are forwarded to the sheet but cost zero.
124
+ // 4. If credits run out, tracking pauses automatically. After topping
125
+ // up they'll need to ask you to re-enable it — call
126
+ // `integrations.websiteTracking.resumeTracking()` when that happens.
100
127
  ```
101
128
 
102
129
  ## Pricing recap
103
130
 
104
- Charged once per ping where:
105
- - `linkedinUrl` is present, AND
106
- - `firstName` is present, AND
107
- - `isRepeatVisit` is not `true`.
131
+ Charged once per ping where `isRepeatVisit` is not `true`. This
132
+ includes both full-person identifications (`linkedinUrl` + `firstName`
133
+ populated) AND company-only matches (only `companyName` / `website`
134
+ populated).
135
+
136
+ Cost: **80 credits per non-repeat visit**. Repeat visits are still
137
+ forwarded to the sheet (so the user can analyze recurring traffic) but
138
+ cost zero.
108
139
 
109
- Cost: **80 credits**. Company-only and repeat visits are still forwarded
110
- to the sheet (so the user can analyze recurring traffic) but cost zero.
140
+ If a customer wants to keep only full-person rows in their sheet, they
141
+ can filter in column code: `if (!v.linkedinUrl) return null;` skips
142
+ company-only rows before `addRows` writes them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orangeslice",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "B2B LinkedIn database prospector - 1.15B profiles, 85M companies",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",