File size: 10,764 Bytes
5cf6791 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | // moth plugin for OpenCode (opencode-ai 1.2-1.19): send the agent's final reply of each turn to the local moth
// companion, and optionally show moth's reaction as a toast. Install: copy to ~/.config/opencode/plugins/moth.js
// (global) or .opencode/plugins/moth.js (project); files there load at startup (docs: opencode.ai/docs/plugins).
// The idle event carries only a sessionID, so the `event` hook keeps a little state from the bus
// (schema: packages/schema/src/v1/session.ts and session-status-event.ts at v1.19.3):
// message.updated info.role === "assistant": remember the message id per session, in arrival order;
// `summary` (compaction) messages are skipped, an `error` (abort, API error) means no reply.
// message.part.updated type "text" parts, minus `synthetic` / `ignored`: part.text is the full text so far
// (streaming deltas go to message.part.delta), so the last update wins.
// session.created/updated info.parentID set = a subagent (task tool) session: never posted. info.directory is
// remembered for the label.
// session.status {type: "idle"} (and the deprecated session.idle, same moment) = the turn is over: post the text
// of the newest assistant message that has any, then forget the session's state, so a
// turn without a reply posts nothing and the second idle event finds nothing.
// Session and label, so moth can tell several sessions apart: the sessionID, and the basename of the session's
// directory (Session.directory, SDK types.gen.d.ts), else of the plugin input's `directory`.
// Fire-and-forget: fetch with a 1 s timeout, never awaited, every rejection swallowed, nothing printed.
// Display: after posting, poll moth's lines (GET /api/says) every 0.5 s for up to WAIT_MS and show moth's newest line
// as a TUI toast (client.tui.showToast, SDK 1.2.10 TuiShowToastData). The poll is started, never awaited, so the
// event bus is not held up; its timers are unref'd. The log cursor is taken when a turn starts (session.status
// busy) and persists, so a reaction slower than WAIT_MS shows after the next turn instead of being lost.
// Per session: the poll asks only for moth's reactions to the session that just posted (`source=opencode&
// session=<sessionID>`), and the cursor, epoch and newest-poll counter are kept per sessionID, so two sessions in
// one OpenCode neither cancel each other's toast nor skip each other's line, and no toast shows another agent's
// reply.
// The toast is OFF by default (moth's own setting `show_in_harness`, see moth/config.py): enabled() reads
// MOTH_SHOW_IN_HARNESS (1/0, on/off, true/false, yes/no), else "show_in_harness" in ~/.moth/config.json
// (MOTH_CONFIG_FILE overrides), at every turn, so `python -m moth config show_in_harness on|off` applies from the
// next turn without restarting OpenCode. Off: the reply is still posted (the desktop pet reacts), no poll, no toast.
// Only ONE export: OpenCode treats every exported function as a plugin. Env: MOTH_URL (default http://127.0.0.1:4747);
// the token is read from ~/.moth/token on every post (MOTH_TOKEN_FILE overrides).
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const BASE = (process.env.MOTH_URL || "http://127.0.0.1:4747").replace(/\/+$/, "");
const MOTH = BASE + "/api/agent";
const TOKEN_FILE = process.env.MOTH_TOKEN_FILE || join(homedir(), ".moth", "token");
const WAIT_MS = 4000;
function token() {
try { return readFileSync(TOKEN_FILE, "utf8").trim(); } catch (e) { return ""; }
}
function flag(v) { // the same spellings as moth/config.py parse_bool; null = not a setting
if (typeof v === "boolean") return v;
if (typeof v !== "string") return null;
const s = v.trim().toLowerCase();
return ["1", "on", "true", "yes"].includes(s) ? true : ["0", "off", "false", "no"].includes(s) ? false : null;
}
function enabled() { // show moth's replies in OpenCode? env, else ~/.moth/config.json, else off. Read every turn.
const env = flag(process.env.MOTH_SHOW_IN_HARNESS || "");
if (env !== null) return env;
try {
const file = process.env.MOTH_CONFIG_FILE || join(homedir(), ".moth", "config.json");
const v = flag((JSON.parse(readFileSync(file, "utf8")) || {}).show_in_harness);
if (v !== null) return v;
} catch (e) { /* no file / bad JSON: the default */ }
return false;
}
function labelOf(dir, session) { // the project folder's name; without one, a short session id
const name = String(dir || "").replace(/[\/\\]+$/, "").split(/[\/\\]/).pop();
return name || String(session || "").slice(0, 8);
}
function body(text, source, session, label) { // session / label only when known
const b = { text, source };
if (session) b.session = String(session);
if (label) b.label = String(label);
return JSON.stringify(b);
}
function post(text, session, label) {
try {
fetch(MOTH, {
method: "POST", headers: { "Content-Type": "application/json", "X-Moth-Token": token() },
body: body(text, "opencode", session, label), signal: AbortSignal.timeout(1000),
}).catch(() => {});
} catch (e) { /* drop it */ }
}
async function getSays(since, epoch, session) { // moth's own lines only: no token needed
let q = epoch ? `since=${since}&epoch=${encodeURIComponent(epoch)}` : `since=${since}`;
if (session) q += `&source=opencode&session=${encodeURIComponent(session)}`; // only moth's reactions to it
const r = await fetch(`${BASE}/api/says?${q}`, { signal: AbortSignal.timeout(1000) });
const d = await r.json();
if (!Array.isArray(d.events) || typeof d.next !== "number") throw new Error("not moth");
return d;
}
export const Moth = async ({ client, directory } = {}) => {
const replies = new Map(); // sessionID -> [{ id, skip, error }] assistant messages in arrival order
const texts = new Map(); // messageID -> { sid, parts: Map(partID -> text) }
const children = new Set(); // subagent session ids
const dirs = new Map(); // sessionID -> its directory (for the label)
const canToast = Boolean(client && client.tui && typeof client.tui.showToast === "function");
// sessionID -> { cursor: index into moth's log (null = not known yet), epoch: changes when moth restarts or the
// chat is reset (the server then sends the new log), run: the newest poll of that session wins }
const polls = new Map();
const pollOf = (sid) => {
let p = polls.get(sid);
if (!p) polls.set(sid, (p = { cursor: null, epoch: null, run: 0 }));
return p;
};
function toast(text) {
try {
const flat = String(text).replace(/\s+/g, " ").trim();
Promise.resolve(client.tui.showToast({ body: { title: "moth", message: flat, variant: "info", duration: 8000 } }))
.catch(() => {});
} catch (e) { /* no TUI */ }
}
const start = (p, d) => { if (p.cursor === null) { p.cursor = d.next; p.epoch = d.epoch; } };
function baseline(sid) {
const p = pollOf(sid);
if (canToast && p.cursor === null && enabled()) getSays(1e9).then((d) => start(p, d), () => {});
}
// Setting off: stop every poll and forget the cursors, so turning it back on starts fresh, never with an old line.
function quiet() { for (const p of polls.values()) { p.cursor = null; p.epoch = null; p.run++; } }
async function refresh(sid) { // one poll: true once a new moth line (a reaction to this session) is shown
const p = pollOf(sid);
if (p.cursor === null) { start(p, await getSays(1e9)); return false; }
const d = await getSays(p.cursor, p.epoch, sid);
p.cursor = d.next; p.epoch = d.epoch;
const says = d.events.filter((e) => e && typeof e.say === "string" && e.say.trim());
if (says.length) toast(says[says.length - 1].say);
return says.length > 0;
}
function watch(sid) {
if (!canToast || !sid) return;
const p = pollOf(sid), mine = ++p.run, until = Date.now() + WAIT_MS;
const step = async () => {
let done = false;
try { done = await refresh(sid); } catch (e) { /* moth asleep: keep trying until the window ends */ }
if (!done && mine === p.run && Date.now() < until) { const t = setTimeout(step, 500); t.unref?.(); }
};
const t = setTimeout(step, 300); t.unref?.();
}
function finalText(sid) {
for (const m of [...(replies.get(sid) || [])].reverse()) {
if (m.error) return "";
if (m.skip) continue;
const t = [...((texts.get(m.id) || {}).parts || new Map()).values()].join("\n");
if (t.trim()) return t;
}
return "";
}
function forget(sid) {
replies.delete(sid);
for (const [id, t] of texts) if (t.sid === sid) texts.delete(id);
}
return {
event: async ({ event }) => {
try {
const p = (event && event.properties) || {};
switch (event && event.type) {
case "session.created":
case "session.updated":
if (p.info && p.info.parentID) children.add(p.info.id);
if (p.info && p.info.id && typeof p.info.directory === "string") dirs.set(p.info.id, p.info.directory);
break;
case "message.updated": {
const m = p.info;
if (!m || m.role !== "assistant" || !m.sessionID) break;
const list = replies.get(m.sessionID) || [];
let r = list.find((x) => x.id === m.id);
if (!r) list.push((r = { id: m.id }));
r.skip = Boolean(m.summary);
r.error = Boolean(m.error);
replies.set(m.sessionID, list);
break;
}
case "message.part.updated": {
const part = p.part;
if (!part || part.type !== "text" || part.synthetic || part.ignored || typeof part.text !== "string") break;
const t = texts.get(part.messageID) || { sid: part.sessionID, parts: new Map() };
t.parts.set(part.id, part.text);
texts.set(part.messageID, t);
break;
}
case "session.status":
case "session.idle": {
if (event.type === "session.status" && !(p.status && p.status.type === "idle")) {
if (p.status && p.status.type === "busy" && !children.has(p.sessionID)) baseline(p.sessionID);
break;
}
const sid = p.sessionID;
const text = finalText(sid);
forget(sid);
if (text && !children.has(sid)) {
post(text, sid, labelOf(dirs.get(sid) || directory, sid)); // always: the pet reacts either way
if (enabled()) watch(sid); else quiet();
}
break;
}
}
} catch (e) { /* fail open */ }
},
};
};
|