viveworker 0.2.1 → 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 +41 -0
- package/package.json +6 -1
- package/scripts/com.viveworker.moltbook-scout.plist.sample +58 -0
- package/scripts/moltbook-api.mjs +201 -0
- package/scripts/moltbook-cli.mjs +1302 -0
- package/scripts/moltbook-scout-run.sh +14 -0
- package/scripts/moltbook-watcher.mjs +294 -0
- package/scripts/viveworker-bridge.mjs +548 -12
- package/scripts/viveworker-claude-hook.mjs +7 -1
- package/scripts/viveworker.mjs +303 -1
- package/web/app.css +75 -1
- package/web/app.js +298 -25
- package/web/i18n.js +54 -0
- package/web/sw.js +1 -1
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.
|
|
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" && 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 <postId> --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
|
+
}
|