viveworker 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ /**
2
+ * a2a-relay-client.mjs — Bridge-side polling client for the viveworker A2A relay.
3
+ *
4
+ * Connects the local viveworker bridge to the Cloudflare Worker relay:
5
+ * - Registers the bridge with the relay on first startup
6
+ * - Polls the relay every 20 seconds for pending A2A tasks
7
+ * - Posts task results (completion/failure/rejection) back to the relay
8
+ *
9
+ * Designed to be imported and used from viveworker-bridge.mjs.
10
+ */
11
+
12
+ import crypto from "node:crypto";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { promises as fs, readFileSync } from "node:fs";
16
+ import { upsertEnvText } from "./lib/pairing.mjs";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const POLL_INTERVAL_MS = 20_000; // 20 seconds
23
+ const REQUEST_TIMEOUT_MS = 15_000; // 15 seconds per HTTP request
24
+ const A2A_ENV_FILE = path.join(os.homedir(), ".viveworker", "a2a.env");
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Module state
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let pollTimer = null;
31
+ let isPolling = false;
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Registration
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * Register this bridge with the A2A relay. On first registration, generates
39
+ * a bridgeSecret and writes it back to ~/.viveworker/a2a.env.
40
+ *
41
+ * @param {object} params
42
+ * @param {object} params.config - Bridge config (must have a2aRelayUrl, a2aRelayUserId, a2aApiKey)
43
+ * @param {Function} params.buildAgentCard - Function to build the local agent card
44
+ * @returns {Promise<{ ok: boolean, relayUrl?: string, error?: string }>}
45
+ */
46
+ export async function registerWithRelay({ config, buildAgentCard }) {
47
+ const relayUrl = config.a2aRelayUrl;
48
+ const userId = config.a2aRelayUserId;
49
+
50
+ if (!relayUrl || !userId) {
51
+ return { ok: false, error: "A2A_RELAY_URL or A2A_RELAY_USER_ID not configured" };
52
+ }
53
+
54
+ // Generate or reuse bridgeSecret
55
+ let bridgeSecret = config.a2aRelaySecret || "";
56
+ const isNewSecret = !bridgeSecret;
57
+ if (isNewSecret) {
58
+ bridgeSecret = crypto.randomBytes(32).toString("hex");
59
+ }
60
+
61
+ const agentCard = typeof buildAgentCard === "function"
62
+ ? buildAgentCard(config)
63
+ : {};
64
+
65
+ const body = {
66
+ userId,
67
+ bridgeSecret,
68
+ a2aApiKey: config.a2aApiKey || "",
69
+ agentCard,
70
+ ...(config.a2aRelayRegisterSecret ? { registerSecret: config.a2aRelayRegisterSecret } : {}),
71
+ };
72
+
73
+ const headers = {
74
+ "content-type": "application/json",
75
+ };
76
+ // Re-registration requires the current bridgeSecret
77
+ if (!isNewSecret) {
78
+ headers["authorization"] = `Bearer ${bridgeSecret}`;
79
+ }
80
+
81
+ try {
82
+ const res = await fetchWithTimeout(`${relayUrl}/internal/register`, {
83
+ method: "POST",
84
+ headers,
85
+ body: JSON.stringify(body),
86
+ });
87
+
88
+ const data = await res.json();
89
+
90
+ if (!res.ok) {
91
+ return { ok: false, error: data.error || `HTTP ${res.status}` };
92
+ }
93
+
94
+ // On success, write bridgeSecret back to a2a.env if new
95
+ if (isNewSecret) {
96
+ await persistRelaySecret(bridgeSecret);
97
+ // Update config in-place so subsequent calls use the secret
98
+ config.a2aRelaySecret = bridgeSecret;
99
+ }
100
+
101
+ console.log(`[a2a-relay] Registered as ${userId} → ${data.relayUrl || relayUrl}`);
102
+ return { ok: true, relayUrl: data.relayUrl };
103
+ } catch (err) {
104
+ return { ok: false, error: err.message };
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Polling
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Start polling the relay for pending A2A tasks. Tasks are ingested into
114
+ * the local runtime just like locally-received A2A tasks.
115
+ *
116
+ * @param {object} params
117
+ * @param {object} params.config - Bridge config
118
+ * @param {object} params.runtime - Bridge runtime state
119
+ * @param {object} params.state - Persistent state
120
+ * @param {object} params.helpers - { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText }
121
+ */
122
+ export function startRelayPolling({ config, runtime, state, helpers }) {
123
+ if (pollTimer) {
124
+ console.warn("[a2a-relay] Polling already started, skipping duplicate start");
125
+ return;
126
+ }
127
+
128
+ const relayUrl = config.a2aRelayUrl;
129
+ const userId = config.a2aRelayUserId;
130
+ const secret = config.a2aRelaySecret;
131
+
132
+ if (!relayUrl || !userId || !secret) {
133
+ console.warn("[a2a-relay] Cannot start polling — missing relay config");
134
+ return;
135
+ }
136
+
137
+ console.log(`[a2a-relay] Starting poll loop (every ${POLL_INTERVAL_MS / 1000}s) for user ${userId}`);
138
+
139
+ // Do an initial poll immediately, then schedule interval
140
+ pollOnce({ config, runtime, state, helpers }).catch((err) => {
141
+ console.error(`[a2a-relay] Initial poll error: ${err.message}`);
142
+ });
143
+
144
+ pollTimer = setInterval(() => {
145
+ if (isPolling || runtime.stopping) return;
146
+ pollOnce({ config, runtime, state, helpers }).catch((err) => {
147
+ console.error(`[a2a-relay] Poll error: ${err.message}`);
148
+ });
149
+ }, POLL_INTERVAL_MS);
150
+
151
+ // Allow Node to exit even if timer is running
152
+ if (pollTimer.unref) pollTimer.unref();
153
+ }
154
+
155
+ /**
156
+ * Stop relay polling. Call on bridge shutdown.
157
+ */
158
+ export function stopRelayPolling() {
159
+ if (pollTimer) {
160
+ clearInterval(pollTimer);
161
+ pollTimer = null;
162
+ }
163
+ isPolling = false;
164
+ console.log("[a2a-relay] Polling stopped");
165
+ }
166
+
167
+ /**
168
+ * Single poll cycle: fetch pending tasks from the relay and ingest them.
169
+ */
170
+ async function pollOnce({ config, runtime, state, helpers }) {
171
+ if (runtime.stopping) return;
172
+ isPolling = true;
173
+
174
+ try {
175
+ const relayUrl = config.a2aRelayUrl;
176
+ const userId = config.a2aRelayUserId;
177
+ const secret = config.a2aRelaySecret;
178
+
179
+ const res = await fetchWithTimeout(`${relayUrl}/internal/poll/${userId}`, {
180
+ method: "GET",
181
+ headers: {
182
+ authorization: `Bearer ${secret}`,
183
+ },
184
+ });
185
+
186
+ if (!res.ok) {
187
+ const text = await res.text().catch(() => "");
188
+ console.error(`[a2a-relay] Poll failed: HTTP ${res.status} ${text.slice(0, 200)}`);
189
+ return;
190
+ }
191
+
192
+ const data = await res.json();
193
+ const tasks = data.tasks || [];
194
+
195
+ if (tasks.length === 0) return;
196
+
197
+ console.log(`[a2a-relay] Received ${tasks.length} task(s) from relay`);
198
+
199
+ for (const relayTask of tasks) {
200
+ await ingestRelayTask({ relayTask, config, runtime, state, helpers });
201
+ }
202
+ } finally {
203
+ isPolling = false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Ingest a single relay task into local runtime. Creates the same structure
209
+ * as a locally-received A2A task, tagged with relayTaskId for result posting.
210
+ */
211
+ async function ingestRelayTask({ relayTask, config, runtime, state, helpers }) {
212
+ const { recordTimelineEntry, deliverWebPushItem, saveState, historyToken, cleanText } = helpers;
213
+
214
+ // Skip if we already have this task (deduplicate)
215
+ for (const existing of runtime.a2aTasksByToken.values()) {
216
+ if (existing.relayTaskId === relayTask.id) {
217
+ return; // already ingested
218
+ }
219
+ }
220
+
221
+ const token = historyToken(`a2a_task:${relayTask.id}`);
222
+ const instruction = cleanText(relayTask.instruction || "");
223
+ const now = Date.now();
224
+
225
+ const task = {
226
+ id: relayTask.id,
227
+ token,
228
+ contextId: relayTask.contextId || crypto.randomUUID(),
229
+ status: "submitted",
230
+ statusMessage: "",
231
+ messages: relayTask.messages || [],
232
+ artifacts: [],
233
+ instruction,
234
+ callerInfo: relayTask.callerInfo || {},
235
+ createdAtMs: relayTask.createdAtMs || now,
236
+ updatedAtMs: now,
237
+ decisionWaiters: [],
238
+ decision: null,
239
+ // Mark as relay-originated so we know to post results back
240
+ relayTaskId: relayTask.id,
241
+ relayUserId: config.a2aRelayUserId,
242
+ };
243
+
244
+ runtime.a2aTasksByToken.set(token, task);
245
+
246
+ // Record timeline entry (same as local A2A handler)
247
+ try {
248
+ recordTimelineEntry({
249
+ config,
250
+ runtime,
251
+ state,
252
+ entry: {
253
+ stableId: `a2a_task:${relayTask.id}`,
254
+ token,
255
+ kind: "a2a_task",
256
+ threadId: "a2a",
257
+ threadLabel: "A2A",
258
+ title: `A2A: ${instruction.slice(0, 80)}`,
259
+ summary: instruction.slice(0, 160),
260
+ messageText: instruction,
261
+ createdAtMs: relayTask.createdAtMs || now,
262
+ readOnly: false,
263
+ provider: "a2a",
264
+ },
265
+ });
266
+ await saveState(config.stateFile, state);
267
+ } catch (err) {
268
+ console.error(`[a2a-relay] Timeline save error: ${err.message}`);
269
+ }
270
+
271
+ // Send Web Push notification
272
+ try {
273
+ await deliverWebPushItem({
274
+ config,
275
+ state,
276
+ kind: "a2a_task",
277
+ token,
278
+ stableId: `a2a_task:${relayTask.id}`,
279
+ title: "A2A Task Request",
280
+ body: instruction.slice(0, 160),
281
+ buildLocalizedContent: ({ locale }) => {
282
+ const lang = locale?.startsWith("ja") ? "ja" : "en";
283
+ return {
284
+ title: lang === "ja" ? "\u{1F91D} A2Aリレー経由のタスク依頼" : "\u{1F91D} A2A task via relay",
285
+ body: instruction.slice(0, 160),
286
+ };
287
+ },
288
+ });
289
+ } catch (err) {
290
+ console.error(`[a2a-relay] Push notification error: ${err.message}`);
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Result posting
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Post a task result back to the relay. Called after task completion,
300
+ * failure, or rejection.
301
+ *
302
+ * @param {object} params
303
+ * @param {object} params.config - Bridge config (a2aRelayUrl, a2aRelayUserId, a2aRelaySecret)
304
+ * @param {object} params.task - A2A task object with relayTaskId
305
+ * @returns {Promise<{ ok: boolean, error?: string }>}
306
+ */
307
+ export async function postRelayResult({ config, task }) {
308
+ if (!task.relayTaskId) {
309
+ return { ok: false, error: "Not a relay task" };
310
+ }
311
+
312
+ const relayUrl = config.a2aRelayUrl;
313
+ const userId = task.relayUserId || config.a2aRelayUserId;
314
+ const secret = config.a2aRelaySecret;
315
+
316
+ if (!relayUrl || !userId || !secret) {
317
+ return { ok: false, error: "Relay not configured" };
318
+ }
319
+
320
+ const body = {
321
+ status: task.status,
322
+ statusMessage: task.statusMessage || "",
323
+ updatedAtMs: task.updatedAtMs || Date.now(),
324
+ artifacts: task.artifacts || [],
325
+ };
326
+
327
+ try {
328
+ const res = await fetchWithTimeout(
329
+ `${relayUrl}/internal/result/${userId}/${task.relayTaskId}`,
330
+ {
331
+ method: "POST",
332
+ headers: {
333
+ "content-type": "application/json",
334
+ authorization: `Bearer ${secret}`,
335
+ },
336
+ body: JSON.stringify(body),
337
+ }
338
+ );
339
+
340
+ const data = await res.json();
341
+
342
+ if (!res.ok) {
343
+ console.error(`[a2a-relay] Result post failed: HTTP ${res.status} ${data.error || ""}`);
344
+ return { ok: false, error: data.error || `HTTP ${res.status}` };
345
+ }
346
+
347
+ console.log(`[a2a-relay] Result posted for task ${task.relayTaskId} → ${data.status}`);
348
+ return { ok: true };
349
+ } catch (err) {
350
+ console.error(`[a2a-relay] Result post error: ${err.message}`);
351
+ return { ok: false, error: err.message };
352
+ }
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Helpers
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Write A2A_RELAY_SECRET back to ~/.viveworker/a2a.env.
361
+ */
362
+ async function persistRelaySecret(secret) {
363
+ try {
364
+ let current = "";
365
+ try {
366
+ current = await fs.readFile(A2A_ENV_FILE, "utf8");
367
+ } catch {
368
+ // File may not exist yet
369
+ }
370
+
371
+ const updated = upsertEnvText(current, {
372
+ A2A_RELAY_SECRET: secret,
373
+ });
374
+
375
+ await fs.mkdir(path.dirname(A2A_ENV_FILE), { recursive: true, mode: 0o700 });
376
+ await fs.writeFile(A2A_ENV_FILE, updated, { mode: 0o600 });
377
+ console.log(`[a2a-relay] Wrote A2A_RELAY_SECRET to ${A2A_ENV_FILE}`);
378
+ } catch (err) {
379
+ console.error(`[a2a-relay] Failed to write relay secret: ${err.message}`);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * fetch() with a timeout via AbortSignal.
385
+ */
386
+ async function fetchWithTimeout(url, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
387
+ const controller = new AbortController();
388
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
389
+ try {
390
+ return await fetch(url, { ...options, signal: controller.signal });
391
+ } finally {
392
+ clearTimeout(timer);
393
+ }
394
+ }
@@ -0,0 +1,58 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ SAMPLE launchd agent for the Moltbook scouting loop.
5
+
6
+ This is provider-neutral: the cron command invokes whichever agent CLI
7
+ you use. Customize the inner command to call `codex exec`, `claude -p`,
8
+ a small Anthropic SDK script, or whatever produces drafts for you.
9
+
10
+ Install:
11
+ 1. Copy this file to ~/Library/LaunchAgents/com.viveworker.moltbook-scout.plist
12
+ 2. Replace the placeholders (PATH/TO/VIVEWORKER, your agent CLI line)
13
+ 3. Make sure ~/.viveworker/moltbook.env is populated (same file the
14
+ watcher uses) and that VIVEWORKER_HOOK_SECRET matches the bridge.
15
+ 4. launchctl load ~/Library/LaunchAgents/com.viveworker.moltbook-scout.plist
16
+ -->
17
+ <plist version="1.0">
18
+ <dict>
19
+ <key>Label</key>
20
+ <string>com.viveworker.moltbook-scout</string>
21
+
22
+ <key>ProgramArguments</key>
23
+ <array>
24
+ <string>/bin/bash</string>
25
+ <string>-lc</string>
26
+ <!--
27
+ Example for a Codex Desktop user with `codex exec`:
28
+
29
+ set -a; . "$HOME/.viveworker/moltbook.env"; set +a;
30
+ cd "$HOME/path/to/viveworker";
31
+ codex exec "Run scripts/moltbook-scout-run.sh. If it prints a
32
+ candidate (status: 'candidate'), draft a 2-3 paragraph reply in
33
+ viveworker voice (informal lowercase, technically substantive,
34
+ no signature) and run: node scripts/viveworker.mjs moltbook
35
+ propose <postId> --text \"<your draft>\" --timeout 900. If
36
+ scout prints anything else, exit quietly."
37
+
38
+ Replace `codex exec` with `claude -p` for Claude Code users, or
39
+ with your own SDK runner.
40
+ -->
41
+ <string>set -a; . "$HOME/.viveworker/moltbook.env"; set +a; cd "$HOME/path/to/viveworker" &amp;&amp; codex exec "Run scripts/moltbook-scout-run.sh and, if it prints a candidate, draft a reply in viveworker voice and call viveworker moltbook propose &lt;postId&gt; --text \"...\" --timeout 900."</string>
42
+ </array>
43
+
44
+ <key>StartInterval</key>
45
+ <integer>120</integer>
46
+
47
+ <key>RunAtLoad</key>
48
+ <false/>
49
+
50
+ <key>StandardOutPath</key>
51
+ <string>/tmp/viveworker-moltbook-scout.out.log</string>
52
+ <key>StandardErrorPath</key>
53
+ <string>/tmp/viveworker-moltbook-scout.err.log</string>
54
+
55
+ <key>ProcessType</key>
56
+ <string>Background</string>
57
+ </dict>
58
+ </plist>
@@ -0,0 +1,206 @@
1
+ // Shared Moltbook API helpers used by the watcher and the CLI.
2
+ // Keeps credential loading + request plumbing in one place.
3
+
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ export const API_BASE = "https://www.moltbook.com/api/v1";
9
+
10
+ export const DEFAULT_ENV_FILE = path.join(os.homedir(), ".viveworker", "moltbook.env");
11
+ export const DEFAULT_INBOX_DIR = path.join(os.homedir(), ".viveworker", "moltbook-inbox");
12
+ export const DEFAULT_SCOUT_STATE_FILE = path.join(os.homedir(), ".viveworker", "moltbook-scout-state.json");
13
+
14
+ export async function loadMoltbookEnv(envFile = DEFAULT_ENV_FILE) {
15
+ const env = {};
16
+ try {
17
+ const raw = await fs.readFile(envFile, "utf8");
18
+ for (const line of raw.split("\n")) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed || trimmed.startsWith("#")) continue;
21
+ const eq = trimmed.indexOf("=");
22
+ if (eq === -1) continue;
23
+ env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
24
+ }
25
+ } catch {
26
+ // Missing env file is fine — caller will validate required keys.
27
+ }
28
+ // Fall back to process.env for anything not in the file.
29
+ for (const key of ["MOLTBOOK_API_KEY", "MOLTBOOK_AGENT_ID", "VIVEWORKER_HOOK_SECRET", "VIVEWORKER_BASE_URL"]) {
30
+ if (!env[key] && process.env[key]) env[key] = process.env[key];
31
+ }
32
+ return env;
33
+ }
34
+
35
+ export function createMoltbookClient(apiKey) {
36
+ if (!apiKey) {
37
+ throw new Error("MOLTBOOK_API_KEY is required");
38
+ }
39
+ return async function mb(pathname, init = {}) {
40
+ const res = await fetch(`${API_BASE}${pathname}`, {
41
+ ...init,
42
+ headers: {
43
+ "content-type": "application/json",
44
+ authorization: `Bearer ${apiKey}`,
45
+ ...(init.headers || {}),
46
+ },
47
+ });
48
+ const text = await res.text().catch(() => "");
49
+ if (!res.ok) {
50
+ throw new Error(`moltbook ${res.status} ${pathname}: ${text}`);
51
+ }
52
+ try {
53
+ return JSON.parse(text);
54
+ } catch {
55
+ return text;
56
+ }
57
+ };
58
+ }
59
+
60
+ export function extractNotifications(payload) {
61
+ const list = [];
62
+ const candidates = [
63
+ payload?.notifications,
64
+ payload?.data?.notifications,
65
+ payload?.unread_notifications,
66
+ payload?.activity,
67
+ ];
68
+ for (const arr of candidates) {
69
+ if (Array.isArray(arr)) list.push(...arr);
70
+ }
71
+ return list;
72
+ }
73
+
74
+ export function isCommentNotification(n) {
75
+ const type = String(n?.type || n?.kind || "").toLowerCase();
76
+ return type.includes("comment") || type.includes("reply") || type.includes("mention");
77
+ }
78
+
79
+ export async function ensureInboxDir(dir = DEFAULT_INBOX_DIR) {
80
+ await fs.mkdir(dir, { recursive: true });
81
+ return dir;
82
+ }
83
+
84
+ export function inboxPathFor(commentId, dir = DEFAULT_INBOX_DIR) {
85
+ return path.join(dir, `${commentId}.json`);
86
+ }
87
+
88
+ export async function writeInboxItem(item, dir = DEFAULT_INBOX_DIR) {
89
+ await ensureInboxDir(dir);
90
+ await fs.writeFile(inboxPathFor(item.commentId, dir), JSON.stringify(item, null, 2) + "\n", "utf8");
91
+ }
92
+
93
+ export async function readInboxItem(commentId, dir = DEFAULT_INBOX_DIR) {
94
+ try {
95
+ const raw = await fs.readFile(inboxPathFor(commentId, dir), "utf8");
96
+ return JSON.parse(raw);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export async function updateInboxStatus(commentId, status, extra = {}, dir = DEFAULT_INBOX_DIR) {
103
+ const existing = await readInboxItem(commentId, dir);
104
+ if (!existing) return null;
105
+ const updated = { ...existing, ...extra, status, updatedAt: new Date().toISOString() };
106
+ await writeInboxItem(updated, dir);
107
+ return updated;
108
+ }
109
+
110
+ // ---------- Scout state ----------
111
+ //
112
+ // Tracks per-day usage of the Moltbook scouting loop so we can enforce a
113
+ // simple daily quota and avoid re-proposing drafts against the same post.
114
+
115
+ export async function readScoutState(file = DEFAULT_SCOUT_STATE_FILE) {
116
+ try {
117
+ const raw = await fs.readFile(file, "utf8");
118
+ const parsed = JSON.parse(raw);
119
+ if (!parsed || typeof parsed !== "object") return defaultScoutState();
120
+ return {
121
+ day: String(parsed.day || ""),
122
+ sentToday: Number(parsed.sentToday) || 0,
123
+ seenPostIds: parsed.seenPostIds && typeof parsed.seenPostIds === "object" ? parsed.seenPostIds : {},
124
+ batch: parsed.batch && typeof parsed.batch === "object" ? parsed.batch : null,
125
+ lastComposeDay: String(parsed.lastComposeDay || ""),
126
+ composedToday: Number(parsed.composedToday) || 0,
127
+ composeSlotsAttempted: Array.isArray(parsed.composeSlotsAttempted) ? parsed.composeSlotsAttempted : [],
128
+ recentComposeTitles: Array.isArray(parsed.recentComposeTitles) ? parsed.recentComposeTitles : [],
129
+ };
130
+ } catch {
131
+ return defaultScoutState();
132
+ }
133
+ }
134
+
135
+ export async function writeScoutState(state, file = DEFAULT_SCOUT_STATE_FILE) {
136
+ await fs.mkdir(path.dirname(file), { recursive: true });
137
+ await fs.writeFile(file, JSON.stringify(state, null, 2) + "\n", "utf8");
138
+ }
139
+
140
+ export function defaultScoutState() {
141
+ return { day: todayKey(), sentToday: 0, seenPostIds: {}, batch: null, lastComposeDay: "", composedToday: 0, composeSlotsAttempted: [], recentComposeTitles: [] };
142
+ }
143
+
144
+ export function todayKey() {
145
+ // Use local timezone with AM 5:00 as the day boundary — hours before 5am
146
+ // count as the previous day so late-night work doesn't consume the next
147
+ // day's quota.
148
+ const d = new Date(Date.now() - 5 * 60 * 60 * 1000);
149
+ const y = d.getFullYear();
150
+ const m = String(d.getMonth() + 1).padStart(2, "0");
151
+ const day = String(d.getDate()).padStart(2, "0");
152
+ return `${y}-${m}-${day}`;
153
+ }
154
+
155
+ export function rollScoutDayIfNeeded(state) {
156
+ const today = todayKey();
157
+ if (state.day !== today) {
158
+ state.day = today;
159
+ state.sentToday = 0;
160
+ state.composedToday = 0;
161
+ state.composeSlotsAttempted = [];
162
+ }
163
+ // Evict seenPostIds entries older than 30 days to keep the file small.
164
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
165
+ for (const [id, entry] of Object.entries(state.seenPostIds)) {
166
+ // Support both legacy (bare timestamp) and new ({ ts, outcome }) formats.
167
+ const ts = typeof entry === "number" ? entry : (entry?.ts ?? 0);
168
+ if (!Number.isFinite(ts) || ts < cutoff) delete state.seenPostIds[id];
169
+ }
170
+ return state;
171
+ }
172
+
173
+ export function recordComposeAttempt(state, title, postId) {
174
+ state.composedToday = (state.composedToday || 0) + 1;
175
+ state.lastComposeDay = todayKey();
176
+ if (!Array.isArray(state.recentComposeTitles)) state.recentComposeTitles = [];
177
+ state.recentComposeTitles.unshift(
178
+ postId ? { title: String(title || ""), postId: String(postId) } : String(title || "")
179
+ );
180
+ if (state.recentComposeTitles.length > 10) state.recentComposeTitles.length = 10;
181
+ return state;
182
+ }
183
+
184
+ export function markPostSeen(state, postId, outcome = "seen") {
185
+ state.seenPostIds[String(postId)] = { ts: Date.now(), outcome };
186
+ return state;
187
+ }
188
+
189
+ export async function listInboxItems(dir = DEFAULT_INBOX_DIR) {
190
+ try {
191
+ const files = await fs.readdir(dir);
192
+ const items = [];
193
+ for (const file of files) {
194
+ if (!file.endsWith(".json")) continue;
195
+ try {
196
+ const raw = await fs.readFile(path.join(dir, file), "utf8");
197
+ items.push(JSON.parse(raw));
198
+ } catch {
199
+ // skip bad file
200
+ }
201
+ }
202
+ return items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
203
+ } catch {
204
+ return [];
205
+ }
206
+ }