@oxygen-agent/cli 1.1014.1225 → 1.1014.1341
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/README.md +1 -1
- package/dist/browser-login.js +4 -1
- package/dist/cli-output.js +14 -0
- package/dist/cli-values.js +1 -1
- package/dist/command-manifest.js +4 -1
- package/dist/commands/billing.js +2 -2
- package/dist/commands/crm.js +12 -4
- package/dist/commands/db.js +30 -0
- package/dist/commands/domains.js +1 -1
- package/dist/commands/feeds.js +29 -1
- package/dist/commands/knowledge.js +24 -1
- package/dist/commands/sequences.js +44 -8
- package/dist/deliverability-evidence-commands.js +6 -6
- package/dist/functions-commands.js +1 -1
- package/dist/help.js +1 -1
- package/dist/local-custom-http-column.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/session.js +1 -1
- package/dist/transcript.js +1 -1
- package/dist/util.d.ts +1 -1
- package/dist/util.js +1 -1
- package/dist/visual-commands.js +1 -1
- package/dist/visual-render-wait.js +1 -1
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +34 -173
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +134 -18
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -0
- package/node_modules/@oxygen/shared/dist/conversation-lineage.d.ts +131 -0
- package/node_modules/@oxygen/shared/dist/conversation-lineage.js +72 -0
- package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +6 -3
- package/node_modules/@oxygen/shared/dist/crm-record-brief.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/crm-record-brief.js +3 -1
- package/node_modules/@oxygen/shared/dist/deliverability-surface.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/deliverability-surface.js +4 -2
- package/node_modules/@oxygen/shared/dist/dev-eval-context.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/enrollment-origin.d.ts +65 -0
- package/node_modules/@oxygen/shared/dist/enrollment-origin.js +92 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/first-month-offer.js +13 -0
- package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.d.ts +54 -0
- package/node_modules/@oxygen/shared/dist/guided-onboarding-recommendations.js +100 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +9 -0
- package/node_modules/@oxygen/shared/dist/hubspot-engagements.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/hubspot-engagements.js +107 -0
- package/node_modules/@oxygen/shared/dist/integration-secrets.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/integration-secrets.js +77 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +15 -4
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +15 -4
- package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/knowledge-brain-draft.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/network-mining-approval.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/network-mining-approval.js +225 -0
- package/node_modules/@oxygen/shared/dist/network-mining-signals.d.ts +25 -0
- package/node_modules/@oxygen/shared/dist/network-mining-signals.js +34 -0
- package/node_modules/@oxygen/shared/dist/reply-oxygent.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/reply-oxygent.js +6 -0
- package/node_modules/@oxygen/shared/dist/research-findings.d.ts +11 -2
- package/node_modules/@oxygen/shared/dist/research-findings.js +16 -3
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +25 -4
- package/node_modules/@oxygen/shared/dist/sequences.js +40 -10
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +18 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +21 -1
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// HubSpot engagements onto the Records timeline (Network Mining S8c, DEV-only).
|
|
2
|
+
//
|
|
3
|
+
// Calls, meetings, notes, emails and tasks map onto existing activity kinds.
|
|
4
|
+
// Every imported row is `metadata.historical: true`: it is history the workspace
|
|
5
|
+
// lived before OXYGEN, so funnel and meeting analytics leave it out, and it
|
|
6
|
+
// replays no reply effect (no stop, suppression or DNC change).
|
|
7
|
+
export const HUBSPOT_ENGAGEMENT_TYPES = ["calls", "meetings", "notes", "emails", "tasks"];
|
|
8
|
+
/** Properties each list page asks for. Email bodies are read only as an excerpt. */
|
|
9
|
+
export const HUBSPOT_ENGAGEMENT_PROPERTIES = {
|
|
10
|
+
calls: ["hs_timestamp", "hs_call_title", "hs_call_body", "hs_call_direction", "hs_call_disposition", "hs_call_duration", "hs_call_status"],
|
|
11
|
+
meetings: ["hs_timestamp", "hs_meeting_title", "hs_meeting_body", "hs_meeting_start_time", "hs_meeting_end_time", "hs_meeting_outcome"],
|
|
12
|
+
notes: ["hs_timestamp", "hs_note_body"],
|
|
13
|
+
emails: ["hs_timestamp", "hs_email_subject", "hs_email_text", "hs_email_direction", "hs_email_status"],
|
|
14
|
+
tasks: ["hs_timestamp", "hs_task_subject", "hs_task_body", "hs_task_status", "hs_task_priority", "hs_task_type"],
|
|
15
|
+
};
|
|
16
|
+
/** The records an engagement may link to: HubSpot association type → registered external object. */
|
|
17
|
+
export const HUBSPOT_ENGAGEMENT_ASSOCIATIONS = ["contacts", "companies"];
|
|
18
|
+
const MAX_SUMMARY_CHARS = 300;
|
|
19
|
+
const MAX_BODY_CHARS = 1_000;
|
|
20
|
+
function text(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
return "";
|
|
23
|
+
// HubSpot rich-text bodies are HTML; the timeline stores plain text.
|
|
24
|
+
return value.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
25
|
+
}
|
|
26
|
+
function clip(value, max) {
|
|
27
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
28
|
+
}
|
|
29
|
+
function iso(value) {
|
|
30
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
31
|
+
return null;
|
|
32
|
+
const raw = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
|
|
33
|
+
const date = new Date(raw);
|
|
34
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
35
|
+
}
|
|
36
|
+
function associationIds(raw, kind) {
|
|
37
|
+
const associations = raw.associations && typeof raw.associations === "object" ? raw.associations : {};
|
|
38
|
+
const block = associations[kind] && typeof associations[kind] === "object" ? associations[kind] : {};
|
|
39
|
+
const results = Array.isArray(block.results) ? block.results : [];
|
|
40
|
+
const ids = results.map((entry) => entry && typeof entry === "object" ? String(entry.id ?? "") : "").filter(Boolean);
|
|
41
|
+
return [...new Set(ids)];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* One HubSpot engagement object (`crm/v3/objects/<type>` with associations)
|
|
45
|
+
* as a timeline activity, or null when it has no id or no timestamp.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeHubspotEngagement(type, raw) {
|
|
48
|
+
if (!raw || typeof raw !== "object")
|
|
49
|
+
return null;
|
|
50
|
+
const entry = raw;
|
|
51
|
+
const id = typeof entry.id === "string" || typeof entry.id === "number" ? String(entry.id) : "";
|
|
52
|
+
const props = entry.properties && typeof entry.properties === "object" ? entry.properties : {};
|
|
53
|
+
const occurredAt = iso(props.hs_timestamp) ?? iso(props.hs_meeting_start_time) ?? iso(entry.createdAt);
|
|
54
|
+
if (!id || !occurredAt)
|
|
55
|
+
return null;
|
|
56
|
+
const kept = {};
|
|
57
|
+
for (const key of HUBSPOT_ENGAGEMENT_PROPERTIES[type]) {
|
|
58
|
+
if (key === "hs_email_text" || key.endsWith("_body"))
|
|
59
|
+
continue;
|
|
60
|
+
const value = props[key];
|
|
61
|
+
if (typeof value === "string" && value.trim())
|
|
62
|
+
kept[key] = clip(value.trim(), 200);
|
|
63
|
+
}
|
|
64
|
+
const base = { hubspotType: type, hubspotId: id, occurredAt, contactIds: associationIds(entry, "contacts"), companyIds: associationIds(entry, "companies"), properties: kept };
|
|
65
|
+
if (type === "calls") {
|
|
66
|
+
const direction = String(props.hs_call_direction ?? "").toUpperCase();
|
|
67
|
+
return {
|
|
68
|
+
...base, activityType: "call", channel: "phone",
|
|
69
|
+
direction: direction === "INBOUND" ? "inbound" : direction === "OUTBOUND" ? "outbound" : null,
|
|
70
|
+
summary: clip(text(props.hs_call_title) || "Call", MAX_SUMMARY_CHARS),
|
|
71
|
+
body: clip(text(props.hs_call_body), MAX_BODY_CHARS) || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (type === "meetings") {
|
|
75
|
+
// Only HubSpot's own outcome says a meeting happened; anything else stays booked.
|
|
76
|
+
const outcome = String(props.hs_meeting_outcome ?? "").toUpperCase();
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
activityType: outcome === "NO_SHOW" ? "no_show" : outcome === "COMPLETED" ? "meeting_completed" : "meeting_booked",
|
|
80
|
+
channel: "meeting", direction: null,
|
|
81
|
+
summary: clip(text(props.hs_meeting_title) || "Meeting", MAX_SUMMARY_CHARS),
|
|
82
|
+
body: clip(text(props.hs_meeting_body), MAX_BODY_CHARS) || null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (type === "notes") {
|
|
86
|
+
const body = text(props.hs_note_body);
|
|
87
|
+
return {
|
|
88
|
+
...base, activityType: "note", channel: null, direction: "internal",
|
|
89
|
+
summary: clip(body || "Note", MAX_SUMMARY_CHARS), body: clip(body, MAX_BODY_CHARS) || null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (type === "emails") {
|
|
93
|
+
const direction = String(props.hs_email_direction ?? "").toUpperCase();
|
|
94
|
+
const inbound = direction === "INCOMING_EMAIL";
|
|
95
|
+
return {
|
|
96
|
+
...base, activityType: inbound ? "email_received" : "email_sent", channel: "email", direction: inbound ? "inbound" : "outbound",
|
|
97
|
+
summary: clip(text(props.hs_email_subject) || "Email", MAX_SUMMARY_CHARS),
|
|
98
|
+
body: clip(text(props.hs_email_text), MAX_BODY_CHARS) || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const status = String(props.hs_task_status ?? "").toUpperCase();
|
|
102
|
+
return {
|
|
103
|
+
...base, activityType: status === "COMPLETED" ? "task_completed" : "task_created", channel: null, direction: "internal",
|
|
104
|
+
summary: clip(text(props.hs_task_subject) || "Task", MAX_SUMMARY_CHARS),
|
|
105
|
+
body: clip(text(props.hs_task_body), MAX_BODY_CHARS) || null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function encryptSecret(plaintext: string, secretKey?: string): string;
|
|
2
|
+
export declare function decryptSecret(ciphertext: string, secretKey?: string): string;
|
|
3
|
+
export declare function isIntegrationSecretDecryptError(error: unknown): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Stable, server-secret-keyed identity for coordinating one upstream credential.
|
|
6
|
+
*
|
|
7
|
+
* The digest may be stored as an internal lock/rate-limit scope. It deliberately
|
|
8
|
+
* is not a plain hash: a control-DB reader must not be able to compare guessed
|
|
9
|
+
* provider credentials or learn that two tenants supplied the same key. Never
|
|
10
|
+
* log or return this value to a client.
|
|
11
|
+
*/
|
|
12
|
+
export declare function integrationSecretScopeFingerprint(plaintext: string, secretKey?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* A purpose-bound 32-byte subkey of the integration secret key (HKDF-SHA256,
|
|
15
|
+
* `info` names the purpose). Use it wherever a new MAC or cipher needs a key, so
|
|
16
|
+
* no two purposes ever share key material with `encryptSecret` or
|
|
17
|
+
* `integrationSecretScopeFingerprint`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function deriveIntegrationSubkey(info: string, secretKey?: string): Buffer;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
import { OxygenError } from "./cli-result.js";
|
|
3
|
+
const VERSION = "v1";
|
|
4
|
+
export function encryptSecret(plaintext, secretKey = resolveIntegrationSecretKey()) {
|
|
5
|
+
const iv = randomBytes(12);
|
|
6
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(secretKey), iv);
|
|
7
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
8
|
+
const tag = cipher.getAuthTag();
|
|
9
|
+
return [
|
|
10
|
+
VERSION,
|
|
11
|
+
iv.toString("base64url"),
|
|
12
|
+
tag.toString("base64url"),
|
|
13
|
+
encrypted.toString("base64url"),
|
|
14
|
+
].join(".");
|
|
15
|
+
}
|
|
16
|
+
export function decryptSecret(ciphertext, secretKey = resolveIntegrationSecretKey()) {
|
|
17
|
+
const [version, iv, tag, encrypted] = ciphertext.split(".");
|
|
18
|
+
if (version !== VERSION || !iv || !tag || !encrypted) {
|
|
19
|
+
throw new Error("Unsupported integration secret ciphertext.");
|
|
20
|
+
}
|
|
21
|
+
const decipher = createDecipheriv("aes-256-gcm", deriveKey(secretKey), Buffer.from(iv, "base64url"));
|
|
22
|
+
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
|
23
|
+
return Buffer.concat([
|
|
24
|
+
decipher.update(Buffer.from(encrypted, "base64url")),
|
|
25
|
+
decipher.final(),
|
|
26
|
+
]).toString("utf8");
|
|
27
|
+
}
|
|
28
|
+
export function isIntegrationSecretDecryptError(error) {
|
|
29
|
+
if (!(error instanceof Error))
|
|
30
|
+
return false;
|
|
31
|
+
return error.message.includes("Unsupported state or unable to authenticate data")
|
|
32
|
+
|| error.message.includes("Unsupported integration secret ciphertext");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Stable, server-secret-keyed identity for coordinating one upstream credential.
|
|
36
|
+
*
|
|
37
|
+
* The digest may be stored as an internal lock/rate-limit scope. It deliberately
|
|
38
|
+
* is not a plain hash: a control-DB reader must not be able to compare guessed
|
|
39
|
+
* provider credentials or learn that two tenants supplied the same key. Never
|
|
40
|
+
* log or return this value to a client.
|
|
41
|
+
*/
|
|
42
|
+
export function integrationSecretScopeFingerprint(plaintext, secretKey = resolveIntegrationSecretKey()) {
|
|
43
|
+
return createHmac("sha256", deriveKey(secretKey))
|
|
44
|
+
.update(plaintext, "utf8")
|
|
45
|
+
.digest("base64url");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A purpose-bound 32-byte subkey of the integration secret key (HKDF-SHA256,
|
|
49
|
+
* `info` names the purpose). Use it wherever a new MAC or cipher needs a key, so
|
|
50
|
+
* no two purposes ever share key material with `encryptSecret` or
|
|
51
|
+
* `integrationSecretScopeFingerprint`.
|
|
52
|
+
*/
|
|
53
|
+
export function deriveIntegrationSubkey(info, secretKey = resolveIntegrationSecretKey()) {
|
|
54
|
+
return Buffer.from(hkdfSync("sha256", deriveKey(secretKey), Buffer.alloc(0), `oxygen:${info}`, 32));
|
|
55
|
+
}
|
|
56
|
+
function resolveIntegrationSecretKey() {
|
|
57
|
+
const key = readNonEmpty(process.env.INTEGRATION_SECRET_KEY)
|
|
58
|
+
?? readNonEmpty(process.env.ENCRYPTION_KEY);
|
|
59
|
+
if (!key) {
|
|
60
|
+
throw new OxygenError("integration_secret_key_missing", "INTEGRATION_SECRET_KEY is not set.", {
|
|
61
|
+
details: { env: "INTEGRATION_SECRET_KEY" },
|
|
62
|
+
exitCode: 1,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return key;
|
|
66
|
+
}
|
|
67
|
+
function readNonEmpty(value) {
|
|
68
|
+
return value?.trim() ? value.trim() : null;
|
|
69
|
+
}
|
|
70
|
+
function deriveKey(secretKey) {
|
|
71
|
+
if (secretKey.startsWith("base64:")) {
|
|
72
|
+
const decoded = Buffer.from(secretKey.slice("base64:".length), "base64");
|
|
73
|
+
if (decoded.length === 32)
|
|
74
|
+
return decoded;
|
|
75
|
+
}
|
|
76
|
+
return createHash("sha256").update(secretKey).digest();
|
|
77
|
+
}
|
|
@@ -55,14 +55,25 @@
|
|
|
55
55
|
* The provider sub-cap covers every external research leg, including fallbacks.
|
|
56
56
|
*/
|
|
57
57
|
import type { HostedAiLevel } from "./hosted-ai.js";
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Hard ceiling on managed credits ONE workspace bootstrap may spend, all phases.
|
|
60
|
+
* 150 since 2026-10-18 (founder, current chat: "increase to at least 100 ... don't
|
|
61
|
+
* save, really make it magical"): the parallel research agents read the whole site,
|
|
62
|
+
* the market, the team and their hiring, so onboarding starts with the full context.
|
|
63
|
+
*/
|
|
64
|
+
export declare const KNOWLEDGE_BOOTSTRAP_MAX_CREDITS = 150;
|
|
60
65
|
/**
|
|
61
66
|
* Tighter sub-ceiling on the provider/enrichment half (search + page fetch), i.e.
|
|
62
67
|
* the external-money phase. Reaching it degrades the pass to `partial` — synthesize
|
|
63
68
|
* from what was read — rather than failing it.
|
|
64
69
|
*/
|
|
65
|
-
export declare const KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS =
|
|
70
|
+
export declare const KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS = 30;
|
|
71
|
+
/**
|
|
72
|
+
* The research agents' share of the cap (knowledge-research-swarm.ts): market, traction,
|
|
73
|
+
* customer-voice and team evidence plus one model call per page they write. The company
|
|
74
|
+
* pass and the draft pages' deepening share the rest of the cap.
|
|
75
|
+
*/
|
|
76
|
+
export declare const KNOWLEDGE_BOOTSTRAP_SWARM_MAX_CREDITS = 60;
|
|
66
77
|
/**
|
|
67
78
|
* Reasoning tier the synthesis pass runs at. `medium` ("Oxygen Balanced") is the
|
|
68
79
|
* deliberate middle: `low` cannot hold a company profile's structure together, and
|
|
@@ -74,7 +85,7 @@ export declare const KNOWLEDGE_BOOTSTRAP_SYNTHESIS_TIER: HostedAiLevel;
|
|
|
74
85
|
* is billed, so raising this raises the read ceiling as well as latency and prompt
|
|
75
86
|
* size. The credit ceilings above remain the money guard.
|
|
76
87
|
*/
|
|
77
|
-
export declare const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ =
|
|
88
|
+
export declare const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 15;
|
|
78
89
|
/**
|
|
79
90
|
* Per-call provider abort budget for every paid leg of the pass (LinkedIn company,
|
|
80
91
|
* person search/profile, page search, page read), in milliseconds.
|
|
@@ -56,14 +56,25 @@
|
|
|
56
56
|
*/
|
|
57
57
|
import { normalizeLinkedinCompanyUrl } from "./dnc-identities.js";
|
|
58
58
|
import { normalizeLinkedinProfileUrl } from "./linkedin-url.js";
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Hard ceiling on managed credits ONE workspace bootstrap may spend, all phases.
|
|
61
|
+
* 150 since 2026-10-18 (founder, current chat: "increase to at least 100 ... don't
|
|
62
|
+
* save, really make it magical"): the parallel research agents read the whole site,
|
|
63
|
+
* the market, the team and their hiring, so onboarding starts with the full context.
|
|
64
|
+
*/
|
|
65
|
+
export const KNOWLEDGE_BOOTSTRAP_MAX_CREDITS = 150;
|
|
61
66
|
/**
|
|
62
67
|
* Tighter sub-ceiling on the provider/enrichment half (search + page fetch), i.e.
|
|
63
68
|
* the external-money phase. Reaching it degrades the pass to `partial` — synthesize
|
|
64
69
|
* from what was read — rather than failing it.
|
|
65
70
|
*/
|
|
66
|
-
export const KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS =
|
|
71
|
+
export const KNOWLEDGE_BOOTSTRAP_ENRICHMENT_MAX_CREDITS = 30;
|
|
72
|
+
/**
|
|
73
|
+
* The research agents' share of the cap (knowledge-research-swarm.ts): market, traction,
|
|
74
|
+
* customer-voice and team evidence plus one model call per page they write. The company
|
|
75
|
+
* pass and the draft pages' deepening share the rest of the cap.
|
|
76
|
+
*/
|
|
77
|
+
export const KNOWLEDGE_BOOTSTRAP_SWARM_MAX_CREDITS = 60;
|
|
67
78
|
/**
|
|
68
79
|
* Reasoning tier the synthesis pass runs at. `medium` ("Oxygen Balanced") is the
|
|
69
80
|
* deliberate middle: `low` cannot hold a company profile's structure together, and
|
|
@@ -75,7 +86,7 @@ export const KNOWLEDGE_BOOTSTRAP_SYNTHESIS_TIER = "medium";
|
|
|
75
86
|
* is billed, so raising this raises the read ceiling as well as latency and prompt
|
|
76
87
|
* size. The credit ceilings above remain the money guard.
|
|
77
88
|
*/
|
|
78
|
-
export const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ =
|
|
89
|
+
export const KNOWLEDGE_BOOTSTRAP_MAX_PAGES_READ = 15;
|
|
79
90
|
/**
|
|
80
91
|
* Per-call provider abort budget for every paid leg of the pass (LinkedIn company,
|
|
81
92
|
* person search/profile, page search, page read), in milliseconds.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The draft's centre page: the company itself. Stable, so a rerun never mints a second one. */
|
|
2
2
|
export declare const KNOWLEDGE_BRAIN_DRAFT_COMPANY_SLUG = "company";
|
|
3
|
+
/** The control-DB organization metadata key holding the draft's claim and outcome. */
|
|
4
|
+
export declare const KNOWLEDGE_BRAIN_DRAFT_METADATA_KEY = "knowledge_brain_draft";
|
|
3
5
|
/** What marks a page as written by the draft; the onboarding graph watches for it. */
|
|
4
6
|
export declare const KNOWLEDGE_BRAIN_DRAFT_TAG = "onboarding-draft";
|
|
5
7
|
/** Tags every drafted page carries. */
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// (apps/web onboarding-brain) can never disagree.
|
|
6
6
|
/** The draft's centre page: the company itself. Stable, so a rerun never mints a second one. */
|
|
7
7
|
export const KNOWLEDGE_BRAIN_DRAFT_COMPANY_SLUG = "company";
|
|
8
|
+
/** The control-DB organization metadata key holding the draft's claim and outcome. */
|
|
9
|
+
export const KNOWLEDGE_BRAIN_DRAFT_METADATA_KEY = "knowledge_brain_draft";
|
|
8
10
|
/** What marks a page as written by the draft; the onboarding graph watches for it. */
|
|
9
11
|
export const KNOWLEDGE_BRAIN_DRAFT_TAG = "onboarding-draft";
|
|
10
12
|
/** Tags every drafted page carries. */
|
|
@@ -41,6 +41,8 @@ export type LinkedInInviteStep = LinkedInStepBase & {
|
|
|
41
41
|
kind: "invite";
|
|
42
42
|
/** Optional connection-request note. Supports {{column}} interpolation. ~300 char max on LinkedIn. */
|
|
43
43
|
note_template?: string;
|
|
44
|
+
/** Network Mining: the goal of a note drafted at send time; note_template is the fallback. */
|
|
45
|
+
ai_prompt?: string;
|
|
44
46
|
};
|
|
45
47
|
export type LinkedInWaitForConnectionStep = LinkedInStepBase & {
|
|
46
48
|
kind: "wait_for_connection";
|
|
@@ -64,6 +66,8 @@ export type LinkedInMessageStep = LinkedInStepBase & {
|
|
|
64
66
|
url: string;
|
|
65
67
|
name?: string;
|
|
66
68
|
}[];
|
|
69
|
+
/** Network Mining: the goal of a DM drafted at send time; template is the fallback. */
|
|
70
|
+
ai_prompt?: string;
|
|
67
71
|
};
|
|
68
72
|
export type LinkedInInMailStep = LinkedInStepBase & {
|
|
69
73
|
kind: "inmail";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export declare const STANDING_APPROVAL_VERSION = 1;
|
|
2
|
+
export declare const PILOT_SAMPLE_MIN = 10;
|
|
3
|
+
export declare const BATCH_SAMPLE_MAX = 30;
|
|
4
|
+
/** What a standing approval binds: digests of the stored configuration, compared verbatim, and the limits as ceilings. */
|
|
5
|
+
export declare const STANDING_APPROVAL_SCOPE_KEYS: readonly ["sources", "qualification", "enrichment", "sender", "copy", "brief_schema_version", "schedule", "recipient_limits", "credit_ceiling"];
|
|
6
|
+
export type StandingApprovalScopeKey = (typeof STANDING_APPROVAL_SCOPE_KEYS)[number];
|
|
7
|
+
export type StandingApprovalScope = Record<StandingApprovalScopeKey, string>;
|
|
8
|
+
export type ApprovalActor = {
|
|
9
|
+
user_id: string;
|
|
10
|
+
/** `member_credential` or `copilot_card`; never an Agent run, a workspace key or Auto-approve. */
|
|
11
|
+
kind: string;
|
|
12
|
+
auth_type: string | null;
|
|
13
|
+
copilot_approval_id?: string;
|
|
14
|
+
};
|
|
15
|
+
export type SequenceFirstLaunch = ApprovalActor & {
|
|
16
|
+
at: string;
|
|
17
|
+
};
|
|
18
|
+
export type SequencePilotReview = {
|
|
19
|
+
preview_hash: string;
|
|
20
|
+
sample_count: number;
|
|
21
|
+
enrollment_ids: string[];
|
|
22
|
+
scope: StandingApprovalScope;
|
|
23
|
+
reviewed_by: ApprovalActor;
|
|
24
|
+
at: string;
|
|
25
|
+
};
|
|
26
|
+
export type SequenceStandingApproval = {
|
|
27
|
+
version: number;
|
|
28
|
+
scope: StandingApprovalScope;
|
|
29
|
+
/** The bound ceilings, kept readable beside their digests. */
|
|
30
|
+
caps: {
|
|
31
|
+
max_credits: number;
|
|
32
|
+
max_live_sends: number | null;
|
|
33
|
+
max_new_enrollments_per_day: number | null;
|
|
34
|
+
};
|
|
35
|
+
pilot_preview_hash: string;
|
|
36
|
+
approved_by: ApprovalActor;
|
|
37
|
+
approved_at: string;
|
|
38
|
+
};
|
|
39
|
+
/** An AI draft generated for a batch preview, kept so the batch a person approves is the one they read. */
|
|
40
|
+
export type SequenceSampleDraft = {
|
|
41
|
+
enrollment_id: string;
|
|
42
|
+
step_id: string;
|
|
43
|
+
text: string;
|
|
44
|
+
};
|
|
45
|
+
/** The drafts of the latest paid batch preview, keyed by its preview_hash. */
|
|
46
|
+
export type SequenceBatchCandidate = {
|
|
47
|
+
preview_hash: string;
|
|
48
|
+
drafts: SequenceSampleDraft[];
|
|
49
|
+
credits_used: number;
|
|
50
|
+
at: string;
|
|
51
|
+
};
|
|
52
|
+
export type SequenceApprovalState = {
|
|
53
|
+
first_launch: SequenceFirstLaunch | null;
|
|
54
|
+
pilot: SequencePilotReview | null;
|
|
55
|
+
standing: SequenceStandingApproval | null;
|
|
56
|
+
candidate: SequenceBatchCandidate | null;
|
|
57
|
+
};
|
|
58
|
+
/** The key in `ox_sequencer.sequences.metadata` that holds the approval state. */
|
|
59
|
+
export declare const SEQUENCE_APPROVAL_METADATA_KEY = "network_mining_approval";
|
|
60
|
+
/** Reads the approval state from a Sequence's metadata. A malformed part reads as absent, so it never grants. */
|
|
61
|
+
export declare function readSequenceApprovalState(metadata: Record<string, unknown> | null | undefined): SequenceApprovalState;
|
|
62
|
+
/** The scope components that left what was approved, in declaration order. */
|
|
63
|
+
export declare function standingApprovalScopeChanges(bound: StandingApprovalScope, current: StandingApprovalScope): StandingApprovalScopeKey[];
|
|
64
|
+
/** A standing approval's caps refuse a start that asks for more than was approved. Returns the exceeded caps. */
|
|
65
|
+
export declare function standingApprovalCapOverruns(caps: SequenceStandingApproval["caps"], requested: {
|
|
66
|
+
maxCredits?: number | null;
|
|
67
|
+
maxLiveSends?: number | null;
|
|
68
|
+
}): Array<"max_credits" | "max_live_sends">;
|
|
69
|
+
export type DraftKind = "invite" | "message" | "comment" | "inmail" | "whatsapp";
|
|
70
|
+
/** LinkedIn's own ceilings: a connection note, a comment and a DM. */
|
|
71
|
+
export declare const DRAFT_CHARACTER_LIMITS: Readonly<Record<DraftKind, number>>;
|
|
72
|
+
export type DraftCheckIssue = {
|
|
73
|
+
code: "draft_empty" | "draft_unresolved_variable" | "draft_third_person_sender" | "draft_too_long";
|
|
74
|
+
message: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The automatic check every draft passes before it goes out under standing approval (D8). Fails closed on: no text,
|
|
78
|
+
* a placeholder left in the copy, the sender named in the third person outside a closing sign-off line (the draft must
|
|
79
|
+
* read as written by the sender), and a draft longer than LinkedIn accepts for its kind.
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkDraft(input: {
|
|
82
|
+
text: string | null | undefined;
|
|
83
|
+
kind: DraftKind;
|
|
84
|
+
senderName?: string | null;
|
|
85
|
+
leadName?: string | null;
|
|
86
|
+
}): DraftCheckIssue[];
|
|
87
|
+
/** The draft kind a step's copy is checked as; any other copy step is checked as a DM. */
|
|
88
|
+
export declare function draftKindForStep(kind: string): DraftKind;
|
|
89
|
+
/** The strictest kind among the steps that carry a draft: a cell read by an invite note is held to 300 characters. */
|
|
90
|
+
export declare function strictestDraftKind(kinds: readonly DraftKind[]): DraftKind;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Approval tiers for Network Mining Sequences (DEV-only, S10).
|
|
2
|
+
//
|
|
3
|
+
// T0: a person accepts each lead's exact copy at first launch (S4a).
|
|
4
|
+
// T1: a person reviews a batch of 10–30 rendered drafts at once and accepts them together.
|
|
5
|
+
// T2: after a reviewed pilot, a person grants standing approval per Sequence. It binds the scope below; anything
|
|
6
|
+
// outside it, or a changed component, needs a person again.
|
|
7
|
+
// Founder decision 3 (2026-10-17) holds regardless of Auto-approve: the first launch of every Sequence needs a person.
|
|
8
|
+
// D8: under standing approval, "copy" is the prompt and voice revision, a person's review of at least 10 samples, and
|
|
9
|
+
// the automatic check below on every draft.
|
|
10
|
+
import { LINKEDIN_INVITE_NOTE_LIMIT_BASIC } from "./linkedin-sequences.js";
|
|
11
|
+
export const STANDING_APPROVAL_VERSION = 1;
|
|
12
|
+
export const PILOT_SAMPLE_MIN = 10;
|
|
13
|
+
export const BATCH_SAMPLE_MAX = 30;
|
|
14
|
+
/** What a standing approval binds: digests of the stored configuration, compared verbatim, and the limits as ceilings. */
|
|
15
|
+
export const STANDING_APPROVAL_SCOPE_KEYS = [
|
|
16
|
+
"sources",
|
|
17
|
+
"qualification",
|
|
18
|
+
"enrichment",
|
|
19
|
+
"sender",
|
|
20
|
+
"copy",
|
|
21
|
+
"brief_schema_version",
|
|
22
|
+
"schedule",
|
|
23
|
+
"recipient_limits",
|
|
24
|
+
"credit_ceiling",
|
|
25
|
+
];
|
|
26
|
+
/** The key in `ox_sequencer.sequences.metadata` that holds the approval state. */
|
|
27
|
+
export const SEQUENCE_APPROVAL_METADATA_KEY = "network_mining_approval";
|
|
28
|
+
function record(value) {
|
|
29
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
30
|
+
}
|
|
31
|
+
function scopeOf(value) {
|
|
32
|
+
const raw = record(value);
|
|
33
|
+
if (!raw)
|
|
34
|
+
return null;
|
|
35
|
+
const scope = {};
|
|
36
|
+
for (const key of STANDING_APPROVAL_SCOPE_KEYS) {
|
|
37
|
+
if (typeof raw[key] !== "string")
|
|
38
|
+
return null;
|
|
39
|
+
scope[key] = raw[key];
|
|
40
|
+
}
|
|
41
|
+
return scope;
|
|
42
|
+
}
|
|
43
|
+
function actorOf(value) {
|
|
44
|
+
const raw = record(value);
|
|
45
|
+
if (!raw || typeof raw.user_id !== "string" || typeof raw.kind !== "string")
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
user_id: raw.user_id,
|
|
49
|
+
kind: raw.kind,
|
|
50
|
+
auth_type: typeof raw.auth_type === "string" ? raw.auth_type : null,
|
|
51
|
+
...(typeof raw.copilot_approval_id === "string" ? { copilot_approval_id: raw.copilot_approval_id } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function nullableCount(value) {
|
|
55
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
56
|
+
}
|
|
57
|
+
/** Reads the approval state from a Sequence's metadata. A malformed part reads as absent, so it never grants. */
|
|
58
|
+
export function readSequenceApprovalState(metadata) {
|
|
59
|
+
const raw = record(metadata?.[SEQUENCE_APPROVAL_METADATA_KEY]);
|
|
60
|
+
const firstRaw = record(raw?.first_launch);
|
|
61
|
+
const firstActor = actorOf(firstRaw);
|
|
62
|
+
const pilotRaw = record(raw?.pilot);
|
|
63
|
+
const pilotScope = scopeOf(pilotRaw?.scope);
|
|
64
|
+
const pilotActor = actorOf(pilotRaw?.reviewed_by);
|
|
65
|
+
const standingRaw = record(raw?.standing);
|
|
66
|
+
const standingScope = scopeOf(standingRaw?.scope);
|
|
67
|
+
const standingActor = actorOf(standingRaw?.approved_by);
|
|
68
|
+
const caps = record(standingRaw?.caps);
|
|
69
|
+
const candidateRaw = record(raw?.candidate);
|
|
70
|
+
return {
|
|
71
|
+
first_launch: firstActor && typeof firstRaw?.at === "string" ? { ...firstActor, at: firstRaw.at } : null,
|
|
72
|
+
pilot: pilotRaw && pilotScope && pilotActor && typeof pilotRaw.preview_hash === "string"
|
|
73
|
+
&& typeof pilotRaw.sample_count === "number" && typeof pilotRaw.at === "string"
|
|
74
|
+
? {
|
|
75
|
+
preview_hash: pilotRaw.preview_hash,
|
|
76
|
+
sample_count: pilotRaw.sample_count,
|
|
77
|
+
enrollment_ids: Array.isArray(pilotRaw.enrollment_ids) ? pilotRaw.enrollment_ids.filter((id) => typeof id === "string") : [],
|
|
78
|
+
scope: pilotScope,
|
|
79
|
+
reviewed_by: pilotActor,
|
|
80
|
+
at: pilotRaw.at,
|
|
81
|
+
}
|
|
82
|
+
: null,
|
|
83
|
+
standing: standingRaw && standingScope && standingActor && standingRaw.version === STANDING_APPROVAL_VERSION
|
|
84
|
+
&& typeof caps?.max_credits === "number" && typeof standingRaw.pilot_preview_hash === "string"
|
|
85
|
+
&& typeof standingRaw.approved_at === "string"
|
|
86
|
+
? {
|
|
87
|
+
version: STANDING_APPROVAL_VERSION,
|
|
88
|
+
scope: standingScope,
|
|
89
|
+
caps: {
|
|
90
|
+
max_credits: caps.max_credits,
|
|
91
|
+
max_live_sends: nullableCount(caps.max_live_sends),
|
|
92
|
+
max_new_enrollments_per_day: nullableCount(caps.max_new_enrollments_per_day),
|
|
93
|
+
},
|
|
94
|
+
pilot_preview_hash: standingRaw.pilot_preview_hash,
|
|
95
|
+
approved_by: standingActor,
|
|
96
|
+
approved_at: standingRaw.approved_at,
|
|
97
|
+
}
|
|
98
|
+
: null,
|
|
99
|
+
candidate: candidateRaw && typeof candidateRaw.preview_hash === "string" && Array.isArray(candidateRaw.drafts) && typeof candidateRaw.at === "string"
|
|
100
|
+
? {
|
|
101
|
+
preview_hash: candidateRaw.preview_hash,
|
|
102
|
+
drafts: candidateRaw.drafts.flatMap((draft) => {
|
|
103
|
+
const item = record(draft);
|
|
104
|
+
return item && typeof item.enrollment_id === "string" && typeof item.step_id === "string" && typeof item.text === "string"
|
|
105
|
+
? [{ enrollment_id: item.enrollment_id, step_id: item.step_id, text: item.text }]
|
|
106
|
+
: [];
|
|
107
|
+
}),
|
|
108
|
+
credits_used: typeof candidateRaw.credits_used === "number" ? candidateRaw.credits_used : 0,
|
|
109
|
+
at: candidateRaw.at,
|
|
110
|
+
}
|
|
111
|
+
: null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** Limits are ceilings, stored as canonical JSON rather than digests: lowering one stays in scope, raising it does not. */
|
|
115
|
+
const CEILING_SCOPE_KEYS = new Set(["recipient_limits", "credit_ceiling"]);
|
|
116
|
+
function parseCeilings(value) {
|
|
117
|
+
try {
|
|
118
|
+
return record(JSON.parse(value));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Within ceilings: each bounded number is set and no higher, an unbounded one is anything, any other value equal. */
|
|
125
|
+
function withinCeilings(bound, current) {
|
|
126
|
+
const approved = parseCeilings(bound);
|
|
127
|
+
const now = parseCeilings(current);
|
|
128
|
+
if (!approved || !now)
|
|
129
|
+
return false;
|
|
130
|
+
const keys = new Set([...Object.keys(approved), ...Object.keys(now)]);
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
const limit = approved[key];
|
|
133
|
+
const value = now[key] ?? null;
|
|
134
|
+
if (typeof limit === "number") {
|
|
135
|
+
if (typeof value !== "number" || value > limit)
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
else if (limit !== null && limit !== undefined && limit !== value) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
/** The scope components that left what was approved, in declaration order. */
|
|
145
|
+
export function standingApprovalScopeChanges(bound, current) {
|
|
146
|
+
return STANDING_APPROVAL_SCOPE_KEYS.filter((key) => (CEILING_SCOPE_KEYS.has(key) ? !withinCeilings(bound[key], current[key]) : bound[key] !== current[key]));
|
|
147
|
+
}
|
|
148
|
+
/** A standing approval's caps refuse a start that asks for more than was approved. Returns the exceeded caps. */
|
|
149
|
+
export function standingApprovalCapOverruns(caps, requested) {
|
|
150
|
+
const overruns = [];
|
|
151
|
+
if (requested.maxCredits != null && requested.maxCredits > caps.max_credits)
|
|
152
|
+
overruns.push("max_credits");
|
|
153
|
+
if (requested.maxLiveSends != null && (caps.max_live_sends === null || requested.maxLiveSends > caps.max_live_sends)) {
|
|
154
|
+
overruns.push("max_live_sends");
|
|
155
|
+
}
|
|
156
|
+
return overruns;
|
|
157
|
+
}
|
|
158
|
+
/** LinkedIn's own ceilings: a connection note, a comment and a DM. */
|
|
159
|
+
export const DRAFT_CHARACTER_LIMITS = {
|
|
160
|
+
// The Basic-account ceiling the dispatcher enforces, safe on every tier.
|
|
161
|
+
invite: LINKEDIN_INVITE_NOTE_LIMIT_BASIC,
|
|
162
|
+
comment: 1250,
|
|
163
|
+
message: 8000,
|
|
164
|
+
inmail: 1900,
|
|
165
|
+
whatsapp: 4096,
|
|
166
|
+
};
|
|
167
|
+
const UNRESOLVED_VARIABLE = /\{\{\s*[^{}]*\}\}|\{\s*(?:first_?name|last_?name|company|name|title)\s*\}|\[(?:first ?name|name|company|your name)\]/i;
|
|
168
|
+
function escapeRegExp(value) {
|
|
169
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
170
|
+
}
|
|
171
|
+
function firstName(name) {
|
|
172
|
+
const first = name?.trim().split(/\s+/)[0];
|
|
173
|
+
return first && first.length >= 2 ? first : null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The automatic check every draft passes before it goes out under standing approval (D8). Fails closed on: no text,
|
|
177
|
+
* a placeholder left in the copy, the sender named in the third person outside a closing sign-off line (the draft must
|
|
178
|
+
* read as written by the sender), and a draft longer than LinkedIn accepts for its kind.
|
|
179
|
+
*/
|
|
180
|
+
export function checkDraft(input) {
|
|
181
|
+
const text = (input.text ?? "").trim();
|
|
182
|
+
if (!text)
|
|
183
|
+
return [{ code: "draft_empty", message: "The draft is empty." }];
|
|
184
|
+
const issues = [];
|
|
185
|
+
if (UNRESOLVED_VARIABLE.test(text)) {
|
|
186
|
+
issues.push({ code: "draft_unresolved_variable", message: "The draft still contains a placeholder." });
|
|
187
|
+
}
|
|
188
|
+
const senderFull = input.senderName?.trim() ?? "";
|
|
189
|
+
const senderFirst = firstName(senderFull);
|
|
190
|
+
if (senderFirst) {
|
|
191
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
192
|
+
// A closing line that is only the name (with an optional greeting) is a sign-off, written by the sender.
|
|
193
|
+
const body = lines.length > 1 && lines.at(-1).split(/\s+/).length <= 4 ? lines.slice(0, -1).join("\n") : lines.join("\n");
|
|
194
|
+
const sameFirstName = firstName(input.leadName)?.toLowerCase() === senderFirst.toLowerCase();
|
|
195
|
+
// A self-introduction ("Philipp here", "I'm Philipp") is the sender speaking, not being spoken about.
|
|
196
|
+
const spoken = [senderFull, senderFirst].filter(Boolean).reduce((text, value) => text
|
|
197
|
+
.replace(new RegExp(`\\b(?:i'?m|i am|this is|it'?s)\\s+${escapeRegExp(value)}(?=$|[^\\p{L}])`, "giu"), " ")
|
|
198
|
+
.replace(new RegExp(`(^|[^\\p{L}])${escapeRegExp(value)}\\s+here\\b`, "giu"), "$1 "), body);
|
|
199
|
+
const named = (value) => new RegExp(`(^|[^\\p{L}])${escapeRegExp(value)}(?=$|[^\\p{L}])`, "iu").test(spoken);
|
|
200
|
+
if ((senderFull.includes(" ") && named(senderFull)) || (!sameFirstName && named(senderFirst))) {
|
|
201
|
+
issues.push({ code: "draft_third_person_sender", message: `The draft names the sender (${senderFirst}) instead of speaking as them.` });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const length = [...text].length;
|
|
205
|
+
const limit = DRAFT_CHARACTER_LIMITS[input.kind];
|
|
206
|
+
if (length > limit) {
|
|
207
|
+
issues.push({ code: "draft_too_long", message: `The draft has ${length} characters; a LinkedIn ${input.kind} allows ${limit}.` });
|
|
208
|
+
}
|
|
209
|
+
return issues;
|
|
210
|
+
}
|
|
211
|
+
const DRAFT_KIND_BY_STEP = {
|
|
212
|
+
invite: "invite",
|
|
213
|
+
message: "message",
|
|
214
|
+
inmail: "inmail",
|
|
215
|
+
comment_post: "comment",
|
|
216
|
+
whatsapp_message: "whatsapp",
|
|
217
|
+
};
|
|
218
|
+
/** The draft kind a step's copy is checked as; any other copy step is checked as a DM. */
|
|
219
|
+
export function draftKindForStep(kind) {
|
|
220
|
+
return DRAFT_KIND_BY_STEP[kind] ?? "message";
|
|
221
|
+
}
|
|
222
|
+
/** The strictest kind among the steps that carry a draft: a cell read by an invite note is held to 300 characters. */
|
|
223
|
+
export function strictestDraftKind(kinds) {
|
|
224
|
+
return kinds.reduce((strictest, kind) => (DRAFT_CHARACTER_LIMITS[kind] < DRAFT_CHARACTER_LIMITS[strictest] ? kind : strictest), "message");
|
|
225
|
+
}
|