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
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# brand-manager-worker
|
|
2
|
+
|
|
3
|
+
The Goose Tools brand worker — one daemon serving **Brand Manager** (inbound: reads your brand
|
|
4
|
+
email, drafts replies in your voice) and **Brand Outreach** (outbound: researches brands you want
|
|
5
|
+
to work with and drafts the pitch). It runs on **your** computer using **your own Claude account** — so there's no API key and nothing to pay
|
|
6
|
+
for beyond the Claude subscription you already have.
|
|
7
|
+
|
|
8
|
+
**It never sends anything.** Every reply is created as a Gmail draft for you to review. The Gmail
|
|
9
|
+
permission granted doesn't include sending, and no code in this package calls Gmail's send endpoint.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
You don't normally run these by hand — <https://goosetools.com/dashboard/brand-manager> gives you a
|
|
14
|
+
one-line command with your token in it. For reference:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @anthropic-ai/claude-code # the thing that writes your replies
|
|
18
|
+
claude # once, to sign in — then type /exit
|
|
19
|
+
|
|
20
|
+
npx --yes brand-manager-worker install --token gt_…
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`install` registers a background service that starts with your computer. You don't need to leave a
|
|
24
|
+
terminal open.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx --yes brand-manager-worker@latest status # what's running, recent logs
|
|
28
|
+
npx --yes brand-manager-worker@latest update # pull the newest version, restart
|
|
29
|
+
npx --yes brand-manager-worker uninstall # remove it
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
One token connects your computer to every Goose Tools worker. If you've already set up the Carousel
|
|
33
|
+
or Caption worker, `install` here needs no `--token`.
|
|
34
|
+
|
|
35
|
+
## Where your stuff lives
|
|
36
|
+
|
|
37
|
+
Everything personal is on your machine, in `~/.goosetools/brand-manager/`:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
base/ prompt files synced down from Goose Tools — don't hand-edit, they get overwritten
|
|
41
|
+
voice/ how you write to brands
|
|
42
|
+
playbook/ rate-card.md and your negotiation moves
|
|
43
|
+
deals/ one markdown ledger per brand: terms, dates, what's outstanding
|
|
44
|
+
stats/ your media-kit numbers and screenshots
|
|
45
|
+
drafts.md every reply it has drafted
|
|
46
|
+
state.json which threads it has already handled
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
These are ordinary files. You can open the folder in Claude Code and talk to the agent directly —
|
|
50
|
+
"bump my Reel rate", "what did this brand agree to?" — exactly as you would in the standalone
|
|
51
|
+
brand-manager repo. The website is a view onto these files, not the other way round.
|
|
52
|
+
|
|
53
|
+
Your email bodies and contracts are never uploaded. What the site stores is thread metadata (who,
|
|
54
|
+
subject, when), your ledgers and knowledge files, and the drafts the agent wrote.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node worker/index.js --url http://localhost:3000 --token gt_…
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Config precedence: command-line flags → environment → `worker/.env` → `~/.goosetools/env`.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "brand-manager-worker",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ernkerr/brand-manager-worker.git"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"worker",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"brand-manager-worker": "worker/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"worker": "node worker/index.js",
|
|
22
|
+
"test": "node --test \"test/*.test.js\""
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"mammoth": "^1.8.0",
|
|
26
|
+
"pdf-parse": "^1.1.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/worker/agent.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The inbound drafting brain: classification, the drafting prompt, decision
|
|
2
|
+
// parsing, and the learn-from-sent-edits diff. Ported from brand-manager's
|
|
3
|
+
// src/core/agent.ts + the classify/pre-filter half of src/core/pipeline.ts.
|
|
4
|
+
//
|
|
5
|
+
// decide() is deliberately single-shot with tools OFF — all context inlined
|
|
6
|
+
// by loadContext() — per the ~36s-vs-10min lesson recorded in claude.js.
|
|
7
|
+
|
|
8
|
+
import { oneShot, parseJson, session } from "./claude.js";
|
|
9
|
+
import { loadContext, invalidateContext } from "./context.js";
|
|
10
|
+
import { BRAND_DIR } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
// Automated / no-reply senders never send brand deals — skip them WITHOUT
|
|
13
|
+
// spending a Claude call. Conservative on purpose: real brands/agencies use
|
|
14
|
+
// human addresses.
|
|
15
|
+
const AUTOMATED_SENDER =
|
|
16
|
+
/no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|notifications?@|accounts\.google\.com|@.*\.(beehiiv|substack)\.com|@e\.|@email\.|@mail\./i;
|
|
17
|
+
|
|
18
|
+
export const isAutomated = (from) => AUTOMATED_SENDER.test(from);
|
|
19
|
+
|
|
20
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decide whether a thread needs the agent, and why. Pure: state lookups are
|
|
24
|
+
* injected so this is unit-testable and reusable from any handler.
|
|
25
|
+
* Returns 'new-or-reply' | 'followup-due' | null.
|
|
26
|
+
*/
|
|
27
|
+
export function classify(thread, { followupDays, isHandled, lastFollowupAt }) {
|
|
28
|
+
const last = thread.last;
|
|
29
|
+
if (!last.fromMe && isAutomated(last.from)) return null;
|
|
30
|
+
const ageDays = (Date.now() - Date.parse(last.date)) / DAY_MS;
|
|
31
|
+
|
|
32
|
+
if (last.fromMe) {
|
|
33
|
+
// Ball is in the brand's court. Nudge only once per followupDays window.
|
|
34
|
+
if (ageDays < followupDays) return null;
|
|
35
|
+
const lastNudge = lastFollowupAt(thread.id);
|
|
36
|
+
if (lastNudge && (Date.now() - Date.parse(lastNudge)) / DAY_MS < followupDays) return null;
|
|
37
|
+
return "followup-due";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Brand sent last — draft a reply, unless we already drafted against this message.
|
|
41
|
+
if (isHandled(thread.id, last.id)) return null;
|
|
42
|
+
return "new-or-reply";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderThread(thread) {
|
|
46
|
+
const n = thread.messages.length;
|
|
47
|
+
return thread.messages
|
|
48
|
+
.map((m, i) => {
|
|
49
|
+
const isLast = i === n - 1;
|
|
50
|
+
const text = (m.body || m.snippet).slice(0, isLast ? 6000 : 1800);
|
|
51
|
+
const who = m.fromMe ? "YOU (the creator)" : "BRAND";
|
|
52
|
+
const tag = isLast ? " <<<<< LATEST MESSAGE — your reply responds to THIS one" : "";
|
|
53
|
+
return `--- ${who} | ${m.from} | ${m.date}${tag}\nSubject: ${m.subject}\n${text}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
59
|
+
const contractBlock = contracts.length
|
|
60
|
+
? `\n\nATTACHED DOCUMENTS (extracted text — verify per the contract mode above):\n${contracts
|
|
61
|
+
.map((d) => `### ${d.filename}\n${d.text.slice(0, 8000) || "(could not extract text)"}`)
|
|
62
|
+
.join("\n\n")}`
|
|
63
|
+
: "";
|
|
64
|
+
|
|
65
|
+
// A user instruction steers THIS draft only. It sits above the mode
|
|
66
|
+
// guidance so it wins for the turn without becoming a learned rule.
|
|
67
|
+
const steeringBlock = instruction
|
|
68
|
+
? [
|
|
69
|
+
``,
|
|
70
|
+
`===== CREATOR'S INSTRUCTION FOR THIS REPLY (overrides mode guidance for this draft only) =====`,
|
|
71
|
+
instruction,
|
|
72
|
+
].join("\n")
|
|
73
|
+
: "";
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
`You are brand-manager, drafting a brand-deal email reply for the creator.`,
|
|
77
|
+
`EVERYTHING you need is provided below. DO NOT use any tools and DO NOT read files —`,
|
|
78
|
+
`all voice, playbook, rate card, stats, deal ledgers, and mode guidance are inline here.`,
|
|
79
|
+
`Respond with ONLY the JSON described at the end. Work from the provided context alone.`,
|
|
80
|
+
``,
|
|
81
|
+
`===== CONTEXT (voice · playbook · rates · stats · deal ledgers · modes) =====`,
|
|
82
|
+
context,
|
|
83
|
+
`===== END CONTEXT =====`,
|
|
84
|
+
steeringBlock,
|
|
85
|
+
``,
|
|
86
|
+
`===== TASK =====`,
|
|
87
|
+
`REASON: ${reason}`,
|
|
88
|
+
`ACCOUNT: ${thread.account}`,
|
|
89
|
+
`THREAD ID: ${thread.id}`,
|
|
90
|
+
`REPLY TO MESSAGE ID: ${thread.last.id}`,
|
|
91
|
+
`BRAND CONTACT: ${thread.last.from}`,
|
|
92
|
+
`EXISTING UNSENT DRAFT IN THREAD: ${thread.hasUnsentDraft ? "yes — produce a distinct SECOND option" : "no"}`,
|
|
93
|
+
``,
|
|
94
|
+
`THREAD (oldest first — read ALL of it before drafting):`,
|
|
95
|
+
renderThread(thread),
|
|
96
|
+
contractBlock,
|
|
97
|
+
``,
|
|
98
|
+
`===== HOW TO READ THE THREAD =====`,
|
|
99
|
+
`- Your reply responds to the LATEST message (marked above). Address that message and its sender.`,
|
|
100
|
+
`- But READ THE WHOLE THREAD first and treat it all as established context. The brand has often`,
|
|
101
|
+
` already told you things earlier in the thread (number of videos, deliverables, channel, budget,`,
|
|
102
|
+
` timeline, usage). NEVER ask for something they already stated anywhere in the thread. If they said`,
|
|
103
|
+
` "2 reels," acknowledge 2 reels and move on — do not ask "how many reels?". Only ask what is`,
|
|
104
|
+
` genuinely still missing, and skip the scoping questions entirely if it's all already answered.`,
|
|
105
|
+
`- Don't re-introduce yourself or repeat points you already made earlier in the thread.`,
|
|
106
|
+
``,
|
|
107
|
+
`===== TONE =====`,
|
|
108
|
+
`Warm and friendly first, professional underneath. Sound like a real person who is glad to hear from`,
|
|
109
|
+
`them, not a terms-and-conditions bot. Open with genuine warmth, keep pushback collaborative and kind`,
|
|
110
|
+
`(questions and requests, never demands or accusations), and close on a friendly note. Firm on the`,
|
|
111
|
+
`substance, soft in the delivery.`,
|
|
112
|
+
``,
|
|
113
|
+
`===== OUTPUT =====`,
|
|
114
|
+
`Output ONLY a single fenced \`\`\`json block with this shape:`,
|
|
115
|
+
`{"action":"draft"|"skip","to":"<email>","subject":"Re: ...","body":"<full reply, warm + professional, no em dashes, never the phrase 'happy to explore this'>","attachments":["stats/screenshots/..."],"summary":"<one line>","flags":["<any contract/exclusivity discrepancy>"]}`,
|
|
116
|
+
`Rules: draft only (never send); honor the voice (no em dashes; banned phrases); reply to the latest`,
|
|
117
|
+
`message using the whole thread as context (never re-ask answered questions); if an unsent draft`,
|
|
118
|
+
`exists, produce a distinct second option; gifted-only -> Story mention, not a free Reel; verify any`,
|
|
119
|
+
`attached contract and FLAG specific discrepancies; check the deal ledgers for exclusivity conflicts.`,
|
|
120
|
+
`If no reply is warranted, use action "skip" with a reason in summary.`,
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run the drafting agent over one thread. Returns the Decision or null on
|
|
126
|
+
* failure — and null MUST mean "retry later", never "skip this thread".
|
|
127
|
+
*/
|
|
128
|
+
export async function decide({ thread, reason, instruction = null, contracts = [] }) {
|
|
129
|
+
const context = loadContext();
|
|
130
|
+
const prompt = buildPrompt({ thread, reason, instruction, contracts, context });
|
|
131
|
+
const stdout = await oneShot(prompt, { cwd: BRAND_DIR });
|
|
132
|
+
const decision = parseJson(stdout);
|
|
133
|
+
if (!decision || (decision.action !== "draft" && decision.action !== "skip")) return null;
|
|
134
|
+
return decision;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalizeWs = (s) => s.replace(/\s+/g, " ").trim();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Word-level LCS diff -> list of `- "DRAFT text" -> "SENT text"` hunks.
|
|
141
|
+
* Ignores whitespace so plain-text re-wrapping on send doesn't register.
|
|
142
|
+
*/
|
|
143
|
+
export function wordDiffHunks(a, b) {
|
|
144
|
+
const A = normalizeWs(a).split(" ");
|
|
145
|
+
const B = normalizeWs(b).split(" ");
|
|
146
|
+
const n = A.length;
|
|
147
|
+
const m = B.length;
|
|
148
|
+
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
149
|
+
for (let i = n - 1; i >= 0; i--)
|
|
150
|
+
for (let j = m - 1; j >= 0; j--)
|
|
151
|
+
dp[i][j] = A[i] === B[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
152
|
+
const hunks = [];
|
|
153
|
+
let i = 0;
|
|
154
|
+
let j = 0;
|
|
155
|
+
let del = [];
|
|
156
|
+
let ins = [];
|
|
157
|
+
const flush = () => {
|
|
158
|
+
if (del.length || ins.length) hunks.push(`- "${del.join(" ")}" -> "${ins.join(" ")}"`);
|
|
159
|
+
del = [];
|
|
160
|
+
ins = [];
|
|
161
|
+
};
|
|
162
|
+
while (i < n && j < m) {
|
|
163
|
+
if (A[i] === B[j]) {
|
|
164
|
+
flush();
|
|
165
|
+
i++;
|
|
166
|
+
j++;
|
|
167
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) del.push(A[i++]);
|
|
168
|
+
else ins.push(B[j++]);
|
|
169
|
+
}
|
|
170
|
+
while (i < n) del.push(A[i++]);
|
|
171
|
+
while (j < m) ins.push(B[j++]);
|
|
172
|
+
flush();
|
|
173
|
+
return hunks;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Diff what the creator actually sent against the body we drafted, and let
|
|
178
|
+
* the agent save any DURABLE preference to the personal layer. Edits files,
|
|
179
|
+
* so it runs as a tool-enabled session scoped to the brand directory.
|
|
180
|
+
* Returns a one-line summary ('' = nothing learned).
|
|
181
|
+
*/
|
|
182
|
+
export async function learnFromEdit({ subject, brandContact, original, sent }) {
|
|
183
|
+
const hunks = wordDiffHunks(original, sent);
|
|
184
|
+
if (hunks.length === 0) return ""; // sent verbatim
|
|
185
|
+
// A huge diff means a rewrite, not a tweak — too noisy to mine reliably.
|
|
186
|
+
if (hunks.length > 40) return "";
|
|
187
|
+
|
|
188
|
+
const prompt = [
|
|
189
|
+
`You are brand-manager. The creator edited your drafted reply before sending it. Learn from the`,
|
|
190
|
+
`edits per base/rules/feedback-loop.md — this IS the "diff the sent draft against the original`,
|
|
191
|
+
`and learn" step.`,
|
|
192
|
+
``,
|
|
193
|
+
`Thread subject: ${subject}`,
|
|
194
|
+
`Brand contact: ${brandContact}`,
|
|
195
|
+
``,
|
|
196
|
+
`The exact changes they made (DRAFT wording -> what they SENT):`,
|
|
197
|
+
...hunks,
|
|
198
|
+
``,
|
|
199
|
+
`For each change, decide whether it reflects a DURABLE preference worth saving:`,
|
|
200
|
+
`- Wording / tone / phrasing -> append to voice/voice.md (or voice/never-words.md if they removed`,
|
|
201
|
+
` a word/phrase they dislike).`,
|
|
202
|
+
`- A rate or number -> playbook/rate-card.md. A negotiation move -> playbook/negotiation.md.`,
|
|
203
|
+
`- A fact specific to this brand -> deals/<brand>.md.`,
|
|
204
|
+
`Rules: APPEND, never overwrite. Choose the MOST SPECIFIC file. Skip typos and one-off phrasings`,
|
|
205
|
+
`that carry no reusable lesson. If nothing is durable, save nothing.`,
|
|
206
|
+
`Finally, output ONE line summarizing what you saved, or exactly "no durable lesson" if you saved nothing.`,
|
|
207
|
+
].join("\n");
|
|
208
|
+
|
|
209
|
+
const { text } = await session(prompt, { cwd: BRAND_DIR, timeoutMs: 4 * 60 * 1000 });
|
|
210
|
+
invalidateContext();
|
|
211
|
+
return text.trim().split("\n").filter(Boolean).pop() ?? "";
|
|
212
|
+
}
|
package/worker/api.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Talking to Goose Tools: config resolution, the job endpoints, and the base
|
|
2
|
+
// asset sync.
|
|
3
|
+
//
|
|
4
|
+
// Config precedence matches the other workers exactly — flags beat env beats
|
|
5
|
+
// worker/.env beats ~/.goosetools/env — so a machine already connected for
|
|
6
|
+
// carousels or captions is already connected for this, no second token.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { mkdirSync } from "node:fs";
|
|
12
|
+
import { GOOSE_DIR, BASE_DIR, ASSETS_VERSION_FILE } from "./paths.js";
|
|
13
|
+
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
function parseEnvFile(file) {
|
|
17
|
+
if (!existsSync(file)) return {};
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
readFileSync(file, "utf8")
|
|
20
|
+
.split("\n")
|
|
21
|
+
.filter((l) => l.includes("=") && !l.trim().startsWith("#"))
|
|
22
|
+
.map((l) => [l.slice(0, l.indexOf("=")).trim(), l.slice(l.indexOf("=") + 1).trim()]),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flag(name) {
|
|
27
|
+
const i = process.argv.indexOf(name);
|
|
28
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const localEnv = parseEnvFile(join(HERE, ".env"));
|
|
32
|
+
const sharedEnv = parseEnvFile(join(GOOSE_DIR, "env"));
|
|
33
|
+
|
|
34
|
+
export const BASE_URL = (
|
|
35
|
+
flag("--url") ??
|
|
36
|
+
process.env.GOOSETOOLS_URL ??
|
|
37
|
+
localEnv.GOOSETOOLS_URL ??
|
|
38
|
+
sharedEnv.GOOSETOOLS_URL ??
|
|
39
|
+
"https://goosetools.com"
|
|
40
|
+
).replace(/\/$/, "");
|
|
41
|
+
|
|
42
|
+
export const TOKEN =
|
|
43
|
+
flag("--token") ??
|
|
44
|
+
process.env.WORKER_TOKEN ??
|
|
45
|
+
localEnv.WORKER_TOKEN ??
|
|
46
|
+
sharedEnv.WORKER_TOKEN;
|
|
47
|
+
|
|
48
|
+
export const POLL_MS = 30_000;
|
|
49
|
+
|
|
50
|
+
/** POST to /api/brand/worker/<path>. Returns null on 204 (no work). */
|
|
51
|
+
export async function api(path, body) {
|
|
52
|
+
const res = await fetch(`${BASE_URL}/api/brand/worker/${path}`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${TOKEN}`,
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
},
|
|
58
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
if (res.status === 204) return null;
|
|
61
|
+
if (res.status === 401) {
|
|
62
|
+
console.error(
|
|
63
|
+
"\nThis computer's token was rejected.\n" +
|
|
64
|
+
"Reconnect it from https://goosetools.com/dashboard/brand-manager\n",
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`${path} failed: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
|
70
|
+
}
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sync the base prompt layer if the server's version differs from ours.
|
|
76
|
+
*
|
|
77
|
+
* The base layer is served rather than bundled so a prompt fix reaches every
|
|
78
|
+
* connected machine on the next poll. Local personal files (voice/, playbook/,
|
|
79
|
+
* deals/) are never touched by this — only base/.
|
|
80
|
+
*/
|
|
81
|
+
export async function syncAssets(serverVersion) {
|
|
82
|
+
const local = existsSync(ASSETS_VERSION_FILE)
|
|
83
|
+
? readFileSync(ASSETS_VERSION_FILE, "utf8").trim()
|
|
84
|
+
: null;
|
|
85
|
+
if (serverVersion && local === serverVersion) return false;
|
|
86
|
+
|
|
87
|
+
const res = await fetch(
|
|
88
|
+
`${BASE_URL}/api/brand/assets${local ? `?version=${encodeURIComponent(local)}` : ""}`,
|
|
89
|
+
{ headers: { authorization: `Bearer ${TOKEN}` } },
|
|
90
|
+
);
|
|
91
|
+
if (!res.ok) throw new Error(`asset sync failed: ${res.status}`);
|
|
92
|
+
const data = await res.json();
|
|
93
|
+
if (data.unchanged) return false;
|
|
94
|
+
|
|
95
|
+
for (const [rel, body] of Object.entries(data.files ?? {})) {
|
|
96
|
+
// Defensive: never let a server response write outside base/.
|
|
97
|
+
if (rel.includes("..") || rel.startsWith("/")) continue;
|
|
98
|
+
const dest = join(BASE_DIR, rel);
|
|
99
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
100
|
+
writeFileSync(dest, body);
|
|
101
|
+
}
|
|
102
|
+
writeFileSync(ASSETS_VERSION_FILE, data.version);
|
|
103
|
+
return data.version;
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Contract text extraction — download contract-like attachments and pull
|
|
2
|
+
// their text so the drafting prompt can verify terms against the thread.
|
|
3
|
+
// Port of brand-manager's src/actions/attachment.ts.
|
|
4
|
+
//
|
|
5
|
+
// Extracted text is inlined into ONE prompt and discarded; nothing here
|
|
6
|
+
// persists contract contents. The durable record is the deal ledger the
|
|
7
|
+
// agent writes after reading it.
|
|
8
|
+
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import mammoth from "mammoth";
|
|
11
|
+
import { getAttachment } from "./gmail.js";
|
|
12
|
+
|
|
13
|
+
// pdf-parse ships as CommonJS with a debug side-effect on default import.
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
const CONTRACT_EXT = /\.(pdf|docx)$/i;
|
|
17
|
+
|
|
18
|
+
export function isContractLike(a) {
|
|
19
|
+
return (
|
|
20
|
+
CONTRACT_EXT.test(a.filename) ||
|
|
21
|
+
a.mimeType === "application/pdf" ||
|
|
22
|
+
a.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function extractText(buf, filename) {
|
|
27
|
+
try {
|
|
28
|
+
if (/\.pdf$/i.test(filename)) {
|
|
29
|
+
const pdfParse = require("pdf-parse");
|
|
30
|
+
return (await pdfParse(buf)).text.trim();
|
|
31
|
+
}
|
|
32
|
+
if (/\.docx$/i.test(filename)) {
|
|
33
|
+
return (await mammoth.extractRawText({ buffer: buf })).value.trim();
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Unsupported/corrupt file → '' and the prompt says so explicitly.
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Download + extract text from every contract-like attachment on a message. */
|
|
42
|
+
export async function extractContracts(accessToken, messageId, attachments) {
|
|
43
|
+
const docs = [];
|
|
44
|
+
for (const a of attachments) {
|
|
45
|
+
if (!isContractLike(a)) continue;
|
|
46
|
+
const buf = await getAttachment(accessToken, messageId, a.id);
|
|
47
|
+
docs.push({ filename: a.filename, mimeType: a.mimeType, text: await extractText(buf, a.filename) });
|
|
48
|
+
}
|
|
49
|
+
return docs;
|
|
50
|
+
}
|
package/worker/claude.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Running Claude on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Two modes, and the split matters for speed. brand-manager learned this the
|
|
4
|
+
// hard way (commit c0c1b33): letting Claude loop on tools to gather its own
|
|
5
|
+
// context took 5–10 minutes per draft versus ~36 seconds when everything was
|
|
6
|
+
// inlined up front. So:
|
|
7
|
+
//
|
|
8
|
+
// oneShot() — drafting. Tools OFF, all context inlined by the caller. Fast.
|
|
9
|
+
// session() — chat, voice-audit, stats, learn. Tools ON, scoped to the
|
|
10
|
+
// brand directory, and resumable so a follow-up message
|
|
11
|
+
// remembers the previous turn.
|
|
12
|
+
//
|
|
13
|
+
// There is no API key anywhere here. This runs on the user's own Claude
|
|
14
|
+
// subscription via the `claude` CLI, same as every other Goose Tools worker.
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN ?? "claude";
|
|
19
|
+
|
|
20
|
+
// stdio[0] MUST be "ignore". Inherited stdin makes every invocation hang
|
|
21
|
+
// forever under launchd — brand-manager hit this exact bug (fixed in 3b1f955)
|
|
22
|
+
// and it presents as the worker silently doing nothing after a reboot.
|
|
23
|
+
function run(args, { timeoutMs, cwd }) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
26
|
+
cwd,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
32
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
child.kill("SIGKILL");
|
|
35
|
+
reject(new Error(`claude timed out after ${timeoutMs}ms`));
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
child.on("error", (err) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
reject(
|
|
40
|
+
err.code === "ENOENT"
|
|
41
|
+
? new Error("Claude Code not found. Install: npm install -g @anthropic-ai/claude-code")
|
|
42
|
+
: err,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
child.on("close", (code) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
if (code === 0) resolve(stdout);
|
|
48
|
+
else reject(new Error(`claude exited ${code}: ${stderr.slice(0, 600)}`));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Single-shot, no tools. For drafting, where the caller inlines all context. */
|
|
54
|
+
export async function oneShot(prompt, { timeoutMs = 4 * 60 * 1000, cwd } = {}) {
|
|
55
|
+
return run(
|
|
56
|
+
[
|
|
57
|
+
"-p",
|
|
58
|
+
prompt,
|
|
59
|
+
"--output-format",
|
|
60
|
+
"text",
|
|
61
|
+
"--disallowedTools",
|
|
62
|
+
"Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch",
|
|
63
|
+
],
|
|
64
|
+
{ timeoutMs, cwd },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A real, resumable Claude Code session with tools, scoped to the brand
|
|
70
|
+
* directory. This is what makes the website chat behave like talking to the
|
|
71
|
+
* agent in a terminal: it can read the ledgers, edit the rate card, search
|
|
72
|
+
* Gmail through the worker's own commands, and remember the last turn.
|
|
73
|
+
*
|
|
74
|
+
* Returns { text, sessionId } — pass sessionId back in to continue.
|
|
75
|
+
*/
|
|
76
|
+
export async function session(
|
|
77
|
+
prompt,
|
|
78
|
+
{
|
|
79
|
+
cwd,
|
|
80
|
+
sessionId,
|
|
81
|
+
timeoutMs = 10 * 60 * 1000,
|
|
82
|
+
// Outreach research adds WebSearch/WebFetch — the one place minutes-long
|
|
83
|
+
// tool use is the point, not the failure mode the oneShot split avoids.
|
|
84
|
+
tools = "Read,Edit,Write,Glob,Grep",
|
|
85
|
+
} = {},
|
|
86
|
+
) {
|
|
87
|
+
const args = [
|
|
88
|
+
"-p",
|
|
89
|
+
prompt,
|
|
90
|
+
"--output-format",
|
|
91
|
+
"json",
|
|
92
|
+
"--permission-mode",
|
|
93
|
+
"acceptEdits",
|
|
94
|
+
"--allowedTools",
|
|
95
|
+
tools,
|
|
96
|
+
];
|
|
97
|
+
if (sessionId) args.push("--resume", sessionId);
|
|
98
|
+
|
|
99
|
+
const raw = await run(args, { timeoutMs, cwd });
|
|
100
|
+
|
|
101
|
+
// --output-format json gives a result envelope carrying the session id.
|
|
102
|
+
// Be forgiving: a shape change shouldn't lose the user's answer, so fall
|
|
103
|
+
// back to treating the output as plain text.
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
return {
|
|
107
|
+
text: parsed.result ?? parsed.text ?? raw,
|
|
108
|
+
sessionId: parsed.session_id ?? parsed.sessionId ?? sessionId ?? null,
|
|
109
|
+
};
|
|
110
|
+
} catch {
|
|
111
|
+
return { text: raw, sessionId: sessionId ?? null };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pull a JSON object out of Claude's text output. Ported verbatim in spirit
|
|
117
|
+
* from brand-manager's parseDecision: prefer a fenced block, else take the
|
|
118
|
+
* first {...last }. Returns null on failure — the caller must treat that as
|
|
119
|
+
* "retry later", never as "skip this thread".
|
|
120
|
+
*/
|
|
121
|
+
export function parseJson(text) {
|
|
122
|
+
const fenced = text.match(/```json\s*([\s\S]*?)```/);
|
|
123
|
+
const candidate = fenced
|
|
124
|
+
? fenced[1]
|
|
125
|
+
: (() => {
|
|
126
|
+
const start = text.indexOf("{");
|
|
127
|
+
const end = text.lastIndexOf("}");
|
|
128
|
+
return start !== -1 && end > start ? text.slice(start, end + 1) : null;
|
|
129
|
+
})();
|
|
130
|
+
if (!candidate) return null;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(candidate);
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/worker/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// brand-manager-worker entrypoint. Like the caption worker, the only
|
|
3
|
+
// prerequisite is Claude Code — it does the drafting, on this machine, with
|
|
4
|
+
// the user's own subscription. Nothing here needs an API key.
|
|
5
|
+
//
|
|
6
|
+
// install save token + register a login service (worker runs forever)
|
|
7
|
+
// update pull the newest code into the background copy + restart
|
|
8
|
+
// status what's installed, whether it's running, recent log lines
|
|
9
|
+
// uninstall remove the service + saved token
|
|
10
|
+
// run run the worker loop in this window (default)
|
|
11
|
+
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
|
|
18
|
+
const command = ["install", "update", "status", "uninstall", "run"].includes(process.argv[2])
|
|
19
|
+
? process.argv[2]
|
|
20
|
+
: "run";
|
|
21
|
+
|
|
22
|
+
if (command === "uninstall") {
|
|
23
|
+
const { uninstall } = await import("./service.js");
|
|
24
|
+
uninstall();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command === "status") {
|
|
29
|
+
const { status } = await import("./service.js");
|
|
30
|
+
status();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flag(name) {
|
|
35
|
+
const i = process.argv.indexOf(name);
|
|
36
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function has(cmd) {
|
|
40
|
+
try {
|
|
41
|
+
execSync(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`, {
|
|
42
|
+
stdio: "ignore",
|
|
43
|
+
shell: true,
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!has("claude")) {
|
|
52
|
+
console.error(
|
|
53
|
+
"\nClaude Code isn't installed yet (it writes your replies).\n" +
|
|
54
|
+
"Install it with: npm install -g @anthropic-ai/claude-code\n" +
|
|
55
|
+
"Then run: claude (once, to sign in — type /exit to leave)\n" +
|
|
56
|
+
"Then run this command again.\n",
|
|
57
|
+
);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (command === "install") {
|
|
62
|
+
const { install } = await import("./service.js");
|
|
63
|
+
install({
|
|
64
|
+
url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
|
|
65
|
+
token: flag("--token"),
|
|
66
|
+
root: ROOT,
|
|
67
|
+
});
|
|
68
|
+
} else if (command === "update") {
|
|
69
|
+
const { update } = await import("./service.js");
|
|
70
|
+
update({ root: ROOT });
|
|
71
|
+
} else {
|
|
72
|
+
await import("./index.js");
|
|
73
|
+
}
|