From ca63625e359812484d271da1e6f37ff1ef134cba Mon Sep 17 00:00:00 2001 From: raptorggi Date: Thu, 29 Oct 2026 21:39:49 -0700 Subject: [PATCH] =?UTF-8?q?skill-compact-resurrect:=20=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=BB=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20compaction=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20OpenCode=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плагин на хуке `context` запоминает скилы, загруженные в сессии (в ctx.storage), и после завершённой compaction возвращает в системный промпт тексты тех, что были загружены до неё и не загружены заново. Один раз на compaction пишет в чат служебное сообщение (без хода модели) и строку в log/skill-compact-resurrect.log. Опции: maxRestoredSkills, notify. --- README.md | 64 +++++++++++++ skill-compact-resurrect.v2.ts | 175 ++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 README.md create mode 100644 skill-compact-resurrect.v2.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a29286 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# skill-compact-resurrect + +Плагин для OpenCode 2. Когда сессия переживает compaction, скилы, загруженные до неё, пропадают из +контекста: сводка их не содержит, а модель об этом не знает и продолжает работать без инструкций скила. +Плагин возвращает эти скилы в системный промпт сразу после compaction. + +## Как работает + +Скил попадает в сессию двумя путями: модель вызывает тул `skill` (`{ id }`) и получает текст скила +результатом тула, или пользователь вызывает скил сам — тогда в истории появляется сообщение типа `skill`. +Compaction заменяет всё это сводкой. + +Плагин висит на хуке `context`, который срабатывает перед каждым запросом к модели: + +1. Читает текущий контекст сессии (`session.context`) и запоминает имена всех загруженных скилов. Список + хранится в `ctx.storage`, поэтому переживает перезапуск OpenCode. Повторный вызов скила переносит его в + конец списка, как самый свежий. +2. Если в контексте есть завершённая compaction, добавляет в системный промпт этого запроса тексты скилов, + загруженных до неё и не загруженных заново после. + +Сами скилы в историю не пишутся: они живут в системном промпте до следующей compaction, а после неё +возвращаются снова. Скил, который модель загрузила повторно, не дублируется. Если скил с тех пор удалили или +переименовали, плагин об этом скажет. + +Системный промпт в интерфейсе не виден, поэтому один раз на каждую compaction плагин: + +- пишет в чат короткое служебное сообщение — «♻ После compaction восстановлены скилы: …». Ход модели оно + не запускает (`resume: false`); +- добавляет строку в `$XDG_DATA_HOME/opencode/log/skill-compact-resurrect.log`. + +## Установка + +Положить ссылку на файл в `plugins/` конфига OpenCode — он подхватывает такие файлы сам: + +```bash +ln -sfn "$PWD/skill-compact-resurrect.v2.ts" "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/" +``` + +В `"plugin"` в `opencode.jsonc` его **не** добавлять: имя там считается npm-пакетом. Изменения в файле +применяются после перезапуска OpenCode. + +## Опции + +Задаются в `opencode.jsonc`: `"plugins": { "package": "<путь>", "options": { … } }`. + +| Опция | По умолчанию | Что | +| --- | --- | --- | +| `maxRestoredSkills` | все | сколько последних скилов возвращать | +| `notify` | `true` | писать ли сообщение в чат | + +## Проверка + +После compaction в сессии, где загружались скилы, в чате должно появиться сообщение «♻ После compaction +восстановлены скилы: …», а в журнале — строка вида: + +``` +2026-10-29T12:00:00.000Z ses_… after msg_…: restored [bmad-build, bmad-spec] +``` + +Нет ни того, ни другого — проверить, что ссылка на плагин лежит в `plugins/` нужного конфига (того, что +OpenCode получает в `XDG_CONFIG_HOME`). + +Требуется OpenCode 2 (API плагинов v2: `ctx.session.hook`, `ctx.session.context`, `ctx.session.synthetic`, +`ctx.skill`, `ctx.storage`). Проверено на 2.1.3. diff --git a/skill-compact-resurrect.v2.ts b/skill-compact-resurrect.v2.ts new file mode 100644 index 0000000..d90d02f --- /dev/null +++ b/skill-compact-resurrect.v2.ts @@ -0,0 +1,175 @@ +/** + * 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() + }, +}