brand-manager-worker 0.1.2 → 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 +16 -1
- package/package.json +2 -2
- package/worker/agent.js +63 -4
- package/worker/compact.js +157 -0
- package/worker/context.js +40 -9
- package/worker/handlers.js +83 -5
- package/worker/ig-stats.js +270 -0
- package/worker/index.js +11 -0
- package/worker/lock.js +103 -0
- package/worker/outreach.js +2 -0
- package/worker/record-write.js +154 -0
- package/worker/record.js +264 -0
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
|
|
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.
|
|
4
|
-
"description": "The Goose Tools brand-deal worker
|
|
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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { oneShot, parseJson, session } from "./claude.js";
|
|
9
9
|
import { loadContext, invalidateContext } from "./context.js";
|
|
10
|
+
import { refreshStatsIfStale } from "./ig-stats.js";
|
|
10
11
|
import { BRAND_DIR } from "./paths.js";
|
|
11
12
|
|
|
12
13
|
// Automated / no-reply senders never send brand deals — skip them WITHOUT
|
|
@@ -68,7 +69,7 @@ function renderThread(thread) {
|
|
|
68
69
|
.join("\n\n");
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
72
|
+
function buildPrompt({ thread, reason, instruction, contracts, context, statsWindows = null }) {
|
|
72
73
|
const contractBlock = contracts.length
|
|
73
74
|
? `\n\nATTACHED DOCUMENTS (extracted text — verify per the contract mode above):\n${contracts
|
|
74
75
|
.map((d) => `### ${d.filename}\n${d.text.slice(0, 8000) || "(could not extract text)"}`)
|
|
@@ -117,6 +118,7 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
|
117
118
|
` genuinely still missing, and skip the scoping questions entirely if it's all already answered.`,
|
|
118
119
|
`- Don't re-introduce yourself or repeat points you already made earlier in the thread.`,
|
|
119
120
|
``,
|
|
121
|
+
statsWindowsBlock(statsWindows),
|
|
120
122
|
`===== TONE =====`,
|
|
121
123
|
`Warm and friendly first, professional underneath. Sound like a real person who is glad to hear from`,
|
|
122
124
|
`them, not a terms-and-conditions bot. Open with genuine warmth, keep pushback collaborative and kind`,
|
|
@@ -125,7 +127,26 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
|
125
127
|
``,
|
|
126
128
|
`===== OUTPUT =====`,
|
|
127
129
|
`Output ONLY a single fenced \`\`\`json block with this shape:`,
|
|
128
|
-
`{"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.`,
|
|
129
150
|
`Rules: draft only (never send); honor the voice (no em dashes; banned phrases); reply to the latest`,
|
|
130
151
|
`message using the whole thread as context (never re-ask answered questions); if an unsent draft`,
|
|
131
152
|
`exists, produce a distinct second option; gifted-only -> Story mention, not a free Reel; verify any`,
|
|
@@ -138,9 +159,47 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
|
138
159
|
* Run the drafting agent over one thread. Returns the Decision or null on
|
|
139
160
|
* failure — and null MUST mean "retry later", never "skip this thread".
|
|
140
161
|
*/
|
|
141
|
-
|
|
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
|
+
}) {
|
|
198
|
+
// Live numbers before the media kit is inlined. Refreshes only when the
|
|
199
|
+
// snapshot is a week old or more; a failure keeps the last one.
|
|
200
|
+
if ((await refreshStatsIfStale()).refreshed) invalidateContext();
|
|
142
201
|
const context = loadContext();
|
|
143
|
-
const prompt = buildPrompt({ thread, reason, instruction, contracts, context });
|
|
202
|
+
const prompt = buildPrompt({ thread, reason, instruction, contracts, context, statsWindows });
|
|
144
203
|
const stdout = await oneShot(prompt, { cwd: BRAND_DIR });
|
|
145
204
|
const decision = parseJson(stdout);
|
|
146
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).
|
|
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
|
-
|
|
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 =
|
|
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;
|
package/worker/handlers.js
CHANGED
|
@@ -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({
|
|
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"),
|