brand-manager-worker 0.1.3 → 0.2.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 CHANGED
@@ -40,7 +40,8 @@ Everything personal is on your machine, in `~/.goosetools/brand-manager/`:
40
40
  base/ prompt files synced down from Goose Tools — don't hand-edit, they get overwritten
41
41
  voice/ how you write to brands
42
42
  playbook/ rate-card.md and your negotiation moves
43
- deals/ one markdown ledger per brand: terms, dates, what's outstanding
43
+ deals/ one record per brand: current terms, dates, money, and the decisions behind them
44
+ archive/ the long-form history, kept out of the drafting prompt
44
45
  stats/ your media-kit numbers and screenshots
45
46
  drafts.md every reply it has drafted
46
47
  state.json which threads it has already handled
@@ -50,6 +51,20 @@ These are ordinary files. You can open the folder in Claude Code and talk to the
50
51
  "bump my Reel rate", "what did this brand agree to?" — exactly as you would in the standalone
51
52
  brand-manager repo. The website is a view onto these files, not the other way round.
52
53
 
54
+ A deal record is a small `<!-- brand … -->` JSON block plus a `## Decisions` log of one-liners. It
55
+ stays small on purpose: everything in `deals/` is loaded into every draft, so a brand whose record
56
+ bloats crowds out the others. The long history moves to `archive/` and is read only when you ask
57
+ for it. Older `<!-- board … -->` ledgers still work and are read as-is.
58
+
59
+ The same applies to what it learns from your edits. `voice/voice.md` and `playbook/negotiation.md`
60
+ grow every time you change a draft before sending, so when one passes 40KB the worker rewrites it
61
+ smaller — merging repeats and dropping superseded entries, never losing a distinct rule. The
62
+ pre-compaction copy is kept in `archive/`.
63
+
64
+ One field is yours alone: whether an invoice was actually paid. The agent scores how confident it
65
+ is from the email evidence ("already processed" from a brand is not the same as money landing), and
66
+ anything short of certain asks you rather than counting itself. Your answer then wins permanently.
67
+
53
68
  Your email bodies and contracts are never uploaded. What the site stores is thread metadata (who,
54
69
  subject, when), your ledgers and knowledge files, and the drafts the agent wrote.
55
70
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "brand-manager-worker",
3
- "version": "0.1.3",
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.",
3
+ "version": "0.2.0",
4
+ "description": "The Goose Tools brand-deal worker \u2014 your computer reads your brand email and drafts replies in your voice for goosetools.com, using your own Claude account. Drafts only; it never sends.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ernkerr/brand-manager-worker.git"
package/worker/agent.js CHANGED
@@ -69,7 +69,7 @@ function renderThread(thread) {
69
69
  .join("\n\n");
70
70
  }
71
71
 
72
- function buildPrompt({ thread, reason, instruction, contracts, context }) {
72
+ function buildPrompt({ thread, reason, instruction, contracts, context, statsWindows = null }) {
73
73
  const contractBlock = contracts.length
74
74
  ? `\n\nATTACHED DOCUMENTS (extracted text — verify per the contract mode above):\n${contracts
75
75
  .map((d) => `### ${d.filename}\n${d.text.slice(0, 8000) || "(could not extract text)"}`)
@@ -118,6 +118,7 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
118
118
  ` genuinely still missing, and skip the scoping questions entirely if it's all already answered.`,
119
119
  `- Don't re-introduce yourself or repeat points you already made earlier in the thread.`,
120
120
  ``,
121
+ statsWindowsBlock(statsWindows),
121
122
  `===== TONE =====`,
122
123
  `Warm and friendly first, professional underneath. Sound like a real person who is glad to hear from`,
123
124
  `them, not a terms-and-conditions bot. Open with genuine warmth, keep pushback collaborative and kind`,
@@ -126,7 +127,26 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
126
127
  ``,
127
128
  `===== OUTPUT =====`,
128
129
  `Output ONLY a single fenced \`\`\`json block with this shape:`,
129
- `{"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>"]}`,
130
+ `{"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>"],`,
131
+ `"recordUpdates":{"brand":"<brand name, matching its deal ledger>","stage":"<only if it changed>","whoseMove":"you|them",`,
132
+ ` "calendar":[{"key":"<stable key, reuse the existing one>","title":"...","date":"YYYY-MM-DD|null","tbd":true|false,"notes":"..."}],`,
133
+ ` "money":{"invoices":[{"id":"inv-1","amount":0,"status":"unpaid|paid|overdue","paidAt":null,"confidence":0.0,"evidence":"<the line that says so>"}]},`,
134
+ ` "exclusivity":{"status":"active|none|proposed|unknown","scope":"...","from":"YYYY-MM-DD|null","to":"YYYY-MM-DD|null"},`,
135
+ ` "decisions":[{"date":"YYYY-MM-DD","what":"<what was accepted or refused>","why":"<the reason worth remembering>"}]}}`,
136
+ ``,
137
+ `recordUpdates keeps the brand record current — omit it entirely when this message changed nothing.`,
138
+ `Only report what THIS message evidences. Never invent a date, a number or a term.`,
139
+ `- A date that moved: reuse the existing calendar key so it updates instead of duplicating. If they`,
140
+ ` pulled a date without naming a new one, set date null and tbd true — that is the honest record,`,
141
+ ` and "no date committed" is a thing she needs to see, not a blank.`,
142
+ `- Never emit "confirmed" on an invoice. That field is hers alone.`,
143
+ `- Payment confidence: 0.9 a payment rail (Mercury/Stripe/PayPal/bank) confirms a matching amount,`,
144
+ ` 0.6 the brand says it was sent or processed, 0.4 she says so with nothing corroborating,`,
145
+ ` 0.1 invoiced and silent past the due date. "Already processed" from a brand is 0.6, not paid.`,
146
+ `- Exclusivity status is a judgement, not prose: "active" only once a window is AGREED.`,
147
+ ` Asked-and-unanswered is "proposed", never "active" — the board reads active as binding.`,
148
+ `- Decisions are durable precedent only: what was accepted or refused and why. Not status updates,`,
149
+ ` not "sent a reply". If nothing was decided, omit decisions.`,
130
150
  `Rules: draft only (never send); honor the voice (no em dashes; banned phrases); reply to the latest`,
131
151
  `message using the whole thread as context (never re-ask answered questions); if an unsent draft`,
132
152
  `exists, produce a distinct second option; gifted-only -> Story mention, not a free Reel; verify any`,
@@ -139,12 +159,47 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
139
159
  * Run the drafting agent over one thread. Returns the Decision or null on
140
160
  * failure — and null MUST mean "retry later", never "skip this thread".
141
161
  */
142
- export async function decide({ thread, reason, instruction = null, contracts = [] }) {
162
+ /**
163
+ * Reach over several windows, from Goose Tools' own daily Instagram history.
164
+ *
165
+ * stats/latest.md holds one rolling 30-day snapshot pulled from the Graph API,
166
+ * which can only ever answer "the last 30 days". A brand asking for 30 days is
167
+ * really asking how the account performs, and the answer depends on which 30 —
168
+ * so quoting whichever window happens to end today can undersell her badly.
169
+ * The site ships these on the claim; they are a truthful menu, not a licence
170
+ * to cherry-pick, which is what the rule below says.
171
+ */
172
+ function statsWindowsBlock(windows) {
173
+ if (!Array.isArray(windows) || !windows.length) return "";
174
+ const rows = windows.map(
175
+ (w) =>
176
+ ` ${String(w.label ?? `${w.days}d`).padEnd(10)} ${w.from} to ${w.to}` +
177
+ ` views ${w.views?.toLocaleString?.() ?? w.views}` +
178
+ ` reach ${w.reach?.toLocaleString?.() ?? w.reach}` +
179
+ ` engagement ${w.engagement}`,
180
+ );
181
+ return [
182
+ `===== REACH BY WINDOW (Goose Tools daily history) =====`,
183
+ ...rows,
184
+ `Only use these if the brand asked for numbers. Quote the window that answers`,
185
+ `what they actually asked, and always say which window it is. Never present a`,
186
+ `longer window as "the last 30 days".`,
187
+ ``,
188
+ ].join("\n");
189
+ }
190
+
191
+ export async function decide({
192
+ thread,
193
+ reason,
194
+ instruction = null,
195
+ contracts = [],
196
+ statsWindows = null,
197
+ }) {
143
198
  // Live numbers before the media kit is inlined. Refreshes only when the
144
199
  // snapshot is a week old or more; a failure keeps the last one.
145
200
  if ((await refreshStatsIfStale()).refreshed) invalidateContext();
146
201
  const context = loadContext();
147
- const prompt = buildPrompt({ thread, reason, instruction, contracts, context });
202
+ const prompt = buildPrompt({ thread, reason, instruction, contracts, context, statsWindows });
148
203
  const stdout = await oneShot(prompt, { cwd: BRAND_DIR });
149
204
  const decision = parseJson(stdout);
150
205
  if (!decision || (decision.action !== "draft" && decision.action !== "skip")) return null;
@@ -0,0 +1,157 @@
1
+ // Keeping the learned layer from eating the prompt.
2
+ //
3
+ // learnFromEdit appends and never removes, by design — losing a lesson is
4
+ // worse than storing it twice. But voice.md reached 66KB and negotiation.md
5
+ // 107KB, together 169KB of a 350KB budget, and deal records load LAST, so the
6
+ // first overflow would drop whole brands off the end of the context.
7
+ //
8
+ // voice.md has been compacted by hand once already (there's a note dated
9
+ // 2026-08-11 saying so) and then regrew to four times the size of its own
10
+ // curated section. So this is the loop closing: compaction runs when a file
11
+ // crosses a threshold, not when someone remembers.
12
+ //
13
+ // The rule is preservation, not summarization. Every distinct lesson survives;
14
+ // what goes is the repetition, the dated scaffolding around a rule that has
15
+ // since become general, and anything a later entry superseded. The original is
16
+ // archived first, and a result that looks like a summary rather than a
17
+ // rewrite is refused.
18
+
19
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { oneShot, parseJson } from "./claude.js";
22
+ import { BRAND_DIR, PLAYBOOK_DIR, VOICE_DIR } from "./paths.js";
23
+
24
+ /**
25
+ * Past this, a file is costing the deal records their place in the prompt.
26
+ * Set against the observed numbers: the curated part of voice.md is ~16KB, so
27
+ * 40KB leaves generous room for real growth before anything is touched.
28
+ */
29
+ export const COMPACT_THRESHOLD = 40_000;
30
+
31
+ /** The learned files worth compacting. never-words is a short list; leave it. */
32
+ export const COMPACTABLE = [
33
+ { target: "voice", path: join(VOICE_DIR, "voice.md") },
34
+ { target: "negotiation", path: join(PLAYBOOK_DIR, "negotiation.md") },
35
+ { target: "rate-card", path: join(PLAYBOOK_DIR, "rate-card.md") },
36
+ ];
37
+
38
+ const ARCHIVE_DIR = join(BRAND_DIR, "archive");
39
+
40
+ export function sizeOf(path) {
41
+ try {
42
+ return statSync(path).size;
43
+ } catch {
44
+ return 0;
45
+ }
46
+ }
47
+
48
+ export function oversized() {
49
+ return COMPACTABLE.filter(({ path }) => sizeOf(path) > COMPACT_THRESHOLD);
50
+ }
51
+
52
+ const prompt = (label, body) =>
53
+ [
54
+ `You are compacting one of the creator's learned-knowledge files. It has grown by`,
55
+ `appending: every edit she made to a draft added an entry, and nothing was ever`,
56
+ `removed. It is now large enough to crowd her deal records out of the prompt.`,
57
+ ``,
58
+ `Rewrite it SMALLER WITHOUT LOSING A SINGLE DISTINCT RULE.`,
59
+ ``,
60
+ `What to remove:`,
61
+ `- Repetition. The same lesson learned from four brands is ONE rule; keep the`,
62
+ ` clearest statement of it and drop the rest.`,
63
+ `- Dated scaffolding. "### 2026-08-19 (Higgsfield — pricing refusal)" wrapping a rule`,
64
+ ` that is now general: keep the rule, drop the wrapper. Keep the date only where`,
65
+ ` it genuinely matters (a standing instruction she gave on a specific day).`,
66
+ `- Anything a later entry contradicts or supersedes. Later wins.`,
67
+ `- Examples that only restate the rule above them.`,
68
+ ``,
69
+ `What to keep, without exception:`,
70
+ `- Every distinct rule, preference, banned word, and standing instruction.`,
71
+ `- Every number: rates, floors, multipliers, windows.`,
72
+ `- Brand-specific facts that only apply to one counterparty, marked as such.`,
73
+ `- The existing top-level section structure where there is one.`,
74
+ ``,
75
+ `This is preservation, not summarizing. If you are unsure whether two entries are`,
76
+ `the same rule, keep both. A lost rule is a draft that sounds wrong to her again;`,
77
+ `a kept duplicate costs a few hundred bytes.`,
78
+ ``,
79
+ `FILE: ${label}`,
80
+ `=====`,
81
+ body,
82
+ `=====`,
83
+ ``,
84
+ `Output ONLY a fenced \`\`\`json block:`,
85
+ `{"markdown":"<the full rewritten file>","rulesBefore":<count you counted>,`,
86
+ ` "rulesAfter":<count in your rewrite>,"dropped":"<one line on what you merged or removed>"}`,
87
+ ].join("\n");
88
+
89
+ /**
90
+ * Compact one file. Returns a result describing what happened; never throws,
91
+ * and never writes unless the rewrite passes the guards below.
92
+ */
93
+ export async function compactFile({ target, path }) {
94
+ const before = readFileSync(path, "utf8");
95
+ // A rewrite emits the whole file, so this generates tens of thousands of
96
+ // characters — the 4-minute default blew up on voice.md's 66KB. It runs at
97
+ // most once per file per threshold crossing, so it can afford the room.
98
+ const out = parseJson(
99
+ await oneShot(prompt(`${target}.md`, before), { cwd: BRAND_DIR, timeoutMs: 20 * 60 * 1000 }),
100
+ );
101
+ const after = typeof out?.markdown === "string" ? out.markdown.trim() : "";
102
+
103
+ if (!after) return { target, ok: false, error: "no parseable rewrite" };
104
+
105
+ // Guards. An agent asked to shrink a file can decide to summarize it, and a
106
+ // 3KB precis of 66KB of learned voice is a catastrophic, silent loss — the
107
+ // archive would be the only copy and nobody would notice for weeks.
108
+ if (after.length < before.length * 0.25) {
109
+ return {
110
+ target,
111
+ ok: false,
112
+ error: `refused: ${before.length} -> ${after.length} bytes looks like a summary, not a rewrite`,
113
+ };
114
+ }
115
+ if (after.length >= before.length) {
116
+ return { target, ok: false, error: "refused: no smaller than the original" };
117
+ }
118
+ // The headings are the file's skeleton; losing most of them means it was
119
+ // restructured rather than compacted.
120
+ const headingsBefore = (before.match(/^##\s/gm) ?? []).length;
121
+ const headingsAfter = (after.match(/^##\s/gm) ?? []).length;
122
+ if (headingsBefore >= 4 && headingsAfter < 2) {
123
+ return { target, ok: false, error: "refused: lost the section structure" };
124
+ }
125
+
126
+ mkdirSync(ARCHIVE_DIR, { recursive: true });
127
+ const stamp = new Date().toISOString().slice(0, 10);
128
+ writeFileSync(join(ARCHIVE_DIR, `${target}-${stamp}.md`), before);
129
+ writeFileSync(path, `${after}\n`);
130
+
131
+ return {
132
+ target,
133
+ ok: true,
134
+ before: before.length,
135
+ after: after.length,
136
+ rulesBefore: out.rulesBefore ?? null,
137
+ rulesAfter: out.rulesAfter ?? null,
138
+ dropped: out.dropped ?? null,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Compact anything over the threshold. Called at the END of a scan, once —
144
+ * not per thread, so a scan pays for at most one of these and only on the day
145
+ * a file actually crosses the line.
146
+ */
147
+ export async function compactIfNeeded() {
148
+ const results = [];
149
+ for (const file of oversized()) {
150
+ try {
151
+ results.push(await compactFile(file));
152
+ } catch (err) {
153
+ results.push({ target: file.target, ok: false, error: err?.message ?? String(err) });
154
+ }
155
+ }
156
+ return results;
157
+ }
package/worker/context.js CHANGED
@@ -51,18 +51,12 @@ export function loadContext() {
51
51
  if (c) parts.push(`### ${label}\n${c}`);
52
52
  }
53
53
 
54
- // All deal ledgers (per-brand context + exclusivity-conflict checks). A
55
- // mature ledger can run tens of thousands of chars; keep the head (brand,
56
- // board block, terms) and the tail (newest updates), dropping the middle.
54
+ // All deal ledgers (per-brand context + exclusivity-conflict checks).
57
55
  if (existsSync(DEALS_DIR)) {
58
56
  for (const name of readdirSync(DEALS_DIR).filter((n) => n.endsWith(".md")).sort()) {
59
57
  const c = readIf(join(DEALS_DIR, name));
60
58
  if (!c) continue;
61
- const body =
62
- c.length <= LEDGER_CAP
63
- ? c
64
- : `${c.slice(0, 2_000)}\n\n[... ledger middle trimmed ...]\n\n${c.slice(-(LEDGER_CAP - 2_000))}`;
65
- parts.push(`### DEAL LEDGER: ${name}\n${body}`);
59
+ parts.push(`### DEAL LEDGER: ${name}\n${ledgerExcerpt(c)}`);
66
60
  }
67
61
  }
68
62
 
@@ -75,9 +69,46 @@ export function loadContext() {
75
69
  return value;
76
70
  }
77
71
 
78
- const LEDGER_CAP = 8_000;
72
+ const LEDGER_CAP = 12_000;
79
73
  const TOTAL_CAP = 350_000;
80
74
 
75
+ const blockRe = (name) => new RegExp(`<!--\\s*${name}\\s[\\s\\S]*?-->`, "i");
76
+
77
+ /**
78
+ * A mature ledger runs tens of thousands of chars, so it gets trimmed to a
79
+ * head and a tail. The terms the agent actually needs — rate, stage,
80
+ * exclusivity window, committed dates — live in the `board` and `calendar`
81
+ * blocks, and the old trim assumed those sat in the head. They don't: the
82
+ * agent appends updates above them, so in a long ledger they sink into the
83
+ * middle and were dropped. navan.md put `board` at byte 15,929 and `calendar`
84
+ * at 27,802 of 55,932, with only 0-2,000 and the last 6,000 kept — so every
85
+ * Navan and Microsoft draft was written blind to its own rate and exclusivity.
86
+ * Hoist both blocks to the front, then spend what's left on head and tail.
87
+ */
88
+ export function ledgerExcerpt(c) {
89
+ if (c.length <= LEDGER_CAP) return c;
90
+
91
+ const pinned = [];
92
+ for (const name of ["board", "calendar"]) {
93
+ const m = c.match(blockRe(name));
94
+ if (m) pinned.push(m[0]);
95
+ }
96
+ const head = pinned.join("\n\n").slice(0, PINNED_CAP);
97
+
98
+ // Everything below shares one budget: the pinned blocks, the marker, and the
99
+ // two joiners all count, or 36 ledgers each overshoot and starve the prompt.
100
+ const rest = LEDGER_CAP - head.length - MARKER.length - 4;
101
+ if (rest <= 0) return head.slice(0, LEDGER_CAP);
102
+
103
+ const headLen = Math.floor(rest * 0.3);
104
+ const body = `${c.slice(0, headLen)}${MARKER}${c.slice(-(rest - headLen))}`;
105
+
106
+ return head ? `${head}\n\n${body}` : body;
107
+ }
108
+
109
+ const PINNED_CAP = 6_000;
110
+ const MARKER = "\n\n[... ledger middle trimmed — terms above are authoritative ...]\n\n";
111
+
81
112
  /** Drop the cache — call after anything edits the personal layer. */
82
113
  export function invalidateContext() {
83
114
  cached = null;
@@ -8,16 +8,18 @@
8
8
  // and nothing else touches Gmail writes. Nothing in this package references
9
9
  // Gmail's send endpoint — test/no-send.test.js enforces that by pattern.
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { classify, decide, learnFromEdit } from "./agent.js";
14
14
  import { extractContracts } from "./attachments.js";
15
15
  import { session, canResume } from "./claude.js";
16
+ import { compactIfNeeded, oversized } from "./compact.js";
16
17
  import { invalidateContext } from "./context.js";
17
18
  import { appendDraftLog, lastDraftFor } from "./drafts-log.js";
18
19
  import { createReplyDraft } from "./gmail-draft.js";
19
20
  import { fetchThreads, getThread } from "./gmail.js";
20
21
  import { buildMirror } from "./mirror.js";
22
+ import { applyPaymentConfirmation, applyRecordUpdates, resolveSlug } from "./record-write.js";
21
23
  import {
22
24
  BRAND_DIR,
23
25
  DEALS_DIR,
@@ -50,7 +52,13 @@ async function draftForThread(job, thread) {
50
52
  thread.last.attachments,
51
53
  );
52
54
  const reason = job.kind === "followup" ? "followup-due" : "new-or-reply";
53
- const decision = await decide({ thread, reason, instruction: job.instruction, contracts });
55
+ const decision = await decide({
56
+ thread,
57
+ reason,
58
+ instruction: job.instruction,
59
+ contracts,
60
+ statsWindows: job.statsWindows ?? null,
61
+ });
54
62
  if (!decision) {
55
63
  // Parse/agent failure: retryable. Do NOT mark handled.
56
64
  return { ok: false, error: "Agent returned no parseable decision (will retry)" };
@@ -88,15 +96,37 @@ async function draftForThread(job, thread) {
88
96
  if (reason === "followup-due") state.markFollowup(thread.id, thread.last.id);
89
97
  else state.markHandled(thread.id, thread.last.id);
90
98
 
99
+ // Fold what the agent observed into the brand record. Same rule the learn
100
+ // step follows: this must never take down a draft that already succeeded —
101
+ // the Gmail draft is the thing she's waiting on, the bookkeeping isn't.
102
+ let recorded = null;
103
+ try {
104
+ const slug = resolveSlug(decision.recordUpdates?.brand ?? decision.brand, listDealSlugs());
105
+ if (slug) {
106
+ recorded = applyRecordUpdates(slug, decision.recordUpdates);
107
+ if (recorded) invalidateContext();
108
+ }
109
+ } catch (err) {
110
+ console.error(` ! record update failed: ${err?.message ?? err}`);
111
+ }
112
+
91
113
  return {
92
114
  ok: true,
93
- summary: decision.summary,
115
+ summary: recorded ? `${decision.summary} · record: ${recorded}` : decision.summary,
94
116
  resultBody: decision.body ?? "",
95
117
  flags: decision.flags ?? [],
96
118
  mirror: buildMirror({ threads: [thread] }),
97
119
  };
98
120
  }
99
121
 
122
+ /** Ledger slugs on disk — the set a draft's brand name may resolve to. */
123
+ function listDealSlugs() {
124
+ if (!existsSync(DEALS_DIR)) return [];
125
+ return readdirSync(DEALS_DIR)
126
+ .filter((n) => n.endsWith(".md"))
127
+ .map((n) => n.replace(/\.md$/, ""));
128
+ }
129
+
100
130
  /** kind: draft | followup — one thread, threadId pinned by the job. */
101
131
  export async function runDraft(job) {
102
132
  if (!job.gmailAccessToken) return noGmail();
@@ -126,7 +156,6 @@ export async function runScan(job) {
126
156
  const last = thread.last;
127
157
  if (last.fromMe && !state.isLearned(thread.id, last.id)) {
128
158
  const original = lastDraftFor(thread.id);
129
- state.markLearned(thread.id, last.id);
130
159
  if (original) {
131
160
  const learned = await learnFromEdit({
132
161
  subject: thread.subject,
@@ -136,6 +165,10 @@ export async function runScan(job) {
136
165
  });
137
166
  if (learned && !/no durable lesson/i.test(learned)) lines.push(`voice learned — ${learned}`);
138
167
  }
168
+ // Mark only once the lesson is actually saved. Marking first meant a
169
+ // crash or timeout mid-learn burned that message permanently: it stays
170
+ // flagged as learned, and the edit it carried can never be mined again.
171
+ state.markLearned(thread.id, last.id);
139
172
  }
140
173
  } catch {
141
174
  // Learning must never block drafting.
@@ -161,6 +194,23 @@ export async function runScan(job) {
161
194
  }
162
195
  }
163
196
 
197
+ // Once per scan, after the drafting is done, and only on the day a learned
198
+ // file actually crosses the threshold. learnFromEdit appends and never
199
+ // removes, so without this voice.md and negotiation.md grow until the deal
200
+ // records — which load last — start falling off the end of the prompt.
201
+ if (oversized().length) {
202
+ try {
203
+ for (const r of await compactIfNeeded()) {
204
+ if (r.ok) lines.push(`compacted ${r.target}.md — ${r.before} -> ${r.after} bytes`);
205
+ else console.error(` ! compaction skipped ${r.target}: ${r.error}`);
206
+ }
207
+ invalidateContext();
208
+ } catch (err) {
209
+ // Same rule as learning: never take down a scan that has already drafted.
210
+ console.error(` ! compaction failed: ${err?.message ?? err}`);
211
+ }
212
+ }
213
+
164
214
  return {
165
215
  ok: true,
166
216
  summary: lines.length ? lines.join(" · ").slice(0, 1900) : "No new brand mail needing a draft.",
@@ -169,6 +219,23 @@ export async function runScan(job) {
169
219
  };
170
220
  }
171
221
 
222
+ /** kind: compact — squeeze the learned layer on demand. */
223
+ export async function runCompact() {
224
+ const results = await compactIfNeeded();
225
+ invalidateContext();
226
+
227
+ if (!results.length) return { ok: true, summary: "Nothing over the size threshold." };
228
+ const failed = results.filter((r) => !r.ok);
229
+ return {
230
+ ok: failed.length < results.length,
231
+ summary: results
232
+ .map((r) => (r.ok ? `${r.target}: ${r.before} -> ${r.after}` : `${r.target}: ${r.error}`))
233
+ .join(" · "),
234
+ error: failed.length === results.length ? failed[0].error : undefined,
235
+ mirror: buildMirror({ threads: [] }),
236
+ };
237
+ }
238
+
172
239
  /**
173
240
  * kind: chat — a real, resumable Claude session scoped to the brand
174
241
  * directory. This is the "talk to it like in Claude Code" surface: it can
@@ -373,7 +440,18 @@ export async function runStats(job) {
373
440
 
374
441
  /** kind: edit — apply a website edit to the local file. Deterministic, no Claude. */
375
442
  export async function runEdit(job) {
376
- const { target, body } = job.payload ?? {};
443
+ const { target, body, confirmPayment } = job.payload ?? {};
444
+
445
+ // "Were you actually paid?" answered on the website. The only fact that
446
+ // starts there rather than here, so it has to land in the file.
447
+ if (confirmPayment && typeof target === "string" && target.startsWith("deals/")) {
448
+ const slug = target.slice(6);
449
+ const done = applyPaymentConfirmation(slug, confirmPayment);
450
+ if (!done) return { ok: false, error: `could not confirm payment on '${target}'` };
451
+ invalidateContext();
452
+ return { ok: true, summary: done, mirror: buildMirror({ threads: [] }) };
453
+ }
454
+
377
455
  const files = {
378
456
  voice: join(VOICE_DIR, "voice.md"),
379
457
  "never-words": join(VOICE_DIR, "never-words.md"),
package/worker/index.js CHANGED
@@ -17,6 +17,7 @@ import { setAgent } from "./claude.js";
17
17
  import {
18
18
  runChat,
19
19
  runDraft,
20
+ runCompact,
20
21
  runEdit,
21
22
  runLearn,
22
23
  runScan,
@@ -75,6 +76,8 @@ async function handle(job) {
75
76
  return runStats(job);
76
77
  case "edit":
77
78
  return runEdit(job);
79
+ case "compact":
80
+ return runCompact(job);
78
81
  // Brand Outreach (its own tool on the site; same worker, same token).
79
82
  case "outreach-chat":
80
83
  return runOutreachChat(job);
@@ -0,0 +1,154 @@
1
+ // Writing observations back into a brand record.
2
+ //
3
+ // The drafting pass used to be read-only: decide() runs tools-off, so a scan
4
+ // that produced ten replies updated zero files. When Heli pulled the Navan
5
+ // go-live at 08:25 on the morning it was due, the agent drafted a reply about
6
+ // it and the calendar block kept the dead date forever.
7
+ //
8
+ // Rather than pay for a second, tool-enabled agent pass per thread, the draft
9
+ // decision carries a `recordUpdates` object and this merges it in with plain
10
+ // code. Same run, no extra call, no added latency — and because the merge is
11
+ // deterministic it can't wander off and rewrite prose it wasn't asked to.
12
+
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { DEALS_DIR } from "./paths.js";
16
+ import { appendDecisions, mergeRecord, parseRecord, serializeRecord } from "./record.js";
17
+
18
+ const SLUG_RE = /^[a-z0-9-]+$/;
19
+
20
+ /** Guess a ledger filename from a brand name: "Navan Edge" -> "navan-edge". */
21
+ export function slugify(brand) {
22
+ return String(brand ?? "")
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-|-$/g, "");
26
+ }
27
+
28
+ /**
29
+ * Find the ledger a brand name belongs to without creating one by accident.
30
+ * An exact slug hit wins; otherwise a prefix match, so a draft that says
31
+ * "Navan" still lands on navan.md. Returns null when nothing matches — the
32
+ * caller must not invent a file, because a typo'd brand name would otherwise
33
+ * quietly fork a deal into two records.
34
+ */
35
+ export function resolveSlug(brand, known) {
36
+ const slug = slugify(brand);
37
+ if (!slug) return null;
38
+ if (known.includes(slug)) return slug;
39
+ return known.find((k) => k.startsWith(slug) || slug.startsWith(k)) ?? null;
40
+ }
41
+
42
+ /**
43
+ * Apply `recordUpdates` to deals/<slug>.md.
44
+ *
45
+ * Returns a short description of what changed, or null if nothing did — the
46
+ * caller logs it against the draft so a surprising edit is traceable to the
47
+ * email that caused it.
48
+ */
49
+ export function applyRecordUpdates(slug, updates) {
50
+ if (!SLUG_RE.test(slug ?? "")) return null;
51
+ if (!updates || typeof updates !== "object") return null;
52
+
53
+ const path = join(DEALS_DIR, `${slug}.md`);
54
+ if (!existsSync(path)) return null;
55
+
56
+ const md = readFileSync(path, "utf8");
57
+ const { record, decisions } = parseRecord(md);
58
+ if (!record) return null;
59
+
60
+ const { decisions: incoming, ...rest } = updates;
61
+ const nextRecord = mergeRecord(record, rest);
62
+ const nextDecisions = appendDecisions(decisions, Array.isArray(incoming) ? incoming : []);
63
+
64
+ const changed = summarize(record, nextRecord, decisions.length, nextDecisions.length);
65
+ if (!changed) return null;
66
+
67
+ writeFileSync(
68
+ path,
69
+ serializeRecord({
70
+ record: nextRecord,
71
+ decisions: nextDecisions,
72
+ archiveRef: existsSync(join(DEALS_DIR, "..", "archive", `${slug}.md`))
73
+ ? `archive/${slug}.md`
74
+ : null,
75
+ }),
76
+ );
77
+ return changed;
78
+ }
79
+
80
+ /**
81
+ * Record her answer to "were you actually paid?".
82
+ *
83
+ * This is the only fact in the system that starts on the website, so it has to
84
+ * end up in the file or the mirror becomes storage. mergeRecord() then treats
85
+ * `confirmed` as untouchable, so the agent re-reading the same ambiguous email
86
+ * can't flip it back.
87
+ */
88
+ export function applyPaymentConfirmation(slug, { invoiceId, paid }) {
89
+ if (!SLUG_RE.test(slug ?? "")) return null;
90
+
91
+ const path = join(DEALS_DIR, `${slug}.md`);
92
+ if (!existsSync(path)) return null;
93
+
94
+ const md = readFileSync(path, "utf8");
95
+ const { record, decisions } = parseRecord(md);
96
+ if (!record) return null;
97
+
98
+ const invoices = record.money?.invoices ?? [];
99
+ const i = invoices.findIndex((inv) => inv.id === invoiceId);
100
+ if (i === -1) return null;
101
+
102
+ const next = {
103
+ ...record,
104
+ money: {
105
+ ...record.money,
106
+ invoices: invoices.map((inv, n) =>
107
+ n === i
108
+ ? {
109
+ ...inv,
110
+ confirmed: paid,
111
+ // The stamp is what makes the confirmation real — sanitizeInvoice
112
+ // discards a `confirmed` that arrives without it.
113
+ confirmedAt: new Date().toISOString(),
114
+ status: paid ? "paid" : "unpaid",
115
+ // Her word is the ceiling on certainty, not the agent's guess.
116
+ confidence: 1,
117
+ evidence: `Confirmed by hand on ${new Date().toISOString().slice(0, 10)}: ${
118
+ paid ? "paid" : "not paid"
119
+ }. Previously: ${inv.evidence ?? "no evidence recorded"}`,
120
+ }
121
+ : inv,
122
+ ),
123
+ },
124
+ };
125
+
126
+ writeFileSync(path, serializeRecord({ record: next, decisions, archiveRef: archiveRefFor(slug) }));
127
+ return `${slug} ${invoiceId}: ${paid ? "confirmed paid" : "confirmed NOT paid"}`;
128
+ }
129
+
130
+ function archiveRefFor(slug) {
131
+ return existsSync(join(DEALS_DIR, "..", "archive", `${slug}.md`)) ? `archive/${slug}.md` : null;
132
+ }
133
+
134
+ function summarize(before, after, beforeDecisions, afterDecisions) {
135
+ const bits = [];
136
+ if (before.stage !== after.stage) bits.push(`stage ${before.stage ?? "?"} -> ${after.stage}`);
137
+ if (before.whoseMove !== after.whoseMove) bits.push(`move -> ${after.whoseMove}`);
138
+
139
+ const dateChanges = (after.calendar ?? []).filter((e) => {
140
+ const old = (before.calendar ?? []).find((o) => o.key === e.key);
141
+ return !old || old.date !== e.date || old.tbd !== e.tbd;
142
+ }).length;
143
+ if (dateChanges) bits.push(`${dateChanges} date(s)`);
144
+
145
+ const invChanges = (after.money?.invoices ?? []).filter((inv) => {
146
+ const old = (before.money?.invoices ?? []).find((o) => o.id === inv.id);
147
+ return !old || old.status !== inv.status;
148
+ }).length;
149
+ if (invChanges) bits.push(`${invChanges} invoice(s)`);
150
+
151
+ if (afterDecisions > beforeDecisions) bits.push(`+${afterDecisions - beforeDecisions} decision(s)`);
152
+
153
+ return bits.length ? bits.join(", ") : null;
154
+ }
@@ -0,0 +1,264 @@
1
+ // The brand record — what replaced the ledger journal.
2
+ //
3
+ // A ledger used to be a chronological log the agent appended to forever.
4
+ // navan.md reached 56KB across 44 dated headings, including entries explicitly
5
+ // marked as superseded ("CORRECTED below, this read was wrong"), all of it
6
+ // spent out of a shared 350k prompt budget. Of that, perhaps 3KB could ever
7
+ // inform a future draft.
8
+ //
9
+ // A record is the opposite: current state, and the decisions that constrain
10
+ // the next one. Two parts, both small.
11
+ //
12
+ // <!-- brand {...} --> machine-readable current state, rewritten in place
13
+ // ## Decisions append-only one-liners — the durable precedent
14
+ //
15
+ // The narrative moves to archive/<slug>.md, which is NOT loaded into drafting
16
+ // context. Chat can still read it when she asks for history.
17
+ //
18
+ // Why a JSON block inside markdown rather than a real database: these files are
19
+ // hers. "Open Claude Code in this folder and talk to it" has to keep working,
20
+ // and the website is a projection of the files, never the owner of them.
21
+
22
+ /** Stages the board understands. Kept in sync with goosetools' BRAND_STAGES. */
23
+ export const STAGES = [
24
+ "inbound",
25
+ "negotiating",
26
+ "contract",
27
+ "producing",
28
+ "posted",
29
+ "awaiting_pay",
30
+ "paid",
31
+ "declined",
32
+ "cancelled",
33
+ ];
34
+
35
+ // The live ledgers drifted: the agent wrote whatever stage word fit the moment.
36
+ const STAGE_ALIASES = {
37
+ scoping: "negotiating",
38
+ "in production": "producing",
39
+ "in-production": "producing",
40
+ signed: "contract",
41
+ dead: "declined",
42
+ rejected: "declined",
43
+ gifting: "inbound",
44
+ live: "posted",
45
+ };
46
+
47
+ export function normalizeStage(raw) {
48
+ if (!raw) return null;
49
+ const s = String(raw).trim().toLowerCase().replace(/\s+/g, " ");
50
+ if (STAGES.includes(s)) return s;
51
+ return STAGE_ALIASES[s] ?? s;
52
+ }
53
+
54
+ /**
55
+ * `confirmed` is the creator's answer and nobody else's: true, false, or null
56
+ * for "never asked". The agent has no business writing it — but it did, twice
57
+ * in one migration (a bare `false` on Navan, a date string on Microsoft), and
58
+ * either one silences the "were you actually paid?" prompt for that invoice
59
+ * and then freezes the value against every later update.
60
+ *
61
+ * A type check can't catch it: the agent's `false` and her "no" are the same
62
+ * boolean. So a confirmation only counts when it carries `confirmedAt`, which
63
+ * applyPaymentConfirmation() stamps and nothing else writes. Provenance, not
64
+ * shape — which also means a record hand-edited in a terminal needs the stamp
65
+ * too, and that is the right trade for a field this load-bearing.
66
+ */
67
+ export function sanitizeInvoice(inv) {
68
+ if (!inv || typeof inv !== "object") return inv;
69
+ const vouched = typeof inv.confirmed === "boolean" && typeof inv.confirmedAt === "string";
70
+ return vouched ? inv : { ...inv, confirmed: null, confirmedAt: null };
71
+ }
72
+
73
+ const blockRe = (name) => new RegExp(`<!--\\s*${name}\\s([\\s\\S]*?)-->`, "i");
74
+
75
+ /**
76
+ * Read one `<!-- name {...} -->` block. Returns null rather than throwing:
77
+ * these blocks are model-written, and a malformed one must degrade to "we
78
+ * don't know" instead of taking down a scan.
79
+ */
80
+ export function readBlock(md, name) {
81
+ const m = String(md ?? "").match(blockRe(name));
82
+ if (!m) return null;
83
+ try {
84
+ return JSON.parse(m[1]);
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** Replace a block in place, or append one if the file has none. */
91
+ export function writeBlock(md, name, value) {
92
+ const body = `<!-- ${name}\n${JSON.stringify(value, null, 2)}\n-->`;
93
+ const src = String(md ?? "");
94
+ return blockRe(name).test(src) ? src.replace(blockRe(name), body) : `${src.trimEnd()}\n\n${body}\n`;
95
+ }
96
+
97
+ const DECISION_RE = /^[-*]\s+(\d{4}-\d{2}-\d{2})\s*·\s*(.+)$/;
98
+
99
+ /** One line per decision: `- 2026-07-15 · accepted $14,000 · 2yr paid usage` */
100
+ export function parseDecisions(md) {
101
+ const section = String(md ?? "").split(/^##\s+Decisions\s*$/im)[1];
102
+ if (!section) return [];
103
+
104
+ const out = [];
105
+ for (const line of section.split("\n")) {
106
+ if (/^##\s/.test(line)) break; // next heading ends the section
107
+ const m = line.trim().match(DECISION_RE);
108
+ if (m) {
109
+ const [what, ...why] = m[2].split("·").map((s) => s.trim());
110
+ out.push({ date: m[1], what, why: why.join(" · ") || null });
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ export function renderDecisions(decisions) {
117
+ return decisions
118
+ .slice()
119
+ .sort((a, b) => a.date.localeCompare(b.date))
120
+ .map((d) => `- ${d.date} · ${[d.what, d.why].filter(Boolean).join(" · ")}`)
121
+ .join("\n");
122
+ }
123
+
124
+ /**
125
+ * Read a record from either format. Files migrate one at a time and a scan can
126
+ * hit an un-migrated one at any point, so the legacy `board` + `calendar` pair
127
+ * has to keep working until every file has moved over.
128
+ */
129
+ export function parseRecord(md) {
130
+ const src = String(md ?? "");
131
+ const brand = readBlock(src, "brand");
132
+
133
+ if (brand) {
134
+ return {
135
+ record: { ...brand, stage: normalizeStage(brand.stage), money: sanitizeMoney(brand.money) },
136
+ decisions: parseDecisions(src),
137
+ legacy: false,
138
+ };
139
+ }
140
+
141
+ const board = readBlock(src, "board");
142
+ const calendar = readBlock(src, "calendar");
143
+ if (!board && !calendar) return { record: null, decisions: parseDecisions(src), legacy: true };
144
+
145
+ return { record: fromLegacyBoard(board ?? {}, calendar), decisions: [], legacy: true };
146
+ }
147
+
148
+ function sanitizeMoney(money) {
149
+ if (!money || typeof money !== "object") return money ?? null;
150
+ return { ...money, invoices: (money.invoices ?? []).map(sanitizeInvoice) };
151
+ }
152
+
153
+ /** Lift the old board block into a record. Terms stay strings — see below. */
154
+ export function fromLegacyBoard(board, calendar) {
155
+ const agencyRaw = typeof board.agency === "string" ? board.agency.trim() : "";
156
+ // Live data writes "Freeman & Forrest · Rebekah Greene" in one string.
157
+ const [agencyName, agencyContact] = agencyRaw.split("·").map((s) => s.trim());
158
+
159
+ return {
160
+ brand: board.brand ?? null,
161
+ agency: agencyName ? { name: agencyName, contact: agencyContact || null } : null,
162
+ contacts: board.contact ? [board.contact] : [],
163
+ stage: normalizeStage(board.stage),
164
+ whoseMove: board.whoseMove ?? null,
165
+ deal: {
166
+ // Deliberately not parsed into a number here. The live values include
167
+ // "asked $2,000, countered $1,200" and "$3.7-4.4K (quoted)" — turning
168
+ // those into one integer would invent a fact. The migration pass reads
169
+ // them with judgement; this path only reshapes what is already there.
170
+ rate: board.rate ?? null,
171
+ deliverables: board.deliverables ?? null,
172
+ usage: board.usage ?? null,
173
+ postWindow: board.postWindow ?? null,
174
+ },
175
+ exclusivity: board.exclusivity ? { raw: board.exclusivity } : null,
176
+ money: null,
177
+ calendar: Array.isArray(calendar) ? calendar : [],
178
+ flags: Array.isArray(board.flags) ? board.flags : [],
179
+ };
180
+ }
181
+
182
+ export function serializeRecord({ record, decisions = [], archiveRef = null }) {
183
+ const parts = [`# ${record.brand ?? "Untitled"}`, "", writeBlock("", "brand", record).trim()];
184
+
185
+ if (decisions.length) parts.push("", "## Decisions", "", renderDecisions(decisions));
186
+ if (archiveRef) parts.push("", `_History: ${archiveRef}_`);
187
+
188
+ return `${parts.join("\n")}\n`;
189
+ }
190
+
191
+ /**
192
+ * Merge what the drafting agent observed into the stored record.
193
+ *
194
+ * Upserts by stable key so a re-observed date updates in place instead of
195
+ * accumulating duplicates. Two things are never overwritten: a payment Erin
196
+ * confirmed by hand, and a decision already on the log.
197
+ */
198
+ export function mergeRecord(record, updates) {
199
+ if (!updates) return record;
200
+ const next = { ...record };
201
+
202
+ for (const k of ["stage", "whoseMove", "status"]) {
203
+ if (updates[k] != null) next[k] = k === "stage" ? normalizeStage(updates[k]) : updates[k];
204
+ }
205
+ if (updates.deal) next.deal = { ...(next.deal ?? {}), ...updates.deal };
206
+ if (updates.exclusivity) next.exclusivity = { ...(next.exclusivity ?? {}), ...updates.exclusivity };
207
+ if (Array.isArray(updates.flags)) next.flags = updates.flags;
208
+
209
+ if (Array.isArray(updates.calendar)) {
210
+ next.calendar = upsertBy(next.calendar ?? [], updates.calendar, "key");
211
+ }
212
+
213
+ if (updates.money) {
214
+ const prev = next.money ?? { invoices: [] };
215
+ next.money = {
216
+ ...prev,
217
+ ...updates.money,
218
+ invoices: upsertBy(prev.invoices ?? [], (updates.money.invoices ?? []).map(dropConfirmed), "id", (old, incoming) =>
219
+ // She answered "was I actually paid?" by hand. The agent re-reading the
220
+ // same ambiguous email must not quietly flip it back. This is the Navan
221
+ // Edge case: she believed she'd been paid and hadn't.
222
+ old.confirmed != null ? { ...incoming, ...pick(old, ["confirmed", "status", "paidAt"]) } : { ...old, ...incoming },
223
+ ),
224
+ };
225
+ }
226
+
227
+ return next;
228
+ }
229
+
230
+ /** An incoming update may never carry `confirmed` — only the creator sets it. */
231
+ function dropConfirmed(inv) {
232
+ if (!inv || typeof inv !== "object") return inv;
233
+ const { confirmed, ...rest } = inv;
234
+ return rest;
235
+ }
236
+
237
+ function upsertBy(existing, incoming, key, resolve) {
238
+ const out = existing.slice();
239
+ for (const item of incoming) {
240
+ if (item?.[key] == null) continue;
241
+ const i = out.findIndex((e) => e?.[key] === item[key]);
242
+ if (i === -1) out.push(item);
243
+ else out[i] = resolve ? resolve(out[i], item) : { ...out[i], ...item };
244
+ }
245
+ return out;
246
+ }
247
+
248
+ function pick(obj, keys) {
249
+ return Object.fromEntries(keys.filter((k) => obj[k] !== undefined).map((k) => [k, obj[k]]));
250
+ }
251
+
252
+ /** Append decisions, skipping any we already recorded on the same date. */
253
+ export function appendDecisions(existing, incoming = []) {
254
+ const seen = new Set(existing.map((d) => `${d.date}|${d.what?.toLowerCase()}`));
255
+ const out = existing.slice();
256
+ for (const d of incoming) {
257
+ if (!d?.date || !d?.what) continue;
258
+ const k = `${d.date}|${String(d.what).toLowerCase()}`;
259
+ if (seen.has(k)) continue;
260
+ seen.add(k);
261
+ out.push(d);
262
+ }
263
+ return out;
264
+ }