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.
- package/README.md +62 -0
- package/package.json +28 -0
- package/worker/agent.js +212 -0
- package/worker/api.js +104 -0
- package/worker/attachments.js +50 -0
- package/worker/claude.js +136 -0
- package/worker/cli.js +73 -0
- package/worker/context.js +84 -0
- package/worker/drafts-log.js +38 -0
- package/worker/gmail-draft.js +211 -0
- package/worker/gmail.js +187 -0
- package/worker/handlers.js +412 -0
- package/worker/index.js +120 -0
- package/worker/mirror.js +95 -0
- package/worker/outreach.js +140 -0
- package/worker/paths.js +50 -0
- package/worker/service.js +347 -0
- package/worker/state.js +65 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// Inbound job handlers: everything Brand Manager does with a claimed job.
|
|
2
|
+
// (Outreach handlers live in outreach.js.)
|
|
3
|
+
//
|
|
4
|
+
// Every handler returns the complete() payload shape:
|
|
5
|
+
// { ok, summary?, resultBody?, flags?, sessionId?, error?, mirror? }
|
|
6
|
+
//
|
|
7
|
+
// The never-send guarantee: handlers call createReplyDraft/createGmailDraft
|
|
8
|
+
// and nothing else touches Gmail writes. Nothing in this package references
|
|
9
|
+
// Gmail's send endpoint — test/no-send.test.js enforces that by pattern.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { classify, decide, learnFromEdit } from "./agent.js";
|
|
14
|
+
import { extractContracts } from "./attachments.js";
|
|
15
|
+
import { session } from "./claude.js";
|
|
16
|
+
import { invalidateContext } from "./context.js";
|
|
17
|
+
import { appendDraftLog, lastDraftFor } from "./drafts-log.js";
|
|
18
|
+
import { createReplyDraft } from "./gmail-draft.js";
|
|
19
|
+
import { fetchThreads, getThread } from "./gmail.js";
|
|
20
|
+
import { buildMirror } from "./mirror.js";
|
|
21
|
+
import {
|
|
22
|
+
BRAND_DIR,
|
|
23
|
+
DEALS_DIR,
|
|
24
|
+
PLAYBOOK_DIR,
|
|
25
|
+
SCREENSHOTS_DIR,
|
|
26
|
+
STATS_DIR,
|
|
27
|
+
VOICE_DIR,
|
|
28
|
+
} from "./paths.js";
|
|
29
|
+
import * as state from "./state.js";
|
|
30
|
+
|
|
31
|
+
const FOLLOWUP_DAYS = 3;
|
|
32
|
+
|
|
33
|
+
const noGmail = () => ({
|
|
34
|
+
ok: false,
|
|
35
|
+
error: "Gmail isn't connected — connect your email on goosetools.com/dashboard/brand-manager",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Thread metadata rows for the mirror, from fetched threads. */
|
|
39
|
+
const threadMeta = (threads) =>
|
|
40
|
+
threads.map((t) => ({
|
|
41
|
+
threadId: t.id,
|
|
42
|
+
accountEmail: t.account,
|
|
43
|
+
fromName: t.last.from.replace(/\s*<[^>]*>\s*/, "").trim() || null,
|
|
44
|
+
fromEmail: (t.last.from.match(/<([^>]+)>/)?.[1] ?? t.last.from).trim() || null,
|
|
45
|
+
subject: t.subject,
|
|
46
|
+
lastMessageAt: t.last.date,
|
|
47
|
+
hasUnsentDraft: t.hasUnsentDraft,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
/** Resolve decision attachment paths ("stats/screenshots/x.png") under BRAND_DIR. */
|
|
51
|
+
const resolveAttachments = (paths = []) =>
|
|
52
|
+
paths.map((p) => (p.startsWith("/") ? p : join(BRAND_DIR, p))).filter((p) => existsSync(p));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Draft a reply on one thread (the on-demand path, and the per-thread step of
|
|
56
|
+
* a scan). `instruction` steers this draft only.
|
|
57
|
+
*/
|
|
58
|
+
async function draftForThread(job, thread) {
|
|
59
|
+
const contracts = await extractContracts(
|
|
60
|
+
job.gmailAccessToken,
|
|
61
|
+
thread.last.id,
|
|
62
|
+
thread.last.attachments,
|
|
63
|
+
);
|
|
64
|
+
const reason = job.kind === "followup" ? "followup-due" : "new-or-reply";
|
|
65
|
+
const decision = await decide({ thread, reason, instruction: job.instruction, contracts });
|
|
66
|
+
if (!decision) {
|
|
67
|
+
// Parse/agent failure: retryable. Do NOT mark handled.
|
|
68
|
+
return { ok: false, error: "Agent returned no parseable decision (will retry)" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (decision.action === "skip") {
|
|
72
|
+
if (reason === "new-or-reply") state.markHandled(thread.id, thread.last.id);
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
summary: `skip — ${decision.summary}`,
|
|
76
|
+
mirror: buildMirror({ threads: threadMeta([thread]) }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const draftId = await createReplyDraft(job.gmailAccessToken, {
|
|
81
|
+
threadId: thread.id,
|
|
82
|
+
inReplyToMessageId: thread.last.id,
|
|
83
|
+
to: decision.to ?? thread.last.from,
|
|
84
|
+
subject: decision.subject ?? `Re: ${thread.subject}`,
|
|
85
|
+
bodyText: decision.body ?? "",
|
|
86
|
+
attachments: resolveAttachments(decision.attachments),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Record the drafted body so a later Gmail edit can be diffed and learned from.
|
|
90
|
+
appendDraftLog({
|
|
91
|
+
account: thread.account,
|
|
92
|
+
subject: thread.subject,
|
|
93
|
+
threadId: thread.id,
|
|
94
|
+
draftId,
|
|
95
|
+
reason,
|
|
96
|
+
summary: decision.summary,
|
|
97
|
+
body: decision.body ?? "",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (reason === "followup-due") state.markFollowup(thread.id, thread.last.id);
|
|
101
|
+
else state.markHandled(thread.id, thread.last.id);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
summary: decision.summary,
|
|
106
|
+
resultBody: decision.body ?? "",
|
|
107
|
+
flags: decision.flags ?? [],
|
|
108
|
+
mirror: buildMirror({ threads: threadMeta([thread]) }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** kind: draft | followup — one thread, threadId pinned by the job. */
|
|
113
|
+
export async function runDraft(job) {
|
|
114
|
+
if (!job.gmailAccessToken) return noGmail();
|
|
115
|
+
if (!job.threadId) return { ok: false, error: "draft job has no threadId" };
|
|
116
|
+
const thread = await getThread(job.gmailAccessToken, job.accountEmail, job.threadId);
|
|
117
|
+
if (!thread) return { ok: false, error: `Thread ${job.threadId} not found or drafts-only` };
|
|
118
|
+
return draftForThread(job, thread);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* kind: scan — the background poll, ported from pipeline.runOnce(). Fetches
|
|
123
|
+
* brand threads, learns from sent edits, classifies, drafts what needs
|
|
124
|
+
* drafting. The server cron enqueues one of these per connected user on the
|
|
125
|
+
* old 15-minute cadence.
|
|
126
|
+
*/
|
|
127
|
+
export async function runScan(job) {
|
|
128
|
+
if (!job.gmailAccessToken) return noGmail();
|
|
129
|
+
const threads = await fetchThreads(job.gmailAccessToken, job.accountEmail, {
|
|
130
|
+
brandOnly: Boolean(job.payload?.brandOnly),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const lines = [];
|
|
134
|
+
const flags = [];
|
|
135
|
+
for (const thread of threads) {
|
|
136
|
+
// Learn from the creator's edits to a sent reply (independent of drafting).
|
|
137
|
+
try {
|
|
138
|
+
const last = thread.last;
|
|
139
|
+
if (last.fromMe && !state.isLearned(thread.id, last.id)) {
|
|
140
|
+
const original = lastDraftFor(thread.id);
|
|
141
|
+
state.markLearned(thread.id, last.id);
|
|
142
|
+
if (original) {
|
|
143
|
+
const learned = await learnFromEdit({
|
|
144
|
+
subject: thread.subject,
|
|
145
|
+
brandContact: last.to.join(", ") || last.from,
|
|
146
|
+
original,
|
|
147
|
+
sent: last.body,
|
|
148
|
+
});
|
|
149
|
+
if (learned && !/no durable lesson/i.test(learned)) lines.push(`voice learned — ${learned}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Learning must never block drafting.
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const reason = classify(thread, {
|
|
157
|
+
followupDays: FOLLOWUP_DAYS,
|
|
158
|
+
isHandled: state.isHandled,
|
|
159
|
+
lastFollowupAt: state.lastFollowupAt,
|
|
160
|
+
});
|
|
161
|
+
if (!reason) continue;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const result = await draftForThread(
|
|
165
|
+
{ ...job, kind: reason === "followup-due" ? "followup" : "draft", instruction: null },
|
|
166
|
+
thread,
|
|
167
|
+
);
|
|
168
|
+
if (result.ok) lines.push(`${thread.subject}: ${result.summary}`);
|
|
169
|
+
else lines.push(`${thread.subject}: ${result.error}`);
|
|
170
|
+
for (const f of result.flags ?? []) flags.push(`${thread.subject}: ${f}`);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
lines.push(`${thread.subject}: ${err.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
summary: lines.length ? lines.join(" · ").slice(0, 1900) : "No new brand mail needing a draft.",
|
|
179
|
+
flags,
|
|
180
|
+
mirror: buildMirror({ threads: threadMeta(threads) }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* kind: chat — a real, resumable Claude session scoped to the brand
|
|
186
|
+
* directory. This is the "talk to it like in Claude Code" surface: it can
|
|
187
|
+
* read ledgers, edit the rate card, and remember the previous turn.
|
|
188
|
+
*/
|
|
189
|
+
export async function runChat(job) {
|
|
190
|
+
// If the chat is about a specific thread, inline it — the session has no
|
|
191
|
+
// Gmail tools yet, so the worker does the fetch.
|
|
192
|
+
let threadBlock = "";
|
|
193
|
+
if (job.threadId && job.gmailAccessToken) {
|
|
194
|
+
const thread = await getThread(job.gmailAccessToken, job.accountEmail, job.threadId);
|
|
195
|
+
if (thread) {
|
|
196
|
+
threadBlock = [
|
|
197
|
+
``,
|
|
198
|
+
`The creator is asking about this Gmail thread (oldest first):`,
|
|
199
|
+
...thread.messages.map(
|
|
200
|
+
(m) => `--- ${m.fromMe ? "CREATOR" : "BRAND"} | ${m.from} | ${m.date}\n${(m.body || m.snippet).slice(0, 4000)}`,
|
|
201
|
+
),
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const history = (job.history ?? [])
|
|
207
|
+
.map((h) => `USER: ${h.instruction ?? ""}\nYOU: ${h.body ?? ""}`)
|
|
208
|
+
.join("\n\n");
|
|
209
|
+
|
|
210
|
+
const prompt = [
|
|
211
|
+
`You are brand-manager, the creator's brand-deal agent, chatting with them on Goose Tools.`,
|
|
212
|
+
`Your working directory is their brand-manager folder: base/ holds modes and rules (read`,
|
|
213
|
+
`base/rules/feedback-loop.md before saving any preference), voice/ playbook/ deals/ stats/ are`,
|
|
214
|
+
`their personal layer — read what you need, and APPEND edits per the feedback loop.`,
|
|
215
|
+
`You cannot send email, and you cannot access Gmail from this session.`,
|
|
216
|
+
job.sessionId ? "" : history ? `\nConversation so far:\n${history}` : "",
|
|
217
|
+
threadBlock,
|
|
218
|
+
``,
|
|
219
|
+
`The creator says:`,
|
|
220
|
+
job.instruction ?? "",
|
|
221
|
+
]
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
.join("\n");
|
|
224
|
+
|
|
225
|
+
const { text, sessionId } = await session(prompt, {
|
|
226
|
+
cwd: BRAND_DIR,
|
|
227
|
+
sessionId: job.sessionId ?? null,
|
|
228
|
+
});
|
|
229
|
+
invalidateContext();
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
summary: "chat turn",
|
|
233
|
+
resultBody: text,
|
|
234
|
+
sessionId,
|
|
235
|
+
mirror: buildMirror({ threads: [] }),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* kind: voice-audit — read the creator's SENT brand threads and write their
|
|
241
|
+
* negotiation voice guide. The onboarding moment; port of modes/voice-audit.md
|
|
242
|
+
* from interactive-only to a job.
|
|
243
|
+
*/
|
|
244
|
+
export async function runVoiceAudit(job) {
|
|
245
|
+
if (!job.gmailAccessToken) return noGmail();
|
|
246
|
+
|
|
247
|
+
// Their own sent brand mail, rendered to a scratch file the session reads.
|
|
248
|
+
const threads = await fetchThreads(job.gmailAccessToken, job.accountEmail, { brandOnly: true });
|
|
249
|
+
const sentHeavy = threads
|
|
250
|
+
.filter((t) => t.messages.some((m) => m.fromMe))
|
|
251
|
+
.slice(0, 20);
|
|
252
|
+
if (sentHeavy.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
error:
|
|
256
|
+
"No sent brand threads found to learn from — describe your voice or paste examples instead.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const tmpDir = join(BRAND_DIR, "tmp");
|
|
261
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
262
|
+
const corpus = sentHeavy
|
|
263
|
+
.map((t) =>
|
|
264
|
+
[
|
|
265
|
+
`## Thread: ${t.subject}`,
|
|
266
|
+
...t.messages
|
|
267
|
+
.filter((m) => m.fromMe)
|
|
268
|
+
.map((m) => `### Sent ${m.date}\n${m.body.slice(0, 3000)}`),
|
|
269
|
+
].join("\n\n"),
|
|
270
|
+
)
|
|
271
|
+
.join("\n\n---\n\n");
|
|
272
|
+
const corpusPath = join(tmpDir, "sent-brand-threads.md");
|
|
273
|
+
writeFileSync(corpusPath, corpus);
|
|
274
|
+
|
|
275
|
+
const prompt = [
|
|
276
|
+
`You are brand-manager. Follow base/modes/voice-audit.md.`,
|
|
277
|
+
`The creator's sent brand emails are in tmp/sent-brand-threads.md — read that file, mine how they`,
|
|
278
|
+
`actually write to brands (openers, how they quote rates, how they push back, sign-off), and write:`,
|
|
279
|
+
`- voice/voice.md (their brand-negotiation voice)`,
|
|
280
|
+
`- playbook/rate-card.md and playbook/negotiation.md IF real rates/moves appear in the emails —`,
|
|
281
|
+
` never invent a number that isn't in their own sent mail.`,
|
|
282
|
+
`This is a brand-negotiation voice, distinct from any social-post voice.`,
|
|
283
|
+
`Finally output a short summary of what you learned, written to the creator.`,
|
|
284
|
+
].join("\n");
|
|
285
|
+
|
|
286
|
+
const { text, sessionId } = await session(prompt, {
|
|
287
|
+
cwd: BRAND_DIR,
|
|
288
|
+
sessionId: job.sessionId ?? null,
|
|
289
|
+
});
|
|
290
|
+
invalidateContext();
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
summary: "voice audit complete",
|
|
294
|
+
resultBody: text,
|
|
295
|
+
sessionId,
|
|
296
|
+
mirror: buildMirror({ threads: [] }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* kind: learn — fold a correction back into the personal layer. Primary
|
|
302
|
+
* path diffs the sent Gmail message against the logged draft; fallback uses
|
|
303
|
+
* the chat-turn history the server sends (caption-maker style).
|
|
304
|
+
*/
|
|
305
|
+
export async function runLearn(job) {
|
|
306
|
+
if (job.threadId && job.gmailAccessToken) {
|
|
307
|
+
const thread = await getThread(job.gmailAccessToken, job.accountEmail, job.threadId);
|
|
308
|
+
const last = thread?.last;
|
|
309
|
+
if (thread && last?.fromMe && !state.isLearned(thread.id, last.id)) {
|
|
310
|
+
const original = lastDraftFor(thread.id);
|
|
311
|
+
state.markLearned(thread.id, last.id);
|
|
312
|
+
if (original) {
|
|
313
|
+
const summary = await learnFromEdit({
|
|
314
|
+
subject: thread.subject,
|
|
315
|
+
brandContact: last.to.join(", ") || last.from,
|
|
316
|
+
original,
|
|
317
|
+
sent: last.body,
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
ok: true,
|
|
321
|
+
summary: summary || "no durable lesson",
|
|
322
|
+
mirror: buildMirror({ threads: [] }),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { ok: true, summary: "nothing to learn from yet" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fallback: learn from a chat correction (history carries the turns).
|
|
330
|
+
const turns = job.history ?? [];
|
|
331
|
+
const before = turns.length >= 2 ? turns[turns.length - 2]?.body : null;
|
|
332
|
+
const after = turns[turns.length - 1]?.body;
|
|
333
|
+
const correction = turns[turns.length - 1]?.instruction;
|
|
334
|
+
if (!after || !correction) return { ok: true, summary: "nothing to learn from" };
|
|
335
|
+
const summary = await learnFromEdit({
|
|
336
|
+
subject: "(chat refinement)",
|
|
337
|
+
brandContact: "(n/a)",
|
|
338
|
+
original: before ?? "",
|
|
339
|
+
sent: after,
|
|
340
|
+
});
|
|
341
|
+
return { ok: true, summary: summary || "no durable lesson", mirror: buildMirror({ threads: [] }) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* kind: stats — read uploaded stat screenshots and rewrite stats/latest.md.
|
|
346
|
+
* payload.photoUrls are public blob URLs; download locally, then a
|
|
347
|
+
* tool-enabled session reads the images (the carousel worker's trick).
|
|
348
|
+
*/
|
|
349
|
+
export async function runStats(job) {
|
|
350
|
+
const urls = job.payload?.photoUrls ?? [];
|
|
351
|
+
if (urls.length === 0) return { ok: false, error: "stats job has no photoUrls" };
|
|
352
|
+
|
|
353
|
+
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
354
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
355
|
+
const saved = [];
|
|
356
|
+
for (let i = 0; i < urls.length; i++) {
|
|
357
|
+
const res = await fetch(urls[i]);
|
|
358
|
+
if (!res.ok) continue;
|
|
359
|
+
const ext = (new URL(urls[i]).pathname.match(/\.(png|jpe?g|webp)$/i)?.[0] ?? ".png").toLowerCase();
|
|
360
|
+
const dest = join(SCREENSHOTS_DIR, `${stamp}-${i}${ext}`);
|
|
361
|
+
writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
|
362
|
+
saved.push(dest);
|
|
363
|
+
}
|
|
364
|
+
if (saved.length === 0) return { ok: false, error: "could not download any screenshots" };
|
|
365
|
+
|
|
366
|
+
const prompt = [
|
|
367
|
+
`You are brand-manager. Follow base/modes/stats-intake.md.`,
|
|
368
|
+
`Read these stat screenshots and update stats/latest.md with the current numbers (followers, reel`,
|
|
369
|
+
`views, reach, engagement, audience demographics). Keep the screenshots referenced for attaching`,
|
|
370
|
+
`to brand replies. Screenshots:\n${saved.join("\n")}`,
|
|
371
|
+
`Report one line summarizing what changed.`,
|
|
372
|
+
].join("\n");
|
|
373
|
+
|
|
374
|
+
const { text } = await session(prompt, { cwd: BRAND_DIR, timeoutMs: 5 * 60 * 1000 });
|
|
375
|
+
invalidateContext();
|
|
376
|
+
const latest = join(STATS_DIR, "latest.md");
|
|
377
|
+
return {
|
|
378
|
+
ok: true,
|
|
379
|
+
summary: text.trim().split("\n").filter(Boolean).pop() ?? "stats updated",
|
|
380
|
+
// The rewritten media kit rides back for the platform stats row (Phase 3).
|
|
381
|
+
resultBody: existsSync(latest) ? readFileSync(latest, "utf8") : "",
|
|
382
|
+
mirror: buildMirror({ threads: [] }),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** kind: edit — apply a website edit to the local file. Deterministic, no Claude. */
|
|
387
|
+
export async function runEdit(job) {
|
|
388
|
+
const { target, body } = job.payload ?? {};
|
|
389
|
+
const files = {
|
|
390
|
+
voice: join(VOICE_DIR, "voice.md"),
|
|
391
|
+
"never-words": join(VOICE_DIR, "never-words.md"),
|
|
392
|
+
"non-negotiables": join(VOICE_DIR, "non-negotiables.md"),
|
|
393
|
+
"rate-card": join(PLAYBOOK_DIR, "rate-card.md"),
|
|
394
|
+
negotiation: join(PLAYBOOK_DIR, "negotiation.md"),
|
|
395
|
+
};
|
|
396
|
+
let dest = files[target];
|
|
397
|
+
// deals/<slug> targets a ledger; the slug is validated to a bare filename.
|
|
398
|
+
if (!dest && typeof target === "string" && target.startsWith("deals/")) {
|
|
399
|
+
const slug = target.slice(6);
|
|
400
|
+
if (/^[a-z0-9-]+$/.test(slug)) dest = join(DEALS_DIR, `${slug}.md`);
|
|
401
|
+
}
|
|
402
|
+
if (!dest || typeof body !== "string") {
|
|
403
|
+
return { ok: false, error: `edit job has an unknown target '${target}'` };
|
|
404
|
+
}
|
|
405
|
+
writeFileSync(dest, body);
|
|
406
|
+
invalidateContext();
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
summary: `updated ${target}`,
|
|
410
|
+
mirror: buildMirror({ threads: [] }),
|
|
411
|
+
};
|
|
412
|
+
}
|
package/worker/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The Goose Tools brand worker loop.
|
|
2
|
+
//
|
|
3
|
+
// Claims a job every 30s, does it here on this machine, reports back. The
|
|
4
|
+
// user's voice, rates, ledgers and drafts are files in ~/.goosetools/brand-manager
|
|
5
|
+
// — this machine owns them; the website shows a copy.
|
|
6
|
+
//
|
|
7
|
+
// It never sends email. Every reply is created as a Gmail DRAFT, the Gmail
|
|
8
|
+
// scopes granted don't include sending, and nothing in this package touches
|
|
9
|
+
// Gmail's send endpoint (test/no-send.test.js enforces this by pattern).
|
|
10
|
+
// That guarantee lives in code, not in a prompt.
|
|
11
|
+
|
|
12
|
+
import { api, syncAssets, BASE_URL, POLL_MS, TOKEN } from "./api.js";
|
|
13
|
+
import { BRAND_DIR, ensureDirs } from "./paths.js";
|
|
14
|
+
import { buildMirror } from "./mirror.js";
|
|
15
|
+
import { runOutreachChat, runOutreachDraft } from "./outreach.js";
|
|
16
|
+
import {
|
|
17
|
+
runChat,
|
|
18
|
+
runDraft,
|
|
19
|
+
runEdit,
|
|
20
|
+
runLearn,
|
|
21
|
+
runScan,
|
|
22
|
+
runStats,
|
|
23
|
+
runVoiceAudit,
|
|
24
|
+
} from "./handlers.js";
|
|
25
|
+
|
|
26
|
+
if (!TOKEN) {
|
|
27
|
+
console.error(
|
|
28
|
+
"\nThis computer isn't connected yet.\n" +
|
|
29
|
+
"Get your command from https://goosetools.com/dashboard/brand-manager\n" +
|
|
30
|
+
" npx --yes brand-manager-worker install --token gt_…\n",
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ensureDirs();
|
|
36
|
+
console.log(`brand worker → ${BASE_URL}`);
|
|
37
|
+
console.log(`files → ${BRAND_DIR}`);
|
|
38
|
+
|
|
39
|
+
async function handle(job) {
|
|
40
|
+
switch (job.kind) {
|
|
41
|
+
case "index": {
|
|
42
|
+
// Pure local read — no Gmail needed, so this works with nothing else
|
|
43
|
+
// configured and is what rebuild-mirror.ts exercises.
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
summary: "Indexed local files",
|
|
47
|
+
mirror: buildMirror({ threads: [] }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Inbound Brand Manager (handlers.js).
|
|
51
|
+
case "draft":
|
|
52
|
+
case "followup":
|
|
53
|
+
return runDraft(job);
|
|
54
|
+
case "scan":
|
|
55
|
+
return runScan(job);
|
|
56
|
+
case "chat":
|
|
57
|
+
return runChat(job);
|
|
58
|
+
case "voice-audit":
|
|
59
|
+
return runVoiceAudit(job);
|
|
60
|
+
case "learn":
|
|
61
|
+
return runLearn(job);
|
|
62
|
+
case "stats":
|
|
63
|
+
return runStats(job);
|
|
64
|
+
case "edit":
|
|
65
|
+
return runEdit(job);
|
|
66
|
+
// Brand Outreach (its own tool on the site; same worker, same token).
|
|
67
|
+
case "outreach-chat":
|
|
68
|
+
return runOutreachChat(job);
|
|
69
|
+
case "outreach-draft":
|
|
70
|
+
return runOutreachDraft(job);
|
|
71
|
+
default:
|
|
72
|
+
return { ok: false, error: `Unknown job kind '${job.kind}'` };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let lastAssetsVersion = null;
|
|
77
|
+
|
|
78
|
+
async function tick() {
|
|
79
|
+
const job = await api("claim");
|
|
80
|
+
if (!job) return false;
|
|
81
|
+
|
|
82
|
+
console.log(`· claimed ${job.kind} (${job.id})`);
|
|
83
|
+
|
|
84
|
+
// Keep the base prompt layer current. Cheap: the claim tells us the server's
|
|
85
|
+
// version, so this only does work when prompts actually changed.
|
|
86
|
+
if (job.assetsVersion && job.assetsVersion !== lastAssetsVersion) {
|
|
87
|
+
try {
|
|
88
|
+
const synced = await syncAssets(job.assetsVersion);
|
|
89
|
+
if (synced) console.log(`· synced base prompts → ${synced}`);
|
|
90
|
+
lastAssetsVersion = job.assetsVersion;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// A failed sync shouldn't drop the job — the previous base layer is
|
|
93
|
+
// still on disk and still valid.
|
|
94
|
+
console.error(`· asset sync failed (continuing): ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let result;
|
|
99
|
+
try {
|
|
100
|
+
result = await handle(job);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
result = { ok: false, error: err?.message ?? String(err) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await api("complete", { jobId: job.id, ...result });
|
|
106
|
+
console.log(result.ok ? `· done ${job.id}` : `· failed ${job.id}: ${result.error}`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
while (true) {
|
|
111
|
+
try {
|
|
112
|
+
// Keep draining while there's work; only sleep once the queue is empty, so
|
|
113
|
+
// a burst of jobs doesn't take 30s each.
|
|
114
|
+
const did = await tick();
|
|
115
|
+
if (did) continue;
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`· ${err?.message ?? err}`);
|
|
118
|
+
}
|
|
119
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
120
|
+
}
|
package/worker/mirror.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Building the projection we push to Goose Tools.
|
|
2
|
+
//
|
|
3
|
+
// The website renders from this. It is deliberately a one-way overwrite: the
|
|
4
|
+
// files on this machine are the truth, and anything the server has is a stale
|
|
5
|
+
// copy that the next push corrects. Nothing here merges.
|
|
6
|
+
//
|
|
7
|
+
// What goes up: deal ledgers, the knowledge files, and thread metadata.
|
|
8
|
+
// What never goes up: incoming message bodies, quoted history, attachment or
|
|
9
|
+
// contract text. brand-manager doesn't persist those either — a contract is
|
|
10
|
+
// extracted at draft time and discarded, and the durable record is the ledger
|
|
11
|
+
// the agent writes after reading it.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { DEALS_DIR, PLAYBOOK_DIR, VOICE_DIR } from "./paths.js";
|
|
16
|
+
|
|
17
|
+
// Same block the ledgers already carry, parsed the same way brand-manager's
|
|
18
|
+
// board export does (src/board/export.ts).
|
|
19
|
+
const BOARD_RE = /<!--\s*board\s*([\s\S]*?)-->/;
|
|
20
|
+
|
|
21
|
+
function readIfExists(file) {
|
|
22
|
+
return existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Every deals/<slug>.md, with stage lifted out of its board block. */
|
|
26
|
+
export function collectDeals() {
|
|
27
|
+
if (!existsSync(DEALS_DIR)) return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const name of readdirSync(DEALS_DIR).sort()) {
|
|
30
|
+
if (!name.endsWith(".md")) continue;
|
|
31
|
+
const slug = basename(name, ".md");
|
|
32
|
+
const ledgerMd = readFileSync(join(DEALS_DIR, name), "utf8");
|
|
33
|
+
|
|
34
|
+
// A malformed block is reported, never guessed at — same rule as the
|
|
35
|
+
// original exporter. We still push the ledger text so the deal doesn't
|
|
36
|
+
// vanish from the UI just because its JSON is temporarily broken.
|
|
37
|
+
let brand = slug;
|
|
38
|
+
let stage = null;
|
|
39
|
+
const match = ledgerMd.match(BOARD_RE);
|
|
40
|
+
if (match) {
|
|
41
|
+
try {
|
|
42
|
+
const block = JSON.parse(match[1]);
|
|
43
|
+
if (typeof block.brand === "string" && block.brand.trim()) brand = block.brand;
|
|
44
|
+
if (typeof block.stage === "string") stage = block.stage;
|
|
45
|
+
} catch {
|
|
46
|
+
// leave brand/stage as the fallbacks
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
out.push({ slug, brand, stage, ledgerMd });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The personal knowledge files, as the site's editable documents. */
|
|
55
|
+
export function collectKnowledge() {
|
|
56
|
+
const files = [
|
|
57
|
+
["voice", join(VOICE_DIR, "voice.md")],
|
|
58
|
+
["never-words", join(VOICE_DIR, "never-words.md")],
|
|
59
|
+
["non-negotiables", join(VOICE_DIR, "non-negotiables.md")],
|
|
60
|
+
["rate-card", join(PLAYBOOK_DIR, "rate-card.md")],
|
|
61
|
+
["negotiation", join(PLAYBOOK_DIR, "negotiation.md")],
|
|
62
|
+
];
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const [kind, file] of files) {
|
|
65
|
+
const body = readIfExists(file);
|
|
66
|
+
if (body !== null) out.push({ kind, body });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Thread metadata for the inbox list. Takes already-fetched threads so this
|
|
73
|
+
* module never touches Gmail itself — and so the shape of what leaves this
|
|
74
|
+
* machine is defined in exactly one place, right here.
|
|
75
|
+
*/
|
|
76
|
+
export function collectThreads(threads) {
|
|
77
|
+
return threads.map((t) => ({
|
|
78
|
+
threadId: t.id,
|
|
79
|
+
accountEmail: t.account ?? null,
|
|
80
|
+
fromName: t.fromName ?? null,
|
|
81
|
+
fromEmail: t.fromEmail ?? null,
|
|
82
|
+
subject: t.subject ?? null,
|
|
83
|
+
lastMessageAt: t.lastMessageAt ?? null,
|
|
84
|
+
hasUnsentDraft: Boolean(t.hasUnsentDraft),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The full mirror payload for a complete() call. */
|
|
89
|
+
export function buildMirror({ threads = [] } = {}) {
|
|
90
|
+
return {
|
|
91
|
+
threads: collectThreads(threads),
|
|
92
|
+
deals: collectDeals(),
|
|
93
|
+
knowledge: collectKnowledge(),
|
|
94
|
+
};
|
|
95
|
+
}
|