Files

556 lines
18 KiB
TypeScript

/**
* owui-sync — opencode plugin
*
* On session.idle:
* 1. Reads the session transcript via the opencode SDK client
* 2. Calls the local vLLM endpoint to summarize
* 3. Writes ## Current State + ## Session Log into {projectDir}/AGENTS.md
* 4. Upserts an Open WebUI memory so context appears in all OWUI conversations
*
* Install: add to ~/.config/opencode/config.json:
* "plugin": [
* ["file:///path/to/opencode-open-webui-sync", {
* "owui_url": "https://your-owui-host",
* "owui_token": "sk-your-api-key"
* }]
* ]
*
* Optional overrides (all have working defaults):
* "vllm_url" — vLLM base URL (default: http://10.10.0.12:8000/v1)
* "vllm_model" — model ID (default: qwen3.6)
* "vllm_key" — vLLM API key (default: token-abc123)
*
* owui_token: Open WebUI → Settings → Account → API Key
*/
import type { Plugin } from "@opencode-ai/plugin";
import { appendFileSync, readFileSync, writeFileSync } from "fs";
import { execFileSync } from "child_process";
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
const LOG_FILE = `${process.env.HOME}/.local/share/opencode/owui-sync.log`;
const LOG_MAX_LINES = 600;
const LOG_TRIM_TO = 500;
function log(...args: unknown[]): void {
const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`;
try {
appendFileSync(LOG_FILE, line);
rotateLogs();
} catch {
// ignore — don't let logging break the plugin
}
}
function rotateLogs(): void {
try {
const content = readFileSync(LOG_FILE, "utf8");
const lines = content.split("\n");
if (lines.length > LOG_MAX_LINES) {
writeFileSync(LOG_FILE, lines.slice(-LOG_TRIM_TO).join("\n") + "\n");
}
} catch {
// file may not exist yet
}
}
// ---------------------------------------------------------------------------
// Defaults — override via plugin options in config.json
// ---------------------------------------------------------------------------
const DEFAULT_VLLM_BASE = "http://10.10.0.12:8000/v1";
const DEFAULT_VLLM_KEY = "token-abc123";
const DEFAULT_VLLM_MODEL = "qwen3.6";
const VLLM_TIMEOUT_MS = 60_000;
const TRANSCRIPT_MAX_MESSAGES = 60;
const OWUI_MEMORY_TAG = "[owui-sync]";
const SUMMARIZER_PROMPT = `You are a concise technical note-taker. Given this coding session transcript, extract:
1. CURRENT STATE: 2-4 bullet points describing where the project is right now.
Focus on what exists, what works, what doesn't. Present tense.
2. SESSION SUMMARY: What was worked on, key decisions made (with brief rationale),
what was completed, any blockers, and immediate next steps.
Be specific and technical. No filler. Use the same terminology as the codebase.
Omit tool call details unless the result is architecturally significant.
Output as JSON:
{
"current_state": ["bullet 1", "bullet 2"],
"worked_on": "...",
"decisions": ["decision + rationale"],
"done": ["..."],
"blockers": ["..."],
"next": ["..."]
}`;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface SummaryJSON {
current_state: string[];
worked_on: string;
decisions: string[];
done: string[];
blockers: string[];
next: string[];
}
interface OwuiMemory {
id: string;
content: string;
}
interface Config {
vllmBase: string;
vllmKey: string;
vllmModel: string;
owuiUrl: string;
owuiToken: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Stable cross-machine project key derived from the git remote origin repo name.
* e.g. https://github.com/user/my-repo.git → my-repo
* git@github.com:user/my-repo.git → my-repo
* Falls back to directory basename if no git remote exists.
*/
function getProjectKey(directory: string): string {
try {
const remote = execFileSync("git", ["-C", directory, "remote", "get-url", "origin"], {
encoding: "utf8",
timeout: 3000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
// Strip trailing .git, then take the last path/colon-separated segment
const name = remote.replace(/\.git$/, "").split(/[/:]/g).at(-1);
if (name) return name;
} catch {
// not a git repo or no remote — fall through
}
return directory.split("/").filter(Boolean).at(-1) ?? "unknown";
}
function stripThinking(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
}
function formatDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}`
);
}
function sanitizeTitle(raw: string): string {
return raw
.replace(/<[^>]*>/g, "") // strip XML/HTML tags (e.g. <system-...>)
.replace(/[^\x20-\x7E]/g, " ") // printable ASCII only
.replace(/\s+/g, " ")
.trim()
.slice(0, 60) || "Untitled session";
}
function buildTranscript(
messages: Array<{ info: { role: string; id?: string }; parts: Array<{ type: string; text?: string; synthetic?: boolean; ignored?: boolean }> }>
): string {
const turns = messages.filter((m) => m.info.role === "user" || m.info.role === "assistant");
const capped = turns.slice(-TRANSCRIPT_MAX_MESSAGES);
log(`transcript: ${messages.length} total messages, ${turns.length} user/assistant turns, using last ${capped.length}`);
const lines: string[] = [];
for (const msg of capped) {
const role = msg.info.role;
const allParts = msg.parts.filter((p) => p.type === "text" && p.text);
// For assistant messages: skip synthetic/ignored parts (injected context, tool descriptions)
// For user messages: keep everything — opencode marks real user input as synthetic too
const kept = role === "assistant"
? allParts.filter((p) => !p.synthetic && !p.ignored)
: allParts;
const dropped = allParts.length - kept.length;
if (dropped > 0) {
log(` [${role}] ${msg.info.id ?? "?"}: dropped ${dropped}/${allParts.length} synthetic/ignored parts`);
}
const text = kept.map((p) => p.text!).join("\n").trim();
if (!text) continue;
lines.push(`[${role.toUpperCase()}]\n${text}`);
}
return lines.join("\n\n");
}
async function callVLLM(transcript: string, cfg: Config): Promise<SummaryJSON | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), VLLM_TIMEOUT_MS);
try {
const resp = await fetch(`${cfg.vllmBase}/chat/completions`, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.vllmKey}`,
},
body: JSON.stringify({
model: cfg.vllmModel,
messages: [
{ role: "system", content: SUMMARIZER_PROMPT },
{ role: "user", content: transcript },
],
temperature: 0.2,
max_tokens: 1024,
chat_template_kwargs: { enable_thinking: false },
}),
});
if (!resp.ok) {
log("[ERROR]", `vLLM ${resp.status}:`, await resp.text());
return null;
}
const data = await resp.json();
const raw: string = data?.choices?.[0]?.message?.content ?? "";
const cleaned = stripThinking(raw);
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
log("[ERROR]", "no JSON in vLLM response:", cleaned);
return null;
}
const parsed = JSON.parse(jsonMatch[0]);
// Normalize: model occasionally returns worked_on as an array despite the schema
if (Array.isArray(parsed.worked_on)) {
parsed.worked_on = (parsed.worked_on as string[]).join("; ");
}
return parsed as SummaryJSON;
} catch (err) {
if ((err as Error).name === "AbortError") {
log("[WARN]", `vLLM timed out after ${VLLM_TIMEOUT_MS / 1000}s`);
} else {
log("[ERROR]", "vLLM error:", err);
}
return null;
} finally {
clearTimeout(timer);
}
}
// ---------------------------------------------------------------------------
// AGENTS.md manipulation
// ---------------------------------------------------------------------------
function parseSections(content: string): Map<string, string> {
const map = new Map<string, string>();
const parts = content.split(/(?=^## )/m);
map.set("preamble", parts[0].startsWith("## ") ? "" : parts[0]);
for (const part of parts) {
if (!part.startsWith("## ")) continue;
const heading = part.match(/^## .+/m)?.[0] ?? "";
map.set(heading, part);
}
return map;
}
function serializeSections(map: Map<string, string>): string {
const preamble = map.get("preamble") ?? "";
const sections = [...map.entries()]
.filter(([k]) => k !== "preamble")
.map(([, v]) => v);
return preamble + sections.join("");
}
function updateAgentsMd(
existing: string,
summary: SummaryJSON,
sessionId: string,
sessionTitle: string
): string {
const sections = parseSections(existing);
// Dedup: check only within the Session Log section, not the whole file
const logSection = sections.get("## Session Log") ?? "";
if (logSection.includes(`(${sessionId})`)) {
log(`session ${sessionId} already in AGENTS.md, skipping`);
return existing;
}
const date = formatDate(new Date());
sections.set(
"## Current State",
`## Current State\n` +
`<!-- Updated by owui-sync on session end -->\n` +
`<!-- Single section, overwritten each time -->\n` +
summary.current_state.map((b) => `- ${b}`).join("\n") +
"\n"
);
const newEntry =
`### ${date}${sessionTitle} (${sessionId})\n` +
`- **Worked on:** ${summary.worked_on}\n` +
(summary.decisions.length ? `- **Decisions:** ${summary.decisions.join("; ")}\n` : "") +
(summary.done.length ? `- **Done:**${summary.done.map((d) => `\n - ${d}`).join("")}\n` : "") +
(summary.blockers.length
? `- **Blockers:**${summary.blockers.map((b) => `\n - ${b}`).join("")}\n`
: "") +
(summary.next.length ? `- **Next:**${summary.next.map((n) => `\n - ${n}`).join("")}\n` : "") +
"\n";
const existingLog = logSection || "## Session Log\n<!-- Append-only, newest first -->\n\n";
const logLines = existingLog.split("\n");
const insertIdx = logLines.findIndex((l) => l.startsWith("### "));
const updatedLog =
insertIdx === -1
? existingLog.trimEnd() + "\n" + newEntry
: logLines.slice(0, insertIdx).join("\n") +
"\n" +
newEntry +
logLines.slice(insertIdx).join("\n");
sections.set("## Session Log", updatedLog);
return serializeSections(sections);
}
// ---------------------------------------------------------------------------
// Memory ID cache — avoids the broken GET /api/v1/memories list endpoint
// Keyed by full project directory path to prevent basename collisions.
// ---------------------------------------------------------------------------
const ID_CACHE_FILE = `${process.env.HOME}/.local/share/opencode/owui-sync-ids.json`;
function loadIdCache(): Record<string, string> {
try {
return JSON.parse(readFileSync(ID_CACHE_FILE, "utf8"));
} catch {
return {};
}
}
function saveIdCache(cache: Record<string, string>): void {
try {
writeFileSync(ID_CACHE_FILE, JSON.stringify(cache, null, 2));
} catch (err) {
log("[ERROR]", "failed to save ID cache:", err);
}
}
// ---------------------------------------------------------------------------
// Open WebUI memory upsert
// ---------------------------------------------------------------------------
async function upsertOwuiMemory(
cfg: Config,
projectDirectory: string,
summary: SummaryJSON,
sessionTitle: string
): Promise<void> {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.owuiToken}`,
};
const base = cfg.owuiUrl.replace(/\/$/, "");
const projectName = getProjectKey(projectDirectory);
const projectTag = `${OWUI_MEMORY_TAG} project:${projectName}`;
const memoryContent =
`${projectTag}\n` +
`Project context as of ${formatDate(new Date())} (session: ${sessionTitle})\n\n` +
`Current state:\n` +
summary.current_state.map((b) => `- ${b}`).join("\n") +
"\n\n" +
`Last session worked on: ${summary.worked_on}\n` +
(summary.next.length
? `Next steps:\n${summary.next.map((n) => `- ${n}`).join("\n")}`
: "");
// Key by git repo name — same repo on any machine maps to the same memory ID
const cache = loadIdCache();
const cachedId = cache[projectName] ?? null;
if (cachedId) {
try {
const resp = await fetch(`${base}/api/v1/memories/${cachedId}/update`, {
method: "POST",
headers,
body: JSON.stringify({ content: memoryContent }),
});
if (resp.ok) {
log(`updated OWUI memory ${cachedId} for project:${projectName}`);
return;
}
log(`cached memory ${cachedId} returned ${resp.status}, recreating`);
} catch (err) {
log("[ERROR]", "OWUI memory update error:", err);
}
}
try {
const resp = await fetch(`${base}/api/v1/memories/add`, {
method: "POST",
headers,
body: JSON.stringify({ content: memoryContent }),
});
const body = await resp.text();
if (resp.ok) {
const created: OwuiMemory = JSON.parse(body);
cache[projectName] = created.id;
saveIdCache(cache);
log(`created OWUI memory ${created.id} for project:${projectName}`);
} else {
log("[ERROR]", `OWUI memory create failed ${resp.status}:`, body.slice(0, 200));
}
} catch (err) {
log("[ERROR]", "OWUI memory create error:", err);
}
}
// ---------------------------------------------------------------------------
// Toast helper — mirrors TPS meter's approach: try showToast, fall back to publish
// ---------------------------------------------------------------------------
function toast(
client: any,
message: string,
variant: "info" | "success" | "warning" | "error" = "info",
duration = 5000
): void {
const body = { title: "owui-sync", message, variant, duration };
try {
if (client.tui?.showToast) {
client.tui.showToast({ body });
return;
}
if (client.tui?.publish) {
client.tui.publish({ body: { type: "tui.toast.show", properties: body } });
}
} catch {
// TUI not available (e.g. non-interactive mode) — silently skip
}
}
// ---------------------------------------------------------------------------
// Plugin entry point
// ---------------------------------------------------------------------------
export const server: Plugin = async (input, options) => {
const cfg: Config = {
vllmBase: (options?.vllm_url as string) || DEFAULT_VLLM_BASE,
vllmKey: (options?.vllm_key as string) || DEFAULT_VLLM_KEY,
vllmModel: (options?.vllm_model as string) || DEFAULT_VLLM_MODEL,
owuiUrl: (options?.owui_url as string) ?? "",
owuiToken: (options?.owui_token as string) ?? "",
};
if (!cfg.owuiUrl || !cfg.owuiToken) {
log("[WARN]", "owui_url/owui_token not set — AGENTS.md only, OWUI memory sync disabled");
}
return {
event: async ({ event }) => {
if (event.type !== "session.idle") return;
const sessionId = event.properties.sessionID;
toast(input.client, "Summarizing session → AGENTS.md + Open WebUI…");
summarizeAndWrite(input.client, sessionId, input.directory, cfg).catch(
(err) => log("[ERROR]", "unhandled error:", err)
);
},
};
};
async function summarizeAndWrite(
client: Parameters<Plugin>[0]["client"],
sessionId: string,
projectDirectory: string,
cfg: Config
): Promise<void> {
let result: Awaited<ReturnType<typeof client.session.messages>>;
try {
result = await client.session.messages({ path: { id: sessionId } });
} catch (err) {
log("[ERROR]", "failed to read session messages:", err);
toast(client, "Failed to read session messages", "error", 8000);
return;
}
const messages = result.data ?? [];
log(`session ${sessionId}: fetched ${messages.length} messages`);
const transcript = buildTranscript(messages as any);
if (!transcript) {
log("empty transcript after filtering, skipping");
return;
}
log(`full transcript (${transcript.length} chars):\n${"─".repeat(40)}\n${transcript}\n${"─".repeat(40)}`);
// Use opencode's own generated session title
let sessionTitle = "Untitled session";
try {
const sessionData = await client.session.get({ path: { id: sessionId } });
const rawTitle = sessionData.data?.title ?? "";
sessionTitle = sanitizeTitle(rawTitle);
log(`session title from API: ${JSON.stringify(rawTitle)}${JSON.stringify(sessionTitle)}`);
} catch (err) {
log("[WARN]", "could not fetch session title:", err);
const firstUser = (messages as any[]).find((m) => m.info?.role === "user");
const rawTitle =
firstUser?.parts
?.find((p: any) => p.type === "text" && !p.synthetic && !p.ignored)
?.text?.slice(0, 120) ?? "";
sessionTitle = sanitizeTitle(rawTitle);
log(`session title fallback: ${JSON.stringify(sessionTitle)}`);
}
const summary = await callVLLM(transcript, cfg);
if (!summary) {
toast(client, "Summarization failed — check owui-sync.log", "error", 8000);
return;
}
log(`summary:\n${JSON.stringify(summary, null, 2)}`);
// Update AGENTS.md
const agentsMdPath = `${projectDirectory}/AGENTS.md`;
let existing = "";
try {
const file = Bun.file(agentsMdPath);
existing = (await file.exists()) ? await file.text() : "";
} catch {
existing = "";
}
const updated = updateAgentsMd(existing, summary, sessionId, sessionTitle);
if (updated !== existing) {
try {
await Bun.write(agentsMdPath, updated);
log(`updated ${agentsMdPath}`);
} catch (err) {
log("[ERROR]", "failed to write AGENTS.md:", err);
}
}
// Upsert Open WebUI memory
if (cfg.owuiUrl && cfg.owuiToken) {
await upsertOwuiMemory(cfg, projectDirectory, summary, sessionTitle);
}
toast(
client,
cfg.owuiUrl ? `Synced: ${sessionTitle}` : `AGENTS.md updated: ${sessionTitle}`,
"success",
4000
);
}