556 lines
18 KiB
TypeScript
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
|
|
);
|
|
}
|