brand-manager-worker 0.1.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.
@@ -0,0 +1,84 @@
1
+ // Preloads everything the drafting agent needs into one string, so the
2
+ // headless call is a single-shot generation instead of ~20 sequential file
3
+ // reads. This is the difference between a draft taking seconds vs minutes.
4
+ // Base files first, then personal (which wins on conflict).
5
+ //
6
+ // Port of brand-manager's src/core/context.ts over the worker's directory
7
+ // layout: base/ is synced from Goose Tools, everything else is the user's own.
8
+
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { BASE_DIR, DEALS_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
12
+
13
+ const readIf = (p) => {
14
+ try {
15
+ return readFileSync(p, "utf8").trim();
16
+ } catch {
17
+ return "";
18
+ }
19
+ };
20
+
21
+ let cached = null;
22
+
23
+ export function loadContext() {
24
+ // Cache for 60s so one burst of jobs doesn't re-read the whole layer.
25
+ if (cached && Date.now() - cached.at < 60_000) return cached.value;
26
+
27
+ const files = [
28
+ ["RULES: voice-lookup", join(BASE_DIR, "rules/voice-lookup.md")],
29
+ ["RULES: draft-safety", join(BASE_DIR, "rules/draft-safety.md")],
30
+ ["MODE: reply", join(BASE_DIR, "modes/reply.md")],
31
+ ["MODE: negotiate", join(BASE_DIR, "modes/negotiate.md")],
32
+ ["MODE: contract", join(BASE_DIR, "modes/contract.md")],
33
+ ["MODE: followup", join(BASE_DIR, "modes/followup.md")],
34
+ ["VOICE never-words (base)", join(BASE_DIR, "voice/never-words.md")],
35
+ ["VOICE never-words (personal)", join(VOICE_DIR, "never-words.md")],
36
+ ["VOICE non-negotiables (base)", join(BASE_DIR, "voice/non-negotiables.md")],
37
+ ["VOICE non-negotiables (personal)", join(VOICE_DIR, "non-negotiables.md")],
38
+ ["VOICE (base)", join(BASE_DIR, "voice/voice.md")],
39
+ ["VOICE (personal — WINS)", join(VOICE_DIR, "voice.md")],
40
+ ["PLAYBOOK glossary", join(BASE_DIR, "playbook/glossary.md")],
41
+ ["PLAYBOOK negotiation (base)", join(BASE_DIR, "playbook/negotiation.md")],
42
+ ["PLAYBOOK negotiation (personal)", join(PLAYBOOK_DIR, "negotiation.md")],
43
+ ["PLAYBOOK red-flags", join(BASE_DIR, "playbook/red-flags.md")],
44
+ ["RATE CARD (personal)", join(PLAYBOOK_DIR, "rate-card.md")],
45
+ ["STATS (current media kit)", join(STATS_DIR, "latest.md")],
46
+ ];
47
+
48
+ const parts = [];
49
+ for (const [label, p] of files) {
50
+ const c = readIf(p);
51
+ if (c) parts.push(`### ${label}\n${c}`);
52
+ }
53
+
54
+ // All deal ledgers (per-brand context + exclusivity-conflict checks). A
55
+ // mature ledger can run tens of thousands of chars; keep the head (brand,
56
+ // board block, terms) and the tail (newest updates), dropping the middle.
57
+ if (existsSync(DEALS_DIR)) {
58
+ for (const name of readdirSync(DEALS_DIR).filter((n) => n.endsWith(".md")).sort()) {
59
+ const c = readIf(join(DEALS_DIR, name));
60
+ if (!c) continue;
61
+ const body =
62
+ c.length <= LEDGER_CAP
63
+ ? c
64
+ : `${c.slice(0, 2_000)}\n\n[... ledger middle trimmed ...]\n\n${c.slice(-(LEDGER_CAP - 2_000))}`;
65
+ parts.push(`### DEAL LEDGER: ${name}\n${body}`);
66
+ }
67
+ }
68
+
69
+ // Backstop only. The old 80k cap silently truncated the rate card, stats
70
+ // and every ledger once voice.md grew past ~70k — the agent then drafted
71
+ // without rates and exclusivity checks were no-ops. Found by the parity
72
+ // harness (2026-08-07); the original repo had the same bug.
73
+ const value = parts.join("\n\n").slice(0, TOTAL_CAP);
74
+ cached = { at: Date.now(), value };
75
+ return value;
76
+ }
77
+
78
+ const LEDGER_CAP = 8_000;
79
+ const TOTAL_CAP = 350_000;
80
+
81
+ /** Drop the cache — call after anything edits the personal layer. */
82
+ export function invalidateContext() {
83
+ cached = null;
84
+ }
@@ -0,0 +1,38 @@
1
+ // Append-only log of every drafted body, and the lookup that powers
2
+ // learn-from-sent-edits. Port of brand-manager's src/actions/draft-log.ts —
3
+ // same block format, so an imported drafts.md keeps working.
4
+
5
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
6
+ import { DRAFTS_LOG } from "./paths.js";
7
+
8
+ export function appendDraftLog({ account, subject, threadId, draftId, reason, summary, body }) {
9
+ const block = [
10
+ `## ${new Date().toISOString()} — [${account}] ${subject}`,
11
+ `- thread: ${threadId} · draft: ${draftId} · reason: ${reason}`,
12
+ `- summary: ${summary}`,
13
+ "",
14
+ "```text",
15
+ body,
16
+ "```",
17
+ "",
18
+ "---",
19
+ "",
20
+ ].join("\n");
21
+ appendFileSync(DRAFTS_LOG, block);
22
+ }
23
+
24
+ /**
25
+ * The most recent drafted body for a thread — the "before" side of the
26
+ * learn-from-edit diff. Last match wins (a thread can be drafted repeatedly).
27
+ */
28
+ export function lastDraftFor(threadId) {
29
+ if (!existsSync(DRAFTS_LOG)) return null;
30
+ const blocks = readFileSync(DRAFTS_LOG, "utf8").split("\n## ");
31
+ let found = null;
32
+ for (const block of blocks) {
33
+ if (!block.includes(`thread: ${threadId} `)) continue;
34
+ const m = block.match(/```text\n([\s\S]*?)\n```/);
35
+ if (m) found = m[1];
36
+ }
37
+ return found;
38
+ }
@@ -0,0 +1,211 @@
1
+ // Creating Gmail DRAFTS — the only Gmail write in this package.
2
+ //
3
+ // Two entry points:
4
+ // createGmailDraft() — new thread (outreach pitches).
5
+ // createReplyDraft() — reply inside an existing thread, with the
6
+ // In-Reply-To/References headers and optional file
7
+ // attachments, ported from brand-manager's
8
+ // src/actions/draft.ts.
9
+ //
10
+ // Both build text+HTML multipart/alternative so Gmail flows paragraphs
11
+ // instead of hard-wrapping long plain-text lines.
12
+ //
13
+ // This file calls drafts.create and NOTHING else. There is no send path in
14
+ // this package — that guarantee lives here in code, not in a prompt.
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { basename, extname } from "node:path";
18
+ import { getMessageIdHeader } from "./gmail.js";
19
+
20
+ const GMAIL_DRAFTS = "https://gmail.googleapis.com/gmail/v1/users/me/drafts";
21
+
22
+ /** Email bodies must use CRLF line endings (RFC 5322). */
23
+ const toCRLF = (s) => s.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n");
24
+
25
+ const escapeHtml = (s) =>
26
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27
+
28
+ /**
29
+ * Plain text → simple clean HTML: blank lines separate paragraphs, "- " lines
30
+ * become bullets, "1. " lines become a numbered list, in-block breaks -> <br>.
31
+ */
32
+ function bodyToHtml(text) {
33
+ const lines = toCRLF(text).split("\r\n");
34
+ const out = [];
35
+ let para = [];
36
+ let list = [];
37
+ let listType = null;
38
+
39
+ const flushPara = () => {
40
+ if (para.length) out.push(`<p>${para.join("<br>")}</p>`);
41
+ para = [];
42
+ };
43
+ const flushList = () => {
44
+ if (list.length && listType)
45
+ out.push(`<${listType}>${list.map((li) => `<li>${li}</li>`).join("")}</${listType}>`);
46
+ list = [];
47
+ listType = null;
48
+ };
49
+
50
+ for (const raw of lines) {
51
+ const line = raw.trimEnd();
52
+ if (line === "") {
53
+ flushPara();
54
+ flushList();
55
+ continue;
56
+ }
57
+ const bullet = /^-\s+(.*)$/.exec(line);
58
+ const numbered = /^\d+\.\s+(.*)$/.exec(line);
59
+ if (bullet) {
60
+ flushPara();
61
+ if (listType && listType !== "ul") flushList();
62
+ listType = "ul";
63
+ list.push(escapeHtml(bullet[1]));
64
+ } else if (numbered) {
65
+ flushPara();
66
+ if (listType && listType !== "ol") flushList();
67
+ listType = "ol";
68
+ list.push(escapeHtml(numbered[1]));
69
+ } else {
70
+ flushList();
71
+ para.push(escapeHtml(line));
72
+ }
73
+ }
74
+ flushPara();
75
+ flushList();
76
+ return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;">${out.join("")}</div>`;
77
+ }
78
+
79
+ function buildMime({ to, subject, bodyText }) {
80
+ const alt = `alt_${Math.random().toString(36).slice(2)}`;
81
+ return [
82
+ `To: ${to}`,
83
+ `Subject: ${subject}`,
84
+ "MIME-Version: 1.0",
85
+ `Content-Type: multipart/alternative; boundary="${alt}"`,
86
+ "",
87
+ `--${alt}`,
88
+ 'Content-Type: text/plain; charset="UTF-8"',
89
+ "",
90
+ toCRLF(bodyText),
91
+ `--${alt}`,
92
+ 'Content-Type: text/html; charset="UTF-8"',
93
+ "",
94
+ toCRLF(bodyToHtml(bodyText)),
95
+ `--${alt}--`,
96
+ ].join("\r\n");
97
+ }
98
+
99
+ async function postDraft(accessToken, message) {
100
+ const res = await fetch(GMAIL_DRAFTS, {
101
+ method: "POST",
102
+ headers: {
103
+ Authorization: `Bearer ${accessToken}`,
104
+ "Content-Type": "application/json",
105
+ },
106
+ body: JSON.stringify({ message }),
107
+ });
108
+ if (!res.ok) {
109
+ const body = await res.text();
110
+ if (res.status === 401)
111
+ throw new Error("Gmail access expired — reconnect Gmail on goosetools.com");
112
+ throw new Error(`Gmail draft failed (${res.status}): ${body.slice(0, 300)}`);
113
+ }
114
+ const json = await res.json();
115
+ return json.id ?? null;
116
+ }
117
+
118
+ /**
119
+ * Create a draft on a NEW thread. Returns the Gmail draft id. Throws with a
120
+ * readable message on failure (expired grant → the site shows "reconnect").
121
+ */
122
+ export async function createGmailDraft(accessToken, { to, subject, bodyText }) {
123
+ const raw = Buffer.from(buildMime({ to, subject, bodyText })).toString("base64url");
124
+ return postDraft(accessToken, { raw });
125
+ }
126
+
127
+ const ATTACH_MIME = {
128
+ ".png": "image/png",
129
+ ".jpg": "image/jpeg",
130
+ ".jpeg": "image/jpeg",
131
+ ".pdf": "application/pdf",
132
+ ".gif": "image/gif",
133
+ };
134
+ const mimeType = (file) => ATTACH_MIME[extname(file).toLowerCase()] ?? "application/octet-stream";
135
+
136
+ /**
137
+ * Full reply MIME: threading headers + text/HTML alternative, wrapped in
138
+ * multipart/mixed when there are file attachments. Exported for tests.
139
+ */
140
+ export function buildReplyMime({ to, subject, bodyText, messageIdHeader = "", attachments = [] }) {
141
+ const headers = [
142
+ `To: ${to}`,
143
+ `Subject: ${subject}`,
144
+ messageIdHeader ? `In-Reply-To: ${messageIdHeader}` : "",
145
+ messageIdHeader ? `References: ${messageIdHeader}` : "",
146
+ "MIME-Version: 1.0",
147
+ ].filter(Boolean);
148
+
149
+ const alt = `alt_${Math.random().toString(36).slice(2)}`;
150
+ const alternative = [
151
+ `--${alt}`,
152
+ 'Content-Type: text/plain; charset="UTF-8"',
153
+ "",
154
+ toCRLF(bodyText),
155
+ `--${alt}`,
156
+ 'Content-Type: text/html; charset="UTF-8"',
157
+ "",
158
+ toCRLF(bodyToHtml(bodyText)),
159
+ `--${alt}--`,
160
+ ];
161
+
162
+ if (attachments.length === 0) {
163
+ return [
164
+ ...headers,
165
+ `Content-Type: multipart/alternative; boundary="${alt}"`,
166
+ "",
167
+ ...alternative,
168
+ ].join("\r\n");
169
+ }
170
+
171
+ const mixed = `mixed_${Math.random().toString(36).slice(2)}`;
172
+ const parts = [
173
+ ...headers,
174
+ `Content-Type: multipart/mixed; boundary="${mixed}"`,
175
+ "",
176
+ `--${mixed}`,
177
+ `Content-Type: multipart/alternative; boundary="${alt}"`,
178
+ "",
179
+ ...alternative,
180
+ ];
181
+ for (const file of attachments) {
182
+ parts.push(
183
+ `--${mixed}`,
184
+ `Content-Type: ${mimeType(file)}; name="${basename(file)}"`,
185
+ "Content-Transfer-Encoding: base64",
186
+ `Content-Disposition: attachment; filename="${basename(file)}"`,
187
+ "",
188
+ readFileSync(file).toString("base64"),
189
+ );
190
+ }
191
+ parts.push(`--${mixed}--`);
192
+ return parts.join("\r\n");
193
+ }
194
+
195
+ /**
196
+ * Create a REPLY draft inside an existing thread. Fetches the Message-ID of
197
+ * the message being replied to so Gmail threads it correctly (best-effort —
198
+ * a missing header degrades threading, never blocks the draft).
199
+ */
200
+ export async function createReplyDraft(
201
+ accessToken,
202
+ { threadId, inReplyToMessageId, to, subject, bodyText, attachments = [] },
203
+ ) {
204
+ const messageIdHeader = inReplyToMessageId
205
+ ? await getMessageIdHeader(accessToken, inReplyToMessageId)
206
+ : "";
207
+ const raw = Buffer.from(
208
+ buildReplyMime({ to, subject, bodyText, messageIdHeader, attachments }),
209
+ ).toString("base64url");
210
+ return postDraft(accessToken, threadId ? { threadId, raw } : { raw });
211
+ }
@@ -0,0 +1,187 @@
1
+ // Reading Gmail with the short-lived access token handed down in each claim.
2
+ //
3
+ // Ported from brand-manager's src/sources/gmail.ts, swapped from googleapis to
4
+ // plain REST (matches gmail-draft.js — fewer deps, same API). Everything here
5
+ // is read-only: threads.list, threads.get, messages.get, attachments.get.
6
+ // Draft creation lives in gmail-draft.js; there is no send path in this
7
+ // package.
8
+
9
+ const API = "https://gmail.googleapis.com/gmail/v1/users/me";
10
+
11
+ const LOOKBACK = "newer_than:14d";
12
+
13
+ /** Keywords that mark a thread as brand/collab outreach (for a personal inbox). */
14
+ const BRAND_TERMS = [
15
+ "collab", "collaboration", "partnership", "sponsor", "sponsorship", "brand",
16
+ '"paid partnership"', "whitelisting", '"rate card"', "UGC", "gifting", "deliverables",
17
+ ];
18
+
19
+ /** Gmail search query. Brand inbox = broad; personal inbox = brand-terms only. */
20
+ export function queryFor(brandOnly) {
21
+ // in:spam is opt-in for Gmail search — without it, misfiled brand outreach is
22
+ // invisible and never gets a draft (real misses found 2026-07-30).
23
+ if (brandOnly)
24
+ return `${LOOKBACK} (${BRAND_TERMS.join(" OR ")}) (in:anywhere OR in:spam) -in:chats -in:trash`;
25
+ return `${LOOKBACK} (in:inbox OR category:promotions OR in:spam) -in:chats`;
26
+ }
27
+
28
+ async function gmailGet(accessToken, path, params = {}) {
29
+ const url = new URL(`${API}/${path}`);
30
+ for (const [k, v] of Object.entries(params)) {
31
+ if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, item);
32
+ else if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
33
+ }
34
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
35
+ if (res.status === 401)
36
+ throw new Error("Gmail access expired — reconnect Gmail on goosetools.com");
37
+ if (!res.ok)
38
+ throw new Error(`Gmail ${path} failed (${res.status}): ${(await res.text()).slice(0, 300)}`);
39
+ return res.json();
40
+ }
41
+
42
+ const header = (headers, name) =>
43
+ headers?.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? "";
44
+
45
+ function collectAttachments(part, out) {
46
+ if (!part) return;
47
+ if (part.body?.attachmentId && part.filename) {
48
+ out.push({
49
+ id: part.body.attachmentId,
50
+ filename: part.filename,
51
+ mimeType: part.mimeType ?? "application/octet-stream",
52
+ });
53
+ }
54
+ for (const child of part.parts ?? []) collectAttachments(child, out);
55
+ }
56
+
57
+ /** Full plain-text body with quoted reply-history stripped. Exported for tests. */
58
+ export function plainBody(payload) {
59
+ const parts = [];
60
+ const walk = (p) => {
61
+ if (!p) return;
62
+ if (p.mimeType === "text/plain" && p.body?.data)
63
+ parts.push(Buffer.from(p.body.data, "base64url").toString("utf8"));
64
+ for (const c of p.parts ?? []) walk(c);
65
+ };
66
+ walk(payload);
67
+ let text = parts.join("\n");
68
+ // Cut quoted history (replies/forwards) so only this message's own content remains.
69
+ text = text.split(/\n>+ ?/)[0];
70
+ text = text.split(/\nOn .{0,80}wrote:/)[0];
71
+ text = text.split(/\n-{3,} ?Original Message|\n-{3,} ?Forwarded message/)[0];
72
+ return text.trim();
73
+ }
74
+
75
+ const cleanSnippet = (s) =>
76
+ (s ?? "").replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/&quot;/g, '"');
77
+
78
+ function toMessage(raw) {
79
+ const headers = raw.payload?.headers;
80
+ const attachments = [];
81
+ collectAttachments(raw.payload, attachments);
82
+ const labelIds = raw.labelIds ?? [];
83
+ const toRaw = header(headers, "To");
84
+ return {
85
+ id: raw.id,
86
+ from: header(headers, "From"),
87
+ to: toRaw ? toRaw.split(",").map((s) => s.trim()) : [],
88
+ subject: header(headers, "Subject"),
89
+ date: raw.internalDate
90
+ ? new Date(Number(raw.internalDate)).toISOString()
91
+ : new Date().toISOString(),
92
+ snippet: cleanSnippet(raw.snippet),
93
+ body: plainBody(raw.payload),
94
+ fromMe: labelIds.includes("SENT"),
95
+ attachments,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * One thread, mapped. Drafts in the thread are our own unsent replies — they
101
+ * must NOT count as the latest message (otherwise the loop tries to "reply"
102
+ * to its own draft). Returns null for a drafts-only thread.
103
+ */
104
+ export async function getThread(accessToken, accountEmail, threadId) {
105
+ const data = await gmailGet(accessToken, `threads/${threadId}`, { format: "full" });
106
+ const all = data.messages ?? [];
107
+ const hasUnsentDraft = all.some((m) => (m.labelIds ?? []).includes("DRAFT"));
108
+ const messages = all
109
+ .filter((m) => !(m.labelIds ?? []).includes("DRAFT"))
110
+ .map(toMessage)
111
+ .sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
112
+ if (messages.length === 0) return null;
113
+ return {
114
+ id: threadId,
115
+ account: accountEmail,
116
+ subject: messages[0]?.subject ?? "",
117
+ messages,
118
+ last: messages[messages.length - 1],
119
+ hasUnsentDraft,
120
+ };
121
+ }
122
+
123
+ /** Candidate brand threads (mapped, newest message last per thread). */
124
+ export async function fetchThreads(accessToken, accountEmail, { brandOnly = false } = {}) {
125
+ const data = await gmailGet(accessToken, "threads", {
126
+ q: queryFor(brandOnly),
127
+ maxResults: 40,
128
+ });
129
+ const threads = [];
130
+ for (const t of data.threads ?? []) {
131
+ if (!t.id) continue;
132
+ const thread = await getThread(accessToken, accountEmail, t.id);
133
+ if (thread) threads.push(thread);
134
+ }
135
+ return threads;
136
+ }
137
+
138
+ /**
139
+ * Raw Gmail search, query passed through as written (no lookback, no brand
140
+ * filter). Metadata only — this is the conversational "look anything up" path.
141
+ */
142
+ export async function searchThreads(accessToken, accountEmail, query, { limit = 15 } = {}) {
143
+ const data = await gmailGet(accessToken, "threads", { q: query, maxResults: limit });
144
+ const hits = [];
145
+ for (const t of data.threads ?? []) {
146
+ if (!t.id) continue;
147
+ const full = await gmailGet(accessToken, `threads/${t.id}`, {
148
+ format: "metadata",
149
+ metadataHeaders: ["Subject", "From", "Date"],
150
+ });
151
+ const msgs = (full.messages ?? []).filter((m) => !(m.labelIds ?? []).includes("DRAFT"));
152
+ const last = msgs[msgs.length - 1];
153
+ if (!last) continue;
154
+ hits.push({
155
+ account: accountEmail,
156
+ id: t.id,
157
+ subject: header(msgs[0]?.payload?.headers, "Subject"),
158
+ from: header(last.payload?.headers, "From"),
159
+ date: last.internalDate ? new Date(Number(last.internalDate)).toISOString() : "",
160
+ snippet: cleanSnippet(last.snippet),
161
+ messageCount: msgs.length,
162
+ });
163
+ }
164
+ return hits.sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
165
+ }
166
+
167
+ /** Raw bytes of one attachment. */
168
+ export async function getAttachment(accessToken, messageId, attachmentId) {
169
+ const data = await gmailGet(
170
+ accessToken,
171
+ `messages/${messageId}/attachments/${attachmentId}`,
172
+ );
173
+ return Buffer.from(data.data ?? "", "base64url");
174
+ }
175
+
176
+ /** RFC822 Message-ID header of a message (for reply threading). Best-effort. */
177
+ export async function getMessageIdHeader(accessToken, messageId) {
178
+ try {
179
+ const data = await gmailGet(accessToken, `messages/${messageId}`, {
180
+ format: "metadata",
181
+ metadataHeaders: ["Message-ID"],
182
+ });
183
+ return header(data.payload?.headers, "Message-ID");
184
+ } catch {
185
+ return "";
186
+ }
187
+ }