/** * skill-compact-resurrect для OpenCode 2: после compaction возвращает в контекст скилы, * которые сессия загружала до неё. * * Как устроено в v2. Скил попадает в сессию двумя путями: модель вызывает тул `skill` * ({ id }), и текст скила приходит результатом тула; или пользователь вызывает скил сам — * тогда в истории сообщение типа `skill`. Compaction заменяет всё это сводкой. * * Плагин висит на хуке `context` (перед каждым запросом к модели): * 1. читает текущий контекст сессии (`session.context`) и запоминает имена всех * загруженных скилов — в `ctx.storage`, чтобы пережить перезапуск OpenCode; * 2. если в контексте есть завершённая compaction, добавляет в системный промпт запроса * тексты скилов, загруженных до неё и не загруженных заново после. * Сами скилы в историю не пишутся: они живут в системном промпте до следующей compaction * (после неё — снова), повторно загруженный моделью скил не дублируется. Системный промпт * в интерфейсе не виден, поэтому один раз на каждую compaction плагин: * - пишет в чат короткое synthetic-сообщение со списком восстановленных скилов * (resume: false — ход модели оно не запускает); * - добавляет строку в $XDG_DATA_HOME/opencode/log/skill-compact-resurrect.log. * * Опции (в "plugins": { "package": "<путь>", "options": { ... } }): * maxRestoredSkills — сколько последних скилов возвращать (по умолчанию все); * notify — писать ли сообщение в чат (по умолчанию true). * * Подключение: файл в /opencode/plugins/ — bin/opencode-web делает ссылку сам. */ import { appendFile, mkdir } from "node:fs/promises" import { homedir } from "node:os" import { join } from "node:path" const LOG_DIR = join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "opencode/log") const LOG_FILE = join(LOG_DIR, "skill-compact-resurrect.log") async function log(line: string) { try { await mkdir(LOG_DIR, { recursive: true }) await appendFile(LOG_FILE, `${new Date().toISOString()} ${line}\n`) } catch {} } type Json = null | boolean | number | string | Json[] | { [k: string]: Json } type Message = { id: string type: string status?: string skill?: string content?: Array<{ type: string; name?: string; state?: { input?: unknown } }> } type SkillInfo = { id: string; name?: string; content?: string } type Context = { options?: Record session: { context(input: { sessionID: string }): Promise hook(name: "context", cb: (input: { sessionID: string; system: Array<{ type: "text"; text: string }> }) => Promise): Promise<{ dispose(): Promise }> synthetic(input: { sessionID: string; text: string; description?: string; resume?: boolean }): Promise } skill: { list(): Promise } storage: { get(key: string): Promise; set(key: string, value: Json): Promise } } const SKILL_TOOLS = new Set(["skill", "use_skill", "skill_use"]) /** Имена скилов, загруженных в этих сообщениях, по порядку. */ function loadedSkills(messages: Message[]): string[] { const out: string[] = [] for (const m of messages) { if (m.type === "skill" && m.skill) out.push(m.skill) if (m.type !== "assistant") continue for (const c of m.content ?? []) { if (c.type !== "tool" || !SKILL_TOOLS.has(c.name ?? "")) continue const input = (c.state?.input ?? {}) as Record const id = input.id ?? input.name ?? input.skill if (typeof id === "string" && id) out.push(id) } } return out } /** Добавляет имена в конец списка; повторный вызов скила переносит его в конец (самый свежий). */ function remember(list: string[], names: string[]): string[] { let next = list for (const n of names) next = [...next.filter((x) => x !== n), n] return next } export default { id: "skill-compact-resurrect", setup: async (ctx: Context) => { const max = typeof ctx.options?.maxRestoredSkills === "number" ? ctx.options.maxRestoredSkills : 0 const notify = ctx.options?.notify !== false const known = new Map() // sessionID → скилы сессии const reported = new Map() // sessionID → id compaction, о которой уже сообщили // Хук срабатывает перед каждым запросом к модели — сообщаем один раз на compaction async function report(sessionID: string, compactionID: string, restored: string[], lost: string[]) { const key = `reported/${sessionID}` if (!reported.has(sessionID)) { const saved = await ctx.storage.get(key).catch(() => undefined) if (typeof saved === "string") reported.set(sessionID, saved) } if (reported.get(sessionID) === compactionID) return reported.set(sessionID, compactionID) await ctx.storage.set(key, compactionID).catch(() => {}) await log(`${sessionID} after ${compactionID}: restored [${restored.join(", ")}]${lost.length ? ` not found [${lost.join(", ")}]` : ""}`) if (!notify) return const text = [ restored.length ? `♻ После compaction восстановлены скилы: ${restored.join(", ")}.` : "", lost.length ? `Не найдены (удалены или переименованы): ${lost.join(", ")}.` : "", ].filter(Boolean).join(" ") // Не ждём: хук держит запрос к модели, а сообщение войдёт в историю на границе шага ctx.session .synthetic({ sessionID, text, description: "skill-compact-resurrect", resume: false }) .catch((e) => log(`${sessionID}: synthetic failed: ${e?.message ?? e}`)) } async function sessionSkills(sessionID: string): Promise { let list = known.get(sessionID) if (!list) { const saved = await ctx.storage.get(`session/${sessionID}`).catch(() => undefined) list = Array.isArray(saved) ? saved.filter((x): x is string => typeof x === "string") : [] known.set(sessionID, list) } return list } async function skillTexts(names: string[]): Promise> { const res = await ctx.skill.list() const all = Array.isArray(res) ? res : (res.data ?? []) const out = new Map() for (const n of names) { const s = all.find((x) => x.id === n) ?? all.find((x) => x.name === n) if (s?.content) out.set(n, s.content) } return out } const reg = await ctx.session.hook("context", async (input) => { const messages = await ctx.session.context({ sessionID: input.sessionID }) const before = await sessionSkills(input.sessionID) const all = remember(before, loadedSkills(messages)) if (all.join("\n") !== before.join("\n")) { known.set(input.sessionID, all) await ctx.storage.set(`session/${input.sessionID}`, all).catch(() => {}) } // Контекст после compaction начинается с её сообщения; всё, что загружено после, — на месте const cut = messages.map((m) => m.type === "compaction" && m.status === "completed").lastIndexOf(true) if (cut < 0) return const present = new Set(loadedSkills(messages.slice(cut + 1))) let missing = all.filter((n) => !present.has(n)) if (max > 0) missing = missing.slice(-max) if (!missing.length) return const texts = await skillTexts(missing) const lost = missing.filter((n) => !texts.has(n)) const lines = [ "# Скилы, загруженные до compaction", "", "Сводка compaction их не содержит, поэтому они восстановлены здесь. Повторно загружать их не нужно.", ] if (lost.length) lines.push(`Не найдены (удалены или переименованы): ${lost.join(", ")}.`) for (const [name, text] of texts) lines.push("", ``, text, "") input.system.push({ type: "text", text: lines.join("\n") }) await report(input.sessionID, messages[cut].id, [...texts.keys()], lost) }) return () => reg.dispose() }, }