feat: initialize opencode-owui-sync project
This commit is contained in:
+555
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user