@oxygen-agent/cli 1.287.12 → 1.310.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/README.md +1 -1
- package/dist/cli-values.d.ts +18 -0
- package/dist/cli-values.js +66 -0
- package/dist/credentials.d.ts +22 -2
- package/dist/credentials.js +80 -17
- package/dist/help.js +1 -1
- package/dist/http-client.d.ts +2 -0
- package/dist/http-client.js +68 -30
- package/dist/index.js +789 -243
- package/dist/knowledge-mirror.d.ts +10 -0
- package/dist/knowledge-mirror.js +18 -0
- package/dist/run-wait.js +2 -26
- package/dist/runtime.d.ts +63 -4
- package/dist/runtime.js +113 -3
- package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
- package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/index.js +7 -1
- package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/log.js +65 -6
- package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
- package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
- package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/timing.js +12 -0
- package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/version.js +33 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
- package/node_modules/@oxygen/workflows/dist/index.js +1 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { extname } from "node:path";
|
|
|
2
2
|
import readXlsxFile from "read-excel-file/node";
|
|
3
3
|
import { inferImportColumnDataType, parseDateValueToIso, } from "./column-types.js";
|
|
4
4
|
import { OxygenError } from "./cli-result.js";
|
|
5
|
-
|
|
5
|
+
import { makeUniqueIdentifier, toSnakeIdentifier } from "./identifiers.js";
|
|
6
6
|
export const MAX_BUFFERED_IMPORT_PARSE_BYTES = 10 * 1024 * 1024;
|
|
7
7
|
export function inferRowsFileFormat(path) {
|
|
8
8
|
const extension = extname(path).toLowerCase();
|
|
@@ -118,17 +118,12 @@ export function normalizeRowsForNewTable(rows) {
|
|
|
118
118
|
}),
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
|
+
// Alias of the canonical @oxygen/shared identifier normalizer. Keep this name:
|
|
122
|
+
// it MATCHES imported file headers back to the column key MINTED by tenant-db's
|
|
123
|
+
// toSnakeIdentifier, and has call sites across web/CLI/API. The two must never
|
|
124
|
+
// diverge \u2014 see packages/shared/src/identifiers.ts + identifiers.test.ts.
|
|
121
125
|
export function normalizeImportColumnKey(value) {
|
|
122
|
-
|
|
123
|
-
.normalize("NFKD")
|
|
124
|
-
.replace(/[\u0300-\u036f]/g, "") // skipcq: JS-0117
|
|
125
|
-
.toLowerCase()
|
|
126
|
-
.replace(/[^a-z0-9]+/g, "_")
|
|
127
|
-
.replace(/^_+|_+$/g, "")
|
|
128
|
-
.replace(/_+/g, "_");
|
|
129
|
-
const base = normalized || "column";
|
|
130
|
-
const prefixed = /^[0-9]/.test(base) ? `c_${base}` : base;
|
|
131
|
-
return prefixed.slice(0, MAX_IDENTIFIER_LENGTH);
|
|
126
|
+
return toSnakeIdentifier(value, "column");
|
|
132
127
|
}
|
|
133
128
|
async function parseXlsxRows(buffer, options) {
|
|
134
129
|
let rows;
|
|
@@ -471,21 +466,8 @@ function parseCsvRecords(text) {
|
|
|
471
466
|
return records;
|
|
472
467
|
}
|
|
473
468
|
function makeUniqueImportKey(label, taken) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
return normalized;
|
|
478
|
-
}
|
|
479
|
-
for (let index = 2; index < 10_000; index += 1) {
|
|
480
|
-
const suffix = `_${index}`;
|
|
481
|
-
const candidate = `${normalized.slice(0, MAX_IDENTIFIER_LENGTH - suffix.length)}${suffix}`;
|
|
482
|
-
if (!taken.has(candidate)) {
|
|
483
|
-
taken.add(candidate);
|
|
484
|
-
return candidate;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
throw new OxygenError("identifier_exhausted", "Unable to create a unique column key.", {
|
|
488
|
-
details: { label },
|
|
489
|
-
exitCode: 1,
|
|
469
|
+
return makeUniqueIdentifier(label, taken, {
|
|
470
|
+
exhaustedMessage: "Unable to create a unique column key.",
|
|
471
|
+
exhaustedDetails: { label },
|
|
490
472
|
});
|
|
491
473
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const MAX_IDENTIFIER_LENGTH = 63;
|
|
2
|
+
/**
|
|
3
|
+
* Canonical column/identifier key normalizer. This is the single source of
|
|
4
|
+
* truth for turning a human label into a physical snake_case key: it is used
|
|
5
|
+
* both to MINT a column's key (tenant-db) and to MATCH an imported file header
|
|
6
|
+
* back to that column (shared file-import). The two sides must return the same
|
|
7
|
+
* string for the same label forever — hence one implementation, one home.
|
|
8
|
+
*
|
|
9
|
+
* The chain deliberately mangles non-ASCII (e.g. "Größe" -> "gro_e") — a latent
|
|
10
|
+
* i18n wart that is identical on both sides, so it is not drift. Do not "fix" it
|
|
11
|
+
* here without a migration: changing it would silently re-key existing columns.
|
|
12
|
+
*/
|
|
13
|
+
export declare function toSnakeIdentifier(value: string, fallback?: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Normalize `base` and disambiguate it against `taken` with a numeric suffix,
|
|
16
|
+
* respecting the 63-byte cap. `options` only customizes the exhaustion error so
|
|
17
|
+
* distinct call sites (column key vs. generic identifier) keep their own message
|
|
18
|
+
* and details key without duplicating the loop.
|
|
19
|
+
*/
|
|
20
|
+
export declare function makeUniqueIdentifier(base: string, taken: Set<string>, options?: {
|
|
21
|
+
exhaustedMessage?: string;
|
|
22
|
+
exhaustedDetails?: Record<string, unknown>;
|
|
23
|
+
}): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { OxygenError } from "./cli-result.js";
|
|
2
|
+
// Postgres identifiers are capped at NAMEDATALEN - 1 = 63 bytes.
|
|
3
|
+
export const MAX_IDENTIFIER_LENGTH = 63;
|
|
4
|
+
/**
|
|
5
|
+
* Canonical column/identifier key normalizer. This is the single source of
|
|
6
|
+
* truth for turning a human label into a physical snake_case key: it is used
|
|
7
|
+
* both to MINT a column's key (tenant-db) and to MATCH an imported file header
|
|
8
|
+
* back to that column (shared file-import). The two sides must return the same
|
|
9
|
+
* string for the same label forever — hence one implementation, one home.
|
|
10
|
+
*
|
|
11
|
+
* The chain deliberately mangles non-ASCII (e.g. "Größe" -> "gro_e") — a latent
|
|
12
|
+
* i18n wart that is identical on both sides, so it is not drift. Do not "fix" it
|
|
13
|
+
* here without a migration: changing it would silently re-key existing columns.
|
|
14
|
+
*/
|
|
15
|
+
export function toSnakeIdentifier(value, fallback = "column") {
|
|
16
|
+
const normalized = value
|
|
17
|
+
.normalize("NFKD")
|
|
18
|
+
.replace(/[\u0300-\u036f]/g, "") // skipcq: JS-0117
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
21
|
+
.replace(/^_+|_+$/g, "")
|
|
22
|
+
.replace(/_+/g, "_");
|
|
23
|
+
const base = normalized || fallback;
|
|
24
|
+
const prefixed = /^[0-9]/.test(base) ? `c_${base}` : base;
|
|
25
|
+
return prefixed.slice(0, MAX_IDENTIFIER_LENGTH);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Normalize `base` and disambiguate it against `taken` with a numeric suffix,
|
|
29
|
+
* respecting the 63-byte cap. `options` only customizes the exhaustion error so
|
|
30
|
+
* distinct call sites (column key vs. generic identifier) keep their own message
|
|
31
|
+
* and details key without duplicating the loop.
|
|
32
|
+
*/
|
|
33
|
+
export function makeUniqueIdentifier(base, taken, options) {
|
|
34
|
+
const normalized = toSnakeIdentifier(base);
|
|
35
|
+
if (!taken.has(normalized)) {
|
|
36
|
+
taken.add(normalized);
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
for (let index = 2; index < 10_000; index += 1) {
|
|
40
|
+
const suffix = `_${index}`;
|
|
41
|
+
const candidate = `${normalized.slice(0, MAX_IDENTIFIER_LENGTH - suffix.length)}${suffix}`;
|
|
42
|
+
if (!taken.has(candidate)) {
|
|
43
|
+
taken.add(candidate);
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new OxygenError("identifier_exhausted", options?.exhaustedMessage ?? "Unable to create a unique identifier.", { details: options?.exhaustedDetails ?? { base }, exitCode: 1 });
|
|
48
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
1
|
+
export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, } from "./version.js";
|
|
2
2
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
3
3
|
export * from "./billing.js";
|
|
4
4
|
export * from "./budget-scopes.js";
|
|
@@ -10,10 +10,14 @@ export * from "./credit-guidance.js";
|
|
|
10
10
|
export * from "./directory.js";
|
|
11
11
|
export * from "./email-tracking-token.js";
|
|
12
12
|
export * from "./email-unsubscribe-token.js";
|
|
13
|
+
export * from "./error-redaction.js";
|
|
14
|
+
export * from "./identifiers.js";
|
|
13
15
|
export * from "./knowledge-constants.js";
|
|
14
16
|
export * from "./knowledge-links.js";
|
|
15
17
|
export * from "./knowledge-markdown.js";
|
|
18
|
+
export * from "./knowledge-seed-content.js";
|
|
16
19
|
export * from "./linkedin-post-url.js";
|
|
20
|
+
export * from "./linkedin-url.js";
|
|
17
21
|
export * from "./linkedin-sequences.js";
|
|
18
22
|
export * from "./networks.js";
|
|
19
23
|
export * from "./sequence-template.js";
|
|
@@ -24,6 +28,8 @@ export * from "./signup-lead-deliveries.js";
|
|
|
24
28
|
export * from "./sql-error.js";
|
|
25
29
|
export * from "./telemetry.js";
|
|
26
30
|
export * from "./tenant-database-secret.js";
|
|
31
|
+
export * from "./timing.js";
|
|
32
|
+
export * from "./type-guards.js";
|
|
27
33
|
export * from "./worker-failures-queue.js";
|
|
28
34
|
export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
29
35
|
export type SemanticVersion = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION } from "./version.js";
|
|
1
|
+
export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, } from "./version.js";
|
|
2
2
|
export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
|
|
3
3
|
export * from "./billing.js";
|
|
4
4
|
export * from "./budget-scopes.js";
|
|
@@ -10,10 +10,14 @@ export * from "./credit-guidance.js";
|
|
|
10
10
|
export * from "./directory.js";
|
|
11
11
|
export * from "./email-tracking-token.js";
|
|
12
12
|
export * from "./email-unsubscribe-token.js";
|
|
13
|
+
export * from "./error-redaction.js";
|
|
14
|
+
export * from "./identifiers.js";
|
|
13
15
|
export * from "./knowledge-constants.js";
|
|
14
16
|
export * from "./knowledge-links.js";
|
|
15
17
|
export * from "./knowledge-markdown.js";
|
|
18
|
+
export * from "./knowledge-seed-content.js";
|
|
16
19
|
export * from "./linkedin-post-url.js";
|
|
20
|
+
export * from "./linkedin-url.js";
|
|
17
21
|
export * from "./linkedin-sequences.js";
|
|
18
22
|
export * from "./networks.js";
|
|
19
23
|
export * from "./sequence-template.js";
|
|
@@ -24,6 +28,8 @@ export * from "./signup-lead-deliveries.js";
|
|
|
24
28
|
export * from "./sql-error.js";
|
|
25
29
|
export * from "./telemetry.js";
|
|
26
30
|
export * from "./tenant-database-secret.js";
|
|
31
|
+
export * from "./timing.js";
|
|
32
|
+
export * from "./type-guards.js";
|
|
27
33
|
export * from "./worker-failures-queue.js";
|
|
28
34
|
// Maximum rows a single row-loop write (insert/upsert/preview) may process. The
|
|
29
35
|
// row-loop engine issues one DB round-trip per row, so a 500-row write already
|
|
@@ -34,5 +34,7 @@ export declare const KNOWLEDGE_PAGE_BODY_MAX_CHARS = 120000;
|
|
|
34
34
|
export declare const KNOWLEDGE_PAGE_SUMMARY_MAX_CHARS = 1000;
|
|
35
35
|
export declare const KNOWLEDGE_GRAPH_DEFAULT_MAX_NODES = 500;
|
|
36
36
|
export declare const KNOWLEDGE_GRAPH_MAX_NODES = 2000;
|
|
37
|
+
export declare const KNOWLEDGE_GRAPH_DEFAULT_DEPTH = 1;
|
|
38
|
+
export declare const KNOWLEDGE_GRAPH_MAX_DEPTH = 3;
|
|
37
39
|
export declare const KNOWLEDGE_SYNC_MAX_PAGE_BATCH = 200;
|
|
38
40
|
export declare const RESERVED_KNOWLEDGE_FRONTMATTER_KEYS: readonly ["oxygen_page", "id", "slug", "type", "title", "status", "tags", "canonical", "summary", "revision", "updated_at", "web_url"];
|
|
@@ -118,6 +118,10 @@ export const KNOWLEDGE_PAGE_BODY_MAX_CHARS = 120_000;
|
|
|
118
118
|
export const KNOWLEDGE_PAGE_SUMMARY_MAX_CHARS = 1_000;
|
|
119
119
|
export const KNOWLEDGE_GRAPH_DEFAULT_MAX_NODES = 500;
|
|
120
120
|
export const KNOWLEDGE_GRAPH_MAX_NODES = 2_000;
|
|
121
|
+
// Local-graph neighborhood depth: default one hop around a center page, clamped to three
|
|
122
|
+
// (the BFS issues one query per hop, so depth bounds round-trips).
|
|
123
|
+
export const KNOWLEDGE_GRAPH_DEFAULT_DEPTH = 1;
|
|
124
|
+
export const KNOWLEDGE_GRAPH_MAX_DEPTH = 3;
|
|
121
125
|
export const KNOWLEDGE_SYNC_MAX_PAGE_BATCH = 200;
|
|
122
126
|
// Frontmatter keys the deterministic renderer owns; user `data` keys colliding
|
|
123
127
|
// with these are skipped at render time (the stored jsonb keeps them).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { KnowledgePageType, KnowledgePageStatus } from "./knowledge-constants.js";
|
|
2
|
+
/** Monotonic scaffold cursor stored in knowledge_state.scaffold_version. Bump to ship a
|
|
3
|
+
* new wave of starter pages (tag them with a higher minScaffoldVersion). */
|
|
4
|
+
export declare const KNOWLEDGE_SCAFFOLD_VERSION = 1;
|
|
5
|
+
export type KnowledgeScaffoldPage = {
|
|
6
|
+
/** Immutable slug; must be valid kebab grammar and NOT a reserved slug. */
|
|
7
|
+
slug: string;
|
|
8
|
+
type: KnowledgePageType;
|
|
9
|
+
title: string;
|
|
10
|
+
/** Nav pages ship active; content stubs ship draft so they never ground AI copy. */
|
|
11
|
+
status: Extract<KnowledgePageStatus, "active" | "draft">;
|
|
12
|
+
tags: string[];
|
|
13
|
+
summary: string;
|
|
14
|
+
body: string;
|
|
15
|
+
/** The first scaffold version that includes this page — seeded when current < this <= target. */
|
|
16
|
+
minScaffoldVersion: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The 9-page starter pack. Two active nav pages first (start-here + the conventions schema
|
|
20
|
+
* page), then the seven draft stub hubs (tag `seed`). `positioning` seeds as a NON-canonical
|
|
21
|
+
* draft so it writes freely — pinning canonical positioning stays a separate human-approved
|
|
22
|
+
* act. All at minScaffoldVersion 1 (the first wave).
|
|
23
|
+
*/
|
|
24
|
+
export declare const KNOWLEDGE_SEED_PAGES: readonly KnowledgeScaffoldPage[];
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Knowledge Graph starter pack (the Karpathy LLM-wiki scaffold). A fresh workspace
|
|
2
|
+
// self-seeds this page set on first onboarding / `oxygen knowledge seed` so the wiki
|
|
3
|
+
// arrives alive: two active nav pages ([[start-here]] + the [[conventions]] schema page)
|
|
4
|
+
// and seven draft stub hubs that show the shape of a healthy second brain without
|
|
5
|
+
// grounding any AI copy until a human fills them.
|
|
6
|
+
//
|
|
7
|
+
// The seeder (tenant-db `ensureKnowledgeScaffold`) is purely additive and version-gated:
|
|
8
|
+
// it creates a page only when its slug is free (never overwriting a user's edits) and
|
|
9
|
+
// only when `minScaffoldVersion` sits in (current, target]. Bump KNOWLEDGE_SCAFFOLD_VERSION
|
|
10
|
+
// and add pages with a higher `minScaffoldVersion` to ship a new wave; existing pages are
|
|
11
|
+
// never rewritten by the scaffold. Bodies are the single source of truth for the starter
|
|
12
|
+
// content — keep them dense and wikilinked.
|
|
13
|
+
/** Monotonic scaffold cursor stored in knowledge_state.scaffold_version. Bump to ship a
|
|
14
|
+
* new wave of starter pages (tag them with a higher minScaffoldVersion). */
|
|
15
|
+
export const KNOWLEDGE_SCAFFOLD_VERSION = 1;
|
|
16
|
+
const START_HERE_BODY = `# Start here
|
|
17
|
+
|
|
18
|
+
This is the workspace's knowledge wiki — one shared, compounding second brain for every human and every AI agent here. It replaces scattered Notion docs, chat history, and one-off prompts.
|
|
19
|
+
|
|
20
|
+
## The contract (read once)
|
|
21
|
+
- **Read before you answer.** Skim [[conventions]], then the index (\`oxygen knowledge index\`). Open only the pages you need and cite them by slug, e.g. "per [[icp]]".
|
|
22
|
+
- **Route before reading.** Use the index and \`oxygen knowledge search\` as a map. Don't scan page bodies to orient.
|
|
23
|
+
- **File durable answers back.** An answer that will be asked again becomes a page + a \`log append\`. Never strand company knowledge in chat or a commit message.
|
|
24
|
+
- **Trust travels.** Every claim cites its source. Canonical live copy (voice / brand / [[positioning]]) is human-approved; everything else auto-writes as revertible revisions. See [[conventions]].
|
|
25
|
+
|
|
26
|
+
## Hubs
|
|
27
|
+
- [[icp]] — who we sell to
|
|
28
|
+
- [[positioning]] — how we describe ourselves vs. alternatives
|
|
29
|
+
- [[offers]] — what we sell and for how much
|
|
30
|
+
- [[competitors]] — competitor teardowns (map of content)
|
|
31
|
+
- [[playbooks]] — how we run each GTM motion (map of content)
|
|
32
|
+
- [[campaign-learnings]] — what outreach and meetings taught us
|
|
33
|
+
- [[decisions]] — standing GTM decisions and what would reverse them
|
|
34
|
+
|
|
35
|
+
## Seeing the shape
|
|
36
|
+
\`oxygen knowledge graph\` (or the web graph) shows hubs, orphans, and dashed unresolved links — the gaps worth filling next. \`oxygen knowledge lint\` is the health report.
|
|
37
|
+
|
|
38
|
+
> Most hub pages are stubs right now. Filling a stub needs no approval — replace the body with cited content and set \`status: active\`. The how-to is in [[conventions]].
|
|
39
|
+
`;
|
|
40
|
+
const CONVENTIONS_BODY = `# Conventions (schema)
|
|
41
|
+
|
|
42
|
+
This page defines how the wiki is structured so agents make the same choices a careful human would. Amending it is a human/approved act — propose changes with evidence from the log.
|
|
43
|
+
|
|
44
|
+
## Working loop
|
|
45
|
+
Index-first → open only relevant pages → answer citing slugs → file durable answers back as a page + \`log append\`. Details in [[start-here]].
|
|
46
|
+
|
|
47
|
+
## Page types (pick the most specific; \`other\` is last resort)
|
|
48
|
+
| Type | Use for |
|
|
49
|
+
|---|---|
|
|
50
|
+
| \`entity\` | one real-world noun: a company, person, product, tool, market |
|
|
51
|
+
| \`topic\` | a concept hub linking entities/notes (a map of content) |
|
|
52
|
+
| \`research_note\` | dated findings from one investigation |
|
|
53
|
+
| \`source_summary\` | condensed digest of ONE raw source — cite, don't copy |
|
|
54
|
+
| \`report\` | a human-facing rollup (weekly synthesis, audit) |
|
|
55
|
+
| \`competitor\` | one competitor: teardown, where they win, how we counter |
|
|
56
|
+
| \`persona\` | one buyer persona: pains, triggers, language |
|
|
57
|
+
| \`strategy\` | durable direction: ICP, offers, bets, quarterly focus |
|
|
58
|
+
| \`campaign\` | one bounded GTM push + its results |
|
|
59
|
+
| \`positioning\` | how we're described vs. alternatives — canonical is gated |
|
|
60
|
+
| \`voice\` / \`brand\` | writing voice / brand facts — canonical is gated |
|
|
61
|
+
| \`playbook\` / \`message_playbook\` | how-we-do-it / message patterns — pinned default is gated |
|
|
62
|
+
| \`schema\` | describes the wiki itself (this page) |
|
|
63
|
+
|
|
64
|
+
## Slugs
|
|
65
|
+
Flat kebab: lowercase alphanumerics + dashes, no slashes, ≤120 chars. **Immutable once created** — choose the durable name (\`competitor-clay\`, not \`clay-notes-july\`). No folders; types, tags, and [[wikilinks]] organize. Reserved (never create): \`index\`, \`log\`, \`schema\`, \`company-profile\`, \`readme\`.
|
|
66
|
+
|
|
67
|
+
## Wikilinks
|
|
68
|
+
\`[[slug]]\`, \`[[slug|alias]]\`, \`[[slug#heading]]\` all key to the slug. **Linking to a page that doesn't exist yet is good** — the dashed node is a visible to-do. Every page should link out (avoid dead-ends) and be linked to (avoid orphans).
|
|
69
|
+
|
|
70
|
+
## New page vs. edit
|
|
71
|
+
New page when it's a distinct entity/concept you'd link to from elsewhere. Edit in place when it's an attribute or update of an existing page. Search first (\`oxygen knowledge search\`) — near-duplicate slugs rot the graph.
|
|
72
|
+
|
|
73
|
+
## Density bar (the compression rule)
|
|
74
|
+
A page must be denser than the sources it distills. If a page is roughly the size of, or a 1:1 mirror of, something cheaper to re-fetch (a live CRM field, one short doc), it's negative value — collapse it into a dense table/summary or delete it. A page is either trusted at query time or it shouldn't exist: never ship "verify against source" pages. No filler, no hedging boilerplate.
|
|
75
|
+
|
|
76
|
+
## Trust tiers (two words, they travel on the page)
|
|
77
|
+
- **verified** — a human confirmed it. Tag \`verified\`.
|
|
78
|
+
- **agent-derived** — machine-authored hint, not yet confirmed. Default for auto-written pages.
|
|
79
|
+
Prune agent-derived claims once a human verifies the same fact. A contested claim gets tag \`disputed\` (excluded from AI grounding).
|
|
80
|
+
|
|
81
|
+
## What auto-files where (write path)
|
|
82
|
+
- Raw outcomes (reply classifications, bounces, won/lost deals, meetings, A/B winners, human edits to AI drafts) auto-file as **immutable sources** on learnings pages — no approval. See [[campaign-learnings]].
|
|
83
|
+
- Working pages auto-write as logged, revertible revisions.
|
|
84
|
+
- **Gated:** editing or pinning the canonical (pinned) instance of voice/brand/[[positioning]]/playbook/message_playbook files a proposal — a human approves. Never bypass this.
|
|
85
|
+
|
|
86
|
+
## Decision pages
|
|
87
|
+
Record standing calls in [[decisions]] using the template there. Every decision needs a rationale AND a \`## Reversal condition\` — the observable evidence that would flip it — so agents can flag when new data contradicts a standing decision.
|
|
88
|
+
|
|
89
|
+
## Stub-fill contract
|
|
90
|
+
Seeded stubs (tag \`seed\`, still at revision 1, \`status: draft\`) are scaffolding. Replace the body with real cited content, keep the [[wikilinks]], set \`status: active\`. **No approval needed.** Until filled they stay draft and never ground AI copy. \`oxygen knowledge lint\` lists unfilled stubs.
|
|
91
|
+
|
|
92
|
+
## Lint discipline
|
|
93
|
+
Run \`oxygen knowledge lint\` regularly. It reports orphans, dead-ends, unresolved links (ranked by demand), untagged pages, duplicate titles, oversized hubs, missing summaries/canonicals, stale/oversized pages, unfilled stubs, and decisions missing a reversal condition. \`oxygen knowledge lint relink\` rebuilds edges after bulk edits.
|
|
94
|
+
`;
|
|
95
|
+
const ICP_BODY = `# ICP — ideal customer profile
|
|
96
|
+
|
|
97
|
+
> **Stub — fill me.** Seeded scaffolding. Replace with real, cited content, keep the [[wikilinks]], then set \`status: active\`. No approval needed. Draft stubs do not ground AI copy. How-to: [[conventions]].
|
|
98
|
+
|
|
99
|
+
## Firmographics
|
|
100
|
+
- Company type / size / stage:
|
|
101
|
+
- Industry / vertical:
|
|
102
|
+
- Geography:
|
|
103
|
+
|
|
104
|
+
## Triggers (why now)
|
|
105
|
+
- Signals that a fit account is in-market:
|
|
106
|
+
|
|
107
|
+
## Disqualifiers (hard no)
|
|
108
|
+
- Who we should NOT pursue and why:
|
|
109
|
+
|
|
110
|
+
## Buyer personas
|
|
111
|
+
- Link one [[persona]] page per buyer (economic buyer, champion, user).
|
|
112
|
+
|
|
113
|
+
## Basis
|
|
114
|
+
- Cite the deals, calls, or market data this is drawn from. Contradicting evidence should route through [[campaign-learnings]] and update [[positioning]].
|
|
115
|
+
`;
|
|
116
|
+
const POSITIONING_BODY = `# Positioning
|
|
117
|
+
|
|
118
|
+
> **Stub — fill me.** Non-canonical draft. Fill it, then a human pins the canonical version (\`oxygen knowledge page pin positioning --approved\`) — pinning is gated because canonical positioning grounds live copy. See [[conventions]].
|
|
119
|
+
|
|
120
|
+
## For whom
|
|
121
|
+
- Our [[icp]], and the moment they buy.
|
|
122
|
+
|
|
123
|
+
## Category / alternative
|
|
124
|
+
- What buyers compare us to (status quo, a [[competitors|competitor]], in-house).
|
|
125
|
+
|
|
126
|
+
## Core value
|
|
127
|
+
- The one thing we do better and why it matters:
|
|
128
|
+
|
|
129
|
+
## Proof
|
|
130
|
+
- Evidence: outcomes, references, [[campaign-learnings]].
|
|
131
|
+
|
|
132
|
+
## Changes our mind if
|
|
133
|
+
- Observable evidence that would force a repositioning (see the reversal rule in [[decisions]]).
|
|
134
|
+
`;
|
|
135
|
+
const OFFERS_BODY = `# Offers
|
|
136
|
+
|
|
137
|
+
> **Stub — fill me.** Seeded scaffolding — fill, keep [[wikilinks]], set \`status: active\`. See [[conventions]].
|
|
138
|
+
|
|
139
|
+
## Offers
|
|
140
|
+
For each: name, who it's for ([[icp]]), price/packaging, the outcome it delivers, and qualifying criteria.
|
|
141
|
+
|
|
142
|
+
- Offer 1 —
|
|
143
|
+
- Offer 2 —
|
|
144
|
+
|
|
145
|
+
## Positioning link
|
|
146
|
+
Tie each offer to the value in [[positioning]] and the motions in [[playbooks]].
|
|
147
|
+
`;
|
|
148
|
+
const COMPETITORS_BODY = `# Competitors (map of content)
|
|
149
|
+
|
|
150
|
+
> **Stub — fill me.** This is a hub. Create one \`competitor\` page per rival (e.g. \`[[competitor-clay]]\`) and link it here; then fill this overview. See [[conventions]].
|
|
151
|
+
|
|
152
|
+
## Landscape
|
|
153
|
+
- One line per competitor + link to its teardown page.
|
|
154
|
+
- [[competitor-example]] — replace with real teardowns.
|
|
155
|
+
|
|
156
|
+
## Our stance
|
|
157
|
+
- Where we consistently win / lose (draw from [[positioning]] and [[campaign-learnings]]).
|
|
158
|
+
`;
|
|
159
|
+
const PLAYBOOKS_BODY = `# Playbooks (map of content)
|
|
160
|
+
|
|
161
|
+
> **Stub — fill me.** Hub. Create one \`playbook\` page per motion and link it here. The pinned/canonical playbook that backs live sends is gated — see [[conventions]].
|
|
162
|
+
|
|
163
|
+
## Motions
|
|
164
|
+
- [[playbook-outbound]] — cold outbound
|
|
165
|
+
- [[playbook-nurture]] — nurture / re-engagement
|
|
166
|
+
- (add: demo, onboarding, expansion)
|
|
167
|
+
|
|
168
|
+
Each playbook cites the [[campaign-learnings]] that shaped it and the [[offers]] it sells.
|
|
169
|
+
`;
|
|
170
|
+
const CAMPAIGN_LEARNINGS_BODY = `# Campaign learnings (map of content)
|
|
171
|
+
|
|
172
|
+
> **Stub — fill me.** Hub for the learning loop. See [[conventions]] for the write path.
|
|
173
|
+
|
|
174
|
+
## How this fills itself
|
|
175
|
+
Learning-worthy outcomes auto-file as **immutable sources** (facts, no approval) onto working learnings pages:
|
|
176
|
+
- reply classifications, bounces, opt-outs → [[outreach-learnings]] and per-sequence \`campaign-learnings-<id>\` pages
|
|
177
|
+
- closed won/lost deals → [[won-lost-learnings]]
|
|
178
|
+
- completed meetings + notes → [[meeting-learnings]]
|
|
179
|
+
- A/B auto-winners and human edits to AI drafts → the relevant learnings page
|
|
180
|
+
|
|
181
|
+
## Distill
|
|
182
|
+
\`oxygen knowledge synthesize\` (and the opt-in scheduled agent) turns accumulated sources into learnings-page revisions — with citations and a "what would change our mind" line per recommendation. Retrieval then grounds the NEXT campaign's drafts on them.
|
|
183
|
+
|
|
184
|
+
## Read before a new campaign
|
|
185
|
+
Skim the learnings pages above; message drafts and AI columns also retrieve them automatically.
|
|
186
|
+
`;
|
|
187
|
+
const DECISIONS_BODY = `# Decisions (log)
|
|
188
|
+
|
|
189
|
+
> **Stub — fill me.** Hub for standing GTM decisions. Each decision is its own page (tag it \`decision\`) linked here, or a short entry below for small calls. See [[conventions]].
|
|
190
|
+
|
|
191
|
+
## Template (copy for each decision)
|
|
192
|
+
\`\`\`
|
|
193
|
+
### <decision, one line>
|
|
194
|
+
- **Date / owner:**
|
|
195
|
+
- **Decision:** what we chose
|
|
196
|
+
- **Rationale:** why, in 2–3 lines
|
|
197
|
+
- **Basis:** cited sources ([[campaign-learnings]], deals, [[competitors]])
|
|
198
|
+
## Reversal condition
|
|
199
|
+
The observable evidence that would flip this decision.
|
|
200
|
+
\`\`\`
|
|
201
|
+
|
|
202
|
+
Every decision MUST have a \`## Reversal condition\` — \`oxygen knowledge lint\` flags decision pages that lack one. Record the call in the log too: \`oxygen knowledge log append --event decision --slug <page> --summary "<what/why>"\`.
|
|
203
|
+
`;
|
|
204
|
+
/**
|
|
205
|
+
* The 9-page starter pack. Two active nav pages first (start-here + the conventions schema
|
|
206
|
+
* page), then the seven draft stub hubs (tag `seed`). `positioning` seeds as a NON-canonical
|
|
207
|
+
* draft so it writes freely — pinning canonical positioning stays a separate human-approved
|
|
208
|
+
* act. All at minScaffoldVersion 1 (the first wave).
|
|
209
|
+
*/
|
|
210
|
+
export const KNOWLEDGE_SEED_PAGES = [
|
|
211
|
+
{
|
|
212
|
+
slug: "start-here",
|
|
213
|
+
type: "topic",
|
|
214
|
+
title: "Start here",
|
|
215
|
+
status: "active",
|
|
216
|
+
tags: ["moc", "start-here"],
|
|
217
|
+
summary: "Home of the workspace wiki and its contract for humans and agents: read index-first, cite slugs by [[wikilink]], file durable answers back as pages. Links every hub.",
|
|
218
|
+
body: START_HERE_BODY,
|
|
219
|
+
minScaffoldVersion: 1,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
slug: "conventions",
|
|
223
|
+
type: "schema",
|
|
224
|
+
title: "Conventions (schema)",
|
|
225
|
+
status: "active",
|
|
226
|
+
tags: ["schema", "conventions"],
|
|
227
|
+
summary: "The wiki's schema file: page types, slug grammar, wikilink rules, new-page-vs-edit heuristic, the density/compression bar, trust tiers, what auto-files where, and the decision-page template with reversal conditions. Read before writing.",
|
|
228
|
+
body: CONVENTIONS_BODY,
|
|
229
|
+
minScaffoldVersion: 1,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
slug: "icp",
|
|
233
|
+
type: "strategy",
|
|
234
|
+
title: "ICP — ideal customer profile",
|
|
235
|
+
status: "draft",
|
|
236
|
+
tags: ["seed", "icp"],
|
|
237
|
+
summary: "Stub: the ideal customer profile — firmographics, triggers, and disqualifiers that define who we sell to. Fill with cited content and set active.",
|
|
238
|
+
body: ICP_BODY,
|
|
239
|
+
minScaffoldVersion: 1,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
slug: "positioning",
|
|
243
|
+
type: "positioning",
|
|
244
|
+
title: "Positioning",
|
|
245
|
+
status: "draft",
|
|
246
|
+
tags: ["seed", "positioning"],
|
|
247
|
+
summary: "Stub (non-canonical): how we describe ourselves against alternatives — for whom, the core value, and proof. Pinning the canonical positioning is a separate human-approved act.",
|
|
248
|
+
body: POSITIONING_BODY,
|
|
249
|
+
minScaffoldVersion: 1,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
slug: "offers",
|
|
253
|
+
type: "strategy",
|
|
254
|
+
title: "Offers",
|
|
255
|
+
status: "draft",
|
|
256
|
+
tags: ["seed", "offers"],
|
|
257
|
+
summary: "Stub: what we sell — packages, pricing, and the qualifying criteria per offer. Fill with cited content and set active.",
|
|
258
|
+
body: OFFERS_BODY,
|
|
259
|
+
minScaffoldVersion: 1,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
slug: "competitors",
|
|
263
|
+
type: "topic",
|
|
264
|
+
title: "Competitors (map of content)",
|
|
265
|
+
status: "draft",
|
|
266
|
+
tags: ["seed", "moc", "competitors"],
|
|
267
|
+
summary: "Stub map-of-content: index of competitor teardown pages. Create one [[competitor-<name>]] page each; this hub links them and summarizes our overall competitive stance.",
|
|
268
|
+
body: COMPETITORS_BODY,
|
|
269
|
+
minScaffoldVersion: 1,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
slug: "playbooks",
|
|
273
|
+
type: "topic",
|
|
274
|
+
title: "Playbooks (map of content)",
|
|
275
|
+
status: "draft",
|
|
276
|
+
tags: ["seed", "moc", "playbooks"],
|
|
277
|
+
summary: "Stub map-of-content: index of how-we-run-it playbooks (outbound, nurture, demo, expansion). Create one playbook page per motion and link it here.",
|
|
278
|
+
body: PLAYBOOKS_BODY,
|
|
279
|
+
minScaffoldVersion: 1,
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
slug: "campaign-learnings",
|
|
283
|
+
type: "topic",
|
|
284
|
+
title: "Campaign learnings (map of content)",
|
|
285
|
+
status: "draft",
|
|
286
|
+
tags: ["seed", "moc", "learnings"],
|
|
287
|
+
summary: "Stub map-of-content for the learning loop: explains that outreach/meeting/deal outcomes auto-file as immutable sources, and how synthesis distills them into learnings pages that ground the next campaign.",
|
|
288
|
+
body: CAMPAIGN_LEARNINGS_BODY,
|
|
289
|
+
minScaffoldVersion: 1,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
slug: "decisions",
|
|
293
|
+
type: "topic",
|
|
294
|
+
title: "Decisions (log)",
|
|
295
|
+
status: "draft",
|
|
296
|
+
tags: ["seed", "moc", "decisions"],
|
|
297
|
+
summary: "Stub decision-log map-of-content with the decision template. Standing GTM decisions each get rationale, cited basis, and a reversal condition; log the call with 'log append --event decision'.",
|
|
298
|
+
body: DECISIONS_BODY,
|
|
299
|
+
minScaffoldVersion: 1,
|
|
300
|
+
},
|
|
301
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type LinkedinUrlNormalization = {
|
|
2
|
+
normalized: string;
|
|
3
|
+
handle: string;
|
|
4
|
+
} | null;
|
|
5
|
+
export declare function normalizeLinkedinProfileUrl(raw: string | null | undefined): LinkedinUrlNormalization;
|
|
6
|
+
/**
|
|
7
|
+
* Extract the addressable activity id from a LinkedIn post URL. Returns null for
|
|
8
|
+
* a bare id (already addressable — pass it through), and null for a linkedin.com
|
|
9
|
+
* URL that is not a post.
|
|
10
|
+
*/
|
|
11
|
+
export declare function linkedinPostIdFromUrl(raw: string | null | undefined): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* True when a value is a URL rather than a provider identifier. Unipile addresses
|
|
14
|
+
* members, posts, and chats as PATH SEGMENTS, so a URL that reaches one is always
|
|
15
|
+
* a bug on our side — and the provider reports it as a permission or validation
|
|
16
|
+
* error that reads like the customer's account is broken. Callers use this to
|
|
17
|
+
* fail closed with an honest message instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare function looksLikeUrlIdentifier(value: string): boolean;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Canonicalize + validate a LinkedIn personal-profile URL. A LinkedIn URL is a
|
|
2
|
+
// strong personal identifier, so a false positive contaminates every downstream
|
|
3
|
+
// step (email finding, outreach, CRM sync). This lives in @oxygen/shared so both
|
|
4
|
+
// the enrichment identity validator and the sequencer (enroll-time acceptance in
|
|
5
|
+
// apps/web + send-time provider-id resolution in @oxygen/integrations) validate a
|
|
6
|
+
// profile URL with the exact same logic — one source of truth for "is this a
|
|
7
|
+
// dispatchable /in/ profile URL".
|
|
8
|
+
// Reject URLs that are not personal profile pages. Sales Navigator, Recruiter,
|
|
9
|
+
// search-result deep links, and company URLs all surface from loosely-typed
|
|
10
|
+
// provider responses and would otherwise pollute the column.
|
|
11
|
+
const PROFILE_REJECT_PATH_FRAGMENTS = [
|
|
12
|
+
"/sales/",
|
|
13
|
+
"/recruiter/",
|
|
14
|
+
"/search/",
|
|
15
|
+
"/talent/",
|
|
16
|
+
"/learning/",
|
|
17
|
+
"/jobs/",
|
|
18
|
+
"/feed/",
|
|
19
|
+
"/posts/",
|
|
20
|
+
"/company/",
|
|
21
|
+
"/school/",
|
|
22
|
+
"/groups/",
|
|
23
|
+
];
|
|
24
|
+
export function normalizeLinkedinProfileUrl(raw) {
|
|
25
|
+
if (typeof raw !== "string")
|
|
26
|
+
return null;
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
return null;
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const host = parsed.hostname.replace(/^www\./i, "").toLowerCase();
|
|
38
|
+
if (host !== "linkedin.com" && !host.endsWith(".linkedin.com"))
|
|
39
|
+
return null;
|
|
40
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
41
|
+
const lowerPath = path.toLowerCase();
|
|
42
|
+
if (PROFILE_REJECT_PATH_FRAGMENTS.some((fragment) => lowerPath.includes(fragment)))
|
|
43
|
+
return null;
|
|
44
|
+
const parts = path.split("/").filter(Boolean);
|
|
45
|
+
const markerIndex = parts.findIndex((part) => part.toLowerCase() === "in" || part.toLowerCase() === "pub");
|
|
46
|
+
if (markerIndex < 0 || markerIndex >= parts.length - 1)
|
|
47
|
+
return null;
|
|
48
|
+
const rawHandle = parts[markerIndex + 1];
|
|
49
|
+
if (!rawHandle)
|
|
50
|
+
return null;
|
|
51
|
+
let handle;
|
|
52
|
+
try {
|
|
53
|
+
handle = decodeURIComponent(rawHandle).trim().toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
handle = rawHandle.trim().toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
if (!handle)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
normalized: `https://www.linkedin.com/in/${handle}`,
|
|
62
|
+
handle,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// A LinkedIn post URL carries the numeric activity id we address the post by:
|
|
66
|
+
// /posts/{slug}-activity-{19 digits}-{4 chars}
|
|
67
|
+
// /feed/update/urn:li:activity:{19 digits}
|
|
68
|
+
// Anything else that happens to live on linkedin.com (a job, a company page) is
|
|
69
|
+
// NOT a post, and must not be handed to a posts endpoint — callers get null and
|
|
70
|
+
// fail closed rather than shipping a URL the provider answers with a confusing
|
|
71
|
+
// "Invalid Post ID".
|
|
72
|
+
const POST_ACTIVITY_ID_PATTERNS = [
|
|
73
|
+
/activity[:-](\d{6,})/i,
|
|
74
|
+
/urn:li:share:(\d{6,})/i,
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* Extract the addressable activity id from a LinkedIn post URL. Returns null for
|
|
78
|
+
* a bare id (already addressable — pass it through), and null for a linkedin.com
|
|
79
|
+
* URL that is not a post.
|
|
80
|
+
*/
|
|
81
|
+
export function linkedinPostIdFromUrl(raw) {
|
|
82
|
+
if (typeof raw !== "string")
|
|
83
|
+
return null;
|
|
84
|
+
const trimmed = raw.trim();
|
|
85
|
+
// Only URLs are rewritten. A bare id or an activity URN is already addressable,
|
|
86
|
+
// so it returns null and the caller passes it through untouched.
|
|
87
|
+
if (!trimmed || !looksLikeUrlIdentifier(trimmed))
|
|
88
|
+
return null;
|
|
89
|
+
for (const pattern of POST_ACTIVITY_ID_PATTERNS) {
|
|
90
|
+
const match = pattern.exec(trimmed);
|
|
91
|
+
if (match?.[1])
|
|
92
|
+
return match[1];
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* True when a value is a URL rather than a provider identifier. Unipile addresses
|
|
98
|
+
* members, posts, and chats as PATH SEGMENTS, so a URL that reaches one is always
|
|
99
|
+
* a bug on our side — and the provider reports it as a permission or validation
|
|
100
|
+
* error that reads like the customer's account is broken. Callers use this to
|
|
101
|
+
* fail closed with an honest message instead.
|
|
102
|
+
*/
|
|
103
|
+
export function looksLikeUrlIdentifier(value) {
|
|
104
|
+
return /^https?:\/\//i.test(value.trim()) || /(^|\.)linkedin\.com\//i.test(value.trim());
|
|
105
|
+
}
|