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 +2 -2
- package/worker/compact.js +71 -9
- package/worker/context.js +6 -0
- package/worker/gmail.js +32 -2
- package/worker/handlers.js +64 -8
- package/worker/ig-stats.js +13 -11
- package/worker/index.js +3 -0
- package/worker/rederive.js +272 -0
- package/worker/screenshots.js +78 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-manager-worker",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "The Goose Tools brand-deal worker
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
175
|
+
return refuse(target, before.length, "lost the section structure");
|
|
124
176
|
}
|
|
125
177
|
|
|
126
178
|
mkdirSync(ARCHIVE_DIR, { recursive: true });
|
|
127
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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)) {
|
package/worker/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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).
|
|
425
|
-
`to
|
|
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
|
|
package/worker/ig-stats.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|