@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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-values.d.ts +18 -0
  3. package/dist/cli-values.js +66 -0
  4. package/dist/credentials.d.ts +22 -2
  5. package/dist/credentials.js +80 -17
  6. package/dist/help.js +1 -1
  7. package/dist/http-client.d.ts +2 -0
  8. package/dist/http-client.js +68 -30
  9. package/dist/index.js +789 -243
  10. package/dist/knowledge-mirror.d.ts +10 -0
  11. package/dist/knowledge-mirror.js +18 -0
  12. package/dist/run-wait.js +2 -26
  13. package/dist/runtime.d.ts +63 -4
  14. package/dist/runtime.js +113 -3
  15. package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
  16. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
  17. package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
  18. package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
  19. package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
  20. package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
  21. package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
  22. package/node_modules/@oxygen/shared/dist/index.js +7 -1
  23. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
  24. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
  29. package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
  30. package/node_modules/@oxygen/shared/dist/log.js +65 -6
  31. package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
  32. package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
  33. package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
  34. package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
  35. package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/timing.js +12 -0
  37. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
  39. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  40. package/node_modules/@oxygen/shared/dist/version.js +33 -2
  41. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
  42. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  43. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
  44. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
  45. 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
- const MAX_IDENTIFIER_LENGTH = 63;
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
- const normalized = value
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
- const normalized = normalizeImportColumnKey(label);
475
- if (!taken.has(normalized)) {
476
- taken.add(normalized);
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
+ }