Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
workspacesAwaitingPhotos,
runCadences,
promoteAbWinners,
bumpQuietThreads,
runCrawlJob,
runDiscoveryJob,
runNichedbDiscoveryJob,
Expand Down Expand Up @@ -124,6 +125,7 @@ const RECEIVE_POLL_MS = Number(process.env.RECEIVE_POLL_MS ?? 300_000);
/** How often A/B tests are checked for a decision. */
const AB_SWEEP_MS = 3_600_000;
const lastAbSweepAt = new Map<string, number>();
const lastBumpSweepAt = new Map<string, number>();

/** Last successful poll per workspace, so the slower clock survives a tick. */
const lastPolledAt = new Map<string, number>();
Expand Down Expand Up @@ -1362,6 +1364,30 @@ async function tick(): Promise<void> {
}
}

// Gone quiet: one bump at 7 days, hourly, in the recipient's business
// hours. Sent unattended only where autonomous replies are allowed;
// otherwise it is a drafted card on the approval queue.
if (Date.now() - (lastBumpSweepAt.get(workspace.id) ?? 0) >= AB_SWEEP_MS) {
lastBumpSweepAt.set(workspace.id, Date.now());
try {
const bumps = await bumpQuietThreads(
{
db,
...(model ? { model } : {}),
...(mailer ? { mailer } : {}),
...(encryptionKey ? { encryptionKey } : {}),
...(appUrl ? { appUrl } : {}),
},
workspace.id,
);
if (bumps.sent > 0 || bumps.carded > 0) {
console.log(`bumps ${workspace.id}: ${bumps.sent} sent, ${bumps.carded} for approval`);
}
} catch (error) {
console.error(`quiet-thread bumps failed for ${workspace.id}`, error);
}
}

// Cadences run before autopilot, not after. A step that falls due this
// tick writes a recommendation, and running the sweep first means that
// card waits a whole cycle before anything looks at it — which for an
Expand Down Expand Up @@ -1414,6 +1440,8 @@ async function tick(): Promise<void> {
// Verify before sending: MX always, an SMTP RCPT probe while port 25
// answers. A blocked port degrades to MX-only, never to a hold.
verifier: smtpProber && !smtpProber.blocked() ? { smtp: smtpProber } : {},
// Cold mail lands in the recipient's working day, not at 3am.
businessHours: true,
},
workspace.id,
);
Expand Down
21 changes: 19 additions & 2 deletions packages/ai/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,18 @@ export interface ReplyComposeInput {
readonly minIdentityConfidence: number;
readonly priorDraftHashes?: readonly string[];
readonly maxAttempts?: number;
/**
* Set when this is the one bump to a thread that went quiet after we
* answered (Hunter's planner: "gone quiet: one bump at 7 days"). Our last
* message is part of the context, and the guidance changes from answering
* their message to nudging ours.
*/
readonly followUp?: { readonly ourLastMessage: string; readonly quietDays: number };
}

const FOLLOW_UP_GUIDANCE =
'They have not answered our last message. Write one short, friendly follow-up of two or three sentences that refers back to it, adds nothing new beyond the CONTEXT, and ends with a question that takes seconds to answer. No guilt, no "just checking in", no pressure.';

const REPLY_GUIDANCE: Partial<Record<ReplyLabel, string>> = {
interested:
'They want to continue. Answer what they asked for as far as the CONTEXT allows, and propose one concrete next step.',
Expand Down Expand Up @@ -407,6 +417,7 @@ export async function composeReply(
// Everything we already said is ours to repeat: it passed these gates
// on its way out.
...ours,
...(input.followUp ? [input.followUp.ourLastMessage] : []),
].filter(Boolean),
offering: [
input.offering.name,
Expand Down Expand Up @@ -508,7 +519,7 @@ function buildReplySystem(input: ReplyComposeInput): string {
`Channel: ${input.network}. This is a reply in a conversation they started answering.`,
limit ? `Hard limit: ${limit} characters.` : '',
words ? `Aim for at most ${words} words.` : 'Aim for at most 90 words.',
REPLY_GUIDANCE[input.label] ?? '',
input.followUp ? FOLLOW_UP_GUIDANCE : (REPLY_GUIDANCE[input.label] ?? ''),
]
.filter(Boolean)
.join('\n');
Expand All @@ -533,8 +544,14 @@ function buildReplyUser(input: ReplyComposeInput, fresh: string, failed?: CheckR
...(history.length > 0 ? ['Earlier in this conversation:', ...history] : []),
`Their latest message${input.inbound.subject ? ` (subject: ${input.inbound.subject})` : ''}:`,
`"""\n${fresh.slice(0, 4000)}\n"""`,
...(input.followUp
? [
`We answered ${input.followUp.quietDays} days ago and have heard nothing since:`,
`"""\n${input.followUp.ourLastMessage.slice(0, 1500)}\n"""`,
]
: []),
'',
`Write the reply to ${name}.`,
input.followUp ? `Write the one follow-up to ${name}.` : `Write the reply to ${name}.`,
];

if (failed) {
Expand Down
24 changes: 23 additions & 1 deletion packages/ai/src/draft-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
isReplyLabel,
newId,
QUIET_BUMP_GUIDANCE,
replySubject,
type Network,
type OutreachStyle,
Expand Down Expand Up @@ -58,9 +59,10 @@ export async function draftReplyForRecommendation(
person_id: string;
network: string;
reply_to_interaction_id: string | null;
guidance: string | null;
}>(
db,
`SELECT id, workspace_id, campaign_id, person_id, network, reply_to_interaction_id
`SELECT id, workspace_id, campaign_id, person_id, network, reply_to_interaction_id, guidance
FROM recommendations WHERE id = ?`,
[recommendationId],
);
Expand Down Expand Up @@ -95,6 +97,25 @@ export async function draftReplyForRecommendation(
body: row.body ?? '',
}));

// The one bump to a thread that went quiet after we answered: our latest
// message is what it follows up on.
let followUp: { ourLastMessage: string; quietDays: number } | undefined;
if (recommendation.guidance === QUIET_BUMP_GUIDANCE) {
const ours = await queryOne<{ body: string | null; occurred_at: string }>(
db,
`SELECT body, occurred_at FROM interactions
WHERE workspace_id = ? AND person_id = ? AND direction = 'outbound'
AND occurred_at > ? AND body IS NOT NULL AND trim(body) <> ''
ORDER BY occurred_at DESC LIMIT 1`,
[recommendation.workspace_id, recommendation.person_id, inbound.occurred_at],
);
if (!ours?.body) return { ok: false, reason: 'no_evidence' };
followUp = {
ourLastMessage: ours.body,
quietDays: Math.max(1, Math.round((Date.now() - Date.parse(ours.occurred_at)) / 86_400_000)),
};
}

const context = await draftingContext(
db,
recommendation.workspace_id,
Expand All @@ -120,6 +141,7 @@ export async function draftReplyForRecommendation(
label: isReplyLabel(inbound.reply_label) ? inbound.reply_label : 'question',
minIdentityConfidence: context.minIdentityConfidence,
priorDraftHashes: priorHashes.map((r) => r.similarity_hash),
...(followUp ? { followUp } : {}),
});

if (!result.ok) {
Expand Down
1 change: 1 addition & 0 deletions packages/domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ export * from './mailbox-health';
export * from './list-quality';
export * from './personas';
export * from './ab-testing';
export * from './timing';
export * from './warmup';
38 changes: 38 additions & 0 deletions packages/domain/src/timing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import { inBusinessHours, nextBusinessOpening, recipientTimezone } from './timing';

describe('recipientTimezone', () => {
test.each([
[{ personLocation: 'San Francisco Bay Area' }, 'America/Los_Angeles'],
[{ personLocation: 'Irvine, CA' }, 'America/Los_Angeles'],
[{ personLocation: 'Toronto, ON, CA' }, 'America/New_York'],
[{ personLocation: 'Austin, TX, United States' }, 'America/Chicago'],
[{ personLocation: 'Berlin, Germany' }, 'Europe/Berlin'],
[{ personLocation: 'Remote or onsite' }, 'America/New_York'],
[{ companyLocation: 'London' }, 'Europe/London'],
[{ address: 'jan@firma.de' }, 'Europe/Berlin'],
[{ address: 'jane@acme.com' }, 'America/New_York'],
] as const)('%o → %s', (input, zone) => {
expect(recipientTimezone(input).zone).toBe(zone);
});

test('the person beats the company, the company beats the domain', () => {
expect(
recipientTimezone({ personLocation: 'Tokyo', companyLocation: 'Berlin', address: 'a@b.fr' }),
).toEqual({ zone: 'Asia/Tokyo', source: 'person' });
});
});

describe('business hours', () => {
// Wednesday 2026-10-07.
test('Wednesday 10:00 in New York is open; 20:00 is not; Saturday is not', () => {
expect(inBusinessHours(new Date('2026-10-07T14:00:00Z'), 'America/New_York')).toBe(true);
expect(inBusinessHours(new Date('2026-10-08T00:00:00Z'), 'America/New_York')).toBe(false);
expect(inBusinessHours(new Date('2026-10-10T15:00:00Z'), 'America/New_York')).toBe(false);
});

test('Friday evening opens Monday 08:00 local', () => {
const opening = nextBusinessOpening(new Date('2026-10-09T23:30:00Z'), 'America/New_York');
expect(opening.toISOString()).toBe('2026-10-12T12:00:00.000Z');
});
});
Loading
Loading