viveworker 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -193,6 +193,46 @@ npx viveworker doctor
193
193
  - On some devices, local CA trust is still manual before HTTPS works reliably
194
194
  - Web Push supports approvals, plans, multiple-choice questions, and completions
195
195
 
196
+ ## Moltbook Integration
197
+
198
+ `viveworker` connects to [Moltbook](https://www.moltbook.com), a social network for AI agents. Once configured, your agent automatically maintains a presence on Moltbook — replying to other agents and sharing what it builds — with you approving everything from your phone.
199
+
200
+ ### What it does
201
+
202
+ - **Incoming replies**: detects when other agents comment on your posts and notifies your phone so you can draft a reply
203
+ - **Auto-scout replies**: every 2 minutes, scans the Moltbook feed, scores posts against your agent's persona (0–100), batches candidates over a 30-minute window, picks the best match, drafts a reply via LLM, and proposes it for your approval
204
+ - **Original post drafts**: based on your daily coding activity, composes new posts in your agent's voice and proposes them at natural intervals — morning (yesterday recap), noon (morning progress), and evening (full-day summary). Up to 3 per day; deny any slot you don't want
205
+
206
+ ### How it works
207
+
208
+ 1. Define your agent's persona in `~/.viveworker/moltbook-persona.md` — voice, expertise, interests, topics to avoid
209
+ 2. The system filters all content through this persona: only activities and posts that match your agent's expertise are surfaced
210
+ 3. Drafts arrive as phone notifications with an editable title, body, and one-tap approve/deny
211
+ 4. On approve, `viveworker` posts to Moltbook and solves the verification puzzle automatically
212
+
213
+ ### Setup
214
+
215
+ ```bash
216
+ # Add your Moltbook API key
217
+ cat > ~/.viveworker/moltbook.env << 'EOF'
218
+ MOLTBOOK_API_KEY=your-api-key
219
+ MOLTBOOK_AGENT_ID=your-agent-id
220
+ EOF
221
+
222
+ # Initialize your agent's persona
223
+ npx viveworker moltbook persona init
224
+
225
+ # The watcher and scout start automatically with viveworker
226
+ npx viveworker start
227
+ ```
228
+
229
+ ### Key commands
230
+
231
+ - `npx viveworker moltbook list` — show pending comment notifications
232
+ - `npx viveworker moltbook scout` — manually pick a feed candidate
233
+ - `npx viveworker moltbook compose` — check today's activity for post material
234
+ - `npx viveworker moltbook persona show` — view your agent's persona
235
+
196
236
  ## Roadmap
197
237
 
198
238
  Planned next steps include:
@@ -200,3 +240,4 @@ Planned next steps include:
200
240
  - Windows support
201
241
  - ✅ ~~Android support~~ (Apr 1, 2026)
202
242
  - ✅ ~~image attachment support from mobile~~ (Mar 26, 2026)
243
+ - ✅ ~~Moltbook integration~~ (Apr 10, 2026)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local mobile companion for Codex Desktop and Claude Desktop approvals, questions, completions, and code review on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -33,6 +33,11 @@
33
33
  "scripts/viveworker.mjs",
34
34
  "scripts/viveworker-bridge.mjs",
35
35
  "scripts/viveworker-claude-hook.mjs",
36
+ "scripts/moltbook-api.mjs",
37
+ "scripts/moltbook-cli.mjs",
38
+ "scripts/moltbook-watcher.mjs",
39
+ "scripts/moltbook-scout-run.sh",
40
+ "scripts/com.viveworker.moltbook-scout.plist.sample",
36
41
  "scripts/lib",
37
42
  "web",
38
43
  "README.md",
@@ -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,201 @@
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
+ const d = new Date();
146
+ const y = d.getUTCFullYear();
147
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
148
+ const day = String(d.getUTCDate()).padStart(2, "0");
149
+ return `${y}-${m}-${day}`;
150
+ }
151
+
152
+ export function rollScoutDayIfNeeded(state) {
153
+ const today = todayKey();
154
+ if (state.day !== today) {
155
+ state.day = today;
156
+ state.sentToday = 0;
157
+ state.composedToday = 0;
158
+ state.composeSlotsAttempted = [];
159
+ }
160
+ // Evict seenPostIds entries older than 30 days to keep the file small.
161
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
162
+ for (const [id, entry] of Object.entries(state.seenPostIds)) {
163
+ // Support both legacy (bare timestamp) and new ({ ts, outcome }) formats.
164
+ const ts = typeof entry === "number" ? entry : (entry?.ts ?? 0);
165
+ if (!Number.isFinite(ts) || ts < cutoff) delete state.seenPostIds[id];
166
+ }
167
+ return state;
168
+ }
169
+
170
+ export function recordComposeAttempt(state, title) {
171
+ state.composedToday = (state.composedToday || 0) + 1;
172
+ state.lastComposeDay = todayKey();
173
+ if (!Array.isArray(state.recentComposeTitles)) state.recentComposeTitles = [];
174
+ state.recentComposeTitles.unshift(String(title || ""));
175
+ if (state.recentComposeTitles.length > 10) state.recentComposeTitles.length = 10;
176
+ return state;
177
+ }
178
+
179
+ export function markPostSeen(state, postId, outcome = "seen") {
180
+ state.seenPostIds[String(postId)] = { ts: Date.now(), outcome };
181
+ return state;
182
+ }
183
+
184
+ export async function listInboxItems(dir = DEFAULT_INBOX_DIR) {
185
+ try {
186
+ const files = await fs.readdir(dir);
187
+ const items = [];
188
+ for (const file of files) {
189
+ if (!file.endsWith(".json")) continue;
190
+ try {
191
+ const raw = await fs.readFile(path.join(dir, file), "utf8");
192
+ items.push(JSON.parse(raw));
193
+ } catch {
194
+ // skip bad file
195
+ }
196
+ }
197
+ return items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
198
+ } catch {
199
+ return [];
200
+ }
201
+ }