-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Agentic CRM wave: lifecycle specialists, hygiene, mailbox, observability #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
52a57ed
1561073
5074fc4
3c20d80
808b835
407280a
c26a08d
d585dc3
d0299d9
7d4a573
56f4eeb
57001e6
ad1d702
fc0c594
4ffe150
14cd220
42e41dc
a91b622
5b9f96e
3713dc2
ae77da2
2f3861b
18ae5e6
a0481cd
a729d01
cf70a19
a832e2e
211d397
f7de7b9
7c60192
1ec13f5
9dc2637
f2aa1f1
0e4ba64
64c773b
91e32d5
7ee58ea
412aac7
a0709ae
74e02d5
591bb73
b5996a7
1fe028f
31646f5
86d5160
1eefad6
8505823
114041b
ef0cb4f
36f8251
ae84334
de9ad1a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { FactBand } from "@crm/db"; | ||
| import { type Evidence, type EvidenceKind, scoreEvidence } from "./evidence"; | ||
| import { isDerivedName } from "./names"; | ||
|
|
||
| export const IDENTITY_PROOF_KINDS = [ | ||
| "profile.email-match", | ||
| "linkedin.employer-and-name", | ||
| "crm.thread-reply", | ||
| "crm.signature-block", | ||
| "github.account-identity", | ||
| ] as const satisfies readonly EvidenceKind[]; | ||
|
|
||
| const identityKinds = new Set<string>(IDENTITY_PROOF_KINDS); | ||
|
|
||
| export type ContactIdentitySnapshot = { | ||
| email: string | null; | ||
| firstName: string; | ||
| lastName: string | null; | ||
| linkedinUrl: string | null; | ||
| hasAppliedName: boolean; | ||
| }; | ||
|
|
||
| export function evidenceProvesIdentity(evidence: Evidence[]): boolean { | ||
| return evidence.some((item) => identityKinds.has(item.kind)); | ||
| } | ||
|
|
||
| export function contactIdentityIsTrustworthy( | ||
| contact: ContactIdentitySnapshot, | ||
| ): boolean { | ||
| if (contact.linkedinUrl) return true; | ||
| if (contact.hasAppliedName) return true; | ||
| if ( | ||
| contact.lastName && | ||
| !isDerivedName(contact.email, contact.firstName, contact.lastName) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: In Prompt for AI agents |
||
| ) { | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| export function refuseBriefReason(input: { | ||
| contact: ContactIdentitySnapshot; | ||
| evidence: Evidence[]; | ||
| }): string | null { | ||
| const identityOk = | ||
| contactIdentityIsTrustworthy(input.contact) || | ||
| evidenceProvesIdentity(input.evidence); | ||
|
|
||
| if (!identityOk) { | ||
| return "Identity is not trustworthy yet. Identify them first, then write the brief."; | ||
| } | ||
|
|
||
| const scored = scoreEvidence(input.evidence); | ||
|
|
||
| if (scored.band === null || scored.band === FactBand.POSSIBLE) { | ||
| return "Nothing here is sourced well enough to put on the record."; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,50 @@ export type Capability = { | |
| readonly from: string; | ||
| }; | ||
|
|
||
| export type EnableChecklistItem = { | ||
| readonly id: string; | ||
| readonly label: string; | ||
| readonly source: string; | ||
| readonly kind: "env" | "setting"; | ||
| }; | ||
|
|
||
| export const FULL_AGENTIC_CHECKLIST: readonly EnableChecklistItem[] = [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: FULL_AGENTIC_CHECKLIST re-declares (id + label + source) the same four env capabilities that capabilitiesFrom() constructs inline, and docs/environment.md explicitly calls this list the 'source of truth'. The runtime capability set actually lives in capabilitiesFrom(), so the two lists can silently drift: adding a new env capability there without also updating the checklist leaves the enable checklist stale even though Prompt for AI agents |
||
| { | ||
| id: "RAPIDAPI_KEY", | ||
| label: "LinkedIn", | ||
| source: "RAPIDAPI_KEY", | ||
| kind: "env", | ||
| }, | ||
| { | ||
| id: "PERPLEXITY_API_KEY", | ||
| label: "Web research", | ||
| source: "PERPLEXITY_API_KEY", | ||
| kind: "env", | ||
| }, | ||
| { | ||
| id: CONTEXT_DEV, | ||
| label: "Company brand data", | ||
| source: "Settings → General", | ||
| kind: "setting", | ||
| }, | ||
| { | ||
| id: "BLOB_READ_WRITE_TOKEN", | ||
| label: "Picture storage", | ||
| source: "BLOB_READ_WRITE_TOKEN", | ||
| kind: "env", | ||
| }, | ||
| { | ||
| id: "AGENT_BRIDGE_SECRET", | ||
| label: "Agent panel", | ||
| source: "AGENT_BRIDGE_SECRET", | ||
| kind: "env", | ||
| }, | ||
| ] as const; | ||
|
|
||
| export const FULL_AGENTIC_ENV_VARS = FULL_AGENTIC_CHECKLIST.filter( | ||
| (item) => item.kind === "env", | ||
| ).map((item) => item.source); | ||
|
|
||
| export async function contextDevKey(): Promise<string | null> { | ||
| try { | ||
| return await readContextDevKey(db); | ||
|
|
@@ -51,13 +95,14 @@ export function capabilitiesFrom( | |
| ...fromEnv("PERPLEXITY_API_KEY"), | ||
| label: "Web research", | ||
| gives: | ||
| "open-web context with citations, and the search that finds a LinkedIn slug in the first place", | ||
| "open-web context with citations for research, not for identity matching", | ||
| }, | ||
| { | ||
| id: CONTEXT_DEV, | ||
| from: "Settings → General", | ||
| label: "Company brand data", | ||
| gives: "a company's logo, industry, location and socials from its domain", | ||
| gives: | ||
| "a company's logo, industry, location and socials from its domain, and Context web search that finds LinkedIn candidate slugs for identity matching", | ||
| enabled: contextDev !== null, | ||
| }, | ||
| { | ||
|
|
@@ -66,6 +111,12 @@ export function capabilitiesFrom( | |
| gives: | ||
| "somewhere to keep a logo or a profile photo. Without it a record has no picture at all, because the URLs these sources hand back expire and are never stored as they are", | ||
| }, | ||
| { | ||
| ...fromEnv("AGENT_BRIDGE_SECRET"), | ||
| label: "Agent panel", | ||
| gives: | ||
| "a rep can open a contact, company or deal Agent tab and talk to you live, and the API can poke dispatch without waiting for the schedule", | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
|
|
@@ -137,3 +188,29 @@ export function markdownFor(all: readonly Capability[]): string { | |
|
|
||
| return lines.join("\n"); | ||
| } | ||
|
|
||
| export function enableChecklistMarkdown( | ||
| all: readonly Capability[] = capabilitiesFrom(null), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The no-argument checklist always shows the stored Context key as missing, so a configured install can be told it is not fully enabled. Requiring resolved capabilities here (or making the helper async and loading Prompt for AI agents |
||
| ): string { | ||
| const byId = new Map(all.map((capability) => [capability.id, capability])); | ||
| const lines = [ | ||
| "## Full agentic mode enable checklist", | ||
| "", | ||
| "Env vars only (names, never values). Same value for `AGENT_BRIDGE_SECRET` on the app and the agent.", | ||
| "", | ||
| ]; | ||
|
|
||
| for (const item of FULL_AGENTIC_CHECKLIST) { | ||
| if (item.kind === "env") { | ||
| const on = byId.get(item.id)?.enabled === true; | ||
| lines.push(`- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`); | ||
| } else { | ||
| const on = byId.get(item.id)?.enabled === true; | ||
| lines.push( | ||
| `- [${on ? "x" : " "}] Context key at \`${item.source}\` — ${item.label} (not an env var)`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return lines.join("\n"); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { db } from "@crm/db"; | ||
| import { | ||
| blankToNull, | ||
| clampDealScore, | ||
| DEAL_SCORE, | ||
| isValidDealScore, | ||
| } from "@crm/db/deal-score"; | ||
|
|
||
| export type WriteDealIntelligenceInput = { | ||
| dealId: string; | ||
| score: number; | ||
| summary: string; | ||
| forecastContext: string; | ||
| }; | ||
|
|
||
| export type WriteDealIntelligenceResult = | ||
| | { | ||
| written: true; | ||
| score: number; | ||
| scoredAt: string; | ||
| } | ||
| | { | ||
| written: false; | ||
| reason: string; | ||
| }; | ||
|
|
||
| export async function writeDealIntelligence( | ||
| input: WriteDealIntelligenceInput, | ||
| ): Promise<WriteDealIntelligenceResult> { | ||
| const score = clampDealScore(input.score); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Invalid scores are silently rewritten before validation, so a malformed score can become a persisted 0, 100, or rounded value and produce misleading deal intelligence. Validate Prompt for AI agents |
||
| if (!isValidDealScore(score)) { | ||
| return { | ||
| written: false, | ||
| reason: `Score must be an integer from ${DEAL_SCORE.min} to ${DEAL_SCORE.max}.`, | ||
| }; | ||
| } | ||
|
|
||
| const summary = blankToNull(input.summary); | ||
| if (!summary) { | ||
| return { | ||
| written: false, | ||
| reason: "Score summary is required.", | ||
| }; | ||
| } | ||
| if (summary.length > DEAL_SCORE.summaryMax) { | ||
| return { | ||
| written: false, | ||
| reason: `Score summary must be at most ${DEAL_SCORE.summaryMax} characters.`, | ||
| }; | ||
| } | ||
|
|
||
| const forecastContext = blankToNull(input.forecastContext); | ||
| if (!forecastContext) { | ||
| return { | ||
| written: false, | ||
| reason: "Forecast context is required.", | ||
| }; | ||
| } | ||
| if (forecastContext.length > DEAL_SCORE.forecastMax) { | ||
| return { | ||
| written: false, | ||
| reason: `Forecast context must be at most ${DEAL_SCORE.forecastMax} characters.`, | ||
| }; | ||
| } | ||
|
|
||
| const deal = await db.deal.findUnique({ | ||
| where: { id: input.dealId }, | ||
| select: { id: true }, | ||
| }); | ||
| if (!deal) { | ||
| return { written: false, reason: "No such deal." }; | ||
| } | ||
|
|
||
| const scoredAt = new Date(); | ||
| await db.deal.update({ | ||
| where: { id: input.dealId }, | ||
| data: { | ||
| dealScore: score, | ||
| dealScoreSummary: summary, | ||
| dealScoredAt: scoredAt, | ||
| forecastContext, | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| written: true, | ||
| score, | ||
| scoredAt: scoredAt.toISOString(), | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: A weak, merely applied name can bypass the brief's identity gate. The applied-name signal should carry a VERIFIED/identity-proof requirement (or its evidence band) instead of accepting every APPLIED name, otherwise briefs can be written for contacts whose identity was only supported by POSSIBLE evidence.
Prompt for AI agents