diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index e6ef979..5acab0d 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -1,200 +1,470 @@ import { spawn } from "node:child_process"; const ADAPTER = "omp-spt"; export function decodeBody(body) { return body .replaceAll("
", "\n") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("&", "&"); } function attribute(tag, name) { const marker = ` ${name}="`; const start = tag.indexOf(marker); if (start < 0) return undefined; const valueStart = start + marker.length; const end = tag.indexOf('"', valueStart); return end < 0 ? undefined : tag.slice(valueStart, end); } export function drainEvents(raw) { const events = []; let cursor = 0; while (true) { const start = raw.indexOf("", start); if (openEnd < 0) return { events, rest: raw.slice(start) }; const close = raw.indexOf("", openEnd + 1); if (close < 0) return { events, rest: raw.slice(start) }; + const end = close + "".length; const tag = raw.slice(start, openEnd); if (attribute(tag, "type") === "msg") { events.push({ from: attribute(tag, "from"), body: decodeBody(raw.slice(openEnd + 1, close)), + envelope: raw.slice(start, end), }); } - cursor = close + "".length; + cursor = end; } } -export function extractReply(messages) { - const assistant = [...(messages ?? [])].reverse().find((message) => message?.role === "assistant"); - if (!assistant) return ""; - if (typeof assistant.content === "string") return assistant.content; - return (assistant.content ?? []) +function messageText(message) { + if (typeof message?.content === "string") return message.content; + return (message?.content ?? []) .filter((part) => part?.type === "text" && typeof part.text === "string") .map((part) => part.text) .join(""); } +export function extractReply(messages, afterUserMessage) { + const allMessages = messages ?? []; + let start = 0; + if (afterUserMessage !== undefined) { + const userIndex = allMessages.findLastIndex((message) => { + if (message?.role !== "user") return false; + const text = messageText(message); + return text === afterUserMessage || text.startsWith(`${afterUserMessage}\n\n message?.role === "assistant"); + return assistant ? messageText(assistant) : ""; +} + function firstLine(text) { return text.split(/\r?\n/).find((line) => line.trim()) ?? ""; } +function errorSummary(error) { + const detail = error instanceof Error ? error.message : String(error); + return firstLine(detail).trim() || "unknown error"; +} + +function failureMessage(reason, error) { + const detail = error === undefined ? "" : `: ${errorSummary(error)}`; + return `[omp-spt] ${reason}${detail}`; +} + +function senderStub(sender) { + const escaped = sender + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); + return ``; +} + +function injectEnvelope(messages, item) { + const index = messages.findLastIndex( + (message) => message?.role === "user" && messageText(message) === item.stub, + ); + if (index < 0) return messages; + const original = messages[index]; + const content = + typeof original.content === "string" + ? `${original.content}\n\n${item.envelope}` + : [...(original.content ?? []), { type: "text", text: `\n\n${item.envelope}` }]; + const injected = [...messages]; + injected[index] = { ...original, content }; + return injected; +} + function runSpt(args, input) { return new Promise((resolve, reject) => { const child = spawn(process.env.OMP_SPT_SPT_BIN || "spt", args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], windowsHide: true, }); let output = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => (output += chunk)); child.stderr.on("data", (chunk) => (output += chunk)); child.on("error", reject); child.on("close", (code) => { if (code === 0) resolve(output.trim()); else reject(new Error(`spt exited ${code}: ${firstLine(output)}`)); }); if (input !== undefined) child.stdin.end(input); }); } -// [impl->REQ-OMP-NATIVE-TUI] -export default function ompSpt(pi) { - const id = process.env.SPT_ENDPOINT_ID?.trim(); - if (!id) return; - - let sid; - let token; - let listener; - let listenerBuffer = ""; - let agentActive = false; - let current; - let stopping = false; - let ui; - const queue = []; - - const logError = (message, error) => { - pi.logger.error(message, { error: String(error) }); - ui?.notify(`${message}: ${error}`, "error"); - }; +export function createOmpSpt(overrides = {}) { + const spawnProcess = overrides.spawnProcess ?? spawn; + const runSptCommand = overrides.runSptCommand ?? runSpt; + const setTimer = overrides.setTimeout ?? globalThis.setTimeout; + const clearTimer = overrides.clearTimeout ?? globalThis.clearTimeout; + const restartDelaysMs = [...(overrides.restartDelaysMs ?? [250, 1000, 4000])]; + const env = overrides.env ?? process.env; - async function setState(state) { - if (!sid) return; - const auth = token ? ["--token", token] : ["--session-id", sid]; - await runSpt(["api", "--adapter", ADAPTER, "state", state, id, ...auth]); - } + // [impl->REQ-OMP-NATIVE-TUI] + return function ompSpt(pi) { + const id = env.SPT_ENDPOINT_ID?.trim(); + if (!id) return; + + let sid; + let token; + let listener; + let listenerBuffer = ""; + let listenerRestartCount = 0; + let restartTimer; + let dispatchTimer; + let agentActive = false; + let dispatching = false; + let current; + let stopping = false; + let ui; + let runtimeCtx; + let endpointState; + let stateOperation = Promise.resolve(); + let endPromise; + let fatalPromise; + const queue = []; + + const logError = (message, error) => { + pi.logger.error(message, { error: errorSummary(error) }); + ui?.notify(`${message}: ${errorSummary(error)}`, "error"); + }; + + function authArgs() { + if (!token) throw new Error("bind did not return an authentication token"); + return ["--token", token]; + } + + function setState(state) { + if (!sid || !token || stopping) return Promise.resolve(); + const operation = stateOperation.catch(() => {}).then(async () => { + if (endpointState === state || stopping) return; + await runSptCommand(["api", "--adapter", ADAPTER, "state", state, id, ...authArgs()]); + endpointState = state; + }); + stateOperation = operation; + return operation; + } - function dispatchNext() { - if (stopping || agentActive || current || queue.length === 0) return; - current = queue.shift(); - try { - pi.sendUserMessage(current.body); - } catch (error) { - logError("omp-spt could not submit an inbound message", error); + function endSession() { + if (!sid || !token) return Promise.resolve(); + if (!endPromise) { + endPromise = (async () => { + await stateOperation.catch(() => {}); + await runSptCommand(["api", "--adapter", ADAPTER, "session-end", id, ...authArgs()]); + endpointState = undefined; + })(); + } + return endPromise; + } + + async function settleItem(item, payload) { + if (!item || item.settled) return; + item.settled = true; + if (!item.from) { + logError("omp-spt received a message without a sender", new Error("missing EVENT from attribute")); + return; + } + try { + await runSptCommand(["send", item.from, "--from", id], payload); + } catch (error) { + logError(`omp-spt could not send the outcome to ${item.from}`, error); + } + } + + function stopResources() { + if (dispatchTimer !== undefined) { + clearTimer(dispatchTimer); + dispatchTimer = undefined; + } + if (restartTimer !== undefined) { + clearTimer(restartTimer); + restartTimer = undefined; + } + const child = listener; + listener = undefined; + if (child) { + try { + child.kill(); + } catch (error) { + pi.logger.error("omp-spt could not reap the listener", { error: errorSummary(error) }); + } + } + listenerBuffer = ""; + } + + async function failPending(reason) { + const pending = current ? [current, ...queue] : [...queue]; current = undefined; - setTimeout(dispatchNext, 0); + queue.length = 0; + for (const item of pending) await settleItem(item, failureMessage(reason)); } - } - function startListener() { - const args = ["ready", id]; - if (process.env.OMP_SPT_SUBNET) args.push("--subnet", process.env.OMP_SPT_SUBNET); - listener = spawn(process.env.OMP_SPT_SPT_BIN || "spt", args, { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - listener.stdout.setEncoding("utf8"); - listener.stderr.setEncoding("utf8"); - listener.stdout.on("data", (chunk) => { - listenerBuffer += chunk; - const drained = drainEvents(listenerBuffer); - listenerBuffer = drained.rest; - for (const event of drained.events) queue.push(event); - dispatchNext(); - }); - listener.stderr.on("data", (chunk) => pi.logger.debug("omp-spt listener", { output: chunk.trim() })); - listener.on("error", (error) => logError("omp-spt listener failed", error)); - listener.on("close", (code) => { + async function failClosed(message, error) { + if (fatalPromise) return fatalPromise; + fatalPromise = (async () => { + stopping = true; + stopResources(); + ui?.setStatus("omp-spt", "spt failed"); + logError(message, error); + await failPending("endpoint stopped before your message could complete"); + try { + await endSession(); + } catch (endError) { + logError("omp-spt session teardown failed", endError); + } + runtimeCtx?.shutdown(); + })(); + return fatalPromise; + } + + function scheduleDispatch() { + if ( + stopping || + agentActive || + dispatching || + current || + queue.length === 0 || + dispatchTimer !== undefined + ) { + return; + } + dispatchTimer = setTimer(() => { + dispatchTimer = undefined; + void dispatchNext().catch((error) => failClosed("omp-spt dispatch failed", error)); + }, 0); + dispatchTimer?.unref?.(); + } + + async function rejectItem(item, reason, error) { + logError(`omp-spt ${reason}`, error); + await settleItem(item, failureMessage(reason, error)); + if (current === item) current = undefined; if (!stopping) { - ui?.setStatus("omp-spt", "spt offline"); - ui?.notify(`omp-spt listener exited (${code})`, "error"); + try { + await setState("idle"); + } catch (stateError) { + logError("omp-spt could not mark the endpoint idle after a failed submission", stateError); + } } - }); - } + } - pi.on("session_start", async (_event, ctx) => { - ui = ctx.ui; - sid = ctx.sessionManager.getSessionId(); - try { - const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid]; - if (process.env.OMP_SPT_SUBNET) bindArgs.push("--subnet", process.env.OMP_SPT_SUBNET); - const bound = await runSpt(bindArgs); - token = bound.match(/\btoken=([^\s]+)/)?.[1]; - await setState("idle"); - startListener(); - ctx.ui.setStatus("omp-spt", `spt:${id}`); - } catch (error) { - ctx.ui.setStatus("omp-spt", "spt bind failed"); - logError(`omp-spt could not bind ${id}`, error); + async function dispatchNext() { + if (stopping || agentActive || dispatching || current || queue.length === 0) return; + dispatching = true; + const item = queue.shift(); + current = item; + try { + try { + await setState("busy"); + } catch (error) { + await rejectItem(item, "could not accept your message", error); + return; + } + if (stopping) return; + if (agentActive) { + if (current === item) current = undefined; + queue.unshift(item); + return; + } + item.stub = senderStub(item.from ?? "unknown"); + item.submitted = true; + try { + pi.sendUserMessage(item.stub); + } catch (error) { + item.submitted = false; + await rejectItem(item, "could not submit your message to OMP", error); + } + } finally { + dispatching = false; + scheduleDispatch(); + } } - }); - pi.on("agent_start", async () => { - agentActive = true; - try { - await setState("busy"); - } catch (error) { - logError("omp-spt could not mark the endpoint busy", error); + function handleListenerDeath(reason) { + listenerBuffer = ""; + if (stopping || restartTimer !== undefined) return; + if (listenerRestartCount >= restartDelaysMs.length) { + void failClosed("omp-spt listener restart budget exhausted", reason); + return; + } + const attempt = listenerRestartCount + 1; + const delay = restartDelaysMs[listenerRestartCount]; + listenerRestartCount = attempt; + const message = `omp-spt listener stopped; restarting ${attempt}/${restartDelaysMs.length} in ${delay}ms`; + pi.logger.error(message, { error: errorSummary(reason) }); + ui?.setStatus("omp-spt", `spt reconnecting (${attempt}/${restartDelaysMs.length})`); + ui?.notify(message, "warning"); + restartTimer = setTimer(() => { + restartTimer = undefined; + startListener(); + }, delay); + restartTimer?.unref?.(); } - }); - pi.on("agent_end", async (event) => { - agentActive = false; - const completed = current; - current = undefined; - if (completed?.from) { - const reply = extractReply(event.messages) || "[omp-spt] turn ended without an assistant response."; + function startListener() { + if (stopping) return; + const args = ["ready", id]; + if (env.OMP_SPT_SUBNET) args.push("--subnet", env.OMP_SPT_SUBNET); + let child; try { - await runSpt(["send", completed.from, "--from", id], reply); + child = spawnProcess(env.OMP_SPT_SPT_BIN || "spt", args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); } catch (error) { - logError(`omp-spt could not reply to ${completed.from}`, error); + handleListenerDeath(error); + return; } + listener = child; + listenerBuffer = ""; + let dead = false; + const died = (reason) => { + if (dead) return; + dead = true; + if (listener === child) listener = undefined; + handleListenerDeath(reason); + }; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (listener !== child || stopping) return; + listenerRestartCount = 0; + listenerBuffer += chunk; + const drained = drainEvents(listenerBuffer); + listenerBuffer = drained.rest; + for (const event of drained.events) { + if (!event.from) { + logError( + "omp-spt received a message without a sender", + new Error("missing EVENT from attribute"), + ); + continue; + } + queue.push(event); + } + if (!agentActive && !current && !dispatching) void dispatchNext(); + }); + child.stderr.on("data", (chunk) => + pi.logger.debug("omp-spt listener", { output: String(chunk).trim() }), + ); + child.on("error", (error) => died(error)); + child.on("close", (code) => died(new Error(`spt ready exited ${code}`))); + ui?.setStatus("omp-spt", `spt:${id}`); } - try { - await setState("idle"); - } catch (error) { - logError("omp-spt could not mark the endpoint idle", error); - } - setTimeout(dispatchNext, 0); - }); - pi.on("session_shutdown", async () => { - stopping = true; - listener?.kill(); - ui?.setStatus("omp-spt", undefined); - if (!sid) return; - const auth = token ? ["--token", token] : ["--session-id", sid]; - try { - await runSpt(["api", "--adapter", ADAPTER, "session-end", id, ...auth]); - } catch (error) { - pi.logger.error("omp-spt session teardown failed", { error: String(error) }); - } - }); + pi.on("session_start", async (_event, ctx) => { + runtimeCtx = ctx; + ui = ctx.ui; + sid = ctx.sessionManager.getSessionId(); + try { + const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid]; + if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); + const bound = await runSptCommand(bindArgs); + token = bound.match(/\btoken=([^\s]+)/)?.[1]; + if (!token) throw new Error("spt bind response did not include token="); + await setState("idle"); + ui.setStatus("omp-spt", `spt:${id}`); + startListener(); + } catch (error) { + ui.setStatus("omp-spt", "spt bind failed"); + await failClosed(`omp-spt could not bind ${id}`, error); + } + }); + + pi.on("session_before_switch", (event, ctx) => { + if (stopping) return; + ctx.ui.notify( + `omp-spt blocked the in-TUI ${event.reason} session switch; end this SPT session first`, + "warning", + ); + return { cancel: true }; + }); + + pi.on("context", (event) => { + if (!current?.submitted || current.settled) return; + const messages = injectEnvelope(event.messages, current); + if (messages !== event.messages) return { messages }; + }); + + pi.on("agent_start", async () => { + if (stopping) return; + agentActive = true; + try { + await setState("busy"); + } catch (error) { + logError("omp-spt could not mark the endpoint busy", error); + } + }); + + pi.on("agent_end", async (event) => { + agentActive = false; + if (stopping) return; + const completed = current; + if (completed?.submitted && !completed.settled) { + const reply = extractReply(event.messages, completed.stub); + await settleItem( + completed, + reply || failureMessage("turn ended without an assistant response"), + ); + } + if (current === completed) current = undefined; + try { + await setState("idle"); + } catch (error) { + logError("omp-spt could not mark the endpoint idle", error); + } + scheduleDispatch(); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + stopping = true; + runtimeCtx ??= ctx; + stopResources(); + ui?.setStatus("omp-spt", undefined); + await failPending("OMP session shut down before your message could complete"); + try { + await endSession(); + } catch (error) { + pi.logger.error("omp-spt session teardown failed", { error: errorSummary(error) }); + } + }); + }; } + +export default createOmpSpt();