brand-manager-worker 0.1.1 → 0.1.3
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/package.json +1 -1
- package/worker/agent-cli.js +275 -0
- package/worker/agent.js +4 -0
- package/worker/api.js +5 -1
- package/worker/claude.js +58 -82
- package/worker/cli.js +14 -3
- package/worker/handlers.js +2 -2
- package/worker/ig-stats.js +270 -0
- package/worker/index.js +12 -0
- package/worker/lock.js +103 -0
- package/worker/outreach.js +4 -2
- package/worker/service.js +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-manager-worker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
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
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Agent CLI adapter — runs a job's prompt through whichever coding-agent CLI
|
|
2
|
+
// the user picked on goosetools.com (claim payloads carry `agentCli`).
|
|
3
|
+
//
|
|
4
|
+
// VENDORED FILE: keep byte-identical across brand-manager-worker,
|
|
5
|
+
// caption-maker, and carousel-maker (worker/agent-cli.js in each). Fix a bug
|
|
6
|
+
// here, copy it to the other two.
|
|
7
|
+
//
|
|
8
|
+
// Modes, chosen for speed-vs-capability (see brand-manager commit c0c1b33 —
|
|
9
|
+
// tool-looping turned 36-second drafts into 10-minute ones):
|
|
10
|
+
// "text" — plain prompt-in/text-out, no tool flags at all (captions).
|
|
11
|
+
// "oneshot" — tools explicitly OFF, all context inlined by the caller.
|
|
12
|
+
// "read" — file-reading allowed (carousel drafts look at photos).
|
|
13
|
+
// "session" — tools ON and resumable where the CLI supports it.
|
|
14
|
+
//
|
|
15
|
+
// Honest capability notes per CLI:
|
|
16
|
+
// claude — full support: fine tool control, JSON envelope, --resume.
|
|
17
|
+
// codex — sandbox-level tool control only; resumable threads; JSONL out.
|
|
18
|
+
// gemini — no headless resume: sessionId is always null, callers replay
|
|
19
|
+
// history (they already do this whenever sessionId is absent).
|
|
20
|
+
// opencode — plain text out; session continuation is version-dependent, so
|
|
21
|
+
// we don't claim it: sessionId is always null.
|
|
22
|
+
//
|
|
23
|
+
// Session ids are namespaced ("claude:<uuid>", "codex:<thread-id>") because
|
|
24
|
+
// the server stores them per conversation and the user can switch agents
|
|
25
|
+
// between turns — a Claude uuid means nothing to Codex. resolveSession()
|
|
26
|
+
// drops a mismatched id so the caller falls back to history replay. Bare
|
|
27
|
+
// un-prefixed ids predate namespacing and are treated as Claude's.
|
|
28
|
+
|
|
29
|
+
import { spawn } from "node:child_process";
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
export const AGENT_IDS = ["claude-code", "codex", "gemini", "opencode"];
|
|
33
|
+
|
|
34
|
+
const INSTALL_HINTS = {
|
|
35
|
+
"claude-code": "npm install -g @anthropic-ai/claude-code",
|
|
36
|
+
codex: "npm install -g @openai/codex, then run: codex login",
|
|
37
|
+
gemini: "npm install -g @google/gemini-cli, then run: gemini (to sign in)",
|
|
38
|
+
opencode: "npm install -g opencode-ai, then run: opencode auth login",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const LABELS = {
|
|
42
|
+
"claude-code": "Claude Code",
|
|
43
|
+
codex: "Codex CLI",
|
|
44
|
+
gemini: "Gemini CLI",
|
|
45
|
+
opencode: "OpenCode",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Session-id namespace per agent. claude-code writes "claude:" for continuity
|
|
49
|
+
// with the bare ids already stored server-side.
|
|
50
|
+
const SESSION_PREFIX = {
|
|
51
|
+
"claude-code": "claude",
|
|
52
|
+
codex: "codex",
|
|
53
|
+
gemini: "gemini",
|
|
54
|
+
opencode: "opencode",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function bin(agent) {
|
|
58
|
+
if (agent === "claude-code") return process.env.CLAUDE_BIN ?? "claude";
|
|
59
|
+
return agent; // codex / gemini / opencode binaries share their id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** "claude" (legacy claim payloads) → "claude-code"; unknown → null. */
|
|
63
|
+
export function normalizeAgent(v) {
|
|
64
|
+
if (v == null || v === "" ) return "claude-code";
|
|
65
|
+
if (v === "claude") return "claude-code";
|
|
66
|
+
return AGENT_IDS.includes(v) ? v : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turn a stored (possibly namespaced, possibly another agent's) session id
|
|
71
|
+
* into one usable by `agent`, or null — null tells the caller to inline the
|
|
72
|
+
* conversation history instead of resuming.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveSession(agent, sessionId) {
|
|
75
|
+
if (!sessionId) return null;
|
|
76
|
+
const idx = sessionId.indexOf(":");
|
|
77
|
+
if (idx === -1) {
|
|
78
|
+
// Legacy bare id — those were always Claude Code session uuids.
|
|
79
|
+
return agent === "claude-code" ? sessionId : null;
|
|
80
|
+
}
|
|
81
|
+
const prefix = sessionId.slice(0, idx);
|
|
82
|
+
return prefix === SESSION_PREFIX[agent] ? sessionId.slice(idx + 1) : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function namespaced(agent, rawId) {
|
|
86
|
+
return rawId ? `${SESSION_PREFIX[agent]}:${rawId}` : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Strip ANSI escapes and other terminal noise some CLIs print around answers.
|
|
90
|
+
export function cleanText(text) {
|
|
91
|
+
// eslint-disable-next-line no-control-regex
|
|
92
|
+
return String(text).replace(/\[[0-9;]*[A-Za-z]/g, "").trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// stdio[0] MUST be "ignore". Inherited stdin makes invocations hang forever
|
|
96
|
+
// under launchd — brand-manager hit this exact bug (fixed in 3b1f955) and it
|
|
97
|
+
// presents as the worker silently doing nothing after a reboot.
|
|
98
|
+
function run(agent, args, { timeoutMs, cwd }) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const child = spawn(bin(agent), args, {
|
|
101
|
+
cwd,
|
|
102
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
103
|
+
});
|
|
104
|
+
let stdout = "";
|
|
105
|
+
let stderr = "";
|
|
106
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
107
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
reject(new Error(`${LABELS[agent]} timed out after ${timeoutMs}ms`));
|
|
111
|
+
}, timeoutMs);
|
|
112
|
+
child.on("error", (err) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(
|
|
115
|
+
err.code === "ENOENT"
|
|
116
|
+
? new Error(
|
|
117
|
+
`${LABELS[agent]} isn't installed on this computer. Install: ${INSTALL_HINTS[agent]} — or switch your agent back to Claude Code in Setup on goosetools.com.`,
|
|
118
|
+
)
|
|
119
|
+
: err,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
child.on("close", (code) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (code === 0) resolve(stdout);
|
|
125
|
+
else
|
|
126
|
+
reject(
|
|
127
|
+
new Error(`${LABELS[agent]} exited ${code}: ${stderr.slice(0, 600)}`),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Is the CLI on PATH? { installed, hint } — cheap, for startup diagnostics. */
|
|
134
|
+
export function detect(agent) {
|
|
135
|
+
const res = spawnSync(bin(agent), ["--version"], {
|
|
136
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
137
|
+
timeout: 10_000,
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
installed: res.status === 0,
|
|
141
|
+
hint: INSTALL_HINTS[agent],
|
|
142
|
+
label: LABELS[agent],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Tools Claude must NOT touch in oneshot mode — everything is inlined.
|
|
147
|
+
const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run a prompt through the chosen agent CLI.
|
|
151
|
+
* Returns { text, sessionId } — sessionId is namespaced and null whenever
|
|
152
|
+
* the CLI can't resume (callers then replay history on the next turn).
|
|
153
|
+
*/
|
|
154
|
+
export async function runAgent({
|
|
155
|
+
agent = "claude-code",
|
|
156
|
+
prompt,
|
|
157
|
+
cwd,
|
|
158
|
+
mode = "text",
|
|
159
|
+
sessionId = null,
|
|
160
|
+
tools = "Read,Edit,Write,Glob,Grep",
|
|
161
|
+
timeoutMs = 5 * 60 * 1000,
|
|
162
|
+
}) {
|
|
163
|
+
const normalized = normalizeAgent(agent);
|
|
164
|
+
if (!normalized) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Unknown agent "${agent}" — update this worker: npx --yes <package>@latest install`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const resume = resolveSession(normalized, sessionId);
|
|
170
|
+
|
|
171
|
+
if (normalized === "claude-code") {
|
|
172
|
+
if (mode === "session") {
|
|
173
|
+
const args = [
|
|
174
|
+
"-p",
|
|
175
|
+
prompt,
|
|
176
|
+
"--output-format",
|
|
177
|
+
"json",
|
|
178
|
+
"--permission-mode",
|
|
179
|
+
"acceptEdits",
|
|
180
|
+
"--allowedTools",
|
|
181
|
+
tools,
|
|
182
|
+
];
|
|
183
|
+
if (resume) args.push("--resume", resume);
|
|
184
|
+
const raw = await run(normalized, args, { timeoutMs, cwd });
|
|
185
|
+
// The json envelope carries the session id. Be forgiving: a shape
|
|
186
|
+
// change shouldn't lose the user's answer.
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
return {
|
|
190
|
+
text: parsed.result ?? parsed.text ?? raw,
|
|
191
|
+
sessionId: namespaced(
|
|
192
|
+
normalized,
|
|
193
|
+
parsed.session_id ?? parsed.sessionId ?? resume ?? null,
|
|
194
|
+
),
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
return { text: raw, sessionId: namespaced(normalized, resume) };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const args = ["-p", prompt, "--output-format", "text"];
|
|
201
|
+
if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
|
|
202
|
+
if (mode === "read") args.push("--allowedTools", "Read");
|
|
203
|
+
const text = await run(normalized, args, { timeoutMs, cwd });
|
|
204
|
+
return { text, sessionId: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (normalized === "codex") {
|
|
208
|
+
// Sandbox is the only tool control codex offers; read-only + the prompt's
|
|
209
|
+
// own "don't use tools" language is the closest match to oneshot.
|
|
210
|
+
const sandbox = mode === "session" ? "workspace-write" : "read-only";
|
|
211
|
+
const args = ["exec"];
|
|
212
|
+
if (resume) args.push("resume", resume);
|
|
213
|
+
args.push("--skip-git-repo-check", "--sandbox", sandbox, "--json");
|
|
214
|
+
if (cwd) args.push("--cd", cwd);
|
|
215
|
+
args.push(prompt);
|
|
216
|
+
const raw = await run(normalized, args, { timeoutMs, cwd });
|
|
217
|
+
// --json emits JSONL events; harvest the thread id and the last agent
|
|
218
|
+
// message, falling back to the raw output if the shape ever changes.
|
|
219
|
+
let threadId = resume ?? null;
|
|
220
|
+
let last = null;
|
|
221
|
+
for (const line of raw.split("\n")) {
|
|
222
|
+
const trimmed = line.trim();
|
|
223
|
+
if (!trimmed.startsWith("{")) continue;
|
|
224
|
+
try {
|
|
225
|
+
const evt = JSON.parse(trimmed);
|
|
226
|
+
threadId =
|
|
227
|
+
evt.thread_id ?? evt.session_id ?? evt.thread?.id ?? threadId;
|
|
228
|
+
const item = evt.item ?? evt;
|
|
229
|
+
const type = item.item_type ?? item.type;
|
|
230
|
+
if (
|
|
231
|
+
(type === "agent_message" || type === "assistant_message") &&
|
|
232
|
+
typeof (item.text ?? item.message) === "string"
|
|
233
|
+
) {
|
|
234
|
+
last = item.text ?? item.message;
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
// not an event line — ignore
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
text: cleanText(last ?? raw),
|
|
242
|
+
sessionId: mode === "session" ? namespaced(normalized, threadId) : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (normalized === "gemini") {
|
|
247
|
+
// No reliable headless resume — never claim one. Session mode gets yolo
|
|
248
|
+
// approvals so its tools can run unattended.
|
|
249
|
+
const args = ["-p", prompt, "--output-format", "json"];
|
|
250
|
+
if (mode === "session") args.push("--yolo");
|
|
251
|
+
let raw;
|
|
252
|
+
try {
|
|
253
|
+
raw = await run(normalized, args, { timeoutMs, cwd });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// Older gemini builds lack --output-format; retry plain.
|
|
256
|
+
if (!/output-format|unknown option/i.test(String(err.message))) throw err;
|
|
257
|
+
raw = await run(normalized, ["-p", prompt], { timeoutMs, cwd });
|
|
258
|
+
return { text: cleanText(raw), sessionId: null };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(raw);
|
|
262
|
+
return {
|
|
263
|
+
text: cleanText(parsed.response ?? parsed.result ?? raw),
|
|
264
|
+
sessionId: null,
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
return { text: cleanText(raw), sessionId: null };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// opencode — plain text out; we don't claim session continuation (it's
|
|
272
|
+
// version-dependent), so every turn replays history.
|
|
273
|
+
const raw = await run(normalized, ["run", prompt], { timeoutMs, cwd });
|
|
274
|
+
return { text: cleanText(raw), sessionId: null };
|
|
275
|
+
}
|
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
|
|
@@ -139,6 +140,9 @@ function buildPrompt({ thread, reason, instruction, contracts, context }) {
|
|
|
139
140
|
* failure — and null MUST mean "retry later", never "skip this thread".
|
|
140
141
|
*/
|
|
141
142
|
export async function decide({ thread, reason, instruction = null, contracts = [] }) {
|
|
143
|
+
// Live numbers before the media kit is inlined. Refreshes only when the
|
|
144
|
+
// snapshot is a week old or more; a failure keeps the last one.
|
|
145
|
+
if ((await refreshStatsIfStale()).refreshed) invalidateContext();
|
|
142
146
|
const context = loadContext();
|
|
143
147
|
const prompt = buildPrompt({ thread, reason, instruction, contracts, context });
|
|
144
148
|
const stdout = await oneShot(prompt, { cwd: BRAND_DIR });
|
package/worker/api.js
CHANGED
|
@@ -45,7 +45,11 @@ export const TOKEN =
|
|
|
45
45
|
localEnv.WORKER_TOKEN ??
|
|
46
46
|
sharedEnv.WORKER_TOKEN;
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// 5 min, not 30s: a 24/7 30s poll kept the prod database awake around the
|
|
49
|
+
// clock and burned its whole compute quota. Brand work is background inbox
|
|
50
|
+
// tending — nobody is watching a spinner — and the loop drains bursts
|
|
51
|
+
// immediately once one job is claimed, so only the first pickup waits.
|
|
52
|
+
export const POLL_MS = 5 * 60 * 1000;
|
|
49
53
|
|
|
50
54
|
/** POST to /api/brand/worker/<path>. Returns null on 204 (no work). */
|
|
51
55
|
export async function api(path, body) {
|
package/worker/claude.js
CHANGED
|
@@ -1,77 +1,59 @@
|
|
|
1
|
-
// Running
|
|
1
|
+
// Running the user's coding agent on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Historically this file shelled out to Claude Code directly; the actual CLI
|
|
4
|
+
// plumbing now lives in agent-cli.js (vendored, shared across the Goose Tools
|
|
5
|
+
// workers) and honors the agent the user picked on goosetools.com — the claim
|
|
6
|
+
// payload's `agentCli`. index.js calls setAgent() once per job; the worker
|
|
7
|
+
// handles jobs serially, so module state is safe.
|
|
2
8
|
//
|
|
3
9
|
// Two modes, and the split matters for speed. brand-manager learned this the
|
|
4
|
-
// hard way (commit c0c1b33): letting
|
|
10
|
+
// hard way (commit c0c1b33): letting the agent loop on tools to gather its own
|
|
5
11
|
// context took 5–10 minutes per draft versus ~36 seconds when everything was
|
|
6
12
|
// inlined up front. So:
|
|
7
13
|
//
|
|
8
14
|
// oneShot() — drafting. Tools OFF, all context inlined by the caller. Fast.
|
|
9
15
|
// session() — chat, voice-audit, stats, learn. Tools ON, scoped to the
|
|
10
|
-
// brand directory, and resumable
|
|
11
|
-
//
|
|
16
|
+
// brand directory, and resumable where the CLI supports it
|
|
17
|
+
// (Claude and Codex do; Gemini and OpenCode return a null
|
|
18
|
+
// sessionId, and callers replay history instead).
|
|
12
19
|
//
|
|
13
|
-
// There is no API key anywhere here. This runs on the user's own
|
|
14
|
-
// subscription via
|
|
20
|
+
// There is no API key anywhere here. This runs on the user's own agent
|
|
21
|
+
// subscription via that agent's CLI, same as every other Goose Tools worker.
|
|
15
22
|
|
|
16
|
-
import {
|
|
23
|
+
import { runAgent, normalizeAgent, resolveSession } from "./agent-cli.js";
|
|
17
24
|
|
|
18
|
-
|
|
25
|
+
let currentAgent = "claude-code";
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
});
|
|
27
|
+
/** Set by index.js from the claim payload before each job's handler runs. */
|
|
28
|
+
export function setAgent(agentCli) {
|
|
29
|
+
currentAgent = normalizeAgent(agentCli) ?? "claude-code";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAgent() {
|
|
33
|
+
return currentAgent;
|
|
51
34
|
}
|
|
52
35
|
|
|
53
36
|
/** Single-shot, no tools. For drafting, where the caller inlines all context. */
|
|
54
37
|
export async function oneShot(prompt, { timeoutMs = 4 * 60 * 1000, cwd } = {}) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
],
|
|
64
|
-
{ timeoutMs, cwd },
|
|
65
|
-
);
|
|
38
|
+
const { text } = await runAgent({
|
|
39
|
+
agent: currentAgent,
|
|
40
|
+
prompt,
|
|
41
|
+
cwd,
|
|
42
|
+
mode: "oneshot",
|
|
43
|
+
timeoutMs,
|
|
44
|
+
});
|
|
45
|
+
return text;
|
|
66
46
|
}
|
|
67
47
|
|
|
68
48
|
/**
|
|
69
|
-
* A real, resumable
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
49
|
+
* A real, resumable agent session with tools, scoped to the brand directory.
|
|
50
|
+
* This is what makes the website chat behave like talking to the agent in a
|
|
51
|
+
* terminal: it can read the ledgers, edit the rate card, and (where the CLI
|
|
52
|
+
* supports resuming) remember the last turn.
|
|
73
53
|
*
|
|
74
|
-
* Returns { text, sessionId } — pass sessionId back in to continue.
|
|
54
|
+
* Returns { text, sessionId } — pass sessionId back in to continue. The id is
|
|
55
|
+
* namespaced per agent; a stored id from a different agent is dropped and the
|
|
56
|
+
* caller's history-replay path covers the gap.
|
|
75
57
|
*/
|
|
76
58
|
export async function session(
|
|
77
59
|
prompt,
|
|
@@ -84,39 +66,33 @@ export async function session(
|
|
|
84
66
|
tools = "Read,Edit,Write,Glob,Grep",
|
|
85
67
|
} = {},
|
|
86
68
|
) {
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
return runAgent({
|
|
70
|
+
agent: currentAgent,
|
|
89
71
|
prompt,
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
"acceptEdits",
|
|
94
|
-
"--allowedTools",
|
|
72
|
+
cwd,
|
|
73
|
+
mode: "session",
|
|
74
|
+
sessionId,
|
|
95
75
|
tools,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const raw = await run(args, { timeoutMs, cwd });
|
|
76
|
+
timeoutMs,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
100
79
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
} catch {
|
|
111
|
-
return { text: raw, sessionId: sessionId ?? null };
|
|
112
|
-
}
|
|
80
|
+
/**
|
|
81
|
+
* Would `sessionId` actually resume under the current agent? Callers check
|
|
82
|
+
* this before building a prompt so they can inline conversation history when
|
|
83
|
+
* the answer is no (agent switched, or the CLI can't resume at all).
|
|
84
|
+
*/
|
|
85
|
+
export function canResume(sessionId) {
|
|
86
|
+
if (!sessionId) return false;
|
|
87
|
+
if (currentAgent === "gemini" || currentAgent === "opencode") return false;
|
|
88
|
+
return resolveSession(currentAgent, sessionId) !== null;
|
|
113
89
|
}
|
|
114
90
|
|
|
115
91
|
/**
|
|
116
|
-
* Pull a JSON object out of
|
|
117
|
-
* from brand-manager's parseDecision: prefer a fenced block, else take
|
|
118
|
-
* first {...last }. Returns null on failure — the caller must treat that
|
|
119
|
-
* "retry later", never as "skip this thread".
|
|
92
|
+
* Pull a JSON object out of the agent's text output. Ported verbatim in
|
|
93
|
+
* spirit from brand-manager's parseDecision: prefer a fenced block, else take
|
|
94
|
+
* the first {...last }. Returns null on failure — the caller must treat that
|
|
95
|
+
* as "retry later", never as "skip this thread".
|
|
120
96
|
*/
|
|
121
97
|
export function parseJson(text) {
|
|
122
98
|
const fenced = text.match(/```json\s*([\s\S]*?)```/);
|
package/worker/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
|
|
16
16
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
17
|
|
|
18
|
-
const command = ["install", "update", "status", "uninstall", "run"].includes(process.argv[2])
|
|
18
|
+
const command = ["install", "update", "status", "uninstall", "run", "doctor"].includes(process.argv[2])
|
|
19
19
|
? process.argv[2]
|
|
20
20
|
: "run";
|
|
21
21
|
|
|
@@ -48,7 +48,10 @@ function has(cmd) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Claude Code is the default agent, so it's required to set up; the other
|
|
52
|
+
// agents (picked in Setup on goosetools.com) are checked lazily per job and
|
|
53
|
+
// reported by `doctor`.
|
|
54
|
+
if (command !== "doctor" && !has("claude")) {
|
|
52
55
|
console.error(
|
|
53
56
|
"\nClaude Code isn't installed yet (it writes your replies).\n" +
|
|
54
57
|
"Install it with: npm install -g @anthropic-ai/claude-code\n" +
|
|
@@ -58,7 +61,15 @@ if (!has("claude")) {
|
|
|
58
61
|
process.exit(1);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
if (command === "
|
|
64
|
+
if (command === "doctor") {
|
|
65
|
+
const { AGENT_IDS, detect } = await import("./agent-cli.js");
|
|
66
|
+
for (const id of AGENT_IDS) {
|
|
67
|
+
const d = detect(id);
|
|
68
|
+
console.log(
|
|
69
|
+
`${d.installed ? "✓" : "✗"} ${d.label}${d.installed ? "" : ` — install: ${d.hint}`}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else if (command === "install") {
|
|
62
73
|
const { install } = await import("./service.js");
|
|
63
74
|
install({
|
|
64
75
|
url: (flag("--url") ?? "https://goosetools.com").replace(/\/$/, ""),
|
package/worker/handlers.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, mkdirSync, 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
|
-
import { session } from "./claude.js";
|
|
15
|
+
import { session, canResume } from "./claude.js";
|
|
16
16
|
import { invalidateContext } from "./context.js";
|
|
17
17
|
import { appendDraftLog, lastDraftFor } from "./drafts-log.js";
|
|
18
18
|
import { createReplyDraft } from "./gmail-draft.js";
|
|
@@ -201,7 +201,7 @@ export async function runChat(job) {
|
|
|
201
201
|
`base/rules/feedback-loop.md before saving any preference), voice/ playbook/ deals/ stats/ are`,
|
|
202
202
|
`their personal layer — read what you need, and APPEND edits per the feedback loop.`,
|
|
203
203
|
`You cannot send email, and you cannot access Gmail from this session.`,
|
|
204
|
-
job.sessionId ? "" : history ? `\nConversation so far:\n${history}` : "",
|
|
204
|
+
canResume(job.sessionId) ? "" : history ? `\nConversation so far:\n${history}` : "",
|
|
205
205
|
threadBlock,
|
|
206
206
|
``,
|
|
207
207
|
`The creator says:`,
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Live Instagram stats, pulled from the Graph API instead of read off
|
|
2
|
+
// screenshots. Rewrites stats/latest.md (and latest.json) so the drafting
|
|
3
|
+
// agent always has a current media kit inlined.
|
|
4
|
+
//
|
|
5
|
+
// Trigger: refreshStatsIfStale() runs before every draft / outreach pitch.
|
|
6
|
+
// It is cheap (four GETs) and never throws — a failed fetch leaves the last
|
|
7
|
+
// snapshot in place and the staleness rule inside latest.md does the rest.
|
|
8
|
+
//
|
|
9
|
+
// Auth: an Instagram User access token (Instagram API with Instagram Login,
|
|
10
|
+
// scope instagram_business_manage_insights) in ~/.goosetools/ig-token. The
|
|
11
|
+
// dashboard's "Generate access tokens" button issues a 60-day long-lived
|
|
12
|
+
// token; we refresh it ourselves once it is a week old, so it never expires
|
|
13
|
+
// as long as the worker keeps running.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { GOOSE_DIR, STATS_DIR } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
export const IG_TOKEN_FILE = join(GOOSE_DIR, "ig-token");
|
|
20
|
+
const LATEST_MD = join(STATS_DIR, "latest.md");
|
|
21
|
+
const LATEST_JSON = join(STATS_DIR, "latest.json");
|
|
22
|
+
const GRAPH = "https://graph.instagram.com/v22.0";
|
|
23
|
+
|
|
24
|
+
/** Refetch when the snapshot is older than this. A brand asking for numbers
|
|
25
|
+
* gets at most a week-old window, and usually today's. */
|
|
26
|
+
export const MAX_AGE_DAYS = 7;
|
|
27
|
+
const TOKEN_REFRESH_DAYS = 7;
|
|
28
|
+
const DAY = 86_400_000;
|
|
29
|
+
|
|
30
|
+
function readToken() {
|
|
31
|
+
if (!existsSync(IG_TOKEN_FILE)) return null;
|
|
32
|
+
const t = readFileSync(IG_TOKEN_FILE, "utf8").trim();
|
|
33
|
+
return t || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function get(token, path, params) {
|
|
37
|
+
const url = new URL(`${GRAPH}/${path}`);
|
|
38
|
+
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
39
|
+
url.searchParams.set("access_token", token);
|
|
40
|
+
const res = await fetch(url);
|
|
41
|
+
const body = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok || body.error) {
|
|
43
|
+
throw new Error(`IG ${path}: ${res.status} ${body.error?.message ?? ""}`.trim());
|
|
44
|
+
}
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Long-lived tokens last 60 days; refreshing needs the token to be >24h old. */
|
|
49
|
+
async function maybeRefreshToken(token) {
|
|
50
|
+
const ageDays = (Date.now() - statSync(IG_TOKEN_FILE).mtimeMs) / DAY;
|
|
51
|
+
if (ageDays < TOKEN_REFRESH_DAYS) return token;
|
|
52
|
+
try {
|
|
53
|
+
const r = await get(token, "refresh_access_token", { grant_type: "ig_refresh_token" });
|
|
54
|
+
if (r.access_token) {
|
|
55
|
+
writeFileSync(IG_TOKEN_FILE, r.access_token, { mode: 0o600 });
|
|
56
|
+
return r.access_token;
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`· ig token refresh failed (using current token): ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
return token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const breakdownOf = (insights) =>
|
|
65
|
+
insights.data?.[0]?.total_value?.breakdowns?.[0]?.results?.map((r) => ({
|
|
66
|
+
key: r.dimension_values[0],
|
|
67
|
+
value: r.value,
|
|
68
|
+
})) ?? [];
|
|
69
|
+
|
|
70
|
+
/** Pull everything the media kit needs. Pure fetch; no file writes. */
|
|
71
|
+
export async function fetchIgStats(token) {
|
|
72
|
+
const now = new Date();
|
|
73
|
+
const since = Math.floor((now.getTime() - 30 * DAY) / 1000);
|
|
74
|
+
const until = Math.floor(now.getTime() / 1000);
|
|
75
|
+
|
|
76
|
+
const profile = await get(token, "me", { fields: "username,followers_count,media_count" });
|
|
77
|
+
|
|
78
|
+
const totals = await get(token, "me/insights", {
|
|
79
|
+
metric: "reach,views,total_interactions,likes,comments,shares,saves,reposts",
|
|
80
|
+
period: "day",
|
|
81
|
+
metric_type: "total_value",
|
|
82
|
+
since: String(since),
|
|
83
|
+
until: String(until),
|
|
84
|
+
});
|
|
85
|
+
const t = Object.fromEntries(
|
|
86
|
+
(totals.data ?? []).map((m) => [m.name, m.total_value?.value ?? 0]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const demo = {};
|
|
90
|
+
for (const b of ["gender", "age", "country", "city"]) {
|
|
91
|
+
const r = await get(token, "me/insights", {
|
|
92
|
+
metric: "follower_demographics",
|
|
93
|
+
period: "lifetime",
|
|
94
|
+
timeframe: "this_month",
|
|
95
|
+
breakdown: b,
|
|
96
|
+
metric_type: "total_value",
|
|
97
|
+
});
|
|
98
|
+
demo[b] = breakdownOf(r).sort((a, z) => z.value - a.value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const media = await get(token, "me/media", {
|
|
102
|
+
fields: "id,media_product_type,timestamp",
|
|
103
|
+
limit: "50",
|
|
104
|
+
});
|
|
105
|
+
const cutoff = new Date(now.getTime() - 30 * DAY).toISOString();
|
|
106
|
+
const recent = (media.data ?? []).filter((m) => m.timestamp > cutoff);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
capturedAt: now.toISOString(),
|
|
110
|
+
windowStart: new Date(since * 1000).toISOString().slice(0, 10),
|
|
111
|
+
windowEnd: now.toISOString().slice(0, 10),
|
|
112
|
+
username: profile.username,
|
|
113
|
+
followers: profile.followers_count,
|
|
114
|
+
reach: t.reach ?? 0,
|
|
115
|
+
views: t.views ?? 0,
|
|
116
|
+
interactions: t.total_interactions ?? 0,
|
|
117
|
+
likes: t.likes ?? 0,
|
|
118
|
+
comments: t.comments ?? 0,
|
|
119
|
+
shares: t.shares ?? 0,
|
|
120
|
+
saves: t.saves ?? 0,
|
|
121
|
+
reposts: t.reposts ?? 0,
|
|
122
|
+
postsPosted: recent.length,
|
|
123
|
+
reelsPosted: recent.filter((m) => m.media_product_type === "REELS").length,
|
|
124
|
+
demographics: demo,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const k = (n) =>
|
|
129
|
+
n >= 1_000_000
|
|
130
|
+
? `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
|
|
131
|
+
: n >= 1_000
|
|
132
|
+
? `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`
|
|
133
|
+
: String(n);
|
|
134
|
+
const pct = (part, whole) => (whole ? `${((100 * part) / whole).toFixed(1)}%` : "n/a");
|
|
135
|
+
const list = (rows, n, whole) =>
|
|
136
|
+
rows
|
|
137
|
+
.slice(0, n)
|
|
138
|
+
.map((r) => `${r.key} ${pct(r.value, whole)}`)
|
|
139
|
+
.join(", ");
|
|
140
|
+
|
|
141
|
+
const AGE_ORDER = ["13-17", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
|
142
|
+
|
|
143
|
+
/** The media kit the agent reads. Keeps the same headings the screenshot
|
|
144
|
+
* reader wrote, so anything that learned the old layout still works. */
|
|
145
|
+
export function renderLatestMd(s, { screenshotsSection = "" } = {}) {
|
|
146
|
+
const captured = s.capturedAt.slice(0, 10);
|
|
147
|
+
const staleOn = new Date(Date.parse(s.capturedAt) + 30 * DAY).toISOString().slice(0, 10);
|
|
148
|
+
const g = s.demographics.gender ?? [];
|
|
149
|
+
const known = g.filter((r) => r.key === "M" || r.key === "F");
|
|
150
|
+
const knownTotal = known.reduce((a, r) => a + r.value, 0);
|
|
151
|
+
const men = known.find((r) => r.key === "M")?.value ?? 0;
|
|
152
|
+
const women = known.find((r) => r.key === "F")?.value ?? 0;
|
|
153
|
+
const ages = (s.demographics.age ?? []).slice();
|
|
154
|
+
const ageTotal = ages.reduce((a, r) => a + r.value, 0);
|
|
155
|
+
ages.sort((a, z) => AGE_ORDER.indexOf(a.key) - AGE_ORDER.indexOf(z.key));
|
|
156
|
+
const core = ages
|
|
157
|
+
.filter((r) => ["18-24", "25-34", "35-44"].includes(r.key))
|
|
158
|
+
.reduce((a, r) => a + r.value, 0);
|
|
159
|
+
const countryTotal = (s.demographics.country ?? []).reduce((a, r) => a + r.value, 0);
|
|
160
|
+
const cityTotal = (s.demographics.city ?? []).reduce((a, r) => a + r.value, 0);
|
|
161
|
+
const eng = pct(s.interactions, s.reach);
|
|
162
|
+
|
|
163
|
+
return `# @${s.username} — media kit
|
|
164
|
+
|
|
165
|
+
_Source: Instagram Graph API (live). Window: ${s.windowStart} to ${s.windowEnd} (30 days). Captured ${captured}._
|
|
166
|
+
|
|
167
|
+
## STALENESS RULE (read before using any number below)
|
|
168
|
+
- This snapshot is **current only within 30 days of its capture date** (line above). Compare against
|
|
169
|
+
today's date. Captured ${captured} means it goes stale on ${staleOn}.
|
|
170
|
+
- If stale: do **not** put any of these numbers or the screenshots in a draft, even when a brand asks.
|
|
171
|
+
Write the sentence as "I can send over current reach and audience numbers" and add the flag
|
|
172
|
+
\`"stats stale since ${staleOn} — refresh before sending"\` to the decision JSON so Erin refreshes.
|
|
173
|
+
- If current: numbers go out only when asked (see \`playbook/negotiation.md\`, standing rule).
|
|
174
|
+
|
|
175
|
+
## Headline (30 days)
|
|
176
|
+
- **Followers:** ${s.followers.toLocaleString("en-US")} (~${k(s.followers)})
|
|
177
|
+
- **Views:** ${k(s.views)}
|
|
178
|
+
- **Accounts reached:** ${k(s.reach)}
|
|
179
|
+
- **Posts:** ${s.postsPosted} (${s.reelsPosted} Reels)
|
|
180
|
+
|
|
181
|
+
## Engagement (30 days)
|
|
182
|
+
- Likes ${k(s.likes)} · Comments ${k(s.comments)} · Reposts ${k(s.reposts)} · Shares ${k(s.shares)} · Saves ${k(s.saves)}
|
|
183
|
+
- ~${k(s.interactions)} total interactions → **~${eng} engagement by reach**
|
|
184
|
+
|
|
185
|
+
## Audience (followers)
|
|
186
|
+
- **Gender:** ${pct(men, knownTotal)} men / ${pct(women, knownTotal)} women (of followers who state one)
|
|
187
|
+
- **Age:** ${ages.map((r) => `${r.key} = ${pct(r.value, ageTotal)}`).join(", ")} — ~${pct(core, ageTotal)} aged 18–44.
|
|
188
|
+
- **Top countries:** ${list(s.demographics.country ?? [], 5, countryTotal)}
|
|
189
|
+
- **Top cities:** ${list(s.demographics.city ?? [], 5, cityTotal)}
|
|
190
|
+
- **Niche:** developers / engineers / tech workers (coding, dev life, WFH).
|
|
191
|
+
|
|
192
|
+
## How to present reach (only when a brand asks for stats / media kit)
|
|
193
|
+
Lead with views/reach, then note that content performs well above the follower count when it does.
|
|
194
|
+
e.g. "Over the last 30 days my Reels pulled ~${k(s.views)} views and reached ~${k(s.reach)} accounts, with ~${eng}
|
|
195
|
+
engagement by reach. Audience is mostly developers, engineers, and tech workers."
|
|
196
|
+
Use these exact figures, never ones from older drafts or the voice file. Never use this framing
|
|
197
|
+
unprompted, to justify a rate, or to correct a brand's benchmark.
|
|
198
|
+
|
|
199
|
+
${screenshotsSection.trim() || `## Screenshots to attach on request
|
|
200
|
+
Screenshots live in \`screenshots/\`; newest by date prefix is current. If none are newer than this
|
|
201
|
+
snapshot's window, say the numbers come from Instagram's own insights and offer a screenshot on request.`}
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Capture date of the current snapshot, or null if there is none. */
|
|
206
|
+
export function snapshotCapturedAt() {
|
|
207
|
+
if (existsSync(LATEST_JSON)) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(readFileSync(LATEST_JSON, "utf8")).capturedAt ?? null;
|
|
210
|
+
} catch {
|
|
211
|
+
/* fall through to the markdown */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!existsSync(LATEST_MD)) return null;
|
|
215
|
+
const m = readFileSync(LATEST_MD, "utf8").match(/Captured (\d{4}-\d{2}-\d{2})/);
|
|
216
|
+
return m ? m[1] : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Fetch now and rewrite latest.md/json. Returns the stats object. */
|
|
220
|
+
export async function refreshStats() {
|
|
221
|
+
let token = readToken();
|
|
222
|
+
if (!token) throw new Error(`no Instagram token at ${IG_TOKEN_FILE}`);
|
|
223
|
+
token = await maybeRefreshToken(token);
|
|
224
|
+
const stats = await fetchIgStats(token);
|
|
225
|
+
|
|
226
|
+
// Keep the screenshot list only if it has a capture inside this window;
|
|
227
|
+
// June screenshots next to September numbers would contradict the kit.
|
|
228
|
+
const prior = existsSync(LATEST_MD) ? readFileSync(LATEST_MD, "utf8") : "";
|
|
229
|
+
const shotsSection = prior.match(/## Screenshots to attach on request[\s\S]*$/)?.[0] ?? "";
|
|
230
|
+
const shotDates = [...shotsSection.matchAll(/screenshots\/(\d{4}-\d{2}-\d{2})/g)].map((m) => m[1]);
|
|
231
|
+
const shots = shotDates.some((d) => d >= stats.windowStart) ? shotsSection : "";
|
|
232
|
+
writeFileSync(LATEST_MD, renderLatestMd(stats, { screenshotsSection: shots }));
|
|
233
|
+
writeFileSync(LATEST_JSON, JSON.stringify(stats, null, 2));
|
|
234
|
+
return stats;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The pre-draft hook. Refreshes when the snapshot is older than MAX_AGE_DAYS
|
|
239
|
+
* (or missing). Never throws: no token, network down, or a Meta error all
|
|
240
|
+
* leave the previous snapshot alone and log one line.
|
|
241
|
+
*/
|
|
242
|
+
export async function refreshStatsIfStale({ maxAgeDays = MAX_AGE_DAYS, log = console } = {}) {
|
|
243
|
+
if (!readToken()) return { refreshed: false, reason: "no-token" };
|
|
244
|
+
const at = snapshotCapturedAt();
|
|
245
|
+
const ageDays = at ? (Date.now() - Date.parse(at)) / DAY : Infinity;
|
|
246
|
+
if (ageDays < maxAgeDays) return { refreshed: false, reason: "fresh", ageDays };
|
|
247
|
+
try {
|
|
248
|
+
const s = await refreshStats();
|
|
249
|
+
log.log(
|
|
250
|
+
`· ig stats refreshed: ${k(s.followers)} followers, ${k(s.views)} views / ${k(s.reach)} reach (30d)`,
|
|
251
|
+
);
|
|
252
|
+
return { refreshed: true, stats: s };
|
|
253
|
+
} catch (err) {
|
|
254
|
+
log.error(`· ig stats refresh failed (keeping last snapshot): ${err.message}`);
|
|
255
|
+
return { refreshed: false, reason: "error", error: err.message };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// `node worker/ig-stats.js` — refresh on demand from a terminal.
|
|
260
|
+
if (process.argv[1] && process.argv[1].endsWith("ig-stats.js")) {
|
|
261
|
+
refreshStats()
|
|
262
|
+
.then((s) => {
|
|
263
|
+
console.log(readFileSync(LATEST_MD, "utf8"));
|
|
264
|
+
console.log(`wrote ${LATEST_MD} (captured ${s.capturedAt})`);
|
|
265
|
+
})
|
|
266
|
+
.catch((err) => {
|
|
267
|
+
console.error(err.message);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
});
|
|
270
|
+
}
|
package/worker/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { api, syncAssets, BASE_URL, POLL_MS, TOKEN } from "./api.js";
|
|
|
13
13
|
import { BRAND_DIR, ensureDirs } from "./paths.js";
|
|
14
14
|
import { buildMirror } from "./mirror.js";
|
|
15
15
|
import { runOutreachChat, runOutreachDraft } from "./outreach.js";
|
|
16
|
+
import { setAgent } from "./claude.js";
|
|
16
17
|
import {
|
|
17
18
|
runChat,
|
|
18
19
|
runDraft,
|
|
@@ -22,6 +23,14 @@ import {
|
|
|
22
23
|
runStats,
|
|
23
24
|
runVoiceAudit,
|
|
24
25
|
} from "./handlers.js";
|
|
26
|
+
import { acquireWorkerLock } from "./lock.js";
|
|
27
|
+
|
|
28
|
+
// One worker of each kind per machine — a second one would split the queue
|
|
29
|
+
// with this one. Stands down with an explanation if another already holds it.
|
|
30
|
+
acquireWorkerLock("brand", {
|
|
31
|
+
label: "The Brand Manager worker",
|
|
32
|
+
stopHint: "launchctl bootout gui/$(id -u)/com.goosetools.brand (or close its terminal)",
|
|
33
|
+
});
|
|
25
34
|
|
|
26
35
|
if (!TOKEN) {
|
|
27
36
|
console.error(
|
|
@@ -37,6 +46,9 @@ console.log(`brand worker → ${BASE_URL}`);
|
|
|
37
46
|
console.log(`files → ${BRAND_DIR}`);
|
|
38
47
|
|
|
39
48
|
async function handle(job) {
|
|
49
|
+
// The claim payload says which agent CLI the user picked; handlers and the
|
|
50
|
+
// claude.js delegates read it for the duration of this job.
|
|
51
|
+
setAgent(job.agentCli);
|
|
40
52
|
switch (job.kind) {
|
|
41
53
|
case "index": {
|
|
42
54
|
// Pure local read — no Gmail needed, so this works with nothing else
|
package/worker/lock.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// One worker of each kind per machine.
|
|
2
|
+
//
|
|
3
|
+
// Nothing stops you starting a second worker: the daemon runs in the
|
|
4
|
+
// background, and `run` in a terminal is the normal way to watch one work or
|
|
5
|
+
// to try a change from a checkout. Both then poll the same queue with the same
|
|
6
|
+
// token.
|
|
7
|
+
//
|
|
8
|
+
// The server is safe — claiming a job is a single compare-and-set, so two
|
|
9
|
+
// workers never get the same one. The damage is quieter than that. They SPLIT
|
|
10
|
+
// the queue, so jobs land on whichever copy happened to pick them up: half
|
|
11
|
+
// from the code you're editing, half from the installed release, with a
|
|
12
|
+
// different state directory behind each. And the polling doubles, which is
|
|
13
|
+
// what the idle interval exists to keep down in the first place.
|
|
14
|
+
//
|
|
15
|
+
// So: whoever gets here first holds the lock, and the second one stands down
|
|
16
|
+
// with an explanation instead of quietly competing.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
const LOCK_DIR = join(homedir(), ".goosetools", "locks");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Take the lock for `name` ("worker", "caption", "brand", "overlay"), or print
|
|
27
|
+
* who has it and exit. Returns nothing — it either succeeds or ends the
|
|
28
|
+
* process.
|
|
29
|
+
*
|
|
30
|
+
* `stopHint` is the command that stops the OTHER copy, and it's the whole
|
|
31
|
+
* point of the message: "already running" without it just moves the puzzle.
|
|
32
|
+
*/
|
|
33
|
+
export function acquireWorkerLock(name, { label, stopHint }) {
|
|
34
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
35
|
+
const file = join(LOCK_DIR, `${name}.pid`);
|
|
36
|
+
|
|
37
|
+
const holder = readHolder(file);
|
|
38
|
+
if (holder && isAlive(holder.pid)) {
|
|
39
|
+
console.log(
|
|
40
|
+
`\n${label} is already running on this computer (pid ${holder.pid}${
|
|
41
|
+
holder.since ? `, since ${holder.since}` : ""
|
|
42
|
+
}).\n\n` +
|
|
43
|
+
"Two of them would split the queue between them — some jobs done by\n" +
|
|
44
|
+
"one copy, some by the other. Stopping here instead.\n\n" +
|
|
45
|
+
` Stop the other one: ${stopHint}\n`,
|
|
46
|
+
);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Either no lock, or one left behind by a worker that was killed. Both are
|
|
51
|
+
// ours to take: an O_EXCL create loses to a worker that beat us here by
|
|
52
|
+
// milliseconds, which is the one race worth caring about.
|
|
53
|
+
if (holder) rmSync(file, { force: true });
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = openSync(file, "wx");
|
|
57
|
+
} catch {
|
|
58
|
+
console.log(`\n${label} started somewhere else a moment ago. Stopping here.\n`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
|
|
62
|
+
closeSync(fd);
|
|
63
|
+
|
|
64
|
+
const release = () => rmSync(file, { force: true });
|
|
65
|
+
process.on("exit", release);
|
|
66
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
67
|
+
process.on(sig, () => {
|
|
68
|
+
release();
|
|
69
|
+
process.exit(0);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readHolder(file) {
|
|
75
|
+
if (!existsSync(file)) return null;
|
|
76
|
+
try {
|
|
77
|
+
const [pid, since] = readFileSync(file, "utf8").split("\n");
|
|
78
|
+
const n = Number.parseInt(pid, 10);
|
|
79
|
+
return Number.isFinite(n) ? { pid: n, since: since?.trim() || null } : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A pid file outlives a SIGKILLed worker, and pids get reused — so "is that
|
|
86
|
+
// pid alive" isn't enough on its own. Checking that it's a node process is
|
|
87
|
+
// cheap and rules out the reuse case that would otherwise lock out the daemon
|
|
88
|
+
// until someone deleted the file by hand.
|
|
89
|
+
function isAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return /node/.test(execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
}));
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/worker/outreach.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { BASE_DIR, BRAND_DIR, PLAYBOOK_DIR, STATS_DIR, VOICE_DIR } from "./paths.js";
|
|
16
|
-
import {
|
|
16
|
+
import { refreshStatsIfStale } from "./ig-stats.js";
|
|
17
|
+
import { oneShot, parseJson, session, canResume } from "./claude.js";
|
|
17
18
|
import { createGmailDraft } from "./gmail-draft.js";
|
|
18
19
|
|
|
19
20
|
const read = (p, cap = 20_000) =>
|
|
@@ -60,7 +61,7 @@ function historyBlock(history) {
|
|
|
60
61
|
*/
|
|
61
62
|
export async function runOutreachChat(job) {
|
|
62
63
|
const prompt = `${outreachMode()}
|
|
63
|
-
${job.sessionId ? "" : historyBlock(job.history)}
|
|
64
|
+
${canResume(job.sessionId) ? "" : historyBlock(job.history)}
|
|
64
65
|
## This turn
|
|
65
66
|
|
|
66
67
|
The creator says: "${job.instruction ?? ""}"
|
|
@@ -104,6 +105,7 @@ export async function runOutreachDraft(job) {
|
|
|
104
105
|
if (!job.gmailAccessToken)
|
|
105
106
|
return { ok: false, error: "Connect Gmail on goosetools.com first" };
|
|
106
107
|
|
|
108
|
+
await refreshStatsIfStale();
|
|
107
109
|
const prompt = `${outreachMode()}
|
|
108
110
|
|
|
109
111
|
## The creator's layers (personal wins over base on any conflict)
|
package/worker/service.js
CHANGED
|
@@ -62,7 +62,16 @@ function servicePath() {
|
|
|
62
62
|
npmGlobalBin(),
|
|
63
63
|
...(process.platform === "win32"
|
|
64
64
|
? [process.env.PATH ?? ""]
|
|
65
|
-
: [
|
|
65
|
+
: [
|
|
66
|
+
"/opt/homebrew/bin",
|
|
67
|
+
"/usr/local/bin",
|
|
68
|
+
// opencode's installer and some npm setups drop binaries here.
|
|
69
|
+
`${process.env.HOME ?? ""}/.local/bin`,
|
|
70
|
+
"/usr/bin",
|
|
71
|
+
"/bin",
|
|
72
|
+
"/usr/sbin",
|
|
73
|
+
"/sbin",
|
|
74
|
+
]),
|
|
66
75
|
].filter(Boolean);
|
|
67
76
|
return [...new Set(parts)].join(process.platform === "win32" ? ";" : ":");
|
|
68
77
|
}
|