brand-manager-worker 0.2.0 → 0.3.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "brand-manager-worker",
3
- "version": "0.2.0",
4
- "description": "The Goose Tools brand-deal worker \u2014 your computer reads your brand email and drafts replies in your voice for goosetools.com, using your own Claude account. Drafts only; it never sends.",
3
+ "version": "0.3.2",
4
+ "description": "The Goose Tools brand-deal worker — your computer reads your brand email and drafts replies in your voice for goosetools.com, using your own Claude account. Drafts only; it never sends.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ernkerr/brand-manager-worker.git"
package/worker/compact.js CHANGED
@@ -37,6 +37,48 @@ export const COMPACTABLE = [
37
37
 
38
38
  const ARCHIVE_DIR = join(BRAND_DIR, "archive");
39
39
 
40
+ /**
41
+ * A free filename. The stamp used to be the date alone, so a second
42
+ * compaction on the same day overwrote the first archive — replacing the true
43
+ * original with an already-compacted copy and quietly destroying the only
44
+ * record of what was dropped.
45
+ */
46
+ function archivePathFor(target) {
47
+ const stamp = new Date().toISOString().slice(0, 10);
48
+ let candidate = join(ARCHIVE_DIR, `${target}-${stamp}.md`);
49
+ for (let n = 2; existsSync(candidate); n += 1) {
50
+ candidate = join(ARCHIVE_DIR, `${target}-${stamp}-${n}.md`);
51
+ }
52
+ return candidate;
53
+ }
54
+
55
+ // Compaction can land a file just above the threshold — negotiation.md came
56
+ // out at 48KB against a 40KB line. Without a memory of that, every scan
57
+ // forever would re-compact it: an agent call each time, for a file that is
58
+ // already as small as it gets. So a file is left alone after a compaction
59
+ // until it has actually grown again.
60
+ const STATE_PATH = () => join(ARCHIVE_DIR, ".compact-state.json");
61
+ const REGROWTH = 1.15;
62
+
63
+ function readState() {
64
+ try {
65
+ return JSON.parse(readFileSync(STATE_PATH(), "utf8"));
66
+ } catch {
67
+ return {};
68
+ }
69
+ }
70
+
71
+ function noteCompaction(target, size) {
72
+ try {
73
+ const state = readState();
74
+ state[target] = { size, at: new Date().toISOString() };
75
+ mkdirSync(ARCHIVE_DIR, { recursive: true });
76
+ writeFileSync(STATE_PATH(), JSON.stringify(state, null, 2));
77
+ } catch {
78
+ // A missing note only costs one redundant compaction; never fail over it.
79
+ }
80
+ }
81
+
40
82
  export function sizeOf(path) {
41
83
  try {
42
84
  return statSync(path).size;
@@ -46,7 +88,14 @@ export function sizeOf(path) {
46
88
  }
47
89
 
48
90
  export function oversized() {
49
- return COMPACTABLE.filter(({ path }) => sizeOf(path) > COMPACT_THRESHOLD);
91
+ const state = readState();
92
+ return COMPACTABLE.filter(({ target, path }) => {
93
+ const size = sizeOf(path);
94
+ if (size <= COMPACT_THRESHOLD) return false;
95
+ const last = state[target];
96
+ // Already squeezed and hasn't meaningfully regrown — nothing left to win.
97
+ return !last?.size || size > last.size * REGROWTH;
98
+ });
50
99
  }
51
100
 
52
101
  const prompt = (label, body) =>
@@ -100,33 +149,36 @@ export async function compactFile({ target, path }) {
100
149
  );
101
150
  const after = typeof out?.markdown === "string" ? out.markdown.trim() : "";
102
151
 
152
+ // A transient failure (bad output, timeout) must stay retryable — the file
153
+ // is over budget and we want another go. Only the deterministic refusals
154
+ // below get remembered, because re-asking would refuse identically.
103
155
  if (!after) return { target, ok: false, error: "no parseable rewrite" };
104
156
 
105
157
  // Guards. An agent asked to shrink a file can decide to summarize it, and a
106
158
  // 3KB precis of 66KB of learned voice is a catastrophic, silent loss — the
107
159
  // archive would be the only copy and nobody would notice for weeks.
108
160
  if (after.length < before.length * 0.25) {
109
- return {
161
+ return refuse(
110
162
  target,
111
- ok: false,
112
- error: `refused: ${before.length} -> ${after.length} bytes looks like a summary, not a rewrite`,
113
- };
163
+ before.length,
164
+ `${before.length} -> ${after.length} bytes looks like a summary, not a rewrite`,
165
+ );
114
166
  }
115
167
  if (after.length >= before.length) {
116
- return { target, ok: false, error: "refused: no smaller than the original" };
168
+ return refuse(target, before.length, "no smaller than the original");
117
169
  }
118
170
  // The headings are the file's skeleton; losing most of them means it was
119
171
  // restructured rather than compacted.
120
172
  const headingsBefore = (before.match(/^##\s/gm) ?? []).length;
121
173
  const headingsAfter = (after.match(/^##\s/gm) ?? []).length;
122
174
  if (headingsBefore >= 4 && headingsAfter < 2) {
123
- return { target, ok: false, error: "refused: lost the section structure" };
175
+ return refuse(target, before.length, "lost the section structure");
124
176
  }
125
177
 
126
178
  mkdirSync(ARCHIVE_DIR, { recursive: true });
127
- const stamp = new Date().toISOString().slice(0, 10);
128
- writeFileSync(join(ARCHIVE_DIR, `${target}-${stamp}.md`), before);
179
+ writeFileSync(archivePathFor(target), before);
129
180
  writeFileSync(path, `${after}\n`);
181
+ noteCompaction(target, after.length);
130
182
 
131
183
  return {
132
184
  target,
@@ -139,6 +191,16 @@ export async function compactFile({ target, path }) {
139
191
  };
140
192
  }
141
193
 
194
+ /**
195
+ * A refusal the agent would repeat verbatim if asked again. Remember the size
196
+ * so the file isn't re-attempted every scan — it becomes eligible again only
197
+ * once it has genuinely regrown.
198
+ */
199
+ function refuse(target, size, why) {
200
+ noteCompaction(target, size);
201
+ return { target, ok: false, error: `refused: ${why}` };
202
+ }
203
+
142
204
  /**
143
205
  * Compact anything over the threshold. Called at the END of a scan, once —
144
206
  * not per thread, so a scan pays for at most one of these and only on the day
package/worker/context.js CHANGED
@@ -9,6 +9,7 @@
9
9
  import { existsSync, readFileSync, readdirSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { BASE_DIR, DEALS_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
12
+ import { currentScreenshots, screenshotsBlock } from "./screenshots.js";
12
13
 
13
14
  const readIf = (p) => {
14
15
  try {
@@ -51,6 +52,11 @@ export function loadContext() {
51
52
  if (c) parts.push(`### ${label}\n${c}`);
52
53
  }
53
54
 
55
+ // Which screenshots exist, worked out here: the drafting agent has no tools
56
+ // and can't look in the folder, so a file it isn't told about can't be
57
+ // attached, and a file it guesses at is silently dropped.
58
+ parts.push(screenshotsBlock(currentScreenshots()));
59
+
54
60
  // All deal ledgers (per-brand context + exclusivity-conflict checks).
55
61
  if (existsSync(DEALS_DIR)) {
56
62
  for (const name of readdirSync(DEALS_DIR).filter((n) => n.endsWith(".md")).sort()) {
package/worker/gmail.js CHANGED
@@ -16,15 +16,45 @@ const BRAND_TERMS = [
16
16
  '"paid partnership"', "whitelisting", '"rate card"', "UGC", "gifting", "deliverables",
17
17
  ];
18
18
 
19
+ /**
20
+ * Where "you got paid" actually shows up.
21
+ *
22
+ * A brand saying "already processed" is not money landing, and the difference
23
+ * between those two is the whole point of the payment confidence score. But
24
+ * the confirmation lands in a notification from the payment rail, which never
25
+ * matches BRAND_TERMS and so was never fetched — leaving the agent to score
26
+ * payments off the brand's own word, and rate every real payment as a guess.
27
+ */
28
+ const PAYMENT_SENDERS = [
29
+ "mercury.com", "stripe.com", "paypal.com", "wise.com", "payoneer.com",
30
+ "bill.com", "tipalti.com", "squareup.com", "intuit.com", "remitly.com",
31
+ ];
32
+
33
+ const PAYMENT_TERMS = [
34
+ '"payment received"', '"you\'ve been paid"', '"has been paid"', '"payment sent"',
35
+ '"deposit"', '"funds available"', '"invoice paid"', '"transfer complete"',
36
+ ];
37
+
19
38
  /** Gmail search query. Brand inbox = broad; personal inbox = brand-terms only. */
20
39
  export function queryFor(brandOnly) {
21
40
  // in:spam is opt-in for Gmail search — without it, misfiled brand outreach is
22
41
  // 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`;
42
+ const payments = `(${PAYMENT_SENDERS.map((d) => `from:${d}`).join(" OR ")} OR ${PAYMENT_TERMS.join(" OR ")})`;
43
+ if (brandOnly) {
44
+ return (
45
+ `${LOOKBACK} ((${BRAND_TERMS.join(" OR ")}) OR ${payments})` +
46
+ ` (in:anywhere OR in:spam) -in:chats -in:trash`
47
+ );
48
+ }
25
49
  return `${LOOKBACK} (in:inbox OR category:promotions OR in:spam) -in:chats`;
26
50
  }
27
51
 
52
+ /** Longer reach for a payment sweep — an invoice can sit well past 14 days. */
53
+ export function paymentQuery(days = 120) {
54
+ const senders = PAYMENT_SENDERS.map((d) => `from:${d}`).join(" OR ");
55
+ return `newer_than:${days}d (${senders} OR ${PAYMENT_TERMS.join(" OR ")}) -in:chats -in:trash`;
56
+ }
57
+
28
58
  async function gmailGet(accessToken, path, params = {}) {
29
59
  const url = new URL(`${API}/${path}`);
30
60
  for (const [k, v] of Object.entries(params)) {
@@ -20,6 +20,7 @@ import { createReplyDraft } from "./gmail-draft.js";
20
20
  import { fetchThreads, getThread } from "./gmail.js";
21
21
  import { buildMirror } from "./mirror.js";
22
22
  import { applyPaymentConfirmation, applyRecordUpdates, resolveSlug } from "./record-write.js";
23
+ import { rederiveAll } from "./rederive.js";
23
24
  import {
24
25
  BRAND_DIR,
25
26
  DEALS_DIR,
@@ -28,6 +29,7 @@ import {
28
29
  STATS_DIR,
29
30
  VOICE_DIR,
30
31
  } from "./paths.js";
32
+ import { archiveScreenshots, currentScreenshots, listScreenshots } from "./screenshots.js";
31
33
  import * as state from "./state.js";
32
34
 
33
35
  const FOLLOWUP_DAYS = 3;
@@ -150,6 +152,21 @@ export async function runScan(job) {
150
152
 
151
153
  const lines = [];
152
154
  const flags = [];
155
+
156
+ // Surfaced once per scan, not per draft: she cannot fix it from a draft, and
157
+ // it is the reason a brand that asked for proof got numbers alone.
158
+ try {
159
+ if (currentScreenshots().length === 0) {
160
+ const newest = listScreenshots()[0]?.date;
161
+ flags.push(
162
+ `Stat screenshots are ${newest ? `out of date (newest ${newest})` : "missing"}, so brands asking ` +
163
+ `for proof get numbers only. Upload fresh ones on the Brand Manager page.`,
164
+ );
165
+ }
166
+ } catch {
167
+ // Never let the screenshots folder stop a scan.
168
+ }
169
+
153
170
  for (const thread of threads) {
154
171
  // Learn from the creator's edits to a sent reply (independent of drafting).
155
172
  try {
@@ -219,6 +236,37 @@ export async function runScan(job) {
219
236
  };
220
237
  }
221
238
 
239
+ /**
240
+ * kind: rederive — rebuild the records from the email rather than from the
241
+ * journal they were first summarized out of. One-shot correction pass; see
242
+ * rederive.js for why it exists.
243
+ */
244
+ export async function runRederive(job) {
245
+ if (!job.gmailAccessToken) return noGmail();
246
+
247
+ const lines = [];
248
+ const results = await rederiveAll(job.gmailAccessToken, job.accountEmail, {
249
+ write: true,
250
+ onProgress: (r) => {
251
+ if (r.changed) lines.push(`${r.slug}: ${r.changed}`);
252
+ if (r.brandCorrection) lines.push(`${r.slug}: ${r.brandCorrection}`);
253
+ },
254
+ });
255
+ invalidateContext();
256
+
257
+ const corrected = results.filter((r) => r.changed || r.brandCorrection).length;
258
+ const failed = results.filter((r) => r.error);
259
+
260
+ return {
261
+ ok: true,
262
+ summary: corrected
263
+ ? `Corrected ${corrected} of ${results.length} records. ${lines.join(" · ")}`.slice(0, 1900)
264
+ : `Checked ${results.length} records against your email; nothing needed correcting.`,
265
+ flags: failed.map((r) => `${r.slug}: ${r.error}`),
266
+ mirror: buildMirror({ threads: [] }),
267
+ };
268
+ }
269
+
222
270
  /** kind: compact — squeeze the learned layer on demand. */
223
271
  export async function runCompact() {
224
272
  const results = await compactIfNeeded();
@@ -405,24 +453,32 @@ export async function runStats(job) {
405
453
  const urls = job.payload?.photoUrls ?? [];
406
454
  if (urls.length === 0) return { ok: false, error: "stats job has no photoUrls" };
407
455
 
408
- mkdirSync(SCREENSHOTS_DIR, { recursive: true });
456
+ // Download first, archive second: a failed download must not leave the
457
+ // folder empty when the old set was still usable.
409
458
  const stamp = new Date().toISOString().slice(0, 10);
410
- const saved = [];
459
+ const downloads = [];
411
460
  for (let i = 0; i < urls.length; i++) {
412
461
  const res = await fetch(urls[i]);
413
462
  if (!res.ok) continue;
414
463
  const ext = (new URL(urls[i]).pathname.match(/\.(png|jpe?g|webp)$/i)?.[0] ?? ".png").toLowerCase();
415
- const dest = join(SCREENSHOTS_DIR, `${stamp}-${i}${ext}`);
416
- writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
417
- saved.push(dest);
464
+ downloads.push({ ext, bytes: Buffer.from(await res.arrayBuffer()) });
418
465
  }
419
- if (saved.length === 0) return { ok: false, error: "could not download any screenshots" };
466
+ if (downloads.length === 0) return { ok: false, error: "could not download any screenshots" };
467
+
468
+ // A new upload replaces the set a brand gets; the old one goes to archive/.
469
+ mkdirSync(SCREENSHOTS_DIR, { recursive: true });
470
+ archiveScreenshots();
471
+ const saved = downloads.map((d, i) => {
472
+ const dest = join(SCREENSHOTS_DIR, `${stamp}-${i}${d.ext}`);
473
+ writeFileSync(dest, d.bytes);
474
+ return dest;
475
+ });
420
476
 
421
477
  const prompt = [
422
478
  `You are brand-manager. Follow base/modes/stats-intake.md.`,
423
479
  `Read these stat screenshots and update stats/latest.md with the current numbers (followers, reel`,
424
- `views, reach, engagement, audience demographics). Keep the screenshots referenced for attaching`,
425
- `to brand replies. Screenshots:\n${saved.join("\n")}`,
480
+ `views, reach, engagement, audience demographics). Which screenshots a brand gets is worked out`,
481
+ `from the folder automatically; you don't need to list them. Screenshots:\n${saved.join("\n")}`,
426
482
  `Report one line summarizing what changed.`,
427
483
  ].join("\n");
428
484
 
@@ -15,6 +15,7 @@
15
15
  import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { GOOSE_DIR, STATS_DIR } from "./paths.js";
18
+ import { currentScreenshots, listScreenshots } from "./screenshots.js";
18
19
 
19
20
  export const IG_TOKEN_FILE = join(GOOSE_DIR, "ig-token");
20
21
  const LATEST_MD = join(STATS_DIR, "latest.md");
@@ -142,7 +143,7 @@ const AGE_ORDER = ["13-17", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
142
143
 
143
144
  /** The media kit the agent reads. Keeps the same headings the screenshot
144
145
  * reader wrote, so anything that learned the old layout still works. */
145
- export function renderLatestMd(s, { screenshotsSection = "" } = {}) {
146
+ export function renderLatestMd(s) {
146
147
  const captured = s.capturedAt.slice(0, 10);
147
148
  const staleOn = new Date(Date.parse(s.capturedAt) + 30 * DAY).toISOString().slice(0, 10);
148
149
  const g = s.demographics.gender ?? [];
@@ -196,9 +197,9 @@ engagement by reach. Audience is mostly developers, engineers, and tech workers.
196
197
  Use these exact figures, never ones from older drafts or the voice file. Never use this framing
197
198
  unprompted, to justify a rate, or to correct a brand's benchmark.
198
199
 
199
- ${screenshotsSection.trim() || `## Screenshots to attach on request
200
- Screenshots live in \`screenshots/\`; newest by date prefix is current. If none are newer than this
201
- snapshot's window, say the numbers come from Instagram's own insights and offer a screenshot on request.`}
200
+ ## Screenshots
201
+ Which screenshot files you may attach is listed in the SCREENSHOTS YOU CAN ATTACH block. Attach them
202
+ only when a brand asks.
202
203
  `;
203
204
  }
204
205
 
@@ -223,14 +224,15 @@ export async function refreshStats() {
223
224
  token = await maybeRefreshToken(token);
224
225
  const stats = await fetchIgStats(token);
225
226
 
226
- // Keep the screenshot list only if it has a capture inside this window;
227
- // June screenshots next to September numbers would contradict the kit.
228
- const prior = existsSync(LATEST_MD) ? readFileSync(LATEST_MD, "utf8") : "";
229
- const shotsSection = prior.match(/## Screenshots to attach on request[\s\S]*$/)?.[0] ?? "";
230
- const shotDates = [...shotsSection.matchAll(/screenshots\/(\d{4}-\d{2}-\d{2})/g)].map((m) => m[1]);
231
- const shots = shotDates.some((d) => d >= stats.windowStart) ? shotsSection : "";
232
- writeFileSync(LATEST_MD, renderLatestMd(stats, { screenshotsSection: shots }));
227
+ // Which screenshots may go to a brand is worked out from the files
228
+ // themselves (screenshots.js) and handed to the drafting agent directly, so
229
+ // latest.md no longer carries a list that a refresh could wipe.
230
+ writeFileSync(LATEST_MD, renderLatestMd(stats));
233
231
  writeFileSync(LATEST_JSON, JSON.stringify(stats, null, 2));
232
+
233
+ const current = currentScreenshots();
234
+ stats.screenshotsStale = current.length === 0 && listScreenshots().length > 0;
235
+ stats.newestScreenshot = listScreenshots()[0]?.date ?? null;
234
236
  return stats;
235
237
  }
236
238
 
package/worker/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  runChat,
19
19
  runDraft,
20
20
  runCompact,
21
+ runRederive,
21
22
  runEdit,
22
23
  runLearn,
23
24
  runScan,
@@ -78,6 +79,8 @@ async function handle(job) {
78
79
  return runEdit(job);
79
80
  case "compact":
80
81
  return runCompact(job);
82
+ case "rederive":
83
+ return runRederive(job);
81
84
  // Brand Outreach (its own tool on the site; same worker, same token).
82
85
  case "outreach-chat":
83
86
  return runOutreachChat(job);
@@ -0,0 +1,272 @@
1
+ // Rebuilding a record from the email, instead of from a story about the email.
2
+ //
3
+ // The records were first derived by reading each ledger — a chronological
4
+ // journal the agent had written over months. That journal never contained the
5
+ // payment-rail mail (nothing fetched it until now), and it inherited whatever
6
+ // the ledger had got wrong. The result looked authoritative and was not:
7
+ // finished deals showing as in-flight, a $14,000 payment scored 40% confident,
8
+ // a campaign filed under the wrong brand name.
9
+ //
10
+ // This goes back to the source. For each brand: its current record, its real
11
+ // threads, and any payment mail that plausibly settles its invoices. The agent
12
+ // corrects only what the email actually evidences, and says what changed.
13
+ //
14
+ // It is deliberately narrow. It does not touch voice, decisions, or anything
15
+ // the creator confirmed by hand — only the facts that email can settle.
16
+
17
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { oneShot, parseJson } from "./claude.js";
20
+ import { fetchThreads, paymentQuery, searchThreads } from "./gmail.js";
21
+ import { BRAND_DIR, DEALS_DIR } from "./paths.js";
22
+ import { mergeRecord, parseRecord, sanitizeInvoice, serializeRecord } from "./record.js";
23
+
24
+ /** Threads per brand handed to the agent. Newest first; older ones add noise. */
25
+ const MAX_THREADS = 6;
26
+ const MAX_BODY = 2_500;
27
+
28
+ const renderThread = (t) =>
29
+ [
30
+ `--- ${t.subject} (${t.last?.date ?? "?"}) ---`,
31
+ ...(t.messages ?? []).slice(-3).map((m) => {
32
+ const who = m.fromMe ? "HER" : m.from;
33
+ return `[${who}] ${String(m.body ?? "").slice(0, MAX_BODY)}`;
34
+ }),
35
+ ].join("\n");
36
+
37
+ const prompt = ({ record, threads, payments }) =>
38
+ [
39
+ `You are correcting one brand record against the creator's actual email.`,
40
+ `The record was built by summarizing an old journal, so it may be wrong about`,
41
+ `what stage the deal is at, whether it was paid, or even which brand it is.`,
42
+ ``,
43
+ `Correct ONLY what the email below evidences. If the email is silent on a`,
44
+ `field, leave it out of your answer entirely — do not restate it, and do not`,
45
+ `guess. Returning nothing is a valid and common answer.`,
46
+ ``,
47
+ `STAGE, what the words mean here:`,
48
+ ` negotiating in conversation, nothing signed`,
49
+ ` contract signed, work not started or in progress`,
50
+ ` producing actively making the deliverables`,
51
+ ` posted the content is live and delivered`,
52
+ ` awaiting_pay delivered, invoice outstanding`,
53
+ ` paid delivered AND the money has landed`,
54
+ ` declined she or they said no`,
55
+ `A deal where the work is done and the money arrived is "paid", not "posted".`,
56
+ ``,
57
+ `PAYMENT. A payment-rail email (Mercury, Stripe, PayPal, a bank) confirming an`,
58
+ `amount that matches an invoice is the strongest evidence there is — score it`,
59
+ `0.9 and mark the invoice paid. A brand saying "processed" or "sent" without a`,
60
+ `rail confirmation is 0.6 and NOT paid. Never write the "confirmed" field; that`,
61
+ `is the creator's own answer and yours would silence her being asked.`,
62
+ ``,
63
+ `BRAND IDENTITY. If the email shows this record is actually a different brand,`,
64
+ `or a campaign belonging to another brand, say so in "brandCorrection" — do not`,
65
+ `quietly rename it.`,
66
+ ``,
67
+ `CURRENT RECORD`,
68
+ `=====`,
69
+ JSON.stringify(record, null, 2).slice(0, 6_000),
70
+ `=====`,
71
+ ``,
72
+ `HER THREADS WITH THIS BRAND (newest last)`,
73
+ `=====`,
74
+ threads.length ? threads.map(renderThread).join("\n\n") : "(no threads matched this brand)",
75
+ `=====`,
76
+ ``,
77
+ `PAYMENT MAIL THAT MIGHT SETTLE THIS DEAL`,
78
+ `=====`,
79
+ payments.length ? payments.map(renderThread).join("\n\n") : "(none found)",
80
+ `=====`,
81
+ ``,
82
+ `Output ONLY a fenced \`\`\`json block. Omit any field the email does not settle:`,
83
+ `{"stage":"…","whoseMove":"you|them",`,
84
+ ` "money":{"invoices":[{"id":"inv-1","amount":0,"status":"paid|unpaid","paidAt":"YYYY-MM-DD",`,
85
+ ` "confidence":0.0,"evidence":"<quote the line that proves it>"}]},`,
86
+ ` "brandCorrection":"<only if this is the wrong brand entirely>",`,
87
+ ` "changed":"<one line: what you corrected and why, or 'nothing'>"}`,
88
+ ].join("\n");
89
+
90
+ /** Payment mail plausibly about this brand: names it, or matches an amount. */
91
+ function paymentsFor(record, allPayments) {
92
+ const name = String(record.brand ?? "").toLowerCase();
93
+ const amounts = (record.money?.invoices ?? [])
94
+ .map((i) => i.amount)
95
+ .filter((n) => typeof n === "number")
96
+ .flatMap((n) => [String(n), n.toLocaleString("en-US")]);
97
+
98
+ return allPayments.filter((t) => {
99
+ const hay = `${t.subject ?? ""} ${(t.messages ?? []).map((m) => m.body ?? "").join(" ")}`.toLowerCase();
100
+ if (name.length > 3 && hay.includes(name)) return true;
101
+ return amounts.some((a) => hay.includes(a));
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Re-derive every record we have evidence for.
107
+ *
108
+ * `onProgress` is called per brand so a long run is visible rather than silent.
109
+ * Brands with no threads and no payment mail are skipped outright — there is
110
+ * nothing to correct against, and asking anyway would invite invention.
111
+ */
112
+ export async function rederiveAll(accessToken, accountEmail, { onProgress, write = false } = {}) {
113
+ const [brandThreads, paymentThreads] = await Promise.all([
114
+ fetchThreads(accessToken, accountEmail, { brandOnly: true }),
115
+ searchThreads(accessToken, accountEmail, paymentQuery()),
116
+ ]);
117
+
118
+ const files = existsSync(DEALS_DIR)
119
+ ? readdirSync(DEALS_DIR).filter((n) => n.endsWith(".md")).sort()
120
+ : [];
121
+
122
+ // Every brand we know about, so a thread from an agency contact can be
123
+ // assigned to the one it actually names rather than all of them.
124
+ setKnownBrands(
125
+ files
126
+ .map((f) => parseRecord(readFileSync(join(DEALS_DIR, f), "utf8")).record?.brand)
127
+ .filter(Boolean),
128
+ );
129
+
130
+ const results = [];
131
+
132
+ for (const file of files) {
133
+ const slug = file.replace(/\.md$/, "");
134
+ const path = join(DEALS_DIR, file);
135
+ const md = readFileSync(path, "utf8");
136
+ const { record, decisions } = parseRecord(md);
137
+ if (!record) continue;
138
+
139
+ const mine = threadsForRecord(record, brandThreads).slice(-MAX_THREADS);
140
+ const payments = paymentsFor(record, paymentThreads);
141
+
142
+ if (!mine.length && !payments.length) {
143
+ results.push({ slug, skipped: "no email evidence" });
144
+ onProgress?.(results.at(-1));
145
+ continue;
146
+ }
147
+
148
+ let out = null;
149
+ try {
150
+ out = parseJson(
151
+ await oneShot(prompt({ record, threads: mine, payments }), {
152
+ cwd: BRAND_DIR,
153
+ timeoutMs: 6 * 60 * 1000,
154
+ }),
155
+ );
156
+ } catch (err) {
157
+ results.push({ slug, error: err?.message ?? String(err) });
158
+ onProgress?.(results.at(-1));
159
+ continue;
160
+ }
161
+
162
+ const changed = out?.changed && !/^nothing/i.test(out.changed) ? out.changed : null;
163
+ if (!changed && !out?.stage && !out?.money) {
164
+ results.push({ slug, skipped: "email confirms the record" });
165
+ onProgress?.(results.at(-1));
166
+ continue;
167
+ }
168
+
169
+ const { changed: _c, brandCorrection, ...updates } = out ?? {};
170
+ // Invoices from the agent get scrubbed of any `confirmed` it invented.
171
+ if (updates.money?.invoices) {
172
+ updates.money.invoices = updates.money.invoices.map(sanitizeInvoice).map((i) => {
173
+ const { confirmed, confirmedAt, ...rest } = i;
174
+ return rest;
175
+ });
176
+ }
177
+
178
+ const next = mergeRecord(record, updates);
179
+ const result = {
180
+ slug,
181
+ changed,
182
+ brandCorrection: brandCorrection ?? null,
183
+ from: { stage: record.stage, paid: paidCount(record) },
184
+ to: { stage: next.stage, paid: paidCount(next) },
185
+ };
186
+
187
+ if (write) {
188
+ writeFileSync(
189
+ path,
190
+ serializeRecord({
191
+ record: next,
192
+ decisions,
193
+ archiveRef: existsSync(join(DEALS_DIR, "..", "archive", file)) ? `archive/${file}` : null,
194
+ }),
195
+ );
196
+ }
197
+
198
+ results.push(result);
199
+ onProgress?.(result);
200
+ }
201
+
202
+ return results;
203
+ }
204
+
205
+ const paidCount = (r) => (r.money?.invoices ?? []).filter((i) => i.status === "paid").length;
206
+
207
+ /**
208
+ * Threads belonging to this record.
209
+ *
210
+ * Address first, then the brand's own name in the text, then the domain.
211
+ *
212
+ * The name check is what makes agency deals work at all. Rebekah Greene at
213
+ * Freeman & Forrest brokers Atlassian, Microsoft, MongoDB and Radar, so her
214
+ * address belongs to four different brands and her domain resolves to none of
215
+ * them unambiguously — matching on sender alone left all four with no threads
216
+ * and skipped them outright. But the brand is named in the mail: that is what
217
+ * the thread is about. So the name decides which of an agency's deals a thread
218
+ * belongs to, and the domain is only a last resort for a brand that nothing
219
+ * else claimed.
220
+ */
221
+ export function threadsForRecord(record, threads) {
222
+ const emails = new Set(
223
+ [...(record.contacts ?? []), record.agency?.contact ?? ""]
224
+ .map((c) => String(c).match(/<([^>]+)>/)?.[1]?.toLowerCase() ?? String(c).toLowerCase())
225
+ .filter((e) => e.includes("@")),
226
+ );
227
+ const domains = new Set([...emails].map((e) => e.split("@")[1]).filter(Boolean));
228
+ const name = String(record.brand ?? "").trim().toLowerCase();
229
+ // Two characters would match half the inbox; "X1" style names lose out here,
230
+ // and that is the right way round — a false match corrects a record against
231
+ // somebody else's deal.
232
+ const nameUsable = name.length >= 3;
233
+
234
+ const namedIn = (t) => {
235
+ if (!nameUsable) return false;
236
+ const hay = [t.subject ?? "", ...(t.messages ?? []).map((m) => m.body ?? "")]
237
+ .join(" ")
238
+ .toLowerCase();
239
+ return hay.includes(name);
240
+ };
241
+
242
+ return threads.filter((t) => {
243
+ const from = String(t.last?.from ?? "").toLowerCase();
244
+ const addr = from.match(/<([^>]+)>/)?.[1] ?? from;
245
+ const domain = addr.split("@")[1];
246
+
247
+ // From a known contact: theirs, unless the mail names a different brand of
248
+ // the same agency — then it belongs to that one instead.
249
+ if (emails.has(addr)) return nameUsable ? namedIn(t) || !mentionsAnotherBrand(t, record) : true;
250
+ if (namedIn(t)) return true;
251
+ return domain ? domains.has(domain) : false;
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Does this thread name some brand other than this record's? Used to stop an
257
+ * agency contact's mail about Microsoft being folded into the Atlassian record
258
+ * just because it came from the same person.
259
+ */
260
+ let knownBrandNames = null;
261
+ export function setKnownBrands(names) {
262
+ knownBrandNames = names.map((n) => String(n).trim().toLowerCase()).filter((n) => n.length >= 3);
263
+ }
264
+
265
+ function mentionsAnotherBrand(t, record) {
266
+ if (!knownBrandNames?.length) return false;
267
+ const mine = String(record.brand ?? "").trim().toLowerCase();
268
+ const hay = [t.subject ?? "", ...(t.messages ?? []).map((m) => m.body ?? "")]
269
+ .join(" ")
270
+ .toLowerCase();
271
+ return knownBrandNames.some((n) => n !== mine && hay.includes(n));
272
+ }
@@ -0,0 +1,78 @@
1
+ // The stat screenshots a brand gets when it asks to see insights.
2
+ //
3
+ // They're uploaded on the Brand Manager page, and runStats saves each batch
4
+ // here as <YYYY-MM-DD>-<n>.<ext>. Deciding which ones are current is done in
5
+ // code, not left to the drafting agent: that agent runs without tools and
6
+ // can't list a folder, so it only ever attaches files it's been told about.
7
+ //
8
+ // A new upload moves the previous batch into archive/, so the folder holds
9
+ // one set, and "current" also means "recent enough": older than
10
+ // MAX_AGE_DAYS and they'd contradict the live numbers they sit beside.
11
+
12
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { SCREENSHOTS_DIR } from "./paths.js";
15
+
16
+ export const MAX_AGE_DAYS = 30;
17
+ const DAY = 24 * 60 * 60 * 1000;
18
+ const IMAGE = /\.(png|jpe?g|webp)$/i;
19
+
20
+ /** The date a screenshot was saved: its YYYY-MM-DD prefix, else the file's mtime. */
21
+ function fileDate(dir, name) {
22
+ const m = name.match(/^(\d{4}-\d{2}-\d{2})/);
23
+ if (m) return m[1];
24
+ return statSync(join(dir, name)).mtime.toISOString().slice(0, 10);
25
+ }
26
+
27
+ /** Every screenshot in the folder (not the archive), newest first. */
28
+ export function listScreenshots(dir = SCREENSHOTS_DIR) {
29
+ if (!existsSync(dir)) return [];
30
+ return readdirSync(dir, { withFileTypes: true })
31
+ .filter((e) => e.isFile() && IMAGE.test(e.name))
32
+ .map((e) => ({ name: e.name, date: fileDate(dir, e.name) }))
33
+ .sort((a, b) => (a.date === b.date ? a.name.localeCompare(b.name) : b.date.localeCompare(a.date)));
34
+ }
35
+
36
+ /**
37
+ * The screenshots that may go to a brand: saved within MAX_AGE_DAYS. Paths
38
+ * are relative to the brand-manager folder, the form a draft's
39
+ * `attachments` uses.
40
+ */
41
+ export function currentScreenshots({ dir = SCREENSHOTS_DIR, now = new Date(), maxAgeDays = MAX_AGE_DAYS } = {}) {
42
+ const cutoff = new Date(now.getTime() - maxAgeDays * DAY).toISOString().slice(0, 10);
43
+ return listScreenshots(dir)
44
+ .filter((s) => s.date >= cutoff)
45
+ .map((s) => ({ ...s, path: `stats/screenshots/${s.name}` }));
46
+ }
47
+
48
+ /** Move the whole current set into archive/, before a new upload lands. */
49
+ export function archiveScreenshots(dir = SCREENSHOTS_DIR) {
50
+ const shots = listScreenshots(dir);
51
+ if (shots.length === 0) return 0;
52
+ const archive = join(dir, "archive");
53
+ mkdirSync(archive, { recursive: true });
54
+ for (const s of shots) {
55
+ let dest = join(archive, s.name);
56
+ // Two uploads on the same day reuse names; keep both rather than overwrite.
57
+ for (let n = 1; existsSync(dest); n++) dest = join(archive, s.name.replace(IMAGE, `~${n}$&`));
58
+ renameSync(join(dir, s.name), dest);
59
+ }
60
+ return shots.length;
61
+ }
62
+
63
+ /** The block the drafting agent reads: exactly which files it may attach. */
64
+ export function screenshotsBlock(current) {
65
+ if (current.length === 0) {
66
+ return [
67
+ "### SCREENSHOTS YOU CAN ATTACH",
68
+ "None are current. Attach nothing. If a brand asks for screenshots, say you'll send current ones",
69
+ 'over, and add the flag "stat screenshots out of date: upload fresh ones on the Brand Manager page".',
70
+ ].join("\n");
71
+ }
72
+ return [
73
+ "### SCREENSHOTS YOU CAN ATTACH",
74
+ `Current (saved ${current[0].date}). Attach these, and only these, when a brand asks for stats,`,
75
+ "screenshots or proof of reach. Never attach them unprompted.",
76
+ ...current.map((s) => `- ${s.path}`),
77
+ ].join("\n");
78
+ }