brand-manager-worker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
+ }