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,140 @@
1
+ // Brand Outreach handlers: the research chat and the pitch draft.
2
+ //
3
+ // Research (outreach-chat) runs as a resumable Claude session WITH
4
+ // WebSearch/WebFetch — the one job where minutes of tool use is the point.
5
+ // Drafting (outreach-draft) is the fast path: personal layer inlined, tools
6
+ // off, exactly like inbound drafting (brand-manager's 36s lesson).
7
+ //
8
+ // Contact-email doctrine (also enforced server-side): never invent an
9
+ // address; only report one actually seen on a fetched page, with the URL it
10
+ // came from. The pitch is created as a Gmail DRAFT — this package has no
11
+ // send path.
12
+
13
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { BASE_DIR, BRAND_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
16
+ import { oneShot, parseJson, session } from "./claude.js";
17
+ import { createGmailDraft } from "./gmail-draft.js";
18
+
19
+ const read = (p, cap = 20_000) =>
20
+ existsSync(p) ? readFileSync(p, "utf8").slice(0, cap) : "";
21
+
22
+ /** Base file with the outreach mode instructions (synced from the cloud). */
23
+ const outreachMode = () => read(join(BASE_DIR, "modes", "outreach.md"));
24
+
25
+ /**
26
+ * The personal layer for pitch writing, inlined. Order per voice-lookup:
27
+ * base voice files first, personal mirrors after (personal wins — stated to
28
+ * the model rather than merged mechanically).
29
+ */
30
+ function pitchContext() {
31
+ const parts = [];
32
+ const add = (label, text) => {
33
+ if (text.trim()) parts.push(`## ${label}\n\n${text}`);
34
+ };
35
+ for (const f of ["never-words.md", "non-negotiables.md", "voice.md"]) {
36
+ add(`base voice: ${f}`, read(join(BASE_DIR, "voice", f)));
37
+ add(`PERSONAL voice (wins on conflict): ${f}`, read(join(VOICE_DIR, f)));
38
+ }
39
+ add("base playbook: negotiation.md", read(join(BASE_DIR, "playbook", "negotiation.md")));
40
+ for (const f of existsSync(PLAYBOOK_DIR) ? readdirSync(PLAYBOOK_DIR) : []) {
41
+ if (f.endsWith(".md")) add(`PERSONAL playbook: ${f}`, read(join(PLAYBOOK_DIR, f)));
42
+ }
43
+ add("latest stats snapshot", read(join(STATS_DIR, "latest.md")));
44
+ return parts.join("\n\n").slice(0, 80_000);
45
+ }
46
+
47
+ /** Replay for a fresh session when there's no session id to resume. */
48
+ function historyBlock(history) {
49
+ if (!history?.length) return "";
50
+ const turns = history
51
+ .map((h) => `Creator: ${h.instruction ?? ""}\nYou: ${h.body ?? ""}`)
52
+ .join("\n\n");
53
+ return `\n## The conversation so far\n\n${turns}\n`;
54
+ }
55
+
56
+ /**
57
+ * kind: outreach-chat — research a brand or category online, reply
58
+ * conversationally, and emit prospect cards as a fenced json block (parsed
59
+ * out here; the visible reply travels without it).
60
+ */
61
+ export async function runOutreachChat(job) {
62
+ const prompt = `${outreachMode()}
63
+ ${job.sessionId ? "" : historyBlock(job.history)}
64
+ ## This turn
65
+
66
+ The creator says: "${job.instruction ?? ""}"
67
+
68
+ Do the research turn as the mode describes. Remember: plain-text reply first,
69
+ then ONE fenced json block with any new prospects (omit the block if none).`;
70
+
71
+ const { text, sessionId } = await session(prompt, {
72
+ cwd: BRAND_DIR,
73
+ sessionId: job.sessionId ?? null,
74
+ tools: "WebSearch,WebFetch,Read,Glob,Grep",
75
+ timeoutMs: 15 * 60 * 1000,
76
+ });
77
+
78
+ const parsed = parseJson(text);
79
+ const prospects = Array.isArray(parsed?.prospects) ? parsed.prospects : [];
80
+ // Strip the fenced card block from what the user reads — cards render as UI.
81
+ const visible = text.replace(/```json\s*[\s\S]*?```/g, "").trim();
82
+
83
+ return {
84
+ ok: true,
85
+ summary:
86
+ prospects.length > 0
87
+ ? `Found ${prospects.length} prospect(s)`
88
+ : "Replied (no new prospects)",
89
+ resultBody: visible,
90
+ prospects,
91
+ sessionId,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * kind: outreach-draft — write the pitch from the personal layer and create
97
+ * it as a Gmail draft on a new thread.
98
+ */
99
+ export async function runOutreachDraft(job) {
100
+ const prospect = job.payload?.prospect;
101
+ if (!prospect) return { ok: false, error: "Job carried no prospect card" };
102
+ if (!prospect.contactEmail)
103
+ return { ok: false, error: "Prospect has no verified contact email" };
104
+ if (!job.gmailAccessToken)
105
+ return { ok: false, error: "Connect Gmail on goosetools.com first" };
106
+
107
+ const prompt = `${outreachMode()}
108
+
109
+ ## The creator's layers (personal wins over base on any conflict)
110
+
111
+ ${pitchContext()}
112
+
113
+ ## The prospect
114
+
115
+ ${JSON.stringify(prospect, null, 2)}
116
+ ${job.instruction ? `\n## Steering note for this pitch only\n\n${job.instruction}\n` : ""}
117
+ ## Your task
118
+
119
+ Write the outreach pitch email per "The pitch turn" section of the mode.
120
+ Output ONLY a json object, no commentary:
121
+ {"subject": "...", "body": "...", "summary": "one line on the angle you took"}`;
122
+
123
+ const text = await oneShot(prompt, { cwd: BRAND_DIR });
124
+ const decision = parseJson(text);
125
+ if (!decision?.subject || !decision?.body)
126
+ return { ok: false, error: "Drafting returned no usable pitch — try again" };
127
+
128
+ const gmailDraftId = await createGmailDraft(job.gmailAccessToken, {
129
+ to: prospect.contactEmail,
130
+ subject: decision.subject,
131
+ bodyText: decision.body,
132
+ });
133
+
134
+ return {
135
+ ok: true,
136
+ summary: decision.summary ?? `Pitch drafted to ${prospect.brand}`,
137
+ gmailDraftId,
138
+ pitchBody: decision.body,
139
+ };
140
+ }
@@ -0,0 +1,50 @@
1
+ // The local brand-manager directory — this machine's copy of everything, and
2
+ // the source of truth for it.
3
+ //
4
+ // This mirrors what brand-manager's data/local/ holds today. The website shows
5
+ // a projection of these files; it never owns them. Claude reads and writes them
6
+ // directly, which is why "open Claude Code in this folder and talk to it" keeps
7
+ // working the same way it does in the original repo.
8
+ //
9
+ // base/ modes + rules + generic voice/playbook, synced down from
10
+ // Goose Tools. Overwritten on sync — never hand-edit.
11
+ // voice/ voice.md, never-words.md, non-negotiables.md (personal)
12
+ // playbook/ rate-card.md, negotiation.md (personal)
13
+ // deals/ <slug>.md ledgers
14
+ // stats/ latest.md + screenshots/
15
+ // drafts.md append-only log of every drafted body
16
+ // state.json processed threads (replaces state/processed.json)
17
+
18
+ import { mkdirSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export const GOOSE_DIR = join(homedir(), ".goosetools");
23
+ export const BRAND_DIR = join(GOOSE_DIR, "brand-manager");
24
+
25
+ export const BASE_DIR = join(BRAND_DIR, "base");
26
+ export const VOICE_DIR = join(BRAND_DIR, "voice");
27
+ export const PLAYBOOK_DIR = join(BRAND_DIR, "playbook");
28
+ export const DEALS_DIR = join(BRAND_DIR, "deals");
29
+ export const STATS_DIR = join(BRAND_DIR, "stats");
30
+ export const SCREENSHOTS_DIR = join(STATS_DIR, "screenshots");
31
+
32
+ export const DRAFTS_LOG = join(BRAND_DIR, "drafts.md");
33
+ export const STATE_FILE = join(BRAND_DIR, "state.json");
34
+ export const ASSETS_VERSION_FILE = join(BASE_DIR, ".version");
35
+
36
+ /** Create the whole tree. Idempotent — safe on every start. */
37
+ export function ensureDirs() {
38
+ for (const dir of [
39
+ BRAND_DIR,
40
+ BASE_DIR,
41
+ VOICE_DIR,
42
+ PLAYBOOK_DIR,
43
+ DEALS_DIR,
44
+ STATS_DIR,
45
+ SCREENSHOTS_DIR,
46
+ ]) {
47
+ mkdirSync(dir, { recursive: true });
48
+ }
49
+ return BRAND_DIR;
50
+ }
@@ -0,0 +1,347 @@
1
+ // Background-service install/uninstall for the Goose Tools brand worker.
2
+ // Mirrors the carousel worker's installer, but installs alongside it — its own
3
+ // login service + its own background copy — so a computer can run both. The
4
+ // worker TOKEN is shared: both tools read ~/.goosetools/env, so if this
5
+ // computer is already connected (for carousels), `install` needs no --token.
6
+ //
7
+ // `install` makes the brand worker persistent; `update` re-runs it against
8
+ // the newest code; `status` reports what's actually running.
9
+
10
+ import { execSync } from "node:child_process";
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+
23
+ export const GOOSE_DIR = join(homedir(), ".goosetools");
24
+ const ENV_FILE = join(GOOSE_DIR, "env"); // shared with the carousel worker
25
+ const APP_PREFIX = join(GOOSE_DIR, "brand-app"); // separate from the carousel/caption copies
26
+ const INSTALLED_CLI = join(APP_PREFIX, "node_modules", "brand-manager-worker", "worker", "cli.js");
27
+
28
+ const MAC_LABEL = "com.goosetools.brand";
29
+ const MAC_PLIST = join(homedir(), "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
30
+ const WIN_TASK = "GooseTools Brand Worker";
31
+ const WIN_VBS = join(GOOSE_DIR, "run-brand-worker.vbs");
32
+ const LOG_FILE = join(GOOSE_DIR, "brand-worker.log");
33
+ const ERR_FILE = join(GOOSE_DIR, "brand-worker.err.log");
34
+
35
+ function sh(cmd, opts = {}) {
36
+ return execSync(cmd, { stdio: ["ignore", "pipe", "pipe"], ...opts })
37
+ .toString()
38
+ .trim();
39
+ }
40
+
41
+ function quietSh(cmd) {
42
+ try {
43
+ sh(cmd);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function npmGlobalBin() {
51
+ try {
52
+ const prefix = sh("npm prefix -g", { shell: true });
53
+ return process.platform === "win32" ? prefix : join(prefix, "bin");
54
+ } catch {
55
+ return "";
56
+ }
57
+ }
58
+
59
+ function servicePath() {
60
+ const parts = [
61
+ dirname(process.execPath),
62
+ npmGlobalBin(),
63
+ ...(process.platform === "win32"
64
+ ? [process.env.PATH ?? ""]
65
+ : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
66
+ ].filter(Boolean);
67
+ return [...new Set(parts)].join(process.platform === "win32" ? ";" : ":");
68
+ }
69
+
70
+ function readEnvFile() {
71
+ if (!existsSync(ENV_FILE)) return {};
72
+ return Object.fromEntries(
73
+ readFileSync(ENV_FILE, "utf8")
74
+ .split("\n")
75
+ .filter((l) => l.includes("=") && !l.trim().startsWith("#"))
76
+ .map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
77
+ );
78
+ }
79
+
80
+ function writeEnvFile({ url, token }) {
81
+ mkdirSync(GOOSE_DIR, { recursive: true });
82
+ writeFileSync(ENV_FILE, `GOOSETOOLS_URL=${url}\nWORKER_TOKEN=${token}\n`);
83
+ chmodSync(ENV_FILE, 0o600);
84
+ }
85
+
86
+ function installStableCopy(root) {
87
+ console.log("Setting up the background copy (this can take a minute)…");
88
+ mkdirSync(APP_PREFIX, { recursive: true });
89
+ const source = existsSync(join(root, ".git")) ? `"${root}"` : "brand-manager-worker@latest";
90
+ execSync(`npm install --no-fund --no-audit --loglevel=error --prefix "${APP_PREFIX}" ${source}`, {
91
+ stdio: ["ignore", "inherit", "inherit"],
92
+ shell: true,
93
+ });
94
+ if (!existsSync(INSTALLED_CLI)) {
95
+ throw new Error("background copy did not install where expected");
96
+ }
97
+ }
98
+
99
+ // ── macOS (launchd) ──────────────────────────────────────────────────────────
100
+
101
+ function macPlist() {
102
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ return `<?xml version="1.0" encoding="UTF-8"?>
104
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
105
+ <plist version="1.0">
106
+ <dict>
107
+ <key>Label</key>
108
+ <string>${MAC_LABEL}</string>
109
+ <key>ProgramArguments</key>
110
+ <array>
111
+ <string>${esc(process.execPath)}</string>
112
+ <string>${esc(INSTALLED_CLI)}</string>
113
+ <string>run</string>
114
+ </array>
115
+ <key>RunAtLoad</key>
116
+ <true/>
117
+ <key>KeepAlive</key>
118
+ <true/>
119
+ <key>ThrottleInterval</key>
120
+ <integer>30</integer>
121
+ <key>StandardOutPath</key>
122
+ <string>${esc(LOG_FILE)}</string>
123
+ <key>StandardErrorPath</key>
124
+ <string>${esc(ERR_FILE)}</string>
125
+ <key>EnvironmentVariables</key>
126
+ <dict>
127
+ <key>PATH</key>
128
+ <string>${esc(servicePath())}</string>
129
+ </dict>
130
+ </dict>
131
+ </plist>
132
+ `;
133
+ }
134
+
135
+ function macInstall() {
136
+ mkdirSync(dirname(MAC_PLIST), { recursive: true });
137
+ writeFileSync(MAC_PLIST, macPlist());
138
+ const uid = process.getuid();
139
+ quietSh(`launchctl bootout gui/${uid}/${MAC_LABEL}`);
140
+ if (!quietSh(`launchctl bootstrap gui/${uid} "${MAC_PLIST}"`)) {
141
+ quietSh(`launchctl unload "${MAC_PLIST}"`);
142
+ execSync(`launchctl load -w "${MAC_PLIST}"`, { stdio: "ignore" });
143
+ }
144
+ }
145
+
146
+ function macUninstall() {
147
+ quietSh(`launchctl bootout gui/${process.getuid()}/${MAC_LABEL}`);
148
+ quietSh(`launchctl unload "${MAC_PLIST}"`);
149
+ rmSync(MAC_PLIST, { force: true });
150
+ }
151
+
152
+ // ── Windows (Scheduled Task at logon, hidden window) ─────────────────────────
153
+
154
+ function winInstall() {
155
+ const vbs = `CreateObject("WScript.Shell").Run """${process.execPath}"" ""${INSTALLED_CLI}"" run", 0, False\r\n`;
156
+ writeFileSync(WIN_VBS, vbs);
157
+ quietSh(`schtasks /End /TN "${WIN_TASK}"`);
158
+ quietSh(`schtasks /Delete /F /TN "${WIN_TASK}"`);
159
+ execSync(`schtasks /Create /F /SC ONLOGON /TN "${WIN_TASK}" /TR "wscript.exe \\"${WIN_VBS}\\""`, {
160
+ stdio: "ignore",
161
+ shell: true,
162
+ });
163
+ execSync(`schtasks /Run /TN "${WIN_TASK}"`, { stdio: "ignore", shell: true });
164
+ }
165
+
166
+ function winUninstall() {
167
+ quietSh(`schtasks /End /TN "${WIN_TASK}"`);
168
+ quietSh(`schtasks /Delete /F /TN "${WIN_TASK}"`);
169
+ rmSync(WIN_VBS, { force: true });
170
+ }
171
+
172
+ // ── Public commands ──────────────────────────────────────────────────────────
173
+
174
+ export function install({ url, token, root }) {
175
+ // The token is shared with the carousel worker — reuse the saved one if the
176
+ // caller didn't pass --token (a computer already connected for carousels).
177
+ const saved = readEnvFile();
178
+ const effectiveToken = token ?? saved.WORKER_TOKEN;
179
+ const effectiveUrl = url ?? saved.GOOSETOOLS_URL ?? "https://goosetools.com";
180
+ if (!effectiveToken) {
181
+ console.error(
182
+ "No worker token found. Connect this computer at " +
183
+ effectiveUrl +
184
+ "/dashboard/brand-manager (or any other tool — same token), then run:\n" +
185
+ " npx --yes brand-manager-worker@latest install --token gt_…",
186
+ );
187
+ process.exit(1);
188
+ }
189
+ writeEnvFile({ url: effectiveUrl, token: effectiveToken });
190
+ installStableCopy(root);
191
+ if (process.platform === "darwin") macInstall();
192
+ else if (process.platform === "win32") winInstall();
193
+ else {
194
+ console.error(
195
+ "Automatic background setup isn't available on this OS yet.\n" +
196
+ "Run the worker directly instead: brand-manager-worker run",
197
+ );
198
+ process.exit(1);
199
+ }
200
+ console.log(
201
+ "\n✓ All set! This computer now drafts your brand replies in the background —\n" +
202
+ " whenever it's on, even after a restart. You can close this window.\n\n" +
203
+ ` Logs: ${LOG_FILE}\n` +
204
+ " Turn off: npx --yes brand-manager-worker uninstall\n",
205
+ );
206
+ }
207
+
208
+ export function uninstall() {
209
+ if (process.platform === "darwin") macUninstall();
210
+ else if (process.platform === "win32") winUninstall();
211
+ // Only remove this worker's background copy + logs; leave the SHARED env
212
+ // (and the carousel worker) untouched.
213
+ rmSync(APP_PREFIX, { recursive: true, force: true });
214
+ rmSync(LOG_FILE, { force: true });
215
+ rmSync(ERR_FILE, { force: true });
216
+ console.log("✓ Brand worker removed. (Your other workers and connection are untouched.)");
217
+ }
218
+
219
+ export function update({ root }) {
220
+ const saved = readEnvFile();
221
+ if (!saved.WORKER_TOKEN) {
222
+ console.error(
223
+ "No saved worker token — this computer hasn't been connected yet.\n" +
224
+ "Connect at https://goosetools.com/dashboard/brand-manager and run:\n" +
225
+ " npx --yes brand-manager-worker@latest install --token gt_…",
226
+ );
227
+ process.exit(1);
228
+ }
229
+ const before = installedVersion();
230
+ installStableCopy(root);
231
+ if (process.platform === "darwin") macInstall();
232
+ else if (process.platform === "win32") winInstall();
233
+ else {
234
+ console.error("Automatic background setup isn't available on this OS yet.");
235
+ process.exit(1);
236
+ }
237
+ const after = installedVersion();
238
+ console.log(
239
+ after && before && after !== before
240
+ ? `\n✓ Updated ${before} → ${after} and restarted. Nothing else to do.\n`
241
+ : `\n✓ Brand worker reinstalled (version ${after ?? "unknown"}) and restarted.\n`,
242
+ );
243
+ }
244
+
245
+ // ── status ───────────────────────────────────────────────────────────────────
246
+ function installedVersion() {
247
+ try {
248
+ const pkg = join(APP_PREFIX, "node_modules", "brand-manager-worker", "package.json");
249
+ return JSON.parse(readFileSync(pkg, "utf8")).version ?? null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function latestVersion() {
256
+ try {
257
+ return sh("npm view brand-manager-worker version", { shell: true, timeout: 15000 });
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ function compareVersions(a, b) {
264
+ const parts = (v) => String(v).split(".").map((n) => Number.parseInt(n, 10) || 0);
265
+ const [pa, pb] = [parts(a), parts(b)];
266
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
267
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
268
+ }
269
+ return 0;
270
+ }
271
+
272
+ function servicePid() {
273
+ try {
274
+ if (process.platform === "darwin") {
275
+ const line = sh(`launchctl list | grep ${MAC_LABEL}`, { shell: true });
276
+ const pid = line.split(/\s+/)[0];
277
+ return pid === "-" ? null : pid;
278
+ }
279
+ const out = sh(`schtasks /Query /TN "${WIN_TASK}" /FO LIST`, { shell: true });
280
+ return /Running/i.test(out) ? "running" : null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function tail(file, n) {
287
+ try {
288
+ return readFileSync(file, "utf8").trimEnd().split("\n").slice(-n).join("\n");
289
+ } catch {
290
+ return "";
291
+ }
292
+ }
293
+
294
+ function lastWritten(file) {
295
+ try {
296
+ const ms = Date.now() - statSync(file).mtimeMs;
297
+ const mins = Math.round(ms / 60000);
298
+ if (mins < 1) return "just now";
299
+ if (mins < 60) return `${mins} min ago`;
300
+ const hours = Math.round(mins / 60);
301
+ if (hours < 24) return `${hours} hr ago`;
302
+ return `${Math.round(hours / 24)} days ago`;
303
+ } catch {
304
+ return "unknown";
305
+ }
306
+ }
307
+
308
+ export function status() {
309
+ const ok = (b) => (b ? "✓" : "✗");
310
+ const env = readEnvFile();
311
+ const installed = installedVersion();
312
+ const latest = latestVersion();
313
+ const pid = servicePid();
314
+ const url = env.GOOSETOOLS_URL ?? "https://goosetools.com";
315
+
316
+ console.log("\nGoose Tools brand worker — status\n");
317
+ console.log(` ${ok(installed)} Installed ${installed ?? "not installed"}`);
318
+ const cmp = installed && latest ? compareVersions(installed, latest) : 0;
319
+ const stale = cmp < 0;
320
+ if (latest) {
321
+ const note = stale ? " ← update available" : cmp > 0 ? " (you're ahead — local dev build)" : "";
322
+ console.log(` ${ok(!stale)} Latest on npm ${latest}${note}`);
323
+ } else {
324
+ console.log(" · Latest on npm couldn't check (offline?)");
325
+ }
326
+ console.log(` ${ok(pid)} Running ${pid ? `yes (pid ${pid})` : "no"}`);
327
+ console.log(` ${ok(env.WORKER_TOKEN)} Token saved ${env.WORKER_TOKEN ? "yes (shared)" : "no — connect a computer first"}`);
328
+ console.log(` · Server ${url}`);
329
+ console.log(` · Logs ${LOG_FILE}`);
330
+
331
+ const errors = tail(ERR_FILE, 15);
332
+ if (errors) {
333
+ console.log(`\nErrors (last written ${lastWritten(ERR_FILE)}):\n${errors.replace(/^/gm, " ")}`);
334
+ }
335
+ const log = tail(LOG_FILE, 15);
336
+ if (log) {
337
+ console.log(`\nActivity (last written ${lastWritten(LOG_FILE)}):\n${log.replace(/^/gm, " ")}`);
338
+ }
339
+
340
+ if (stale) {
341
+ console.log("\nTo update: npx --yes brand-manager-worker@latest update\n");
342
+ } else if (!pid && installed) {
343
+ console.log("\nNot running. Restart it with: npx --yes brand-manager-worker@latest update\n");
344
+ } else {
345
+ console.log("");
346
+ }
347
+ }
@@ -0,0 +1,65 @@
1
+ // Processed-thread state — the dedup that stops the worker re-drafting
2
+ // against the same message every poll. Port of brand-manager's
3
+ // src/core/state.ts, same shape so an imported state file works as-is:
4
+ //
5
+ // { "version": 1,
6
+ // "threads": { "<threadId>": { lastHandledMessageId, lastDraftAt,
7
+ // lastFollowupAt, lastLearnedMessageId } } }
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import { STATE_FILE } from "./paths.js";
12
+
13
+ function load() {
14
+ if (!existsSync(STATE_FILE)) return { version: 1, threads: {} };
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8"));
17
+ return { version: 1, threads: parsed.threads ?? {} };
18
+ } catch {
19
+ // A corrupt state file means re-drafting at worst — never crashing.
20
+ return { version: 1, threads: {} };
21
+ }
22
+ }
23
+
24
+ function save(state) {
25
+ mkdirSync(dirname(STATE_FILE), { recursive: true });
26
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
27
+ }
28
+
29
+ function entry(state, threadId) {
30
+ return (state.threads[threadId] ??= {});
31
+ }
32
+
33
+ export function isHandled(threadId, messageId) {
34
+ return load().threads[threadId]?.lastHandledMessageId === messageId;
35
+ }
36
+
37
+ export function markHandled(threadId, messageId) {
38
+ const state = load();
39
+ const e = entry(state, threadId);
40
+ e.lastHandledMessageId = messageId;
41
+ e.lastDraftAt = new Date().toISOString();
42
+ save(state);
43
+ }
44
+
45
+ export function lastFollowupAt(threadId) {
46
+ return load().threads[threadId]?.lastFollowupAt ?? null;
47
+ }
48
+
49
+ export function markFollowup(threadId, messageId) {
50
+ const state = load();
51
+ const e = entry(state, threadId);
52
+ e.lastHandledMessageId = messageId;
53
+ e.lastFollowupAt = new Date().toISOString();
54
+ save(state);
55
+ }
56
+
57
+ export function isLearned(threadId, messageId) {
58
+ return load().threads[threadId]?.lastLearnedMessageId === messageId;
59
+ }
60
+
61
+ export function markLearned(threadId, messageId) {
62
+ const state = load();
63
+ entry(state, threadId).lastLearnedMessageId = messageId;
64
+ save(state);
65
+ }