skill-compact-resurrect: скилы после compaction для OpenCode 2
Плагин на хуке `context` запоминает скилы, загруженные в сессии (в ctx.storage), и после завершённой compaction возвращает в системный промпт тексты тех, что были загружены до неё и не загружены заново. Один раз на compaction пишет в чат служебное сообщение (без хода модели) и строку в log/skill-compact-resurrect.log. Опции: maxRestoredSkills, notify.
This commit is contained in:
@@ -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.
|
||||||
@@ -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).
|
||||||
|
*
|
||||||
|
* Подключение: файл в <config>/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<string, unknown>
|
||||||
|
session: {
|
||||||
|
context(input: { sessionID: string }): Promise<Message[]>
|
||||||
|
hook(name: "context", cb: (input: { sessionID: string; system: Array<{ type: "text"; text: string }> }) => Promise<void>): Promise<{ dispose(): Promise<void> }>
|
||||||
|
synthetic(input: { sessionID: string; text: string; description?: string; resume?: boolean }): Promise<unknown>
|
||||||
|
}
|
||||||
|
skill: { list(): Promise<SkillInfo[] | { data?: SkillInfo[] }> }
|
||||||
|
storage: { get(key: string): Promise<Json | undefined>; set(key: string, value: Json): Promise<void> }
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, string[]>() // sessionID → скилы сессии
|
||||||
|
const reported = new Map<string, string>() // 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<string[]> {
|
||||||
|
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<Map<string, string>> {
|
||||||
|
const res = await ctx.skill.list()
|
||||||
|
const all = Array.isArray(res) ? res : (res.data ?? [])
|
||||||
|
const out = new Map<string, string>()
|
||||||
|
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("", `<skill_content name="${name}">`, text, "</skill_content>")
|
||||||
|
input.system.push({ type: "text", text: lines.join("\n") })
|
||||||
|
await report(input.sessionID, messages[cut].id, [...texts.keys()], lost)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => reg.dispose()
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user