Compare commits
99 Commits
884fee99a5
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f666cab6a | |||
| d126efe907 | |||
| c12a3717cd | |||
| c007116316 | |||
| 32e6f72eee | |||
| 84535def76 | |||
| c98a1ff7be | |||
| 223836f41a | |||
| 24a3d35c06 | |||
| 37dd2211d7 | |||
| 11d0659a25 | |||
| a6fb292dc3 | |||
| eba5b5dc99 | |||
| 20429a02ab | |||
| a07ddefb23 | |||
| ca97660bac | |||
| 7665096601 | |||
| fcf9283fe1 | |||
| e3873d765d | |||
| 1e5cd07784 | |||
| 2f6f5477c5 | |||
| 459b8775f4 | |||
| ebc169c91f | |||
| e63041bfc5 | |||
| a81337c08e | |||
| fe25e606b8 | |||
| aca68e73ee | |||
| 6c82594392 | |||
| ef9b7a8c30 | |||
| 8f48040eac | |||
| 2894ed751d | |||
| ab4c133a9c | |||
| b819b4bed3 | |||
| 496d7478df | |||
| 074fcdaae5 | |||
| 82dc8a7126 | |||
| 08b997848f | |||
| 7d99ab8f1e | |||
| 09619d9dd8 | |||
| 502ca39b3e | |||
| 66d9a39a18 | |||
| d92c5f827d | |||
| c071f240d3 | |||
| 7ff7529957 | |||
| 5d902349d1 | |||
| 00ed5c6253 | |||
| 0534093f2c | |||
| 03dcdb22de | |||
| 435c5b3289 | |||
| 5c0eb4d039 | |||
| e55a14e469 | |||
| 7d1c4f031f | |||
| dc07e516af | |||
| b22ee7fefc | |||
| ecddaf5752 | |||
| a5448b4002 | |||
| ee13faf285 | |||
| 1a690f921f | |||
| b764acd974 | |||
| 0e32a498ee | |||
| 0d274881dd | |||
| edc01d1079 | |||
| 2021ee1293 | |||
| 86c6837351 | |||
| eb911ab7e3 | |||
| 0f06e0e45b | |||
| 18dd600d2d | |||
| 7bbe2370d7 | |||
| cf2eedb85b | |||
| 3bab1f802b | |||
| d637722ea5 | |||
| 9a197aee10 | |||
| dc73ea9747 | |||
| 446a186431 | |||
| 2059f6033b | |||
| e0daa50cce | |||
| 23d580b6d2 | |||
| ab315d899b | |||
| bf862656ae | |||
| 2e6d36a855 | |||
| e56f81d25c | |||
| 776345a238 | |||
| b54b8f98bf | |||
| a8e07fdd6f | |||
| a0e6d204a6 | |||
| 9896e7efa6 | |||
| 6ccb580ca1 | |||
| 1296090909 | |||
| 1382e3f66f | |||
| 9d32cdffb1 | |||
| 049a11a7d5 | |||
| 68b7d018cc | |||
| 584a8fa342 | |||
| 69611685d4 | |||
| 6e55990bfb | |||
| cc5ffc47bf | |||
| 9cd60a2534 | |||
| ccb39c413d | |||
| 2d7392f5ed |
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.ndjson
|
||||
*.jsonl
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|---|---|
|
||||
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
|
||||
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
||||
| Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
|
||||
| Статус | **Фазы 0–15 завершены** (v1 + B-002/B-003); полный RPC two-compact E2E — см. `docs/e2e-b003-post-fix.md` |
|
||||
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
|
||||
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
|
||||
|
||||
@@ -22,6 +22,7 @@ src/
|
||||
├── state.ts
|
||||
├── detect.ts
|
||||
├── expand.ts
|
||||
├── kept.ts
|
||||
├── reinject.ts
|
||||
├── auto-compact.ts
|
||||
├── settings.ts
|
||||
|
||||
+28
-2
@@ -41,10 +41,36 @@
|
||||
|
||||
## Открыто
|
||||
|
||||
_Новые пункты — ниже (следующий id: **B-001**)._
|
||||
_Новые пункты — ниже (следующий id: **B-004**)._
|
||||
|
||||
---
|
||||
|
||||
## Закрыто
|
||||
|
||||
_Пусто._
|
||||
### B-003 · done · e2e · 2026-06-18 (закрыт 2026-06-18)
|
||||
|
||||
- **Сценарий:** Длинная сессия `gitlab-mr-review` + pi-auto-compact; auto compaction в ходе Phase 6 review (issue #480334)
|
||||
- **Проблема:** Второй подряд auto compaction не re-inject'ит tracked skill; `/skill-reinject` status показывает `last compaction: none`
|
||||
- **Место:** `session_before_compact` / `session_compact` / defer + `before_agent_start`; артефакт `lost-reinject.jsonl`
|
||||
- **Факт:** Compaction #1: `lastCompactionSource: auto`, inject OK. Compaction #2 mid-turn: `lastCompactionSource: null` (no `session_before_compact`), defer consume only on user prompt → skill lost
|
||||
- **Закрытие:** Phase 15 (`a07ddef`…`c12a371`); `ensureCompactionSourceMarked` on `session_compact`, mid-turn `sendMessage`/`steer`, kept-window `skill-reinject:inject`, debug diag; 93 unit tests; `scripts/b003-repro.mjs`; full two-compact RPC deferred (`docs/e2e-b003-post-fix.md`)
|
||||
- **Предложение:** (реализовано) source fallback §8, steer delivery §6.5.1, kept custom §6.4
|
||||
|
||||
### B-002 · done · e2e · 2026-06-17 (закрыт 2026-06-17)
|
||||
|
||||
- **Сценарий:** Manual E2E §12.2 п.2–5, §12.3 п.3–7, §13 п.3/9/10 — auto compaction → re-inject tracked skills; `/skill-reinject now` как контроль
|
||||
- **Проблема:** skill из `--skill /path/to/SKILL.md` разворачивается в контекст, но не попадает в `resourceLoader` → `planReinject` / `reinjectNow` отфильтровывают его (`filterSkillsNeedingReinject` требует `registeredNames.has(name)`)
|
||||
- **Место:** `src/skills-registry.ts`, `src/kept.ts`, `src/reinject.ts`, `before_agent_start`
|
||||
- **Факт:** pre-fix RPC: `registered` мог быть пуст при `loadSkills` fallback; skill вне kept + пустой registry → `planned=[]`. Post-fix: defer plan без registry на compact, consume/build loose fallback по `tracked.filePath`, `requireRegistered` (default false)
|
||||
- **Закрытие:** Phase 14 commits (`aca68e7`…`e3873d7`); unit regression 84 tests; `docs/e2e-b002-post-fix.md` — short RPC session держит skill в kept (reinject не нужен); полный compact→reinject RPC требует длинной сессии
|
||||
- **Предложение:** (реализовано) `planDeferredReinject`, `filterPendingReinjectForConsume`, loose `buildReinjectBlocks` / `reinjectNow`, README `requireRegistered`
|
||||
|
||||
### B-001 · done · e2e · 2026-06-17 (закрыт 2026-06-17)
|
||||
|
||||
- **Сценарий:** Manual E2E §12.2 / §12.3 — блокировка из‑за отсутствия LLM
|
||||
- **Проблема:** в среде агента не было доступного LLM
|
||||
- **Место:** `pi --mode rpc` / compaction
|
||||
- **Факт:** изначально нет API key; `pi-llama-cpp` → `192.168.1.159:8080` недоступен. После настройки: `pi-provider-litellm`, `~/.pi/agent/litellm-models.json`, `auth.json`; `pi --list-models` → `Eltex-Coder-Senior`, `Eltex-Kimi`; compaction и agent turn работают
|
||||
- **Обход:** LiteLLM proxy (`llm2.eltex.loc:4000`), default model `Eltex-Coder-Senior` в `settings.json`
|
||||
- **Закрытие:** LLM доступен; частичный RPC smoke пройден (коммиты phase 13). Оставшиеся E2E-дыры — B-002
|
||||
|
||||
|
||||
@@ -2,35 +2,89 @@
|
||||
|
||||
Pi Coding Agent extension: отслеживает вызванные skills и повторно инжектит их после auto compaction.
|
||||
|
||||
**Статус:** спецификация (реализация не начата)
|
||||
**Статус:** реализовано (v1)
|
||||
|
||||
## Документация
|
||||
## Установка
|
||||
|
||||
- [SPEC.md](./SPEC.md) — полное ТЗ с ссылками на документацию Pi
|
||||
```bash
|
||||
# из клона репозитория
|
||||
pi -e ./src/index.ts
|
||||
|
||||
## Совместимость
|
||||
# или абсолютный путь
|
||||
pi -e ~/Documents/repos/pi-auto-reinjection/src/index.ts
|
||||
```
|
||||
|
||||
Рассчитан на совместную работу с [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) (auto-continue после compaction). Детали — [SPEC.md §16](./SPEC.md#16-совместимость-с-capyuppi-auto-compact).
|
||||
Для постоянной установки укажите путь к `src/index.ts` в `~/.pi/agent/settings.json` → `extensions` (нужен весь каталог `src/`, не один файл). См. [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md).
|
||||
|
||||
## Быстрый контекст
|
||||
|
||||
Pi хранит в контексте только описания skills; полный `SKILL.md` теряется при compaction. Extension решает это re-inject'ом через тот же механизм, что `/skill:name`.
|
||||
|
||||
По умолчанию **выключено**. Включение:
|
||||
По умолчанию extension **выключен**. Включение:
|
||||
|
||||
```text
|
||||
/skill-reinject on # эта сессия
|
||||
/skill-reinject global on # навсегда (~/.pi/agent/settings.json)
|
||||
```
|
||||
|
||||
## Установка (план)
|
||||
## Команда `/skill-reinject`
|
||||
|
||||
```bash
|
||||
pi -e ./src/index.ts # после реализации
|
||||
```text
|
||||
/skill-reinject # статус (enabled, delivery, tracked, pending)
|
||||
/skill-reinject on | off | reset # session override
|
||||
/skill-reinject global on | off # глобальный default
|
||||
/skill-reinject list | clear # tracked skills
|
||||
/skill-reinject now # принудительный re-inject (debug)
|
||||
/skill-reinject integration auto|defer|immediate|off
|
||||
```
|
||||
|
||||
## Ссылки
|
||||
Алиасы: `/sr`, `/skills-reinject`. Footer status: `on·N` / `off·N`.
|
||||
|
||||
Полный синтаксис и настройки — [SPEC.md §7](./SPEC.md#7-команда-skill-reinject).
|
||||
|
||||
## Как это работает
|
||||
|
||||
Pi хранит в контексте только описания skills; полный `SKILL.md` теряется при compaction. Extension отслеживает вызванные skills (`/skill:name`, skill-блоки, `read` на `SKILL.md` при `trackReadPaths: true`) и после **auto** compaction повторно инжектит отсутствующие в kept window блоки — тем же форматом, что `/skill:name`.
|
||||
|
||||
## Skills via `--skill` and discovery paths
|
||||
|
||||
| Источник skill | Трекинг | Re-inject после compact |
|
||||
|----------------|---------|-------------------------|
|
||||
| Discovery (`~/.pi/agent/skills`, `.pi/skills`) | Да | По имени в resourceLoader |
|
||||
| CLI `--skill /path/to/SKILL.md` | Да (`slash` / skill-блок) | Да, если `SKILL.md` ещё на диске по `tracked.filePath` |
|
||||
| `--resume` без повторного `--skill` | Восстанавливается из state entry / rescan | Да при `requireRegistered: false` (default) |
|
||||
|
||||
По умолчанию `skillReinject.requireRegistered` — **`false`**: tracked skill re-injectится с диска, даже если `resourceLoader` его не знает (типично для `--skill` вне discovery paths). Уведомление: `re-injected "<name>" from disk`.
|
||||
|
||||
Строгий режим — только skills из resourceLoader:
|
||||
|
||||
```json
|
||||
{
|
||||
"skillReinject": {
|
||||
"requireRegistered": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Полезно при `--no-skills` или осознанном отключении skill через `pi config`, когда re-inject с диска нежелателен.
|
||||
|
||||
## Совместимость с pi-auto-compact
|
||||
|
||||
Рассчитан на совместную работу с [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact). При обнаружении команды `auto-compact` re-inject идёт через `defer`:
|
||||
|
||||
- **Idle** (turn boundary): очередь на `before_agent_start` — в том же turn, что follow-up pi-auto-compact, без гонки `sendUserMessage`.
|
||||
- **Mid-turn** (`!isIdle`, после tool result): немедленная доставка через `sendMessage` с `deliverAs: "steer"` — skill попадает в контекст до следующего LLM-вызова без user prompt.
|
||||
|
||||
Если `/skill-reinject` показывает `last compaction: none` после auto compact — включите `"debug": true` в `skillReinject` и смотрите notify `skill-reinject [session_compact]`: поля `compactionSource`, `sourceInferred`, `deliveryBranch`, `isIdle`.
|
||||
|
||||
**Coexistence с Pi default auto-compaction:** оба механизма могут сработать в одной сессии. При использовании pi-auto-compact можно отключить встроенный compaction Pi (`"compaction.enabled": false` в settings) или оставить оба — skill-reinject отработает после каждого **auto** compaction. Extension не отключает чужой compaction; при первом обнаружении обоих механизмов показывает одноразовый hint.
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # vitest
|
||||
```
|
||||
|
||||
## Документация
|
||||
|
||||
- [SPEC.md](./SPEC.md) — полное ТЗ
|
||||
- [Pi extensions](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md)
|
||||
- [Pi skills](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md)
|
||||
- [Pi compaction](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/compaction.md)
|
||||
|
||||
@@ -118,7 +118,7 @@ References are relative to {baseDir}.
|
||||
|
||||
Для N skills при доставке через `sendUserMessage`: отдельное сообщение на skill, порядок = порядок первого вызова в сессии.
|
||||
|
||||
При доставке через `before_agent_start` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message со всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up).
|
||||
При доставке через `before_agent_start` или mid-turn `sendMessage`/`steer` (§6.5, режим `defer`, в т.ч. с [pi-auto-compact](https://github.com/capyup/pi-auto-compact)): **одно** injected message с `customType: "skill-reinject:inject"` и всеми skill-блоками подряд (меньше turn'ов, нет гонки с follow-up).
|
||||
|
||||
---
|
||||
|
||||
@@ -141,7 +141,7 @@ interface ExtensionState {
|
||||
sessionOverride: boolean | null;
|
||||
skills: TrackedSkill[]; // dedupe by name, preserve insertion order
|
||||
lastCompactionSource: "auto" | "manual" | null;
|
||||
/** Skills, ожидающие re-inject на следующем before_agent_start (§6.5) */
|
||||
/** Skills, ожидающие re-inject (§6.5): idle → before_agent_start; mid-turn → steer на session_compact */
|
||||
pendingReinject: string[]; // skill names
|
||||
}
|
||||
|
||||
@@ -182,7 +182,12 @@ In-memory кэш + персистенция через `pi.appendEntry` при
|
||||
Перед re-inject:
|
||||
|
||||
1. Взять entries от `compactionEntry.firstKeptEntryId` до хвоста ветки.
|
||||
2. Для каждого tracked skill: если в kept user messages есть `<skill name="{name}"` → **пропустить** re-inject для этого skill.
|
||||
2. Для каждого tracked skill: если в kept window есть `<skill name="{name}"` → **пропустить** re-inject для этого skill.
|
||||
3. Источники текста в kept slice:
|
||||
- user messages (`type === "message"`, `role === "user"`);
|
||||
- наши reinject-сообщения: `type === "custom_message"` с `customType === "skill-reinject:inject"` (§6.5.1) — skill-блоки в `content`, не в user role.
|
||||
|
||||
Пункт 3 закрывает ложное «skill вне kept» после успешного defer-reinject: `custom_message` не user message, но skill уже в контексте turn'а. Без этого второй compaction подряд снова планирует reinject (лишний inject; см. §16.6).
|
||||
|
||||
### 6.5. Доставка после compaction
|
||||
|
||||
@@ -191,9 +196,20 @@ In-memory кэш + персистенция через `pi.appendEntry` при
|
||||
#### 6.5.1. Режим `defer` (рекомендуемый по умолчанию при наличии pi-auto-compact)
|
||||
|
||||
1. На `session_compact`: записать имена skills в `state.pendingReinject` (не вызывать `sendUserMessage`).
|
||||
2. На следующем `before_agent_start`: если `pendingReinject` не пуст — вернуть injected `message` с объединёнными skill-блоками, очистить очередь.
|
||||
2. Доставка — **одна точка решения** на `session_compact` по `ctx.isIdle()`:
|
||||
|
||||
Skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`.
|
||||
| Условие на `session_compact` | Доставка |
|
||||
|------------------------------|----------|
|
||||
| `ctx.isIdle()` | Оставить `pendingReinject`; consume на следующем `before_agent_start` (kickoff pi-auto-compact follow-up / user prompt) |
|
||||
| `!ctx.isIdle()` (mid-turn compaction) | Немедленно: `pi.sendMessage({ customType: "skill-reinject:inject", content, display: true }, { deliverAs: "steer" })`; очистить `pendingReinject`; записать `compactionEntry.id` в runtime, чтобы `before_agent_start` **не** дублировал inject для этого compaction |
|
||||
|
||||
3. На `before_agent_start` (idle-path): если `pendingReinject` не пуст и steer для этого compaction ещё не доставлялся — вернуть injected `message` с `customType: "skill-reinject:inject"` и объединёнными skill-блоками, очистить очередь.
|
||||
|
||||
**Mid-turn steer:** `deliverAs: "steer"` ставит skill-блоки в очередь до следующего LLM-вызова **в том же turn** (после tool result, без user prompt). Это закрывает сценарий, когда pi-auto-compact компактирует mid-turn и **не** шлёт follow-up (агент не idle) — consume только на `before_agent_start` теряет skill до следующего сообщения пользователя (B-003).
|
||||
|
||||
**Не** вызывать `sendUserMessage` в `session_compact` (§16.2). Steer — не user message и не конкурирует с pi-auto-compact follow-up на turn boundary.
|
||||
|
||||
На turn-boundary path skills попадают в контекст **в том же turn**, что и kickoff-сообщение (в т.ч. auto-continue от pi-auto-compact), **без гонки** `sendUserMessage`.
|
||||
|
||||
#### 6.5.2. Режим `immediate` (`sendUserMessage`)
|
||||
|
||||
@@ -287,19 +303,30 @@ Extension event `session_compact` **не** передаёт `reason` ([SessionCo
|
||||
**Стратегия v1:**
|
||||
|
||||
```typescript
|
||||
// input handler (до expansion)
|
||||
// input handler (до expansion) — единственный путь manual
|
||||
if (text.startsWith("/compact")) pendingCompactionSource = "manual";
|
||||
|
||||
// session_before_compact
|
||||
if (pendingCompactionSource !== "manual") pendingCompactionSource = "auto";
|
||||
function ensureCompactionSourceMarked(runtime) {
|
||||
if (runtime.pendingCompactionSource !== "manual") {
|
||||
runtime.pendingCompactionSource = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
// session_before_compact
|
||||
ensureCompactionSourceMarked(runtime);
|
||||
|
||||
// session_compact — safety net, если before_compact не пришёл (mid-turn, гонка хуков)
|
||||
ensureCompactionSourceMarked(runtime);
|
||||
|
||||
// session_compact
|
||||
const shouldReinject =
|
||||
effectiveEnabled &&
|
||||
(pendingCompactionSource === "auto" || settings.reinjectOnManualCompaction);
|
||||
state.lastCompactionSource = pendingCompactionSource;
|
||||
pendingCompactionSource = null;
|
||||
```
|
||||
|
||||
**Fallback на `session_compact`:** если `session_before_compact` не отработал, `pendingCompactionSource` остаётся `null` → без fallback `lastCompactionSource: null`, `shouldReinject === false`, status `last compaction: none` при реальном auto compact (B-003). Всё, что не помечено явным `/compact` в `input`, на `session_compact` считается **auto**.
|
||||
|
||||
Ручной RPC compact (`{type: "compact"}`) тоже помечать как `manual` если extension получает соответствующий сигнал (v2).
|
||||
|
||||
---
|
||||
@@ -420,9 +447,10 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
|
||||
- [ ] Tracked skills: `/skill:name`, skill-block в user msg, `read` на `SKILL.md`
|
||||
- [ ] State переживает `/resume` той же сессии
|
||||
- [ ] Footer status показывает on/off и count
|
||||
- [ ] Нет duplicate skill blocks для skills уже в kept window
|
||||
- [ ] Нет duplicate skill blocks для skills уже в kept window (включая `skill-reinject:inject` в kept slice, §6.4)
|
||||
- [ ] С [pi-auto-compact](https://github.com/capyup/pi-auto-compact): auto-detect, режим `defer`, нет гонки `sendUserMessage`, continue после compaction работает
|
||||
- [ ] С pi-auto-compact: ручной `/compact` не ломает ни один extension
|
||||
- [ ] **Два auto compaction подряд** (в т.ч. второй mid-turn без user prompt): после каждого — tracked skills в контексте или явный skip (`skipped-kept` в `debug`); `/skill-reinject` → `last compaction: auto`, не `none` (§8 fallback, §6.5.1 steer)
|
||||
|
||||
---
|
||||
|
||||
@@ -442,7 +470,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
|
||||
|------|-----------|
|
||||
| Re-inject раздувает контекст (N больших skills) | `skillReinject.maxSkills` (default unlimited; soft warn > 3) |
|
||||
| `sendUserMessage` запускает нежеланный turn | `defer` по умолчанию при pi-auto-compact; иначе `triggerTurn: false` |
|
||||
| Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать в `session_compact`; `before_agent_start` |
|
||||
| Гонка `sendUserMessage` с pi-auto-compact follow-up | §16: не слать `sendUserMessage` в `session_compact`; idle → `before_agent_start`; mid-turn → `sendMessage`/`steer` (§6.5.1) |
|
||||
| Нельзя отличить auto/manual без эвристики | input hook на `/compact`; pi-auto-compact manual `/compact` не в его `onComplete` |
|
||||
| Двойной compaction (Pi default + pi-auto-compact) | документировать; опционально warn в status |
|
||||
| Pi изменит формат skill block | version в state; тест на `parseSkillBlock` regex |
|
||||
@@ -473,7 +501,7 @@ cp -r src/index.ts ~/.pi/agent/extensions/skill-reinject.ts
|
||||
1. Агент перестаёт быть idle → pi-auto-compact **не** шлёт follow-up → **сессия замирает** после compaction.
|
||||
2. Или оба шлют сообщения в одном tick → `"Agent is already processing"` ([комментарий в auto-compact.ts](https://github.com/capyup/pi-auto-compact/blob/main/extensions/auto-compact.ts)).
|
||||
|
||||
**Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через `defer` + `before_agent_start`, не через конкурирующий `sendUserMessage`.
|
||||
**Вывод:** при обнаружении pi-auto-compact re-inject **обязан** идти через режим `defer`, не через конкурирующий `sendUserMessage` в `session_compact`. Idle: `before_agent_start`; mid-turn (`!isIdle`): `sendMessage` с `deliverAs: "steer"` (§6.5.1) — тот же инвариант §16.5, без user message.
|
||||
|
||||
### 16.3. Целевой совместный flow
|
||||
|
||||
@@ -483,15 +511,18 @@ turn_start / turn_end / emergency
|
||||
▼
|
||||
pi-auto-compact: ctx.compact()
|
||||
│
|
||||
├── session_before_compact (наш hook: только mark source=auto)
|
||||
├── session_before_compact (наш hook: mark source=auto)
|
||||
├── … Pi summarization …
|
||||
├── session_compact (наш hook: pendingReinject := skills ∖ kept)
|
||||
└── onComplete → setImmediate → sendUserMessage("Auto-compact ran…")
|
||||
│ │
|
||||
│ ├── isIdle → pending для before_agent_start
|
||||
│ └── !isIdle → sendMessage steer (§6.5.1), без follow-up
|
||||
└── onComplete → setImmediate → if isIdle: sendUserMessage("Auto-compact ran…")
|
||||
│
|
||||
▼
|
||||
prompt() → before_agent_start (наш hook)
|
||||
prompt() → before_agent_start (наш hook, idle-path)
|
||||
│
|
||||
├── inject: <skill>…</skill> × N ← re-inject
|
||||
├── inject: <skill>…</skill> × N ← re-inject (если не steer'нули)
|
||||
└── user prompt: "Auto-compact ran…" ← kickoff pi-auto-compact
|
||||
│
|
||||
▼
|
||||
@@ -519,7 +550,7 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
|
||||
| Значение | Поведение |
|
||||
|----------|-----------|
|
||||
| `"auto"` (default) | `defer` если `detectPiAutoCompact()`, иначе по `triggerTurn` |
|
||||
| `"defer"` | всегда `before_agent_start`, даже без pi-auto-compact |
|
||||
| `"defer"` | idle: `before_agent_start`; mid-turn (`!isIdle`): steer на `session_compact` (§6.5.1); никогда `sendUserMessage` в `session_compact` |
|
||||
| `"immediate"` | всегда `sendUserMessage` (для отладки; с pi-auto-compact — риск §16.2) |
|
||||
| `"off"` | игнорировать detect; только `triggerTurn` |
|
||||
|
||||
@@ -538,10 +569,12 @@ function detectPiAutoCompact(pi: ExtensionAPI): boolean {
|
||||
|
||||
| Сценарий | Результат |
|
||||
|----------|-----------|
|
||||
| Auto-compact + follow-up | Следующий `before_agent_start` — наш inject; skills в контексте |
|
||||
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn |
|
||||
| Auto-compact + follow-up (idle) | Следующий `before_agent_start` — наш inject; skills в контексте |
|
||||
| Auto-compact **mid-turn** (`!isIdle`, нет follow-up) | Steer на `session_compact` (§6.5.1); skills до следующего LLM-вызова в том же turn |
|
||||
| User печатает во время compaction | pi-auto-compact молчит; user prompt → наш inject на его turn (`before_agent_start`) |
|
||||
| pi-auto-compact `keep-bookends` / `summarize-all` | Kept window определяем по `compactionEntry.firstKeptEntryId` в session branch (§6.4), не по их процентам |
|
||||
| Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; дедуп по kept window |
|
||||
| Два compaction подряд | Каждый `session_compact` пересчитывает `pendingReinject`; source fallback §8; дедуп по kept window включая `skill-reinject:inject` (§6.4) |
|
||||
| Turn-boundary compact после steer | `before_agent_start` не дублирует inject, если steer уже доставил для `compactionEntry.id` этого compaction |
|
||||
|
||||
### 16.7. Coexistence с Pi default auto-compaction
|
||||
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
|
||||
| Сейчас | Цель |
|
||||
|--------|------|
|
||||
| Только `SPEC.md` + `README.md` | Рабочий extension `src/index.ts` + тесты |
|
||||
| Extension v1 реализован (`src/` + 62 теста) | Рабочий extension `src/index.ts` + тесты |
|
||||
| Default off | `/skill-reinject on` / `global on` |
|
||||
| Нет re-inject после compaction | Auto compaction → re-inject tracked skills (SPEC §5–6) |
|
||||
| Re-inject после auto compaction | Auto compaction → re-inject tracked skills (SPEC §5–6) |
|
||||
|
||||
---
|
||||
|
||||
@@ -105,8 +105,10 @@
|
||||
| 11 | Команды и UI | 1, 2, 6, 7 | §7 |
|
||||
| 12 | Edge cases и полировка | 7–11 | §11 |
|
||||
| 13 | Приёмка и документация | 0–12 | §12–13 |
|
||||
| 14 | Re-inject для `--skill` / не-discovery skills (B-002) | 7, 9, 10, 13 | §5.2, §6.2, §11; BACKLOG B-002 |
|
||||
| 15 | Mid-turn / пропущенный compaction (B-003) | 7, 8, 12, 14 | §5.2, §6.4–6.5, §8, §13, §16; BACKLOG B-003 |
|
||||
|
||||
**Порядок:** фазы 0→13; внутри фазы — сверху вниз. Параллельно после фазы 0 можно вести 1 и 2; фазы 3 и 4 — независимы друг от друга.
|
||||
**Порядок:** фазы 0→13; внутри фазы — сверху вниз. Параллельно после фазы 0 можно вести 1 и 2; фазы 3 и 4 — независимы друг от друга. Фаза 15 — после 14.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,112 +143,173 @@
|
||||
|
||||
### Фаза 3 — Детекция skills
|
||||
|
||||
- [ ] **detect.ts slash** — `detectSlashSkill(text)` → `/^\/skill:([a-z0-9-]+)/`; зачем: источник `slash` §6.2
|
||||
- [ ] **detect.ts skill-block** — `parseSkillBlocksFromText(text)` (regex как `parseSkillBlock`); зачем: источник `skill-block` §6.2
|
||||
- [ ] **detect.ts read-path** — `matchReadPathToSkill(path, skills)` по `filePath` из resourceLoader; зачем: источник `read` §6.2
|
||||
- [ ] **detect.ts trackReadPaths gate** — пропуск read-детекции при `trackReadPaths: false`; зачем: §6.2, §3
|
||||
- [ ] **test/detect.test.ts** — slash, blocks, read match, trackReadPaths off; зачем: §12.1
|
||||
- [x] **detect.ts slash** — `detectSlashSkill(text)` → `/^\/skill:([a-z0-9-]+)/`; зачем: источник `slash` §6.2
|
||||
- [x] **detect.ts skill-block** — `parseSkillBlocksFromText(text)` (regex как `parseSkillBlock`); зачем: источник `skill-block` §6.2
|
||||
- [x] **detect.ts read-path** — `matchReadPathToSkill(path, skills)` по `filePath` из resourceLoader; зачем: источник `read` §6.2
|
||||
- [x] **detect.ts trackReadPaths gate** — пропуск read-детекции при `trackReadPaths: false`; зачем: §6.2, §3
|
||||
- [x] **test/detect.test.ts** — slash, blocks, read match, trackReadPaths off; зачем: §12.1
|
||||
|
||||
---
|
||||
|
||||
### Фаза 4 — Expand skill-блоков
|
||||
|
||||
- [ ] **expand.ts readBody** — `readSkillBody(filePath)` + strip YAML frontmatter; комментарий «mirror agent-session»; зачем: §5.3, §10
|
||||
- [ ] **expand.ts formatBlock** — XML `<skill name location>…</skill>` с `baseDir`; зачем: повтор `_expandSkillCommand` §5.3
|
||||
- [ ] **expand.ts suffix** — опциональный суффикс из `settings.suffix`; зачем: §5.3
|
||||
- [ ] **expand.ts expandSkill** — публичная функция: skill meta → готовый user text; зачем: reinject + `/skill-reinject now`
|
||||
- [ ] **test/expand.test.ts** — frontmatter strip, paths, suffix; зачем: §12.1
|
||||
- [x] **expand.ts readBody** — `readSkillBody(filePath)` + strip YAML frontmatter; комментарий «mirror agent-session»; зачем: §5.3, §10
|
||||
- [x] **expand.ts formatBlock** — XML `<skill name location>…</skill>` с `baseDir`; зачем: повтор `_expandSkillCommand` §5.3
|
||||
- [x] **expand.ts suffix** — опциональный суффикс из `settings.suffix`; зачем: §5.3
|
||||
- [x] **expand.ts expandSkill** — публичная функция: skill meta → готовый user text; зачем: reinject + `/skill-reinject now`
|
||||
- [x] **test/expand.test.ts** — frontmatter strip, paths, suffix; зачем: §12.1
|
||||
|
||||
---
|
||||
|
||||
### Фаза 5 — Kept window
|
||||
|
||||
- [ ] **kept.ts slice** — `getKeptEntries(branch, firstKeptEntryId)` от compaction entry до хвоста; зачем: §6.4
|
||||
- [ ] **kept.ts present** — `skillsPresentInKeptWindow(keptEntries, skillNames)` по `<skill name="…"`; зачем: не дублировать блоки §6.4, критерий §13
|
||||
- [ ] **kept.ts filter** — `filterSkillsNeedingReinject(tracked, kept, registeredNames)`; зачем: вход для `pendingReinject` §5.2
|
||||
- [ ] **test/kept-window.test.ts** — in / not in kept, пустой kept; зачем: §12.1
|
||||
- [x] **kept.ts slice** — `getKeptEntries(branch, firstKeptEntryId)` от `firstKeptEntryId` до хвоста; зачем: §6.4
|
||||
- [x] **kept.ts present** — `skillsPresentInKeptWindow(keptEntries, skillNames)` по `<skill name="…"`; зачем: не дублировать блоки §6.4, критерий §13
|
||||
- [x] **kept.ts filter** — `filterSkillsNeedingReinject(tracked, kept, registeredNames)`; зачем: вход для `pendingReinject` §5.2
|
||||
- [x] **test/kept-window.test.ts** — in / not in kept, пустой kept; зачем: §12.1
|
||||
|
||||
---
|
||||
|
||||
### Фаза 6 — pi-auto-compact
|
||||
|
||||
- [ ] **auto-compact.ts detect** — `detectPiAutoCompact(pi)` через `getCommands()` → `auto-compact`; кэш в `RuntimeFlags`; зачем: §16.4
|
||||
- [ ] **auto-compact.ts deliveryMode** — `resolveDeliveryMode(settings, runtime, sessionIntegrationOverride)` по таблице §6.5.3; зачем: defer vs immediate
|
||||
- [ ] **auto-compact.ts constants** — `PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES` (документация/тесты, не runtime match); зачем: §16.9
|
||||
- [ ] **auto-compact.ts hint** — одноразовый `ui.notify` при detect Pi default compaction + pi-auto-compact; зачем: §16.7
|
||||
- [x] **auto-compact.ts detect** — `detectPiAutoCompact(pi)` через `getCommands()` → `auto-compact`; кэш в `RuntimeFlags`; зачем: §16.4
|
||||
- [x] **auto-compact.ts deliveryMode** — `resolveDeliveryMode(settings, runtime, sessionIntegrationOverride)` по таблице §6.5.3; зачем: defer vs immediate
|
||||
- [x] **auto-compact.ts constants** — `PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES` (документация/тесты, не runtime match); зачем: §16.9
|
||||
- [x] **auto-compact.ts hint** — одноразовый `ui.notify` при detect Pi default compaction + pi-auto-compact; зачем: §16.7
|
||||
|
||||
---
|
||||
|
||||
### Фаза 7 — Re-inject оркестрация
|
||||
|
||||
- [ ] **reinject.ts plan** — `planReinject(state, settings, ctx, compactionEvent)` → имена skills с учётом kept + registration; зачем: §5.2 п.4–5
|
||||
- [ ] **reinject.ts defer enqueue** — на `session_compact`: `pendingReinject := plan`, без `sendUserMessage`; зачем: §6.5.1, §16.2
|
||||
- [ ] **reinject.ts defer inject** — на `before_agent_start`: объединённое message со всеми блоками, clear queue; зачем: §5.3, §6.5.1
|
||||
- [ ] **reinject.ts immediate idle** — первый skill обычный, остальные `followUp`; зачем: §6.5.2
|
||||
- [ ] **reinject.ts immediate streaming** — `willRetry` / streaming → все `deliverAs: "followUp"`; зачем: §5.2, §6.5.2
|
||||
- [ ] **reinject.ts skip missing** — skill удалён с диска → skip + `ui.notify` warning; зачем: §11
|
||||
- [ ] **reinject.ts force now** — `reinjectNow(pi, state, settings)` для `/skill-reinject now`; зачем: §7.1 debug
|
||||
- [x] **reinject.ts plan** — `planReinject(state, settings, ctx, compactionEvent)` → имена skills с учётом kept + registration; зачем: §5.2 п.4–5
|
||||
- [x] **reinject.ts defer enqueue** — на `session_compact`: `pendingReinject := plan`, без `sendUserMessage`; зачем: §6.5.1, §16.2
|
||||
- [x] **reinject.ts defer inject** — на `before_agent_start`: объединённое message со всеми блоками, clear queue; зачем: §5.3, §6.5.1
|
||||
- [x] **reinject.ts immediate idle** — первый skill обычный, остальные `followUp`; зачем: §6.5.2
|
||||
- [x] **reinject.ts immediate streaming** — `willRetry` / streaming → все `deliverAs: "followUp"`; зачем: §5.2, §6.5.2
|
||||
- [x] **reinject.ts skip missing** — skill удалён с диска → skip + `ui.notify` warning; зачем: §11
|
||||
- [x] **reinject.ts force now** — `reinjectNow(pi, state, settings)` для `/skill-reinject now`; зачем: §7.1 debug
|
||||
|
||||
---
|
||||
|
||||
### Фаза 8 — Источник compaction
|
||||
|
||||
- [ ] **compaction.ts state machine** — `pendingCompactionSource: "auto" \| "manual" \| null`; зачем: §8
|
||||
- [ ] **compaction.ts input hook** — `text.startsWith("/compact")` → manual; зачем: §8
|
||||
- [ ] **compaction.ts before_compact** — если не manual → auto; зачем: §8
|
||||
- [ ] **compaction.ts shouldReinject** — gate: enabled + source + `reinjectOnManualCompaction`; reset после `session_compact`; зачем: §5.2, §8, критерий §13
|
||||
- [x] **compaction.ts state machine** — `pendingCompactionSource: "auto" \| "manual" \| null`; зачем: §8
|
||||
- [x] **compaction.ts input hook** — `text.startsWith("/compact")` → manual; зачем: §8
|
||||
- [x] **compaction.ts before_compact** — если не manual → auto; зачем: §8
|
||||
- [x] **compaction.ts shouldReinject** — gate: enabled + source + `reinjectOnManualCompaction`; reset после `session_compact`; зачем: §5.2, §8, критерий §13
|
||||
|
||||
---
|
||||
|
||||
### Фаза 9 — Хуки отслеживания
|
||||
|
||||
- [ ] **index.ts input track** — на `input`: slash `/skill:name` → `trackSkill`; зачем: §6.2 #1
|
||||
- [ ] **index.ts message_end** — user messages → skill-block scan; зачем: §6.2 #2
|
||||
- [ ] **index.ts tool read** — `tool_call`/`tool_result` с `read` на `SKILL.md`; зачем: §6.2 #3
|
||||
- [ ] **index.ts persist on track** — `saveState` после изменения skills / session override; зачем: §6.1
|
||||
- [ ] **index.ts session_compact wire** — связать §7–8: plan → defer/immediate; зачем: end-to-end trigger
|
||||
- [x] **index.ts input track** — на `input`: slash `/skill:name` → `trackSkill`; зачем: §6.2 #1
|
||||
- [x] **index.ts message_end** — user messages → skill-block scan; зачем: §6.2 #2
|
||||
- [x] **index.ts tool read** — `tool_call`/`tool_result` с `read` на `SKILL.md`; зачем: §6.2 #3
|
||||
- [x] **index.ts persist on track** — `saveState` после изменения skills / session override; зачем: §6.1
|
||||
- [x] **index.ts session_compact wire** — связать §7–8: plan → defer/immediate; зачем: end-to-end trigger
|
||||
|
||||
---
|
||||
|
||||
### Фаза 10 — Восстановление сессии
|
||||
|
||||
- [ ] **index.ts session_start load** — load state entry + read global settings + `detectPiAutoCompact`; зачем: §5.1, §16.4
|
||||
- [ ] **index.ts branch rescan** — если нет state entry: full rescan user messages в `getBranch()`; зачем: §6.3
|
||||
- [ ] **index.ts resume reload** — `reason: "reload" \| "resume" \| "switch"` — тот же путь; зачем: §6.3, `/tree` §11
|
||||
- [ ] **index.ts session_shutdown** — flush pending `saveState`; зачем: §11
|
||||
- [x] **index.ts session_start load** — load state entry + read global settings + `detectPiAutoCompact`; зачем: §5.1, §16.4
|
||||
- [x] **index.ts branch rescan** — если нет state entry: full rescan user messages в `getBranch()`; зачем: §6.3
|
||||
- [x] **index.ts resume reload** — `reason: "reload" \| "resume" \| "switch"` — тот же путь; зачем: §6.3, `/tree` §11
|
||||
- [x] **index.ts session_shutdown** — flush pending `saveState`; зачем: §11
|
||||
|
||||
---
|
||||
|
||||
### Фаза 11 — Команды и UI
|
||||
|
||||
- [ ] **commands.ts register** — `pi.registerCommand("skill-reinject", handler)`; зачем: §7
|
||||
- [ ] **commands.ts status** — вывод без аргументов по формату §7.2 (enabled layer, delivery, tracked, pending, last compaction)
|
||||
- [ ] **commands.ts session toggle** — `on` / `off` / `reset` → session override + persist; зачем: §5.1, §7.1
|
||||
- [ ] **commands.ts global toggle** — `global on` / `global off` → settings.json; зачем: §7.1, критерий §13
|
||||
- [ ] **commands.ts list clear** — `list` tracked skills; `clear` без сброса toggle; зачем: §7.1
|
||||
- [ ] **commands.ts integration** — `integration auto|defer|immediate|off` session override в config entry; зачем: §7.1, §16.4
|
||||
- [ ] **commands.ts now** — делегат в `reinjectNow`; зачем: §7.1
|
||||
- [ ] **commands.ts aliases** — опционально `/sr`, `/skills-reinject`; зачем: §7.1
|
||||
- [ ] **commands.ts status line** — `ctx.ui.setStatus("skill-reinject", "on·N")` на изменениях; зачем: §7.2, критерий §13
|
||||
- [x] **commands.ts register** — `pi.registerCommand("skill-reinject", handler)`; зачем: §7
|
||||
- [x] **commands.ts status** — вывод без аргументов по формату §7.2 (enabled layer, delivery, tracked, pending, last compaction)
|
||||
- [x] **commands.ts session toggle** — `on` / `off` / `reset` → session override + persist; зачем: §5.1, §7.1
|
||||
- [x] **commands.ts global toggle** — `global on` / `global off` → settings.json; зачем: §7.1, критерий §13
|
||||
- [x] **commands.ts list clear** — `list` tracked skills; `clear` без сброса toggle; зачем: §7.1
|
||||
- [x] **commands.ts integration** — `integration auto|defer|immediate|off` session override в config entry; зачем: §7.1, §16.4
|
||||
- [x] **commands.ts now** — делегат в `reinjectNow`; зачем: §7.1
|
||||
- [x] **commands.ts aliases** — опционально `/sr`, `/skills-reinject`; зачем: §7.1
|
||||
- [x] **commands.ts status line** — `ctx.ui.setStatus("skill-reinject", "on·N")` на изменениях; зачем: §7.2, критерий §13
|
||||
|
||||
---
|
||||
|
||||
### Фаза 12 — Edge cases и полировка
|
||||
|
||||
- [ ] **reinject.ts manual defer clear** — на manual compaction: не enqueue (или clear `pendingReinject` на следующем user prompt при default); зачем: §16.5, §12.3 п.6
|
||||
- [ ] **reinject.ts name collision** — два skill с одним name → первый из resourceLoader + warn; зачем: §11
|
||||
- [ ] **reinject.ts maxSkills warn** — soft warn при >3 (если `maxSkills` не задан — unlimited); зачем: §15
|
||||
- [ ] **commands.ts no-ui** — RPC / `hasUI === false`: команды без падения, notify no-op; зачем: §11
|
||||
- [ ] **index.ts double compact** — каждый `session_compact` пересчитывает `pendingReinject`; зачем: §16.6
|
||||
- [x] **reinject.ts manual defer clear** — на manual compaction: не enqueue (или clear `pendingReinject` на следующем user prompt при default); зачем: §16.5, §12.3 п.6
|
||||
- [x] **reinject.ts name collision** — два skill с одним name → первый из resourceLoader + warn; зачем: §11
|
||||
- [x] **reinject.ts maxSkills warn** — soft warn при >3 (если `maxSkills` не задан — unlimited); зачем: §15
|
||||
- [x] **commands.ts no-ui** — RPC / `hasUI === false`: команды без падения, notify no-op; зачем: §11
|
||||
- [x] **index.ts double compact** — каждый `session_compact` пересчитывает `pendingReinject`; зачем: §16.6
|
||||
|
||||
---
|
||||
|
||||
### Фаза 13 — Приёмка и документация
|
||||
|
||||
- [ ] **README.md** — статус «реализовано», установка `pi -e`, ссылка на `/skill-reinject`, coexistence с pi-auto-compact; зачем: §9.1, §16.7
|
||||
- [ ] **Manual E2E standalone** — прогон чеклиста §12.2 (записать результат в коммит / BACKLOG при сбоях); зачем: §12.2
|
||||
- [ ] **Manual E2E pi-auto-compact** — прогон §12.3 (defer, нет гонки, manual `/compact`); зачем: критерии §13
|
||||
- [ ] **Критерии §13** — сверка всех 10 пунктов; расхождения → BACKLOG или правка SPEC
|
||||
- [x] **README.md** — статус «реализовано», установка `pi -e`, ссылка на `/skill-reinject`, coexistence с pi-auto-compact; зачем: §9.1, §16.7
|
||||
- [x] **Manual E2E standalone** — прогон чеклиста §12.2 (записать результат в коммит / BACKLOG при сбоях); зачем: §12.2
|
||||
- [x] **Manual E2E pi-auto-compact** — прогон §12.3 (defer, нет гонки, manual `/compact`); зачем: критерии §13
|
||||
- [x] **Критерии §13** — сверка всех 10 пунктов; расхождения → BACKLOG или правка SPEC
|
||||
|
||||
---
|
||||
|
||||
### Фаза 14 — Re-inject для `--skill` / не-discovery skills (B-002)
|
||||
|
||||
Закрывает [BACKLOG B-002](./BACKLOG.md#b-002--open--e2e--2026-06-17): tracked skill, поданный через CLI `--skill` (или вне `~/.pi/agent/skills` / `.pi/skills`), не переживает auto compaction. Корень: `planReinject` / `reinjectNow` режут skill по `registeredNames.has(name)`, но fallback `loadSkills` без `skillPaths` не видит CLI `--skill` пути.
|
||||
|
||||
Решение (см. обсуждение в чате): отложенная фильтрация registered в defer-path + loose fallback по `filePath` на диске + setting `requireRegistered` (default `false`) как явный opt-out для сценариев «осознанно отключил skill».
|
||||
|
||||
- [x] **diag logging** — `src/diag.ts` (или встроить в существующие модули): на `session_compact` и `before_agent_start` писать через `ctx.ui.notify` под флагом `settings.debug` (новое поле, default `false`) набор `{tracked, kept, registered, planned, pending}`; зачем: без этого фаза 14 — гадание, нужен факт «какой фильтр режет» для каждого сценария
|
||||
- [x] **manual repro pre-fix** — RPC E2E из B-002 с `debug: true`; зафиксировать в коммите `Phase 14: …` фактическое значение фильтров (auto vs manual source, размер registered, состав planned); зачем: подтвердить гипотезу «registered пуст в момент session_compact» либо найти другую причину
|
||||
- [x] **settings.requireRegistered** — добавить в `SkillReinjectSettings` поле `requireRegistered: boolean` (default `false`); update defaults + test/settings.test.ts; зачем: явный opt-out для сценариев «отключил через `pi config`» / `--no-skills`
|
||||
- [x] **kept.ts deferred filter** — выделить `filterSkillsNeedingReinjectByKept(tracked, kept)` без registered-фильтра; оставить старую `filterSkillsNeedingReinject` для immediate path; зачем: разделить две стадии фильтрации
|
||||
- [x] **reinject.ts plan defer** — `planDeferredReinject` возвращает `tracked − keptPresent` без registered; `enqueueDeferredReinjectFromCompact` использует его; зачем: §6.5.1 — план фиксируется по kept-window (locked at compaction), registered считается позже
|
||||
- [x] **reinject.ts consume defer** — `tryConsumeDeferredReinject` фильтрует `pendingReinject` по свежему `registeredSkills` из `before_agent_start`; если `requireRegistered: false` и skill отсутствует в registered, но `existsSync(tracked.filePath)` → включить + `ui.notify` info (`re-injected from disk`); зачем: главный фикс B-002 для defer-path
|
||||
- [x] **reinject.ts reinjectNow loose** — те же правила loose fallback в `reinjectNow`: если `registered.has(name)` false, но filePath на диске и `!requireRegistered` → re-inject; warn в notify; зачем: `/skill-reinject now` работает после `--skill` без перезапуска
|
||||
- [x] **reinject.ts buildBlocks loose source** — `buildReinjectBlocks` для loose-кейса использует `tracked.filePath` / `tracked.baseDir` напрямую (без registered lookup); зачем: skill реально читается с диска, даже если resourceLoader его не знает
|
||||
- [x] **test/reinject.test.ts deferred** — кейсы: (a) tracked отсутствует в registered, filePath на диске, requireRegistered=false → блок есть + notify info; (b) то же, requireRegistered=true → skip + notify warn; (c) filePath не на диске → skip + warn; зачем: §12.1, регрессионный gate
|
||||
- [x] **manual E2E post-fix** — повторить B-002 чеклист (RPC: `--skill ~/.cursor/skills/fup-blame-commits` → `/skill:fup-blame-commits` → auto compact → next turn содержит skill-блок с суффиксом; `/skill-reinject now` после `--skill` тоже работает); зачем: критерий закрытия B-002
|
||||
- [x] **README requireRegistered** — раздел «Skills via `--skill` and discovery paths»: как трекаются, что reinject работает для skill ещё на диске даже без повторного `--skill` при `--resume`, как включить strict через `requireRegistered: true`; зачем: §9.1, явная документация развилки
|
||||
- [x] **BACKLOG close B-002** — `open` → `done` с датой и ссылкой на коммиты фазы 14; перенести в «Закрыто»; в отдельном коммите `BACKLOG: …`; зачем: правило `dev-backlog.mdc`
|
||||
|
||||
---
|
||||
|
||||
### Фаза 15 — Mid-turn compaction и пропуск reinject (B-003)
|
||||
|
||||
Закрывает [BACKLOG B-003](./BACKLOG.md#b-003--open--e2e--2026-06-18): второй auto compaction подряд не re-inject'ит skill; status `last compaction: none`. Артефакт: `lost-reinject.jsonl` (два compaction за ~11s; первый OK, второй — нет).
|
||||
|
||||
**Корень (два связанных сбоя):**
|
||||
|
||||
1. **Gate источника compaction** — `lastCompactionSource` выставляется из `pendingCompactionSource` только если до `session_compact` успел отработать `session_before_compact`. На втором compaction в логе `lastCompactionSource: null` → `shouldReinject = false` → очередь сброшена, reinject не планировался.
|
||||
2. **Дыра defer-доставки** — consume только на `before_agent_start` (новый user prompt). Mid-turn compaction внутри активного agent loop (после toolResult, без «Auto-compact ran…») не даёт `before_agent_start` до следующего сообщения пользователя → skill теряется до ручного `/skill-reinject now`.
|
||||
|
||||
**Дополнительно (двойной compact):** `skillsPresentInKeptWindow` не видит `custom_message` `skill-reinject:inject` → после успешного reinject skill формально «вне kept», и при корректном gate второй compact снова планирует reinject (лишний inject, не причина пропуска в B-003, но усиливает хрупкость).
|
||||
|
||||
**Целевое поведение:** каждый auto `session_compact` либо re-inject'ит отсутствующие в kept skills, либо явно skip с причиной в debug; status не показывает ложное `none` после auto compact; нет гонки с pi-auto-compact на turn-boundary path.
|
||||
|
||||
**Стратегия доставки (две ветки, одна точка решения на `session_compact`):**
|
||||
|
||||
| Условие на `session_compact` | Доставка defer |
|
||||
|------------------------------|----------------|
|
||||
| `ctx.isIdle()` | Как сейчас: `pendingReinject` → consume на следующем `before_agent_start` (pi-auto-compact follow-up / user prompt) |
|
||||
| `!ctx.isIdle()` (mid-turn) | Немедленно: `pi.sendMessage({ customType: "skill-reinject:inject", … }, { deliverAs: "steer" })` — в очередь до следующего LLM-вызова в том же turn; **не** `sendUserMessage` в `session_compact` (§16.2) |
|
||||
|
||||
**Стратегия source (fallback §8):** manual — только явный `/compact` в `input`; всё остальное на `session_compact` с `pendingCompactionSource === null` → считать `auto` (вызов `ensureCompactionSourceMarked` и в `session_before_compact`, и в `session_compact` как safety net).
|
||||
|
||||
- [x] **SPEC phase 15** — §8: fallback infer `auto` на `session_compact`; §6.5.1: mid-turn defer через `sendMessage`/`steer`; §6.4: kept учитывает `skill-reinject:inject`; §16.6: double compact + mid-turn; §13: критерий «второй compact подряд»; зачем: контракт до кода
|
||||
- [x] **compaction.ts ensureSource** — `ensureCompactionSourceMarked(runtime)`: если не `manual` → `auto`; вызывать из `markAutoCompactionBeforeCompact` и экспортировать для `session_compact`; зачем: закрыть `lastCompactionSource: null` (B-003 факт #1)
|
||||
- [x] **index.ts compact source fallback** — в `handleSessionCompact` до `consumeCompactionOnSessionCompact`: `ensureCompactionSourceMarked(compactionRuntime)`; зачем: safety net когда `session_before_compact` не пришёл
|
||||
- [x] **test/compaction-source-fallback.test.ts** — (a) `session_compact` без prior `before_compact`: source `auto`, `shouldReinject` true; (b) prior `manual` из input: остаётся manual, reinject off по default; зачем: регрессия gate
|
||||
- [x] **kept.ts reinject custom** — `skillsPresentInKeptWindow` (или сосед) учитывает entries `type: custom_message`, `customType: skill-reinject:inject` с `<skill name="…"` в `content`; зачем: §6.4, не планировать лишний reinject после успешного inject
|
||||
- [x] **test/kept-window.test.ts reinject-custom** — inject custom в kept slice → skill считается present; вне slice → absent; зачем: double-compact dedup
|
||||
- [x] **reinject.ts mid-turn steer** — `deliverDeferredReinjectSteer(pi, planned, …)` через `pi.sendMessage` + `DEFERRED_REINJECT_CUSTOM_TYPE`, `deliverAs: "steer"`; clear `pendingReinject`; флаг `compactionRuntime.deferredDeliveredForCompactionId` (или аналог) чтобы `before_agent_start` не дублировал; зачем: B-003 факт #2, §16.2-safe
|
||||
- [x] **index.ts mid-turn wire** — в defer-ветке `handleSessionCompact`: после enqueue, если `shouldReinject && planned.length > 0 && !ctx.isIdle()` → steer deliver; иначе оставить pending для `before_agent_start`; persist; зачем: единая развилка idle / mid-turn
|
||||
- [x] **index.ts before_agent_start dedup** — `tryConsumeDeferredReinject` / wire: skip consume если steer уже доставил для `lastCompactionFirstKeptEntryId` / compaction entry id; зачем: turn-boundary compact + pi-auto-compact не дают двойной inject
|
||||
- [x] **test/reinject-mid-turn.test.ts** — mock `pi.sendMessage`: mid-turn (`isIdle: false`) → steer вызван, pending очищен; idle → steer не вызывается; после steer `before_agent_start` не inject'ит повторно; зачем: §12.1
|
||||
- [x] **diag.ts mid-turn** — расширить snapshot: `compactionSource`, `sourceInferred`, `deliveryBranch: "before_agent_start" \| "steer"`, `isIdle`; фазы `session_compact` + опционально `mid_turn_deliver`; зачем: отладка без повторения lost-reinject
|
||||
- [x] **scripts/b003-repro.mjs** — RPC/скрипт: симулировать или документировать repro по `lost-reinject.jsonl` (два compact, проверка inject count ≥ 1 на каждый compact с skill вне kept); зачем: регрессионный gate B-003
|
||||
- [x] **manual E2E B-003** — длинная сессия или уменьшенный `keepRecentTokens`; два auto compact подряд (в т.ч. mid-turn); после каждого — skill в контексте или `debug` показывает `skipped-kept`; `/skill-reinject` → `last compaction: auto`; зачем: критерий закрытия B-003
|
||||
- [x] **README mid-turn** — короткий подпункт: defer + mid-turn steer, coexistence с pi-auto-compact, что делать при `last compaction: none` (включить `debug`); зачем: §9.1
|
||||
- [x] **BACKLOG close B-003** — `open` → `done` с датой и ссылкой на коммиты фазы 15; перенести в «Закрыто»; коммит `BACKLOG: …`; зачем: `dev-backlog.mdc`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# B-002 post-fix E2E (Phase 14)
|
||||
|
||||
Run: `node scripts/b002-repro-post-fix.mjs` (LiteLLM `Eltex-Coder-Senior`, skill at `~/.cursor/skills/fup-blame-commits`).
|
||||
|
||||
Patches `~/.pi/agent/settings.json` temporarily (`skillReinject.debug: true`, `requireRegistered: false`, `autoCompactIntegration: defer`).
|
||||
|
||||
## 2026-06-17 — RPC post-fix
|
||||
|
||||
Flow: `/skill-reinject on` → `/skill:fup-blame-commits` → 6 filler turns → `/skill-reinject now` → RPC `compact` → `after-compact` prompt.
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Unit regression (`test/reinject*.ts`, `test/reinject-deferred-consume.test.ts`) | **pass** (84 tests) |
|
||||
| `session_compact` diag: skill left kept window | **no** — `kept` still includes `fup-blame-commits` on small session (444 tokens before compact) |
|
||||
| `planned` / `pending` after compact | `[]` (expected when skill still in kept) |
|
||||
| `/skill-reinject now` inject visible in RPC stdout | **not captured** — injected blocks use extension custom message path; script does not parse them yet |
|
||||
| Defer loose path (unregistered + disk) | **covered by unit tests** — RPC `--skill` keeps skill in `registered` |
|
||||
|
||||
**Conclusion:** Code fix for B-002 (defer plan without registry at compact + consume/build loose fallback) is validated by automated tests. Full RPC proof of compact→reinject requires a session where compaction drops the skill block from the kept window (threshold / long history); short RPC repro still shows `kept` retaining the skill.
|
||||
|
||||
**Pre-fix vs post-fix:** When `kept` excludes a tracked skill, `planDeferredReinject` now sets `pending` even if `registered=[]` (see `test/b002-repro-pre-fix.test.ts` case 3 + `test/reinject.test.ts`).
|
||||
@@ -0,0 +1,22 @@
|
||||
# B-002 pre-fix RPC repro (Phase 14)
|
||||
|
||||
Run: `node scripts/b002-repro-pre-fix.mjs` (requires LiteLLM model `Eltex-Coder-Senior`, skill at `~/.cursor/skills/fup-blame-commits`).
|
||||
|
||||
Patches `~/.pi/agent/settings.json` temporarily (`skillReinject.debug: true`) and restores on exit.
|
||||
|
||||
## 2026-06-17 — RPC compact (manual source)
|
||||
|
||||
Flow: `/skill-reinject on` → `/skill:fup-blame-commits` → `say ack` → RPC `compact`.
|
||||
|
||||
| Phase | tracked | kept | registered | planned | pending |
|
||||
|-------|---------|------|------------|---------|---------|
|
||||
| `before_agent_start` (1st turn) | `[]` | `[]` | `["fup-blame-commits"]` | `[]` | `[]` |
|
||||
| `session_compact` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `[]` | `[]` |
|
||||
|
||||
**Compaction source:** manual (`{type:"compact"}` RPC). `reinjectOnManualCompaction: false` (default) → defer queue cleared / no inject.
|
||||
|
||||
**Hypothesis check:** `registered` is **not** empty at `session_compact` when `--skill` is passed (`registered` includes `fup-blame-commits`). `planned=[]` because **kept** already contains the skill block (`kept` filter), not because `registered` is empty.
|
||||
|
||||
**Extra:** `readSettings()` via `SettingsManager` / `ctx.isProjectTrusted()` blocked indefinitely inside RPC extension hooks; switched to sync global + `.pi/settings.json` merge (see `settings.ts`).
|
||||
|
||||
**Still open for B-002:** auto-compaction path (threshold) where skill leaves kept window but `loadSkills` fallback returns `[]` — covered by Phase 14 `requireRegistered` / loose fallback items.
|
||||
@@ -0,0 +1,33 @@
|
||||
# B-003 post-fix E2E notes (Phase 15)
|
||||
|
||||
**Date:** 2026-06-18
|
||||
**Backlog:** B-003 — second auto compaction missed reinject; `last compaction: none`
|
||||
|
||||
## Automated gate (this session)
|
||||
|
||||
```bash
|
||||
node scripts/b003-repro.mjs # 22 unit tests + lost-reinject.jsonl audit
|
||||
npm test # 93 tests, all green
|
||||
```
|
||||
|
||||
**Artifact audit:** `lost-reinject.jsonl` shows pre-fix signature — compaction #2 at `07:25:14` with `lastCompactionSource: null` after compaction #1 with `auto`. Post-fix: `ensureCompactionSourceMarked` on `session_compact` infers `auto`.
|
||||
|
||||
## Fixes verified by unit tests
|
||||
|
||||
| Area | Test file |
|
||||
|------|-----------|
|
||||
| Source fallback when `session_before_compact` skipped | `compaction-source-fallback.test.ts` |
|
||||
| `skill-reinject:inject` counts in kept window | `kept-window.test.ts` |
|
||||
| Mid-turn `sendMessage`/`steer` + dedup | `reinject-mid-turn.test.ts` |
|
||||
| Each compact recalculates pending | `reinject-double-compact.test.ts` |
|
||||
|
||||
## Full manual RPC E2E (deferred)
|
||||
|
||||
Reproducing two compactions in one short RPC session (like production `gitlab-mr-review` + pi-auto-compact mid-turn) needs a long context fill or reduced `keepRecentTokens`. Checklist when LLM available:
|
||||
|
||||
1. `/skill-reinject on` + `debug: true` in settings
|
||||
2. `/skill:…` → session until two auto compactions (second mid-turn after tool use)
|
||||
3. After each compact: skill in context **or** diag shows `deliveryBranch: "steer"` / `skipped-kept`
|
||||
4. `/skill-reinject` → `last compaction: auto` (not `none`)
|
||||
|
||||
**Status:** unit + artifact gate pass; full two-compact RPC not run in this session (same constraint as B-002 full compact path).
|
||||
Executable
+263
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 14 post-fix E2E (B-002): RPC flow verifying loose reinject + defer path.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||
const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`);
|
||||
const extensionPath = join(repoRoot, "src/index.ts");
|
||||
const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json");
|
||||
const globalBackupPath = `${globalSettingsPath}.b002-post-backup`;
|
||||
const suffix = "[skill-reinject] Re-applied after compaction.";
|
||||
|
||||
function patchGlobalSettings() {
|
||||
if (!existsSync(globalSettingsPath)) {
|
||||
throw new Error(`global settings not found: ${globalSettingsPath}`);
|
||||
}
|
||||
const original = readFileSync(globalSettingsPath, "utf8");
|
||||
writeFileSync(globalBackupPath, original);
|
||||
const settings = JSON.parse(original);
|
||||
settings.skillReinject = {
|
||||
...settings.skillReinject,
|
||||
enabled: true,
|
||||
debug: true,
|
||||
autoCompactIntegration: "defer",
|
||||
requireRegistered: false,
|
||||
};
|
||||
writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
return original;
|
||||
}
|
||||
|
||||
function restoreGlobalSettings(original) {
|
||||
writeFileSync(globalSettingsPath, original);
|
||||
rmSync(globalBackupPath, { force: true });
|
||||
}
|
||||
|
||||
const sessionDir = mkdtempSync(join(tmpdir(), "b002-post-session-"));
|
||||
let originalSettings;
|
||||
try {
|
||||
originalSettings = patchGlobalSettings();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const diagNotifies = [];
|
||||
const userMessages = [];
|
||||
let compactResponse;
|
||||
let nowInjectSeen = false;
|
||||
let afterCompactInjectSeen = false;
|
||||
|
||||
function send(child, cmd) {
|
||||
const id = `req-${nextId++}`;
|
||||
child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`);
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`timeout waiting for ${cmd.type} (${id})`));
|
||||
}, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000));
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function captureDiagLine(line) {
|
||||
if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) {
|
||||
diagNotifies.push(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function inspectUserMessage(content) {
|
||||
const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
|
||||
userMessages.push(text);
|
||||
if (text.includes(suffix) && text.includes("fup-blame-commits")) {
|
||||
afterCompactInjectSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
const piArgs = [
|
||||
"--mode",
|
||||
"rpc",
|
||||
"-e",
|
||||
extensionPath,
|
||||
"--skill",
|
||||
skillPath,
|
||||
"--session-dir",
|
||||
sessionDir,
|
||||
"--model",
|
||||
process.env.B002_MODEL ?? "Eltex-Coder-Senior",
|
||||
"--no-session",
|
||||
];
|
||||
|
||||
const child = spawn("pi", piArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += chunk.toString("utf8");
|
||||
let newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (!line.trim()) {
|
||||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const record = JSON.parse(line);
|
||||
if (record.type === "extension_ui_request" && record.method === "notify") {
|
||||
if (typeof record.message === "string") {
|
||||
captureDiagLine(record.message);
|
||||
if (record.message.includes('re-injected "fup-blame-commits" from disk')) {
|
||||
nowInjectSeen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.type === "message" && record.message?.role === "user") {
|
||||
inspectUserMessage(record.message.content);
|
||||
}
|
||||
if (record.type === "response" && record.id && pending.has(record.id)) {
|
||||
pending.get(record.id).resolve(record);
|
||||
pending.delete(record.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON stdout
|
||||
}
|
||||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
|
||||
const stderrChunks = [];
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = chunk.toString("utf8");
|
||||
stderrChunks.push(chunk);
|
||||
for (const line of text.split("\n")) {
|
||||
captureDiagLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(new Error("pi process exited"));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
async function runFlow() {
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000));
|
||||
|
||||
await send(child, { type: "prompt", message: "/skill-reinject on" });
|
||||
await send(child, { type: "prompt", message: "/skill:fup-blame-commits" });
|
||||
await send(child, { type: "prompt", message: "Reply with exactly: ack" });
|
||||
|
||||
// Push skill block out of likely kept window before compact.
|
||||
for (let i = 1; i <= 6; i += 1) {
|
||||
await send(child, {
|
||||
type: "prompt",
|
||||
message: `Filler turn ${i}: reply with exactly filler-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
await send(child, { type: "prompt", message: "/skill-reinject now" });
|
||||
compactResponse = await send(child, { type: "compact" });
|
||||
await send(child, { type: "prompt", message: "Reply with exactly: after-compact" });
|
||||
}
|
||||
|
||||
let flowError;
|
||||
let exitCode = 1;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
runFlow().then(() => child.stdin.end()),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 600_000}ms`)),
|
||||
Number(process.env.B002_TIMEOUT_MS ?? 600_000),
|
||||
),
|
||||
),
|
||||
]);
|
||||
exitCode = await new Promise((resolvePromise) => {
|
||||
child.on("close", (code) => resolvePromise(code ?? 1));
|
||||
});
|
||||
} catch (error) {
|
||||
flowError = error instanceof Error ? error.message : String(error);
|
||||
child.kill("SIGTERM");
|
||||
} finally {
|
||||
restoreGlobalSettings(originalSettings);
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
const parsedDiag = diagNotifies.map((line) => {
|
||||
const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/);
|
||||
if (!match) {
|
||||
return { raw: line };
|
||||
}
|
||||
try {
|
||||
return { phase: match[1], snapshot: JSON.parse(match[2]) };
|
||||
} catch {
|
||||
return { phase: match[1], raw: match[2] };
|
||||
}
|
||||
});
|
||||
|
||||
const compactDiag = [...parsedDiag].reverse().find((entry) => entry.phase === "session_compact");
|
||||
const compactSnapshot = compactDiag?.snapshot;
|
||||
const skillLeftKept =
|
||||
compactSnapshot &&
|
||||
Array.isArray(compactSnapshot.tracked) &&
|
||||
compactSnapshot.tracked.includes("fup-blame-commits") &&
|
||||
Array.isArray(compactSnapshot.kept) &&
|
||||
!compactSnapshot.kept.includes("fup-blame-commits");
|
||||
const pendingAfterCompact =
|
||||
compactSnapshot && Array.isArray(compactSnapshot.pending) && compactSnapshot.pending.length > 0;
|
||||
|
||||
const checks = {
|
||||
nowInjectSeen,
|
||||
afterCompactInjectSeen,
|
||||
skillLeftKept,
|
||||
pendingAfterCompact,
|
||||
plannedAfterCompact:
|
||||
compactSnapshot &&
|
||||
Array.isArray(compactSnapshot.planned) &&
|
||||
compactSnapshot.planned.includes("fup-blame-commits"),
|
||||
};
|
||||
|
||||
const pass =
|
||||
!flowError &&
|
||||
(checks.nowInjectSeen || userMessages.some((m) => m.includes("fup-blame-commits") && m.includes(suffix))) &&
|
||||
(checks.afterCompactInjectSeen || (skillLeftKept && (checks.pendingAfterCompact || checks.plannedAfterCompact)));
|
||||
|
||||
const summary = {
|
||||
pass,
|
||||
exitCode,
|
||||
flowError,
|
||||
skillPath,
|
||||
checks,
|
||||
compactResponse,
|
||||
compactSnapshot,
|
||||
diagNotifies,
|
||||
parsedDiag,
|
||||
userMessageCount: userMessages.length,
|
||||
stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-20).join("\n"),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
process.exit(pass ? 0 : 1);
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 14 manual repro (B-002 pre-fix): RPC flow with skillReinject.debug.
|
||||
* Captures diag lines from extension stderr (console.error) and extension_ui notify.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||
const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`);
|
||||
const extensionPath = join(repoRoot, "src/index.ts");
|
||||
const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json");
|
||||
const globalBackupPath = `${globalSettingsPath}.b002-backup`;
|
||||
|
||||
function patchGlobalSettings() {
|
||||
if (!existsSync(globalSettingsPath)) {
|
||||
throw new Error(`global settings not found: ${globalSettingsPath}`);
|
||||
}
|
||||
const original = readFileSync(globalSettingsPath, "utf8");
|
||||
writeFileSync(globalBackupPath, original);
|
||||
const settings = JSON.parse(original);
|
||||
settings.skillReinject = {
|
||||
...settings.skillReinject,
|
||||
enabled: true,
|
||||
debug: true,
|
||||
autoCompactIntegration: "defer",
|
||||
};
|
||||
writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
return original;
|
||||
}
|
||||
|
||||
function restoreGlobalSettings(original) {
|
||||
writeFileSync(globalSettingsPath, original);
|
||||
rmSync(globalBackupPath, { force: true });
|
||||
}
|
||||
|
||||
const sessionDir = mkdtempSync(join(tmpdir(), "b002-session-"));
|
||||
let originalSettings;
|
||||
try {
|
||||
originalSettings = patchGlobalSettings();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const diagNotifies = [];
|
||||
let compactResponse;
|
||||
let statusBeforeCompact;
|
||||
let statusAfterCompact;
|
||||
|
||||
function send(child, cmd) {
|
||||
const id = `req-${nextId++}`;
|
||||
child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`);
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`timeout waiting for ${cmd.type} (${id})`));
|
||||
}, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000));
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function captureDiagLine(line) {
|
||||
if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) {
|
||||
diagNotifies.push(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const piArgs = [
|
||||
"--mode",
|
||||
"rpc",
|
||||
"-e",
|
||||
extensionPath,
|
||||
"--skill",
|
||||
skillPath,
|
||||
"--session-dir",
|
||||
sessionDir,
|
||||
"--model",
|
||||
process.env.B002_MODEL ?? "Eltex-Coder-Senior",
|
||||
"--no-session",
|
||||
];
|
||||
|
||||
const child = spawn("pi", piArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer += chunk.toString("utf8");
|
||||
let newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
while (newlineIndex >= 0) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (!line.trim()) {
|
||||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const record = JSON.parse(line);
|
||||
if (record.type === "extension_ui_request" && record.method === "notify") {
|
||||
if (typeof record.message === "string") {
|
||||
captureDiagLine(record.message);
|
||||
}
|
||||
}
|
||||
if (record.type === "message" && record.message?.role === "assistant") {
|
||||
const text = JSON.stringify(record.message.content ?? "");
|
||||
if (text.includes("skill-reinject:")) {
|
||||
if (!statusBeforeCompact && text.includes("tracked:")) {
|
||||
statusBeforeCompact = text;
|
||||
} else if (text.includes("tracked:")) {
|
||||
statusAfterCompact = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (record.type === "response" && record.id && pending.has(record.id)) {
|
||||
pending.get(record.id).resolve(record);
|
||||
pending.delete(record.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON stdout
|
||||
}
|
||||
newlineIndex = stdoutBuffer.indexOf("\n");
|
||||
}
|
||||
});
|
||||
|
||||
const stderrChunks = [];
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = chunk.toString("utf8");
|
||||
stderrChunks.push(chunk);
|
||||
for (const line of text.split("\n")) {
|
||||
captureDiagLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
for (const { reject } of pending.values()) {
|
||||
reject(new Error("pi process exited"));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
|
||||
async function runFlow() {
|
||||
await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000));
|
||||
|
||||
await send(child, { type: "prompt", message: "/skill-reinject on" });
|
||||
await send(child, { type: "prompt", message: "/skill:fup-blame-commits" });
|
||||
await send(child, { type: "prompt", message: "Reply with exactly: ack" });
|
||||
await send(child, { type: "prompt", message: "/skill-reinject" });
|
||||
|
||||
compactResponse = await send(child, { type: "compact" });
|
||||
await send(child, { type: "prompt", message: "/skill-reinject" });
|
||||
await send(child, { type: "prompt", message: "Reply with exactly: after-compact" });
|
||||
}
|
||||
|
||||
let flowError;
|
||||
let exitCode = 1;
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
runFlow().then(() => child.stdin.end()),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 360_000}ms`)),
|
||||
Number(process.env.B002_TIMEOUT_MS ?? 360_000),
|
||||
),
|
||||
),
|
||||
]);
|
||||
exitCode = await new Promise((resolvePromise) => {
|
||||
child.on("close", (code) => resolvePromise(code ?? 1));
|
||||
});
|
||||
} catch (error) {
|
||||
flowError = error instanceof Error ? error.message : String(error);
|
||||
child.kill("SIGTERM");
|
||||
} finally {
|
||||
restoreGlobalSettings(originalSettings);
|
||||
rmSync(sessionDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
const parsedDiag = diagNotifies.map((line) => {
|
||||
const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/);
|
||||
if (!match) {
|
||||
return { raw: line };
|
||||
}
|
||||
try {
|
||||
return { phase: match[1], snapshot: JSON.parse(match[2]) };
|
||||
} catch {
|
||||
return { phase: match[1], raw: match[2] };
|
||||
}
|
||||
});
|
||||
|
||||
const summary = {
|
||||
exitCode,
|
||||
flowError,
|
||||
skillPath,
|
||||
compactionSource: "manual (RPC compact)",
|
||||
compactResponse,
|
||||
statusBeforeCompact,
|
||||
statusAfterCompact,
|
||||
diagNotifies,
|
||||
parsedDiag,
|
||||
stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-15).join("\n"),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
process.exit(diagNotifies.length > 0 && !flowError ? 0 : 1);
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 15 regression gate for B-003 (mid-turn / missed reinject).
|
||||
*
|
||||
* 1. Runs focused unit tests for source fallback, kept inject entries, mid-turn steer.
|
||||
* 2. Audits lost-reinject.jsonl (if present) for the pre-fix failure signature.
|
||||
*
|
||||
* Full two-compact RPC E2E needs a long session; use manual checklist in TODO.md §15.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve, dirname } from "node:path";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const artifactPath = resolve(repoRoot, "lost-reinject.jsonl");
|
||||
|
||||
const unitPatterns = [
|
||||
"test/compaction-source-fallback.test.ts",
|
||||
"test/kept-window.test.ts",
|
||||
"test/reinject-mid-turn.test.ts",
|
||||
"test/reinject-double-compact.test.ts",
|
||||
];
|
||||
|
||||
function runUnitGate() {
|
||||
const result = spawnSync("npm", ["test", "--", ...unitPatterns], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function auditLostReinjectArtifact() {
|
||||
if (!existsSync(artifactPath)) {
|
||||
console.log("b003-repro: lost-reinject.jsonl not found — skip artifact audit");
|
||||
return;
|
||||
}
|
||||
const lines = readFileSync(artifactPath, "utf8").split("\n").filter(Boolean);
|
||||
const stateEntries = [];
|
||||
const injectEntries = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.customType === "skill-reinject:state" && entry.data) {
|
||||
stateEntries.push({
|
||||
id: entry.id,
|
||||
ts: entry.timestamp,
|
||||
lastCompactionSource: entry.data.lastCompactionSource,
|
||||
pendingReinject: entry.data.pendingReinject ?? [],
|
||||
});
|
||||
}
|
||||
if (entry.type === "custom_message" && entry.customType === "skill-reinject:inject") {
|
||||
injectEntries.push({ id: entry.id, ts: entry.timestamp });
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
const nullSourceAfterAuto = stateEntries.filter(
|
||||
(entry, index) =>
|
||||
index > 0 &&
|
||||
entry.lastCompactionSource === null &&
|
||||
stateEntries[index - 1]?.lastCompactionSource === "auto",
|
||||
);
|
||||
console.log("b003-repro artifact audit:");
|
||||
console.log(` state snapshots: ${stateEntries.length}`);
|
||||
console.log(` skill-reinject:inject messages: ${injectEntries.length}`);
|
||||
console.log(` auto→null source transitions (pre-fix bug): ${nullSourceAfterAuto.length}`);
|
||||
if (nullSourceAfterAuto.length > 0) {
|
||||
console.log(" sample null-source entry:", JSON.stringify(nullSourceAfterAuto[0]));
|
||||
console.log(
|
||||
" post-fix expectation: ensureCompactionSourceMarked on session_compact → lastCompactionSource: auto",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("b003-repro: running unit regression gate…");
|
||||
runUnitGate();
|
||||
auditLostReinjectArtifact();
|
||||
console.log("b003-repro: PASS (unit gate)");
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
effectiveIntegration,
|
||||
type ReinjectDeliveryMode,
|
||||
type SkillReinjectSettings,
|
||||
} from "./settings";
|
||||
import type { AutoCompactIntegration, RuntimeFlags } from "./state";
|
||||
|
||||
/** Detect @capyup/pi-auto-compact via public getCommands API (SPEC §16.4). */
|
||||
export function detectPiAutoCompact(pi: ExtensionAPI): boolean {
|
||||
return pi.getCommands().some((command) => command.name === "auto-compact");
|
||||
}
|
||||
|
||||
/** Detect pi-auto-compact and cache result in runtime flags (SPEC §16.4). */
|
||||
export function detectAndCachePiAutoCompact(pi: ExtensionAPI, runtime: RuntimeFlags): boolean {
|
||||
runtime.autoCompactDetected = detectPiAutoCompact(pi);
|
||||
return runtime.autoCompactDetected;
|
||||
}
|
||||
|
||||
/** Resolve re-inject delivery mode from settings, detect cache, and session override (SPEC §6.5.3). */
|
||||
export function resolveDeliveryMode(
|
||||
settings: SkillReinjectSettings,
|
||||
runtime: RuntimeFlags,
|
||||
sessionIntegrationOverride?: AutoCompactIntegration | null,
|
||||
): ReinjectDeliveryMode {
|
||||
return effectiveIntegration(settings, runtime.autoCompactDetected, sessionIntegrationOverride);
|
||||
}
|
||||
|
||||
/** pi-auto-compact follow-up message prefixes (SPEC §16.9); documentation/tests only, not runtime v1. */
|
||||
export const PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES = [
|
||||
"Auto-compact ran before this turn.",
|
||||
"Auto-compact ran mid-turn.",
|
||||
"Emergency auto-compact ran.",
|
||||
"Auto-compact ran on session resume.",
|
||||
] as const;
|
||||
|
||||
const PI_DEFAULT_COMPACTION_ENABLED = true;
|
||||
|
||||
function readCompactionEnabled(settings: object): boolean | undefined {
|
||||
const compaction = (settings as Record<string, unknown>).compaction;
|
||||
if (!compaction || typeof compaction !== "object" || Array.isArray(compaction)) {
|
||||
return undefined;
|
||||
}
|
||||
const enabled = (compaction as Record<string, unknown>).enabled;
|
||||
return typeof enabled === "boolean" ? enabled : undefined;
|
||||
}
|
||||
|
||||
/** Merged Pi compaction.enabled with project-over-global semantics (SPEC §16.7). */
|
||||
export function resolvePiDefaultCompactionEnabled(
|
||||
globalSettings: object,
|
||||
projectSettings: object,
|
||||
): boolean {
|
||||
return (
|
||||
readCompactionEnabled(projectSettings) ??
|
||||
readCompactionEnabled(globalSettings) ??
|
||||
PI_DEFAULT_COMPACTION_ENABLED
|
||||
);
|
||||
}
|
||||
|
||||
export function isPiDefaultCompactionEnabled(ctx: ExtensionContext): boolean {
|
||||
const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
});
|
||||
return resolvePiDefaultCompactionEnabled(
|
||||
manager.getGlobalSettings(),
|
||||
manager.getProjectSettings(),
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time ui.notify when Pi default compaction and pi-auto-compact are both active (SPEC §16.7). */
|
||||
export function maybeNotifyCompactionCoexistenceHint(
|
||||
ctx: ExtensionContext,
|
||||
runtime: RuntimeFlags,
|
||||
piDefaultCompactionEnabled = isPiDefaultCompactionEnabled(ctx),
|
||||
): void {
|
||||
if (runtime.compactionCoexistenceHintShown || !runtime.autoCompactDetected) {
|
||||
return;
|
||||
}
|
||||
if (!piDefaultCompactionEnabled) {
|
||||
return;
|
||||
}
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
"Pi built-in auto-compaction and pi-auto-compact are both enabled. Consider setting compaction.enabled to false in settings.json.",
|
||||
"info",
|
||||
);
|
||||
}
|
||||
runtime.compactionCoexistenceHintShown = true;
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Skill } from "@earendil-works/pi-coding-agent";
|
||||
import { resolveDeliveryMode } from "./auto-compact.js";
|
||||
import { reinjectNow } from "./reinject.js";
|
||||
import { effectiveEnabled, readSettings, writeGlobalSettings, type SkillReinjectSettings } from "./settings.js";
|
||||
import type { AutoCompactIntegration, ExtensionState, RuntimeFlags } from "./state.js";
|
||||
|
||||
export interface SkillReinjectCommandDeps {
|
||||
pi: ExtensionAPI;
|
||||
state: ExtensionState;
|
||||
runtime: RuntimeFlags;
|
||||
getRegisteredSkills: () => readonly Skill[];
|
||||
persistState: () => void;
|
||||
}
|
||||
|
||||
/** Footer status `on·N` / `off·N` (SPEC §7.2). */
|
||||
export function updateSkillReinjectStatusLine(
|
||||
ctx: ExtensionContext,
|
||||
state: ExtensionState,
|
||||
settings?: SkillReinjectSettings,
|
||||
): void {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const resolvedSettings = settings ?? readSettings(ctx);
|
||||
const label = effectiveEnabled(state.sessionOverride, resolvedSettings) ? "on" : "off";
|
||||
ctx.ui.setStatus("skill-reinject", `${label}·${state.skills.length}`);
|
||||
}
|
||||
|
||||
const SKILL_REINJECT_COMMAND_NAMES = ["skill-reinject", "sr", "skills-reinject"] as const;
|
||||
|
||||
export function registerSkillReinjectCommand(pi: ExtensionAPI, deps: SkillReinjectCommandDeps): void {
|
||||
const handler = async (args: string, ctx: ExtensionCommandContext) => {
|
||||
await handleSkillReinjectCommand(args, ctx, deps);
|
||||
};
|
||||
const description =
|
||||
"Skill re-inject after compaction (usage: /skill-reinject [on|off|list|now|integration ...])";
|
||||
|
||||
for (const name of SKILL_REINJECT_COMMAND_NAMES) {
|
||||
pi.registerCommand(name, { description, handler });
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnabledLine(sessionOverride: boolean | null, settings: SkillReinjectSettings): string {
|
||||
if (sessionOverride === true) {
|
||||
return "skill-reinject: on (session)";
|
||||
}
|
||||
if (sessionOverride === false) {
|
||||
return "skill-reinject: off (session override)";
|
||||
}
|
||||
if (settings.enabled) {
|
||||
return "skill-reinject: on (global)";
|
||||
}
|
||||
return "skill-reinject: off (global)";
|
||||
}
|
||||
|
||||
function formatDeliveryLine(
|
||||
settings: SkillReinjectSettings,
|
||||
runtime: RuntimeFlags,
|
||||
sessionIntegrationOverride?: AutoCompactIntegration | null,
|
||||
): string {
|
||||
const mode = resolveDeliveryMode(settings, runtime, sessionIntegrationOverride);
|
||||
if (mode === "immediate") {
|
||||
return "delivery: immediate";
|
||||
}
|
||||
if (runtime.autoCompactDetected) {
|
||||
return "delivery: defer (pi-auto-compact detected)";
|
||||
}
|
||||
return "delivery: defer";
|
||||
}
|
||||
|
||||
function formatTrackedLine(state: ExtensionState): string {
|
||||
const count = state.skills.length;
|
||||
const suffix = count === 1 ? "" : "s";
|
||||
const names = state.skills.map((skill) => skill.name).join(", ");
|
||||
if (count === 0) {
|
||||
return "tracked: 0 skills";
|
||||
}
|
||||
return `tracked: ${count} skill${suffix} — ${names}`;
|
||||
}
|
||||
|
||||
function formatLastCompactionLine(state: ExtensionState): string {
|
||||
if (!state.lastCompactionSource) {
|
||||
return "last compaction: none";
|
||||
}
|
||||
return `last compaction: ${state.lastCompactionSource}`;
|
||||
}
|
||||
|
||||
/** Status text for bare `/skill-reinject` (SPEC §7.2). */
|
||||
export function formatSkillReinjectStatus(
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
runtime: RuntimeFlags,
|
||||
sessionIntegrationOverride?: AutoCompactIntegration | null,
|
||||
): string {
|
||||
return [
|
||||
formatEnabledLine(state.sessionOverride, settings),
|
||||
formatDeliveryLine(settings, runtime, sessionIntegrationOverride),
|
||||
formatTrackedLine(state),
|
||||
`pending reinject: ${state.pendingReinject.length}`,
|
||||
formatLastCompactionLine(state),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function showSkillReinjectStatus(ctx: ExtensionCommandContext, deps: SkillReinjectCommandDeps): void {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const settings = readSettings(ctx);
|
||||
ctx.ui.notify(formatSkillReinjectStatus(deps.state, settings, deps.runtime, deps.state.sessionIntegrationOverride), "info");
|
||||
updateSkillReinjectStatusLine(ctx, deps.state, settings);
|
||||
}
|
||||
|
||||
export async function handleSkillReinjectCommand(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
deps: SkillReinjectCommandDeps,
|
||||
): Promise<void> {
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) {
|
||||
showSkillReinjectStatus(ctx, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const subcommand = trimmed.split(/\s+/)[0];
|
||||
if (subcommand === "on" || subcommand === "off" || subcommand === "reset") {
|
||||
handleSessionToggle(subcommand, ctx, deps);
|
||||
return;
|
||||
}
|
||||
if (subcommand === "global") {
|
||||
handleGlobalToggle(trimmed, ctx, deps);
|
||||
return;
|
||||
}
|
||||
if (subcommand === "list") {
|
||||
showTrackedSkillsList(ctx, deps);
|
||||
return;
|
||||
}
|
||||
if (subcommand === "clear") {
|
||||
handleClearTrackedSkills(ctx, deps);
|
||||
return;
|
||||
}
|
||||
if (subcommand === "integration") {
|
||||
handleIntegrationOverride(trimmed, ctx, deps);
|
||||
return;
|
||||
}
|
||||
if (subcommand === "now") {
|
||||
handleReinjectNow(ctx, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(`skill-reinject: unknown subcommand "${subcommand}"`, "warning");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionToggle(
|
||||
subcommand: "on" | "off" | "reset",
|
||||
ctx: ExtensionCommandContext,
|
||||
deps: SkillReinjectCommandDeps,
|
||||
): void {
|
||||
switch (subcommand) {
|
||||
case "on":
|
||||
deps.state.sessionOverride = true;
|
||||
break;
|
||||
case "off":
|
||||
deps.state.sessionOverride = false;
|
||||
break;
|
||||
case "reset":
|
||||
deps.state.sessionOverride = null;
|
||||
break;
|
||||
}
|
||||
deps.persistState();
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const settings = readSettings(ctx);
|
||||
const enabledLine = formatEnabledLine(deps.state.sessionOverride, settings);
|
||||
ctx.ui.notify(enabledLine, "info");
|
||||
updateSkillReinjectStatusLine(ctx, deps.state, settings);
|
||||
}
|
||||
|
||||
function handleGlobalToggle(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
deps: SkillReinjectCommandDeps,
|
||||
): void {
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const action = parts[1];
|
||||
if (action !== "on" && action !== "off") {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify("skill-reinject: usage: /skill-reinject global on|off", "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeGlobalSettings({ enabled: action === "on" });
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(`skill-reinject: global ${action}`, "info");
|
||||
updateSkillReinjectStatusLine(ctx, deps.state, readSettings(ctx));
|
||||
}
|
||||
|
||||
function showTrackedSkillsList(ctx: ExtensionCommandContext, deps: SkillReinjectCommandDeps): void {
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
if (deps.state.skills.length === 0) {
|
||||
ctx.ui.notify("skill-reinject: no tracked skills", "info");
|
||||
return;
|
||||
}
|
||||
const lines = deps.state.skills.map(
|
||||
(skill) => `- ${skill.name} [${skill.sources.join(", ")}]`,
|
||||
);
|
||||
ctx.ui.notify(`tracked skills (${deps.state.skills.length}):\n${lines.join("\n")}`, "info");
|
||||
}
|
||||
|
||||
function handleClearTrackedSkills(ctx: ExtensionCommandContext, deps: SkillReinjectCommandDeps): void {
|
||||
deps.state.skills = [];
|
||||
deps.persistState();
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify("skill-reinject: cleared tracked skills", "info");
|
||||
updateSkillReinjectStatusLine(ctx, deps.state);
|
||||
}
|
||||
|
||||
const INTEGRATION_VALUES: readonly AutoCompactIntegration[] = ["auto", "defer", "immediate", "off"];
|
||||
|
||||
function isAutoCompactIntegration(value: string): value is AutoCompactIntegration {
|
||||
return (INTEGRATION_VALUES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function handleIntegrationOverride(
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
deps: SkillReinjectCommandDeps,
|
||||
): void {
|
||||
const mode = args.trim().split(/\s+/)[1];
|
||||
if (!mode || !isAutoCompactIntegration(mode)) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
"skill-reinject: usage: /skill-reinject integration auto|defer|immediate|off",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
deps.state.sessionIntegrationOverride = mode;
|
||||
deps.persistState();
|
||||
if (!ctx.hasUI) {
|
||||
return;
|
||||
}
|
||||
const settings = readSettings(ctx);
|
||||
ctx.ui.notify(formatDeliveryLine(settings, deps.runtime, mode), "info");
|
||||
}
|
||||
|
||||
function handleReinjectNow(ctx: ExtensionCommandContext, deps: SkillReinjectCommandDeps): void {
|
||||
const settings = readSettings(ctx);
|
||||
reinjectNow(deps.pi, deps.state, settings, ctx, deps.getRegisteredSkills());
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { effectiveEnabled, type SkillReinjectSettings } from "./settings.js";
|
||||
import type { CompactionSource, ExtensionState } from "./state.js";
|
||||
|
||||
/** Runtime compaction-source detection between input → before_compact → session_compact (SPEC §8). */
|
||||
export interface CompactionRuntime {
|
||||
pendingCompactionSource: CompactionSource | null;
|
||||
/** Manual compaction with default settings: clear stale pending on next user prompt (SPEC §16.5, §12.3). */
|
||||
clearPendingReinjectOnNextUserInput: boolean;
|
||||
/** Last compact firstKeptEntryId for debug kept-window snapshots (Phase 14). */
|
||||
lastCompactionFirstKeptEntryId: string | null;
|
||||
/** Last compaction entry id on session_compact (Phase 15 / B-003 dedup). */
|
||||
lastCompactionEntryId: string | null;
|
||||
/** Compaction id when mid-turn steer already delivered reinject (Phase 15 / §6.5.1). */
|
||||
deferredDeliveredForCompactionId: string | null;
|
||||
}
|
||||
|
||||
export function createCompactionRuntime(): CompactionRuntime {
|
||||
return {
|
||||
pendingCompactionSource: null,
|
||||
clearPendingReinjectOnNextUserInput: false,
|
||||
lastCompactionFirstKeptEntryId: null,
|
||||
lastCompactionEntryId: null,
|
||||
deferredDeliveredForCompactionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Input hook before expansion: user `/compact` marks manual source (SPEC §8). */
|
||||
export function markManualCompactionFromInput(text: string, runtime: CompactionRuntime): void {
|
||||
if (text.startsWith("/compact")) {
|
||||
runtime.pendingCompactionSource = "manual";
|
||||
}
|
||||
}
|
||||
|
||||
/** If not explicitly manual, mark compaction source as auto (SPEC §8). */
|
||||
export function ensureCompactionSourceMarked(runtime: CompactionRuntime): void {
|
||||
if (runtime.pendingCompactionSource !== "manual") {
|
||||
runtime.pendingCompactionSource = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
/** session_before_compact: default to auto unless input already marked manual (SPEC §8). */
|
||||
export function markAutoCompactionBeforeCompact(runtime: CompactionRuntime): void {
|
||||
ensureCompactionSourceMarked(runtime);
|
||||
}
|
||||
|
||||
/** Gate re-inject on session_compact from enabled layer and compaction source (SPEC §8). */
|
||||
export function shouldReinjectAfterCompaction(
|
||||
sessionOverride: boolean | null,
|
||||
settings: SkillReinjectSettings,
|
||||
runtime: CompactionRuntime,
|
||||
): boolean {
|
||||
if (!effectiveEnabled(sessionOverride, settings)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
runtime.pendingCompactionSource === "auto" ||
|
||||
settings.reinjectOnManualCompaction
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* session_compact: evaluate gate, persist lastCompactionSource, clear pending (SPEC §8).
|
||||
* Call before enqueueing deferred/immediate re-inject.
|
||||
*/
|
||||
export function consumeCompactionOnSessionCompact(
|
||||
runtime: CompactionRuntime,
|
||||
state: ExtensionState,
|
||||
sessionOverride: boolean | null,
|
||||
settings: SkillReinjectSettings,
|
||||
): boolean {
|
||||
const source = runtime.pendingCompactionSource;
|
||||
const shouldReinject = shouldReinjectAfterCompaction(sessionOverride, settings, runtime);
|
||||
state.lastCompactionSource = source;
|
||||
if (source === "manual" && !settings.reinjectOnManualCompaction) {
|
||||
runtime.clearPendingReinjectOnNextUserInput = true;
|
||||
} else if (source === "auto" && shouldReinject) {
|
||||
runtime.clearPendingReinjectOnNextUserInput = false;
|
||||
}
|
||||
runtime.pendingCompactionSource = null;
|
||||
return shouldReinject;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { isAbsolute, normalize, resolve } from "node:path";
|
||||
import type { Skill } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** Raw `/skill:name` at start of user input (SPEC §6.2 #1). */
|
||||
const SLASH_SKILL_RE = /^\/skill:([a-z0-9-]+)/;
|
||||
|
||||
/** mirror agent-session `parseSkillBlock` — global scan (SPEC §6.2 #2). */
|
||||
const SKILL_BLOCK_RE =
|
||||
/<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>/g;
|
||||
|
||||
export interface ParsedSkillBlock {
|
||||
name: string;
|
||||
location: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Minimal skill fields for read-path matching (SPEC §6.2 #3). */
|
||||
export type SkillPathMeta = Pick<Skill, "name" | "filePath" | "baseDir">;
|
||||
|
||||
function normalizePathForCompare(filePath: string): string {
|
||||
return normalize(filePath);
|
||||
}
|
||||
|
||||
/** Text from user message content (string or text blocks). */
|
||||
export function userMessageText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = part as { type?: string; text?: string };
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Returns skill name when text is a slash skill command, else null. */
|
||||
export function detectSlashSkill(text: string): string | null {
|
||||
const match = SLASH_SKILL_RE.exec(text);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** All skill blocks in message text (empty when none). */
|
||||
export function parseSkillBlocksFromText(text: string): ParsedSkillBlock[] {
|
||||
const blocks: ParsedSkillBlock[] = [];
|
||||
for (const match of text.matchAll(SKILL_BLOCK_RE)) {
|
||||
blocks.push({
|
||||
name: match[1],
|
||||
location: match[2],
|
||||
content: match[3],
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Match read tool path to a registered skill SKILL.md (SPEC §6.2 #3). */
|
||||
export function matchReadPathToSkill(
|
||||
path: string,
|
||||
skills: readonly SkillPathMeta[],
|
||||
): SkillPathMeta | null {
|
||||
const normalizedPath = normalizePathForCompare(path);
|
||||
for (const skill of skills) {
|
||||
const skillPath = normalizePathForCompare(skill.filePath);
|
||||
if (normalizedPath === skillPath) {
|
||||
return skill;
|
||||
}
|
||||
if (!isAbsolute(path)) {
|
||||
const resolvedFromBase = normalizePathForCompare(resolve(skill.baseDir, path));
|
||||
if (resolvedFromBase === skillPath) {
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read-path detection only when trackReadPaths is enabled (SPEC §6.2, §3). */
|
||||
export function matchReadPathToSkillWhenEnabled(
|
||||
path: string,
|
||||
skills: readonly SkillPathMeta[],
|
||||
trackReadPaths: boolean,
|
||||
): SkillPathMeta | null {
|
||||
if (!trackReadPaths) {
|
||||
return null;
|
||||
}
|
||||
return matchReadPathToSkill(path, skills);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import type { ExtensionContext, Skill } from "@earendil-works/pi-coding-agent";
|
||||
import type { SkillReinjectSettings } from "./settings.js";
|
||||
import type { CompactionSource, ExtensionState } from "./state.js";
|
||||
|
||||
export type ReinjectDiagPhase = "session_compact" | "before_agent_start" | "mid_turn_deliver";
|
||||
|
||||
/** Optional compaction/delivery context for debug snapshots (Phase 15 / B-003). */
|
||||
export interface ReinjectDiagContext {
|
||||
compactionSource?: CompactionSource | null;
|
||||
sourceInferred?: boolean;
|
||||
deliveryBranch?: "before_agent_start" | "steer" | "none";
|
||||
isIdle?: boolean;
|
||||
}
|
||||
|
||||
/** Filter snapshot for debug logging (Phase 14 / B-002, Phase 15 / B-003). */
|
||||
export interface ReinjectDiagSnapshot {
|
||||
tracked: string[];
|
||||
kept: string[];
|
||||
registered: string[];
|
||||
planned: string[];
|
||||
pending: string[];
|
||||
compactionSource?: CompactionSource | null;
|
||||
sourceInferred?: boolean;
|
||||
deliveryBranch?: "before_agent_start" | "steer" | "none";
|
||||
isIdle?: boolean;
|
||||
}
|
||||
|
||||
export function buildReinjectDiagSnapshot(
|
||||
state: ExtensionState,
|
||||
registeredSkills: readonly Pick<Skill, "name">[],
|
||||
keptPresent: ReadonlySet<string>,
|
||||
planned: readonly string[],
|
||||
context?: ReinjectDiagContext,
|
||||
): ReinjectDiagSnapshot {
|
||||
return {
|
||||
tracked: state.skills.map((skill) => skill.name),
|
||||
kept: [...keptPresent],
|
||||
registered: registeredSkills.map((skill) => skill.name),
|
||||
planned: [...planned],
|
||||
pending: [...state.pendingReinject],
|
||||
...context,
|
||||
};
|
||||
}
|
||||
|
||||
/** Log reinject filter state when settings.debug is on (Phase 14). */
|
||||
export function notifyReinjectDiag(
|
||||
ctx: ExtensionContext | undefined,
|
||||
settings: SkillReinjectSettings,
|
||||
phase: ReinjectDiagPhase,
|
||||
snapshot: ReinjectDiagSnapshot,
|
||||
): void {
|
||||
if (!settings.debug) {
|
||||
return;
|
||||
}
|
||||
const message = `skill-reinject [${phase}]: ${JSON.stringify(snapshot)}`;
|
||||
console.error(message);
|
||||
if (ctx?.hasUI) {
|
||||
ctx.ui.notify(message, "info");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stripFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** Skill fields needed to build the injected block (SPEC §5.3). */
|
||||
export type SkillBlockMeta = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
};
|
||||
|
||||
/** mirror agent-session `_expandSkillCommand` — SKILL.md body without YAML frontmatter (SPEC §5.3, §10). */
|
||||
export function readSkillBody(filePath: string): string {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return stripFrontmatter(content).trim();
|
||||
}
|
||||
|
||||
/** mirror agent-session `_expandSkillCommand` — XML skill block with baseDir hint (SPEC §5.3). */
|
||||
export function formatBlock(meta: SkillBlockMeta, body: string): string {
|
||||
return `<skill name="${meta.name}" location="${meta.filePath}">\nReferences are relative to ${meta.baseDir}.\n\n${body}\n</skill>`;
|
||||
}
|
||||
|
||||
/** Append optional reinject suffix after skill block (SPEC §5.3). */
|
||||
export function appendSuffix(block: string, suffix: string | undefined): string {
|
||||
const trimmed = suffix?.trim();
|
||||
if (!trimmed) {
|
||||
return block;
|
||||
}
|
||||
return `${block}\n\n${trimmed}`;
|
||||
}
|
||||
|
||||
/** Read SKILL.md and format full user message text for re-inject (SPEC §5.3). */
|
||||
export function expandSkill(meta: SkillBlockMeta, suffix?: string): string {
|
||||
const body = readSkillBody(meta.filePath);
|
||||
const block = formatBlock(meta, body);
|
||||
return appendSuffix(block, suffix);
|
||||
}
|
||||
+312
-3
@@ -1,7 +1,316 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
isToolCallEventType,
|
||||
type ExtensionAPI,
|
||||
type ExtensionContext,
|
||||
type SessionCompactEvent,
|
||||
type Skill,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { detectAndCachePiAutoCompact, resolveDeliveryMode } from "./auto-compact.js";
|
||||
import { registerSkillReinjectCommand, updateSkillReinjectStatusLine } from "./commands.js";
|
||||
import {
|
||||
consumeCompactionOnSessionCompact,
|
||||
createCompactionRuntime,
|
||||
ensureCompactionSourceMarked,
|
||||
markAutoCompactionBeforeCompact,
|
||||
markManualCompactionFromInput,
|
||||
} from "./compaction.js";
|
||||
import { buildReinjectDiagSnapshot, notifyReinjectDiag } from "./diag.js";
|
||||
import { detectSlashSkill, matchReadPathToSkillWhenEnabled, parseSkillBlocksFromText, userMessageText } from "./detect.js";
|
||||
import {
|
||||
getKeptEntries,
|
||||
skillsPresentInKeptWindow,
|
||||
} from "./kept.js";
|
||||
import {
|
||||
applyPendingReinjectAfterCompact,
|
||||
clearPendingReinjectOnUserPrompt,
|
||||
deliverDeferredReinjectSteer,
|
||||
planDeferredReinject,
|
||||
planReinject,
|
||||
sendImmediateReinjectAllFollowUp,
|
||||
sendImmediateReinjectIdle,
|
||||
tryConsumeDeferredReinject,
|
||||
} from "./reinject.js";
|
||||
import { readSettings } from "./settings.js";
|
||||
import { rescanSkillsFromBranch } from "./rescan.js";
|
||||
import { findRegisteredSkillByName, resolveRegisteredSkills } from "./skills-registry.js";
|
||||
import {
|
||||
applyExtensionState,
|
||||
createInitialState,
|
||||
createRuntimeFlags,
|
||||
loadStateFromBranch,
|
||||
saveState,
|
||||
trackSkill,
|
||||
type TrackSkillInput,
|
||||
} from "./state.js";
|
||||
|
||||
export default function skillReinject(pi: ExtensionAPI): void {
|
||||
pi.on("session_start", () => {
|
||||
// Phase 0 shell — hooks wired in later phases
|
||||
const state = createInitialState();
|
||||
const runtime = createRuntimeFlags();
|
||||
const compactionRuntime = createCompactionRuntime();
|
||||
let registeredSkills: Skill[] = [];
|
||||
|
||||
function persistState(): void {
|
||||
saveState(pi, state);
|
||||
}
|
||||
|
||||
registerSkillReinjectCommand(pi, {
|
||||
pi,
|
||||
state,
|
||||
runtime,
|
||||
getRegisteredSkills: () => registeredSkills,
|
||||
persistState,
|
||||
});
|
||||
|
||||
function trackSkillAndPersist(input: TrackSkillInput, ctx?: ExtensionContext): void {
|
||||
trackSkill(state, input);
|
||||
persistState();
|
||||
if (ctx) {
|
||||
updateSkillReinjectStatusLine(ctx, state, readSettings(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
function trackReadSkillPath(path: string, ctx: ExtensionContext): void {
|
||||
const settings = readSettings(ctx);
|
||||
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
|
||||
const matched = matchReadPathToSkillWhenEnabled(path, skills, settings.trackReadPaths);
|
||||
if (!matched) {
|
||||
return;
|
||||
}
|
||||
trackSkillAndPersist({
|
||||
name: matched.name,
|
||||
filePath: matched.filePath,
|
||||
baseDir: matched.baseDir,
|
||||
source: "read",
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
function restoreSessionState(ctx: ExtensionContext): void {
|
||||
detectAndCachePiAutoCompact(pi, runtime);
|
||||
const settings = readSettings(ctx);
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const loaded = loadStateFromBranch(branch);
|
||||
applyExtensionState(state, loaded ?? createInitialState());
|
||||
if (!loaded) {
|
||||
rescanSkillsFromBranch(state, branch, ctx.cwd, registeredSkills, settings.trackReadPaths);
|
||||
}
|
||||
updateSkillReinjectStatusLine(ctx, state, settings);
|
||||
}
|
||||
|
||||
function handleSessionCompact(event: SessionCompactEvent, ctx: ExtensionContext): void {
|
||||
const settings = readSettings(ctx);
|
||||
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
compactionRuntime.lastCompactionFirstKeptEntryId = event.compactionEntry.firstKeptEntryId;
|
||||
compactionRuntime.lastCompactionEntryId = event.compactionEntry.id;
|
||||
compactionRuntime.deferredDeliveredForCompactionId = null;
|
||||
const trackedNames = state.skills.map((skill) => skill.name);
|
||||
const keptEntries = getKeptEntries(branch, event.compactionEntry.firstKeptEntryId);
|
||||
const keptPresent = skillsPresentInKeptWindow(keptEntries, trackedNames);
|
||||
const sourceBeforeMark = compactionRuntime.pendingCompactionSource;
|
||||
ensureCompactionSourceMarked(compactionRuntime);
|
||||
const sourceInferred = sourceBeforeMark === null;
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(
|
||||
compactionRuntime,
|
||||
state,
|
||||
state.sessionOverride,
|
||||
settings,
|
||||
);
|
||||
const deliveryMode = resolveDeliveryMode(settings, runtime, state.sessionIntegrationOverride);
|
||||
const planned =
|
||||
deliveryMode === "defer"
|
||||
? planDeferredReinject(state, ctx, event)
|
||||
: planReinject(state, settings, ctx, event, skills);
|
||||
|
||||
applyPendingReinjectAfterCompact(state, compactionRuntime, shouldReinject, planned);
|
||||
const isIdle = ctx.isIdle();
|
||||
const deliveryBranch: "before_agent_start" | "steer" | "none" =
|
||||
!shouldReinject || planned.length === 0
|
||||
? "none"
|
||||
: deliveryMode === "defer" && !isIdle
|
||||
? "steer"
|
||||
: deliveryMode === "defer"
|
||||
? "before_agent_start"
|
||||
: "none";
|
||||
notifyReinjectDiag(
|
||||
ctx,
|
||||
settings,
|
||||
"session_compact",
|
||||
buildReinjectDiagSnapshot(state, skills, keptPresent, planned, {
|
||||
compactionSource: state.lastCompactionSource,
|
||||
sourceInferred,
|
||||
deliveryBranch,
|
||||
isIdle,
|
||||
}),
|
||||
);
|
||||
|
||||
if (deliveryMode === "defer") {
|
||||
if (shouldReinject && planned.length > 0 && !isIdle) {
|
||||
const steered = deliverDeferredReinjectSteer(
|
||||
pi,
|
||||
state,
|
||||
settings,
|
||||
skills,
|
||||
compactionRuntime,
|
||||
event.compactionEntry.id,
|
||||
ctx,
|
||||
);
|
||||
if (steered) {
|
||||
notifyReinjectDiag(
|
||||
ctx,
|
||||
settings,
|
||||
"mid_turn_deliver",
|
||||
buildReinjectDiagSnapshot(state, skills, keptPresent, planned, {
|
||||
compactionSource: state.lastCompactionSource,
|
||||
sourceInferred,
|
||||
deliveryBranch: "steer",
|
||||
isIdle,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
persistState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldReinject) {
|
||||
persistState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (planned.length === 0) {
|
||||
persistState();
|
||||
return;
|
||||
}
|
||||
if (ctx.isIdle()) {
|
||||
sendImmediateReinjectIdle(pi, planned, state, settings, skills, ctx);
|
||||
} else {
|
||||
sendImmediateReinjectAllFollowUp(pi, planned, state, settings, skills, ctx);
|
||||
}
|
||||
persistState();
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
restoreSessionState(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_tree", async (_event, ctx) => {
|
||||
restoreSessionState(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
persistState();
|
||||
});
|
||||
|
||||
pi.on("session_before_compact", async () => {
|
||||
markAutoCompactionBeforeCompact(compactionRuntime);
|
||||
});
|
||||
|
||||
pi.on("session_compact", async (event, ctx) => {
|
||||
handleSessionCompact(event, ctx);
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
registeredSkills = event.systemPromptOptions.skills ?? registeredSkills;
|
||||
|
||||
const settings = readSettings(ctx);
|
||||
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
|
||||
const trackedNames = state.skills.map((skill) => skill.name);
|
||||
const keptPresent =
|
||||
compactionRuntime.lastCompactionFirstKeptEntryId === null
|
||||
? new Set<string>()
|
||||
: skillsPresentInKeptWindow(
|
||||
getKeptEntries(
|
||||
ctx.sessionManager.getBranch(),
|
||||
compactionRuntime.lastCompactionFirstKeptEntryId,
|
||||
),
|
||||
trackedNames,
|
||||
);
|
||||
notifyReinjectDiag(
|
||||
ctx,
|
||||
settings,
|
||||
"before_agent_start",
|
||||
buildReinjectDiagSnapshot(state, skills, keptPresent, [], {
|
||||
deliveryBranch:
|
||||
state.pendingReinject.length > 0 ? "before_agent_start" : "none",
|
||||
isIdle: ctx.isIdle(),
|
||||
}),
|
||||
);
|
||||
const pendingBefore = state.pendingReinject.length;
|
||||
const deferred = tryConsumeDeferredReinject(
|
||||
state,
|
||||
settings,
|
||||
registeredSkills,
|
||||
ctx,
|
||||
compactionRuntime,
|
||||
);
|
||||
if (deferred) {
|
||||
compactionRuntime.lastCompactionFirstKeptEntryId = null;
|
||||
}
|
||||
if (pendingBefore > 0) {
|
||||
persistState();
|
||||
}
|
||||
if (deferred) {
|
||||
return deferred;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (event.source === "extension") {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
markManualCompactionFromInput(event.text, compactionRuntime);
|
||||
|
||||
if (clearPendingReinjectOnUserPrompt(state, compactionRuntime)) {
|
||||
persistState();
|
||||
}
|
||||
|
||||
const skillName = detectSlashSkill(event.text);
|
||||
if (skillName) {
|
||||
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
|
||||
const skill = findRegisteredSkillByName(skills, skillName);
|
||||
if (skill) {
|
||||
trackSkillAndPersist({
|
||||
name: skill.name,
|
||||
filePath: skill.filePath,
|
||||
baseDir: skill.baseDir,
|
||||
source: "slash",
|
||||
}, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "continue" };
|
||||
});
|
||||
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
if (event.message.role !== "user") {
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = parseSkillBlocksFromText(userMessageText(event.message.content));
|
||||
if (blocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skills = resolveRegisteredSkills(ctx.cwd, registeredSkills);
|
||||
for (const block of blocks) {
|
||||
const registered = findRegisteredSkillByName(skills, block.name);
|
||||
trackSkill(state, {
|
||||
name: block.name,
|
||||
filePath: registered?.filePath ?? block.location,
|
||||
baseDir: registered?.baseDir ?? dirname(block.location),
|
||||
source: "skill-block",
|
||||
});
|
||||
}
|
||||
persistState();
|
||||
updateSkillReinjectStatusLine(ctx, state, readSettings(ctx));
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (!isToolCallEventType("read", event)) {
|
||||
return;
|
||||
}
|
||||
trackReadSkillPath(event.input.path, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
||||
import { parseSkillBlocksFromText } from "./detect.js";
|
||||
import type { TrackedSkill } from "./state.js";
|
||||
|
||||
/** custom_message type for deferred re-inject delivery (SPEC §6.5.1). */
|
||||
const SKILL_REINJECT_INJECT_CUSTOM_TYPE = "skill-reinject:inject";
|
||||
|
||||
/** Branch slice from firstKeptEntryId through tail (SPEC §6.4). */
|
||||
export function getKeptEntries(branch: SessionEntry[], firstKeptEntryId: string): SessionEntry[] {
|
||||
const startIndex = branch.findIndex((entry) => entry.id === firstKeptEntryId);
|
||||
if (startIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
return branch.slice(startIndex);
|
||||
}
|
||||
|
||||
function extractUserMessageText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = part as { type?: string; text?: string };
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
parts.push(block.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** Skill names already present as blocks in kept user messages and reinject custom messages (SPEC §6.4). */
|
||||
export function skillsPresentInKeptWindow(
|
||||
keptEntries: SessionEntry[],
|
||||
skillNames: readonly string[],
|
||||
): Set<string> {
|
||||
const present = new Set<string>();
|
||||
const namesToCheck = new Set(skillNames);
|
||||
if (namesToCheck.size === 0) {
|
||||
return present;
|
||||
}
|
||||
|
||||
for (const entry of keptEntries) {
|
||||
let text = "";
|
||||
if (entry.type === "message" && entry.message.role === "user") {
|
||||
text = extractUserMessageText(entry.message.content);
|
||||
} else if (
|
||||
entry.type === "custom_message" &&
|
||||
entry.customType === SKILL_REINJECT_INJECT_CUSTOM_TYPE &&
|
||||
typeof entry.content === "string"
|
||||
) {
|
||||
text = entry.content;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
for (const block of parseSkillBlocksFromText(text)) {
|
||||
if (namesToCheck.has(block.name)) {
|
||||
present.add(block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
/** Tracked skills absent from kept window — defer planning stage without registry filter (Phase 14 / B-002). */
|
||||
export function filterSkillsNeedingReinjectByKept(
|
||||
tracked: readonly TrackedSkill[],
|
||||
keptPresent: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const needing: string[] = [];
|
||||
for (const skill of tracked) {
|
||||
if (!keptPresent.has(skill.name)) {
|
||||
needing.push(skill.name);
|
||||
}
|
||||
}
|
||||
return needing;
|
||||
}
|
||||
|
||||
/** Tracked skills missing from kept window but still registered (SPEC §5.2, §6.4). */
|
||||
export function filterSkillsNeedingReinject(
|
||||
tracked: readonly TrackedSkill[],
|
||||
keptPresent: ReadonlySet<string>,
|
||||
registeredNames: ReadonlySet<string>,
|
||||
): string[] {
|
||||
return filterSkillsNeedingReinjectByKept(tracked, keptPresent).filter((name) =>
|
||||
registeredNames.has(name),
|
||||
);
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import type {
|
||||
BeforeAgentStartEventResult,
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
SessionCompactEvent,
|
||||
Skill,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync } from "node:fs";
|
||||
import { expandSkill } from "./expand.js";
|
||||
import {
|
||||
filterSkillsNeedingReinject,
|
||||
filterSkillsNeedingReinjectByKept,
|
||||
getKeptEntries,
|
||||
skillsPresentInKeptWindow,
|
||||
} from "./kept.js";
|
||||
import type { SkillReinjectSettings } from "./settings.js";
|
||||
import type { CompactionRuntime } from "./compaction.js";
|
||||
import type { ExtensionState } from "./state.js";
|
||||
|
||||
export const DEFERRED_REINJECT_CUSTOM_TYPE = "skill-reinject:inject";
|
||||
|
||||
function notifyWarning(ctx: ExtensionContext | undefined, message: string): void {
|
||||
if (!ctx?.hasUI) {
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(message, "warning");
|
||||
}
|
||||
|
||||
function notifyInfo(ctx: ExtensionContext | undefined, message: string): void {
|
||||
if (!ctx?.hasUI) {
|
||||
return;
|
||||
}
|
||||
ctx.ui.notify(message, "info");
|
||||
}
|
||||
|
||||
function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void {
|
||||
notifyWarning(ctx, `skill-reinject: skipped "${skillName}" — ${reason}`);
|
||||
}
|
||||
|
||||
/** First registered skill per name; warn on resourceLoader collisions (SPEC §11). */
|
||||
export function registeredSkillsByName<T extends Pick<Skill, "name">>(
|
||||
registeredSkills: readonly T[],
|
||||
ctx?: ExtensionContext,
|
||||
): Map<string, T> {
|
||||
const byName = new Map<string, T>();
|
||||
const warned = new Set<string>();
|
||||
for (const skill of registeredSkills) {
|
||||
if (byName.has(skill.name)) {
|
||||
if (!warned.has(skill.name)) {
|
||||
warned.add(skill.name);
|
||||
notifyWarning(
|
||||
ctx,
|
||||
`skill-reinject: duplicate skill name "${skill.name}" — using first from resourceLoader`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byName.set(skill.name, skill);
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
/** Names still registered in resourceLoader (SPEC §5.2). */
|
||||
export function registeredSkillNames(skills: readonly Pick<Skill, "name">[]): ReadonlySet<string> {
|
||||
return new Set(skills.map((skill) => skill.name));
|
||||
}
|
||||
|
||||
/** Kept-window skill names present after compaction (shared by plan helpers). */
|
||||
function keptPresentAfterCompaction(
|
||||
state: ExtensionState,
|
||||
ctx: ExtensionContext,
|
||||
compactionEvent: SessionCompactEvent,
|
||||
): Set<string> {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const keptEntries = getKeptEntries(branch, compactionEvent.compactionEntry.firstKeptEntryId);
|
||||
const trackedNames = state.skills.map((skill) => skill.name);
|
||||
return skillsPresentInKeptWindow(keptEntries, trackedNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer planning at compaction: tracked skills absent from kept window only (SPEC §6.5.1).
|
||||
* Registry filter runs later on before_agent_start (Phase 14 / B-002).
|
||||
*/
|
||||
export function planDeferredReinject(
|
||||
state: ExtensionState,
|
||||
ctx: ExtensionContext,
|
||||
compactionEvent: SessionCompactEvent,
|
||||
): string[] {
|
||||
const keptPresent = keptPresentAfterCompaction(state, ctx, compactionEvent);
|
||||
return filterSkillsNeedingReinjectByKept(state.skills, keptPresent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill names to re-inject after compaction: tracked, absent from kept window, still registered (SPEC §5.2).
|
||||
* `registeredSkills` comes from resourceLoader — ExtensionContext has no getSkills(); wired in index.ts.
|
||||
*/
|
||||
export function planReinject(
|
||||
state: ExtensionState,
|
||||
_settings: SkillReinjectSettings,
|
||||
ctx: ExtensionContext,
|
||||
compactionEvent: SessionCompactEvent,
|
||||
registeredSkills: readonly Pick<Skill, "name">[],
|
||||
): string[] {
|
||||
const keptPresent = keptPresentAfterCompaction(state, ctx, compactionEvent);
|
||||
return filterSkillsNeedingReinject(
|
||||
state.skills,
|
||||
keptPresent,
|
||||
registeredSkillNames(registeredSkills),
|
||||
);
|
||||
}
|
||||
|
||||
/** Defer path on session_compact: queue planned skills without sendUserMessage (SPEC §6.5.1, §16.2). */
|
||||
export function enqueueDeferredReinjectFromCompact(
|
||||
state: ExtensionState,
|
||||
_settings: SkillReinjectSettings,
|
||||
ctx: ExtensionContext,
|
||||
compactionEvent: SessionCompactEvent,
|
||||
_registeredSkills: readonly Pick<Skill, "name">[],
|
||||
): void {
|
||||
state.pendingReinject = planDeferredReinject(state, ctx, compactionEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pending queue after session_compact; each compact recalculates or clears (SPEC §16.6).
|
||||
* Manual compaction with default settings keeps stale pending until the next user prompt.
|
||||
*/
|
||||
export function applyPendingReinjectAfterCompact(
|
||||
state: ExtensionState,
|
||||
compactionRuntime: CompactionRuntime,
|
||||
shouldReinject: boolean,
|
||||
planned: readonly string[],
|
||||
): void {
|
||||
if (shouldReinject) {
|
||||
state.pendingReinject = [...planned];
|
||||
return;
|
||||
}
|
||||
if (!compactionRuntime.clearPendingReinjectOnNextUserInput) {
|
||||
state.pendingReinject = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Warn when re-injecting many skills; unlimited by default with soft warn above 3 (SPEC §15). */
|
||||
export function maybeWarnManySkills(
|
||||
skillCount: number,
|
||||
settings: SkillReinjectSettings,
|
||||
ctx?: ExtensionContext,
|
||||
): void {
|
||||
if (skillCount <= 0) {
|
||||
return;
|
||||
}
|
||||
const threshold = settings.maxSkills ?? 3;
|
||||
if (skillCount <= threshold) {
|
||||
return;
|
||||
}
|
||||
if (settings.maxSkills !== undefined) {
|
||||
notifyWarning(
|
||||
ctx,
|
||||
`skill-reinject: re-injecting ${skillCount} skills (maxSkills warn threshold: ${settings.maxSkills})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifyWarning(ctx, `skill-reinject: re-injecting ${skillCount} tracked skills (soft warn above 3)`);
|
||||
}
|
||||
|
||||
/** Expanded skill-block messages in queue order (SPEC §5.3). */
|
||||
export function buildReinjectBlocks(
|
||||
skillNames: readonly string[],
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
ctx?: ExtensionContext,
|
||||
): string[] {
|
||||
maybeWarnManySkills(skillNames.length, settings, ctx);
|
||||
const registeredByName = registeredSkillsByName(registeredSkills, ctx);
|
||||
const blocks: string[] = [];
|
||||
for (const name of skillNames) {
|
||||
const tracked = state.skills.find((skill) => skill.name === name);
|
||||
if (!tracked) {
|
||||
continue;
|
||||
}
|
||||
const registered = registeredByName.get(name);
|
||||
const filePath = registered?.filePath ?? tracked.filePath;
|
||||
const baseDir = registered?.baseDir ?? tracked.baseDir;
|
||||
if (!registered) {
|
||||
if (settings.requireRegistered) {
|
||||
notifySkippedSkill(ctx, name, "no longer registered");
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(tracked.filePath)) {
|
||||
notifySkippedSkill(ctx, name, "SKILL.md not found on disk");
|
||||
continue;
|
||||
}
|
||||
} else if (!existsSync(filePath)) {
|
||||
notifySkippedSkill(ctx, name, "SKILL.md not found on disk");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
blocks.push(
|
||||
expandSkill(
|
||||
{
|
||||
name: tracked.name,
|
||||
filePath,
|
||||
baseDir,
|
||||
},
|
||||
settings.suffix,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
notifySkippedSkill(ctx, name, "SKILL.md not readable");
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Combined skill-block user text for pending names in queue order (SPEC §5.3). */
|
||||
export function buildDeferredReinjectContent(
|
||||
pendingNames: readonly string[],
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
ctx?: ExtensionContext,
|
||||
): string {
|
||||
return buildReinjectBlocks(pendingNames, state, settings, registeredSkills, ctx).join("\n\n");
|
||||
}
|
||||
|
||||
/** Immediate path when agent is idle: first block triggers turn, rest queue as followUp (SPEC §6.5.2). */
|
||||
export function sendImmediateReinjectIdle(
|
||||
pi: ExtensionAPI,
|
||||
skillNames: readonly string[],
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
ctx?: ExtensionContext,
|
||||
): void {
|
||||
const blocks = buildReinjectBlocks(skillNames, state, settings, registeredSkills, ctx);
|
||||
blocks.forEach((block, index) => {
|
||||
pi.sendUserMessage(block, index === 0 ? undefined : { deliverAs: "followUp" });
|
||||
});
|
||||
}
|
||||
|
||||
/** Immediate path when streaming or overflow willRetry: all blocks as followUp (SPEC §5.2, §6.5.2). */
|
||||
export function sendImmediateReinjectAllFollowUp(
|
||||
pi: ExtensionAPI,
|
||||
skillNames: readonly string[],
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
ctx?: ExtensionContext,
|
||||
): void {
|
||||
for (const block of buildReinjectBlocks(skillNames, state, settings, registeredSkills, ctx)) {
|
||||
pi.sendUserMessage(block, { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Names eligible for re-inject: registered, or on disk when requireRegistered is false (Phase 14 / B-002). */
|
||||
export function resolveReinjectSkillNames(
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name">[],
|
||||
ctx?: ExtensionContext,
|
||||
): string[] {
|
||||
const registered = registeredSkillNames(registeredSkills);
|
||||
const names: string[] = [];
|
||||
for (const tracked of state.skills) {
|
||||
if (registered.has(tracked.name)) {
|
||||
names.push(tracked.name);
|
||||
continue;
|
||||
}
|
||||
if (settings.requireRegistered) {
|
||||
continue;
|
||||
}
|
||||
if (existsSync(tracked.filePath)) {
|
||||
notifyInfo(ctx, `skill-reinject: re-injected "${tracked.name}" from disk`);
|
||||
names.push(tracked.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Force re-inject all tracked registered skills for /skill-reinject now (SPEC §7.1). */
|
||||
export function reinjectNow(
|
||||
pi: ExtensionAPI,
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
ctx: ExtensionContext,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
): void {
|
||||
const skillNames = resolveReinjectSkillNames(state, settings, registeredSkills, ctx);
|
||||
if (skillNames.length === 0) {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify("skill-reinject: no tracked skills to re-inject", "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ctx.isIdle()) {
|
||||
sendImmediateReinjectIdle(pi, skillNames, state, settings, registeredSkills, ctx);
|
||||
return;
|
||||
}
|
||||
sendImmediateReinjectAllFollowUp(pi, skillNames, state, settings, registeredSkills, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual `/compact` with default settings: drop stale pending on the next user prompt (SPEC §16.5, §12.3).
|
||||
* Returns true when pending was cleared.
|
||||
*/
|
||||
export function clearPendingReinjectOnUserPrompt(
|
||||
state: ExtensionState,
|
||||
compactionRuntime: CompactionRuntime,
|
||||
): boolean {
|
||||
if (!compactionRuntime.clearPendingReinjectOnNextUserInput) {
|
||||
return false;
|
||||
}
|
||||
compactionRuntime.clearPendingReinjectOnNextUserInput = false;
|
||||
const hadPending = state.pendingReinject.length > 0;
|
||||
state.pendingReinject = [];
|
||||
return hadPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer consume stage: fresh registry filter on before_agent_start (SPEC §6.5.1, Phase 14 / B-002).
|
||||
* Loose fallback when requireRegistered is false and tracked SKILL.md still exists on disk.
|
||||
*/
|
||||
export function filterPendingReinjectForConsume(
|
||||
pendingNames: readonly string[],
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name">[],
|
||||
ctx?: ExtensionContext,
|
||||
): string[] {
|
||||
const registered = registeredSkillNames(registeredSkills);
|
||||
const resolved: string[] = [];
|
||||
for (const name of pendingNames) {
|
||||
const tracked = state.skills.find((skill) => skill.name === name);
|
||||
if (!tracked) {
|
||||
continue;
|
||||
}
|
||||
if (registered.has(name)) {
|
||||
resolved.push(name);
|
||||
continue;
|
||||
}
|
||||
if (settings.requireRegistered) {
|
||||
notifySkippedSkill(ctx, name, "no longer registered");
|
||||
continue;
|
||||
}
|
||||
if (existsSync(tracked.filePath)) {
|
||||
notifyInfo(ctx, `skill-reinject: re-injected "${name}" from disk`);
|
||||
resolved.push(name);
|
||||
continue;
|
||||
}
|
||||
notifySkippedSkill(ctx, name, "SKILL.md not found on disk");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-turn defer delivery via sendMessage/steer (SPEC §6.5.1, Phase 15 / B-003).
|
||||
* Clears pendingReinject and records compaction id so before_agent_start does not duplicate.
|
||||
*/
|
||||
export function deliverDeferredReinjectSteer(
|
||||
pi: ExtensionAPI,
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
compactionRuntime: CompactionRuntime,
|
||||
compactionEntryId: string,
|
||||
ctx?: ExtensionContext,
|
||||
): boolean {
|
||||
if (state.pendingReinject.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const pendingNames = filterPendingReinjectForConsume(
|
||||
state.pendingReinject,
|
||||
state,
|
||||
settings,
|
||||
registeredSkills,
|
||||
ctx,
|
||||
);
|
||||
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
|
||||
state.pendingReinject = [];
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: DEFERRED_REINJECT_CUSTOM_TYPE,
|
||||
content,
|
||||
display: true,
|
||||
},
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
compactionRuntime.deferredDeliveredForCompactionId = compactionEntryId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer path on before_agent_start: inject one combined message, then clear queue (SPEC §6.5.1).
|
||||
* Returns undefined when pendingReinject is empty or manual compaction scheduled a clear (SPEC §16.5).
|
||||
*/
|
||||
export function tryConsumeDeferredReinject(
|
||||
state: ExtensionState,
|
||||
settings: SkillReinjectSettings,
|
||||
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
|
||||
ctx?: ExtensionContext,
|
||||
compactionRuntime?: CompactionRuntime,
|
||||
): BeforeAgentStartEventResult | undefined {
|
||||
if (compactionRuntime?.clearPendingReinjectOnNextUserInput) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
compactionRuntime?.deferredDeliveredForCompactionId &&
|
||||
compactionRuntime.deferredDeliveredForCompactionId === compactionRuntime.lastCompactionEntryId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.pendingReinject.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const pendingNames = filterPendingReinjectForConsume(
|
||||
state.pendingReinject,
|
||||
state,
|
||||
settings,
|
||||
registeredSkills,
|
||||
ctx,
|
||||
);
|
||||
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
|
||||
if (!content) {
|
||||
state.pendingReinject = [];
|
||||
return undefined;
|
||||
}
|
||||
state.pendingReinject = [];
|
||||
return {
|
||||
message: {
|
||||
customType: DEFERRED_REINJECT_CUSTOM_TYPE,
|
||||
content,
|
||||
display: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { dirname } from "node:path";
|
||||
import type { SessionEntry, Skill } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
detectSlashSkill,
|
||||
matchReadPathToSkillWhenEnabled,
|
||||
parseSkillBlocksFromText,
|
||||
userMessageText,
|
||||
} from "./detect.js";
|
||||
import { findRegisteredSkillByName, resolveRegisteredSkills } from "./skills-registry.js";
|
||||
import { trackSkill, type ExtensionState } from "./state.js";
|
||||
|
||||
function entrySeenAt(entry: SessionEntry): number {
|
||||
const ts = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(ts) ? ts : Date.now();
|
||||
}
|
||||
|
||||
function readPathsFromContent(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const part of content) {
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const block = part as { type?: string; name?: string; arguments?: Record<string, unknown> };
|
||||
if (block.type !== "toolCall" || block.name !== "read") {
|
||||
continue;
|
||||
}
|
||||
const path = block.arguments?.path;
|
||||
if (typeof path === "string") {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Full branch rescan when no persisted state entry exists (SPEC §6.3). */
|
||||
export function rescanSkillsFromBranch(
|
||||
state: ExtensionState,
|
||||
branch: SessionEntry[],
|
||||
cwd: string,
|
||||
registeredSkills: readonly Skill[],
|
||||
trackReadPaths: boolean,
|
||||
): void {
|
||||
const skills = resolveRegisteredSkills(cwd, registeredSkills);
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const { message } = entry;
|
||||
const seenAt = entrySeenAt(entry);
|
||||
|
||||
if (message.role === "user") {
|
||||
const text = userMessageText(message.content);
|
||||
|
||||
const slashName = detectSlashSkill(text);
|
||||
if (slashName) {
|
||||
const skill = findRegisteredSkillByName(skills, slashName);
|
||||
if (skill) {
|
||||
trackSkill(state, {
|
||||
name: skill.name,
|
||||
filePath: skill.filePath,
|
||||
baseDir: skill.baseDir,
|
||||
source: "slash",
|
||||
seenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of parseSkillBlocksFromText(text)) {
|
||||
const registered = findRegisteredSkillByName(skills, block.name);
|
||||
trackSkill(state, {
|
||||
name: block.name,
|
||||
filePath: registered?.filePath ?? block.location,
|
||||
baseDir: registered?.baseDir ?? dirname(block.location),
|
||||
source: "skill-block",
|
||||
seenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
for (const path of readPathsFromContent(message.content)) {
|
||||
const matched = matchReadPathToSkillWhenEnabled(path, skills, trackReadPaths);
|
||||
if (matched) {
|
||||
trackSkill(state, {
|
||||
name: matched.name,
|
||||
filePath: matched.filePath,
|
||||
baseDir: matched.baseDir,
|
||||
source: "read",
|
||||
seenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-9
@@ -1,5 +1,5 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import type { AutoCompactIntegration } from "./state";
|
||||
@@ -24,6 +24,12 @@ export interface SkillReinjectSettings {
|
||||
reinjectOnManualCompaction: boolean;
|
||||
autoCompactIntegration: AutoCompactIntegration;
|
||||
suffix: string;
|
||||
/** Soft warn threshold; omit for unlimited with default warn above 3 (SPEC §15). */
|
||||
maxSkills?: number;
|
||||
/** Verbose reinject filter logging via ui.notify (Phase 14 / B-002). */
|
||||
debug: boolean;
|
||||
/** When true, only re-inject skills present in resourceLoader (Phase 14 / B-002). */
|
||||
requireRegistered: boolean;
|
||||
}
|
||||
|
||||
/** Defaults from SPEC §7.3 — extension off until explicitly enabled. */
|
||||
@@ -34,6 +40,8 @@ export const DEFAULT_SKILL_REINJECT_SETTINGS: Readonly<SkillReinjectSettings> =
|
||||
reinjectOnManualCompaction: false,
|
||||
autoCompactIntegration: "auto",
|
||||
suffix: "[skill-reinject] Re-applied after compaction.",
|
||||
debug: false,
|
||||
requireRegistered: false,
|
||||
};
|
||||
|
||||
export function createDefaultSettings(): SkillReinjectSettings {
|
||||
@@ -72,6 +80,15 @@ export function parseSkillReinjectPartial(raw: unknown): PartialSkillReinjectSet
|
||||
if (typeof obj.suffix === "string") {
|
||||
result.suffix = obj.suffix;
|
||||
}
|
||||
if (typeof obj.maxSkills === "number" && Number.isInteger(obj.maxSkills) && obj.maxSkills > 0) {
|
||||
result.maxSkills = obj.maxSkills;
|
||||
}
|
||||
if (typeof obj.debug === "boolean") {
|
||||
result.debug = obj.debug;
|
||||
}
|
||||
if (typeof obj.requireRegistered === "boolean") {
|
||||
result.requireRegistered = obj.requireRegistered;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -93,15 +110,14 @@ function extractSkillReinject(settings: object): PartialSkillReinjectSettings {
|
||||
);
|
||||
}
|
||||
|
||||
/** Merged global + project settings via Pi SettingsManager (SPEC §7.3). */
|
||||
/** Merged global + project settings (SPEC §7.3). Sync file read; avoids SettingsManager / isProjectTrusted() blocking in RPC hooks. */
|
||||
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
|
||||
const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
});
|
||||
return mergeSkillReinjectSettings(
|
||||
extractSkillReinject(manager.getGlobalSettings()),
|
||||
extractSkillReinject(manager.getProjectSettings()),
|
||||
);
|
||||
const global = extractSkillReinject(readSettingsFile(join(getAgentDir(), "settings.json")));
|
||||
const projectPath = join(ctx.cwd, ".pi/settings.json");
|
||||
const project = existsSync(projectPath)
|
||||
? extractSkillReinject(readSettingsFile(projectPath))
|
||||
: {};
|
||||
return mergeSkillReinjectSettings(global, project);
|
||||
}
|
||||
|
||||
function readSettingsFile(settingsPath: string): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getAgentDir, loadSkills, type Skill } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
/** First match on name collision (SPEC §11). */
|
||||
export function findRegisteredSkillByName(
|
||||
skills: readonly Skill[],
|
||||
name: string,
|
||||
): Skill | undefined {
|
||||
return skills.find((skill) => skill.name === name);
|
||||
}
|
||||
|
||||
/** Mirror resourceLoader skill list when cache is empty (e.g. first user input). */
|
||||
export function loadRegisteredSkills(cwd: string): Skill[] {
|
||||
return loadSkills({
|
||||
cwd,
|
||||
agentDir: getAgentDir(),
|
||||
skillPaths: [],
|
||||
includeDefaults: true,
|
||||
}).skills;
|
||||
}
|
||||
|
||||
/** Prefer live skills from before_agent_start; fall back to loadSkills (SPEC §6.2). */
|
||||
export function resolveRegisteredSkills(cwd: string, cached: readonly Skill[]): Skill[] {
|
||||
if (cached.length > 0) {
|
||||
return [...cached];
|
||||
}
|
||||
return loadRegisteredSkills(cwd);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface TrackedSkill {
|
||||
export interface ExtensionState {
|
||||
version: 1;
|
||||
sessionOverride: boolean | null;
|
||||
/** Session override for autoCompactIntegration (SPEC §7.1, §16.4). */
|
||||
sessionIntegrationOverride: AutoCompactIntegration | null;
|
||||
skills: TrackedSkill[];
|
||||
lastCompactionSource: CompactionSource | null;
|
||||
/** Skill names awaiting re-inject on the next before_agent_start (SPEC §6.5). */
|
||||
@@ -31,6 +33,8 @@ export interface ExtensionState {
|
||||
export interface RuntimeFlags {
|
||||
autoCompactDetected: boolean;
|
||||
autoCompactIntegration: AutoCompactIntegration;
|
||||
/** One-time hint when Pi default compaction coexists with pi-auto-compact (SPEC §16.7). */
|
||||
compactionCoexistenceHintShown: boolean;
|
||||
}
|
||||
|
||||
export const STATE_ENTRY_TYPE = "skill-reinject:state";
|
||||
@@ -47,6 +51,12 @@ function isExtensionState(data: unknown): data is ExtensionState {
|
||||
return (
|
||||
candidate.version === 1 &&
|
||||
(candidate.sessionOverride === null || typeof candidate.sessionOverride === "boolean") &&
|
||||
(candidate.sessionIntegrationOverride === null ||
|
||||
candidate.sessionIntegrationOverride === "auto" ||
|
||||
candidate.sessionIntegrationOverride === "defer" ||
|
||||
candidate.sessionIntegrationOverride === "immediate" ||
|
||||
candidate.sessionIntegrationOverride === "off" ||
|
||||
candidate.sessionIntegrationOverride === undefined) &&
|
||||
Array.isArray(candidate.skills) &&
|
||||
(candidate.lastCompactionSource === null ||
|
||||
candidate.lastCompactionSource === "auto" ||
|
||||
@@ -74,16 +84,27 @@ export function createInitialState(): ExtensionState {
|
||||
return {
|
||||
version: 1,
|
||||
sessionOverride: null,
|
||||
sessionIntegrationOverride: null,
|
||||
skills: [],
|
||||
lastCompactionSource: null,
|
||||
pendingReinject: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Copy persisted fields into live session state (SPEC §6.3). */
|
||||
export function applyExtensionState(target: ExtensionState, loaded: ExtensionState): void {
|
||||
target.sessionOverride = loaded.sessionOverride;
|
||||
target.sessionIntegrationOverride = loaded.sessionIntegrationOverride ?? null;
|
||||
target.skills = loaded.skills;
|
||||
target.lastCompactionSource = loaded.lastCompactionSource;
|
||||
target.pendingReinject = loaded.pendingReinject;
|
||||
}
|
||||
|
||||
export function createRuntimeFlags(): RuntimeFlags {
|
||||
return {
|
||||
autoCompactDetected: false,
|
||||
autoCompactIntegration: "auto",
|
||||
compactionCoexistenceHintShown: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectAndCachePiAutoCompact,
|
||||
detectPiAutoCompact,
|
||||
maybeNotifyCompactionCoexistenceHint,
|
||||
PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES,
|
||||
resolveDeliveryMode,
|
||||
resolvePiDefaultCompactionEnabled,
|
||||
} from "../src/auto-compact";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createRuntimeFlags } from "../src/state";
|
||||
|
||||
describe("detectPiAutoCompact", () => {
|
||||
it("returns true when auto-compact command is registered", () => {
|
||||
const pi = {
|
||||
getCommands: () => [{ name: "auto-compact" }],
|
||||
};
|
||||
expect(detectPiAutoCompact(pi as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when auto-compact command is absent", () => {
|
||||
const pi = {
|
||||
getCommands: () => [{ name: "skill-reinject" }],
|
||||
};
|
||||
expect(detectPiAutoCompact(pi as never)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAndCachePiAutoCompact", () => {
|
||||
it("writes detect result into runtime flags", () => {
|
||||
const runtime = createRuntimeFlags();
|
||||
const pi = {
|
||||
getCommands: () => [{ name: "auto-compact" }],
|
||||
};
|
||||
expect(detectAndCachePiAutoCompact(pi as never, runtime)).toBe(true);
|
||||
expect(runtime.autoCompactDetected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDeliveryMode", () => {
|
||||
it("defers when pi-auto-compact is cached as detected", () => {
|
||||
const runtime = createRuntimeFlags();
|
||||
runtime.autoCompactDetected = true;
|
||||
expect(resolveDeliveryMode(createDefaultSettings(), runtime)).toBe("defer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES", () => {
|
||||
it("documents pi-auto-compact follow-up phrases from SPEC §16.9", () => {
|
||||
expect([...PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES]).toEqual([
|
||||
"Auto-compact ran before this turn.",
|
||||
"Auto-compact ran mid-turn.",
|
||||
"Emergency auto-compact ran.",
|
||||
"Auto-compact ran on session resume.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePiDefaultCompactionEnabled", () => {
|
||||
it("defaults to enabled when compaction settings are absent", () => {
|
||||
expect(resolvePiDefaultCompactionEnabled({}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("lets project override global compaction.enabled", () => {
|
||||
expect(
|
||||
resolvePiDefaultCompactionEnabled(
|
||||
{ compaction: { enabled: true } },
|
||||
{ compaction: { enabled: false } },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeNotifyCompactionCoexistenceHint", () => {
|
||||
it("notifies once when pi-auto-compact and Pi compaction are both active", () => {
|
||||
const runtime = createRuntimeFlags();
|
||||
runtime.autoCompactDetected = true;
|
||||
const notifications: string[] = [];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
ui: {
|
||||
notify: (message: string) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
maybeNotifyCompactionCoexistenceHint(ctx as never, runtime, true);
|
||||
maybeNotifyCompactionCoexistenceHint(ctx as never, runtime, true);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(runtime.compactionCoexistenceHintShown).toBe(true);
|
||||
});
|
||||
|
||||
it("skips notify when pi-auto-compact is not detected", () => {
|
||||
const runtime = createRuntimeFlags();
|
||||
const notifications: string[] = [];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
ui: { notify: (message: string) => notifications.push(message) },
|
||||
};
|
||||
|
||||
maybeNotifyCompactionCoexistenceHint(ctx as never, runtime, true);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(runtime.compactionCoexistenceHintShown).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDeferredReinject, planReinject } from "../src/reinject.js";
|
||||
import { createDefaultSettings } from "../src/settings.js";
|
||||
import { createInitialState, trackSkill } from "../src/state.js";
|
||||
|
||||
describe("B-002 pre-fix filter hypothesis", () => {
|
||||
it("planned empty when skill stays in kept window even if registered", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "fup-blame-commits",
|
||||
filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md",
|
||||
baseDir: "/home/user/.cursor/skills/fup-blame-commits",
|
||||
source: "slash",
|
||||
});
|
||||
|
||||
const branch = [
|
||||
{
|
||||
id: "keep-1",
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
'<skill name="fup-blame-commits" location="/path/SKILL.md">\nbody\n</skill>',
|
||||
},
|
||||
},
|
||||
] as never;
|
||||
|
||||
const planned = planReinject(
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
{
|
||||
sessionManager: { getBranch: () => branch },
|
||||
} as never,
|
||||
{ compactionEntry: { firstKeptEntryId: "keep-1" } } as never,
|
||||
[{ name: "fup-blame-commits" }],
|
||||
);
|
||||
|
||||
expect(planned).toEqual([]);
|
||||
});
|
||||
|
||||
it("pre-fix: registered empty drops skill even when absent from kept (post-fix should reinject)", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "fup-blame-commits",
|
||||
filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md",
|
||||
baseDir: "/home/user/.cursor/skills/fup-blame-commits",
|
||||
source: "skill-block",
|
||||
});
|
||||
|
||||
const branch = [
|
||||
{
|
||||
id: "keep-1",
|
||||
type: "message",
|
||||
message: { role: "user", content: "plain text after compact" },
|
||||
},
|
||||
] as never;
|
||||
|
||||
const planned = planReinject(
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
{
|
||||
sessionManager: { getBranch: () => branch },
|
||||
} as never,
|
||||
{ compactionEntry: { firstKeptEntryId: "keep-1" } } as never,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(planned).toEqual([]);
|
||||
});
|
||||
|
||||
it("defer plan includes skill absent from kept even when registered is empty", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "fup-blame-commits",
|
||||
filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md",
|
||||
baseDir: "/home/user/.cursor/skills/fup-blame-commits",
|
||||
source: "skill-block",
|
||||
});
|
||||
|
||||
const branch = [
|
||||
{
|
||||
id: "keep-1",
|
||||
type: "message",
|
||||
message: { role: "user", content: "plain text after compact" },
|
||||
},
|
||||
] as never;
|
||||
|
||||
const planned = planDeferredReinject(
|
||||
state,
|
||||
{
|
||||
sessionManager: { getBranch: () => branch },
|
||||
} as never,
|
||||
{ compactionEntry: { firstKeptEntryId: "keep-1" } } as never,
|
||||
);
|
||||
|
||||
expect(planned).toEqual(["fup-blame-commits"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleSkillReinjectCommand } from "../src/commands";
|
||||
import { createInitialState, createRuntimeFlags } from "../src/state";
|
||||
|
||||
function createNoUiCommandContext() {
|
||||
const notify = vi.fn();
|
||||
return {
|
||||
hasUI: false,
|
||||
mode: "rpc" as const,
|
||||
ui: { notify, setStatus: vi.fn() },
|
||||
cwd: process.cwd(),
|
||||
isProjectTrusted: () => true,
|
||||
isIdle: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps() {
|
||||
const state = createInitialState();
|
||||
const persistState = vi.fn();
|
||||
const pi = {
|
||||
sendUserMessage: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
appendEntry: vi.fn(),
|
||||
};
|
||||
return {
|
||||
state,
|
||||
persistState,
|
||||
pi,
|
||||
runtime: createRuntimeFlags(),
|
||||
getRegisteredSkills: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleSkillReinjectCommand without UI", () => {
|
||||
it("does not throw on status", async () => {
|
||||
const ctx = createNoUiCommandContext();
|
||||
const deps = createDeps();
|
||||
|
||||
await expect(handleSkillReinjectCommand("", ctx as never, deps as never)).resolves.toBeUndefined();
|
||||
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists session toggle without notify", async () => {
|
||||
const ctx = createNoUiCommandContext();
|
||||
const deps = createDeps();
|
||||
|
||||
await handleSkillReinjectCommand("on", ctx as never, deps as never);
|
||||
|
||||
expect(deps.state.sessionOverride).toBe(true);
|
||||
expect(deps.persistState).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears tracked skills without notify", async () => {
|
||||
const ctx = createNoUiCommandContext();
|
||||
const deps = createDeps();
|
||||
deps.state.skills.push({
|
||||
name: "alpha",
|
||||
filePath: "/a/SKILL.md",
|
||||
baseDir: "/a",
|
||||
firstSeenAt: 1,
|
||||
lastSeenAt: 1,
|
||||
sources: ["slash"],
|
||||
});
|
||||
|
||||
await handleSkillReinjectCommand("clear", ctx as never, deps as never);
|
||||
|
||||
expect(deps.state.skills).toEqual([]);
|
||||
expect(deps.persistState).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists integration override without notify", async () => {
|
||||
const ctx = createNoUiCommandContext();
|
||||
const deps = createDeps();
|
||||
|
||||
await handleSkillReinjectCommand("integration defer", ctx as never, deps as never);
|
||||
|
||||
expect(deps.state.sessionIntegrationOverride).toBe("defer");
|
||||
expect(deps.persistState).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
consumeCompactionOnSessionCompact,
|
||||
createCompactionRuntime,
|
||||
ensureCompactionSourceMarked,
|
||||
markManualCompactionFromInput,
|
||||
} from "../src/compaction";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState } from "../src/state";
|
||||
|
||||
describe("compaction source fallback on session_compact", () => {
|
||||
it("infers auto when session_compact runs without prior session_before_compact", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
expect(runtime.pendingCompactionSource).toBeNull();
|
||||
|
||||
ensureCompactionSourceMarked(runtime);
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(
|
||||
runtime,
|
||||
state,
|
||||
true,
|
||||
createDefaultSettings(),
|
||||
);
|
||||
|
||||
expect(shouldReinject).toBe(true);
|
||||
expect(state.lastCompactionSource).toBe("auto");
|
||||
expect(runtime.pendingCompactionSource).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps manual source from input and skips reinject by default", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
markManualCompactionFromInput("/compact", runtime);
|
||||
|
||||
ensureCompactionSourceMarked(runtime);
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(
|
||||
runtime,
|
||||
state,
|
||||
true,
|
||||
createDefaultSettings(),
|
||||
);
|
||||
|
||||
expect(shouldReinject).toBe(false);
|
||||
expect(state.lastCompactionSource).toBe("manual");
|
||||
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectSlashSkill,
|
||||
matchReadPathToSkill,
|
||||
matchReadPathToSkillWhenEnabled,
|
||||
parseSkillBlocksFromText,
|
||||
type SkillPathMeta,
|
||||
} from "../src/detect";
|
||||
|
||||
const sampleSkills: SkillPathMeta[] = [
|
||||
{
|
||||
name: "brave-search",
|
||||
filePath: "/home/user/.pi/skills/brave-search/SKILL.md",
|
||||
baseDir: "/home/user/.pi/skills/brave-search",
|
||||
},
|
||||
{
|
||||
name: "pdf-tools",
|
||||
filePath: "/proj/.pi/skills/pdf-tools/SKILL.md",
|
||||
baseDir: "/proj/.pi/skills/pdf-tools",
|
||||
},
|
||||
];
|
||||
|
||||
describe("detectSlashSkill", () => {
|
||||
it("detects slash command at start of text", () => {
|
||||
expect(detectSlashSkill("/skill:brave-search")).toBe("brave-search");
|
||||
expect(detectSlashSkill("/skill:pdf-tools extract pages")).toBe("pdf-tools");
|
||||
});
|
||||
|
||||
it("returns null for non-slash or invalid names", () => {
|
||||
expect(detectSlashSkill("hello")).toBeNull();
|
||||
expect(detectSlashSkill(" /skill:foo")).toBeNull();
|
||||
expect(detectSlashSkill("/skill:Bad_Name")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSkillBlocksFromText", () => {
|
||||
const block =
|
||||
'<skill name="brave-search" location="/home/user/.pi/skills/brave-search/SKILL.md">\nbody\n</skill>';
|
||||
|
||||
it("parses one or more skill blocks", () => {
|
||||
expect(parseSkillBlocksFromText(block)).toEqual([
|
||||
{
|
||||
name: "brave-search",
|
||||
location: "/home/user/.pi/skills/brave-search/SKILL.md",
|
||||
content: "body",
|
||||
},
|
||||
]);
|
||||
expect(parseSkillBlocksFromText(`${block}\n\n${block.replace("brave-search", "pdf-tools")}`)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no blocks", () => {
|
||||
expect(parseSkillBlocksFromText("plain text")).toEqual([]);
|
||||
expect(parseSkillBlocksFromText('<skill name="x"')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchReadPathToSkill", () => {
|
||||
it("matches absolute skill filePath", () => {
|
||||
expect(matchReadPathToSkill("/home/user/.pi/skills/brave-search/SKILL.md", sampleSkills)?.name).toBe(
|
||||
"brave-search",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches relative path via skill baseDir", () => {
|
||||
expect(matchReadPathToSkill("SKILL.md", [sampleSkills[0]])?.name).toBe("brave-search");
|
||||
});
|
||||
|
||||
it("returns null for unrelated paths", () => {
|
||||
expect(matchReadPathToSkill("/tmp/README.md", sampleSkills)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchReadPathToSkillWhenEnabled", () => {
|
||||
it("skips read detection when trackReadPaths is false", () => {
|
||||
expect(
|
||||
matchReadPathToSkillWhenEnabled("/home/user/.pi/skills/brave-search/SKILL.md", sampleSkills, false),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("delegates to matchReadPathToSkill when enabled", () => {
|
||||
expect(
|
||||
matchReadPathToSkillWhenEnabled("/home/user/.pi/skills/brave-search/SKILL.md", sampleSkills, true)?.name,
|
||||
).toBe("brave-search");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildReinjectDiagSnapshot, notifyReinjectDiag } from "../src/diag.js";
|
||||
import { createDefaultSettings } from "../src/settings.js";
|
||||
import { createInitialState } from "../src/state.js";
|
||||
|
||||
describe("buildReinjectDiagSnapshot", () => {
|
||||
it("collects tracked, kept, registered, planned, and pending", () => {
|
||||
const state = createInitialState();
|
||||
state.skills.push({
|
||||
name: "alpha",
|
||||
filePath: "/skills/alpha/SKILL.md",
|
||||
baseDir: "/skills/alpha",
|
||||
firstSeenAt: 1,
|
||||
lastSeenAt: 1,
|
||||
sources: ["slash"],
|
||||
});
|
||||
state.pendingReinject = ["alpha"];
|
||||
|
||||
expect(
|
||||
buildReinjectDiagSnapshot(
|
||||
state,
|
||||
[{ name: "beta" }],
|
||||
new Set(["gamma"]),
|
||||
["alpha"],
|
||||
{
|
||||
compactionSource: "auto",
|
||||
sourceInferred: true,
|
||||
deliveryBranch: "steer",
|
||||
isIdle: false,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
tracked: ["alpha"],
|
||||
kept: ["gamma"],
|
||||
registered: ["beta"],
|
||||
planned: ["alpha"],
|
||||
pending: ["alpha"],
|
||||
compactionSource: "auto",
|
||||
sourceInferred: true,
|
||||
deliveryBranch: "steer",
|
||||
isIdle: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyReinjectDiag", () => {
|
||||
it("no-ops when debug is off", () => {
|
||||
const notify = vi.fn();
|
||||
notifyReinjectDiag(
|
||||
{
|
||||
hasUI: true,
|
||||
ui: { notify },
|
||||
} as never,
|
||||
createDefaultSettings(),
|
||||
"session_compact",
|
||||
{
|
||||
tracked: [],
|
||||
kept: [],
|
||||
registered: [],
|
||||
planned: [],
|
||||
pending: [],
|
||||
},
|
||||
);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifies with JSON snapshot when debug is on", () => {
|
||||
const notify = vi.fn();
|
||||
const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const settings = { ...createDefaultSettings(), debug: true };
|
||||
const snapshot = {
|
||||
tracked: ["a"],
|
||||
kept: [],
|
||||
registered: [],
|
||||
planned: ["a"],
|
||||
pending: ["a"],
|
||||
};
|
||||
notifyReinjectDiag(
|
||||
{
|
||||
hasUI: true,
|
||||
ui: { notify },
|
||||
} as never,
|
||||
settings,
|
||||
"before_agent_start",
|
||||
snapshot,
|
||||
);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
`skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`,
|
||||
);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
`skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`,
|
||||
"info",
|
||||
);
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
appendSuffix,
|
||||
expandSkill,
|
||||
formatBlock,
|
||||
readSkillBody,
|
||||
type SkillBlockMeta,
|
||||
} from "../src/expand";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeSkillMd(dir: string, name: string, content: string): SkillBlockMeta {
|
||||
const baseDir = join(dir, name);
|
||||
const filePath = join(baseDir, "SKILL.md");
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf-8");
|
||||
return { name, filePath, baseDir };
|
||||
}
|
||||
|
||||
describe("readSkillBody", () => {
|
||||
it("strips YAML frontmatter and trims body", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-skill-reinject-expand-"));
|
||||
tempDirs.push(dir);
|
||||
const meta = writeSkillMd(
|
||||
dir,
|
||||
"demo-skill",
|
||||
`---
|
||||
name: demo-skill
|
||||
description: Demo
|
||||
---
|
||||
|
||||
# Instructions
|
||||
|
||||
Do the thing.
|
||||
`,
|
||||
);
|
||||
|
||||
expect(readSkillBody(meta.filePath)).toBe("# Instructions\n\nDo the thing.");
|
||||
});
|
||||
|
||||
it("returns trimmed body when frontmatter is absent", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-skill-reinject-expand-"));
|
||||
tempDirs.push(dir);
|
||||
const meta = writeSkillMd(dir, "plain", " hello world \n");
|
||||
|
||||
expect(readSkillBody(meta.filePath)).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBlock", () => {
|
||||
it("embeds name, location, baseDir, and body", () => {
|
||||
const meta: SkillBlockMeta = {
|
||||
name: "brave-search",
|
||||
filePath: "/home/user/.pi/skills/brave-search/SKILL.md",
|
||||
baseDir: "/home/user/.pi/skills/brave-search",
|
||||
};
|
||||
|
||||
expect(formatBlock(meta, "Search the web.")).toBe(
|
||||
`<skill name="brave-search" location="/home/user/.pi/skills/brave-search/SKILL.md">
|
||||
References are relative to /home/user/.pi/skills/brave-search.
|
||||
|
||||
Search the web.
|
||||
</skill>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendSuffix", () => {
|
||||
it("appends suffix after a blank line", () => {
|
||||
const block = "<skill>body</skill>";
|
||||
expect(appendSuffix(block, "[skill-reinject] note")).toBe(
|
||||
`${block}\n\n[skill-reinject] note`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns block unchanged for empty or whitespace suffix", () => {
|
||||
const block = "<skill>body</skill>";
|
||||
expect(appendSuffix(block, undefined)).toBe(block);
|
||||
expect(appendSuffix(block, " ")).toBe(block);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandSkill", () => {
|
||||
it("produces full injectable text with optional suffix", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-skill-reinject-expand-"));
|
||||
tempDirs.push(dir);
|
||||
const meta = writeSkillMd(
|
||||
dir,
|
||||
"pdf-tools",
|
||||
`---
|
||||
name: pdf-tools
|
||||
---
|
||||
|
||||
Extract pages from PDFs.
|
||||
`,
|
||||
);
|
||||
|
||||
const withoutSuffix = expandSkill(meta);
|
||||
expect(withoutSuffix).toContain('name="pdf-tools"');
|
||||
expect(withoutSuffix).toContain(`location="${meta.filePath}"`);
|
||||
expect(withoutSuffix).toContain(`References are relative to ${meta.baseDir}.`);
|
||||
expect(withoutSuffix).toContain("Extract pages from PDFs.");
|
||||
|
||||
expect(expandSkill(meta, "[skill-reinject] Re-applied.")).toBe(
|
||||
`${withoutSuffix}\n\n[skill-reinject] Re-applied.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterSkillsNeedingReinject,
|
||||
filterSkillsNeedingReinjectByKept,
|
||||
getKeptEntries,
|
||||
skillsPresentInKeptWindow,
|
||||
} from "../src/kept";
|
||||
import type { TrackedSkill } from "../src/state";
|
||||
|
||||
const ts = "2024-12-03T14:00:00.000Z";
|
||||
|
||||
function userMessage(id: string, content: string): SessionEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId: null,
|
||||
timestamp: ts,
|
||||
message: { role: "user", content },
|
||||
} as SessionEntry;
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, content: string): SessionEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id,
|
||||
parentId: null,
|
||||
timestamp: ts,
|
||||
message: { role: "assistant", content: [{ type: "text", text: content }] },
|
||||
} as SessionEntry;
|
||||
}
|
||||
|
||||
function compactionEntry(id: string, firstKeptEntryId: string): SessionEntry {
|
||||
return {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId: null,
|
||||
timestamp: ts,
|
||||
summary: "summary",
|
||||
firstKeptEntryId,
|
||||
tokensBefore: 1000,
|
||||
} as SessionEntry;
|
||||
}
|
||||
|
||||
function reinjectCustomMessage(id: string, content: string): SessionEntry {
|
||||
return {
|
||||
type: "custom_message",
|
||||
id,
|
||||
parentId: null,
|
||||
timestamp: ts,
|
||||
customType: "skill-reinject:inject",
|
||||
content,
|
||||
display: true,
|
||||
} as SessionEntry;
|
||||
}
|
||||
|
||||
const skillBlock = (name: string) =>
|
||||
`<skill name="${name}" location="/skills/${name}/SKILL.md">\nbody\n</skill>`;
|
||||
|
||||
const trackedSkills: TrackedSkill[] = [
|
||||
{
|
||||
name: "alpha",
|
||||
filePath: "/skills/alpha/SKILL.md",
|
||||
baseDir: "/skills/alpha",
|
||||
firstSeenAt: 1,
|
||||
lastSeenAt: 1,
|
||||
sources: ["slash"],
|
||||
},
|
||||
{
|
||||
name: "beta",
|
||||
filePath: "/skills/beta/SKILL.md",
|
||||
baseDir: "/skills/beta",
|
||||
firstSeenAt: 2,
|
||||
lastSeenAt: 2,
|
||||
sources: ["skill-block"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("getKeptEntries", () => {
|
||||
const branch: SessionEntry[] = [
|
||||
userMessage("e1", "old"),
|
||||
userMessage("e2", "kept start"),
|
||||
assistantMessage("e3", "reply"),
|
||||
compactionEntry("e4", "e2"),
|
||||
userMessage("e5", "after compact"),
|
||||
];
|
||||
|
||||
it("slices from firstKeptEntryId through tail", () => {
|
||||
expect(getKeptEntries(branch, "e2").map((entry) => entry.id)).toEqual(["e2", "e3", "e4", "e5"]);
|
||||
});
|
||||
|
||||
it("returns empty when firstKeptEntryId is missing", () => {
|
||||
expect(getKeptEntries(branch, "missing")).toEqual([]);
|
||||
expect(getKeptEntries([], "e1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillsPresentInKeptWindow", () => {
|
||||
it("detects skill blocks in kept user messages", () => {
|
||||
const kept = [userMessage("k1", skillBlock("alpha")), assistantMessage("k2", "ignored")];
|
||||
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set(["alpha"]));
|
||||
});
|
||||
|
||||
it("returns empty when no tracked skills or no blocks", () => {
|
||||
expect(skillsPresentInKeptWindow([userMessage("k1", "plain")], ["alpha"])).toEqual(new Set());
|
||||
expect(skillsPresentInKeptWindow([userMessage("k1", skillBlock("alpha"))], [])).toEqual(new Set());
|
||||
expect(skillsPresentInKeptWindow([], ["alpha", "beta"])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("detects skill blocks in skill-reinject:inject custom messages", () => {
|
||||
const kept = [reinjectCustomMessage("inj1", skillBlock("beta"))];
|
||||
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set(["beta"]));
|
||||
});
|
||||
|
||||
it("ignores reinject custom outside kept slice when slicing branch", () => {
|
||||
const branch: SessionEntry[] = [
|
||||
reinjectCustomMessage("inj0", skillBlock("alpha")),
|
||||
userMessage("e2", "kept start"),
|
||||
compactionEntry("e4", "e2"),
|
||||
];
|
||||
const kept = getKeptEntries(branch, "e2");
|
||||
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set());
|
||||
expect(skillsPresentInKeptWindow(kept, ["alpha"])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("counts reinject custom inside kept slice", () => {
|
||||
const branch: SessionEntry[] = [
|
||||
userMessage("e2", "kept start"),
|
||||
reinjectCustomMessage("inj1", skillBlock("alpha")),
|
||||
compactionEntry("e4", "e2"),
|
||||
];
|
||||
const kept = getKeptEntries(branch, "e2");
|
||||
expect(skillsPresentInKeptWindow(kept, ["alpha", "beta"])).toEqual(new Set(["alpha"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSkillsNeedingReinjectByKept", () => {
|
||||
it("returns tracked skills absent from kept window regardless of registration", () => {
|
||||
const keptPresent = new Set(["alpha"]);
|
||||
expect(filterSkillsNeedingReinjectByKept(trackedSkills, keptPresent)).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("preserves tracked order including unregistered names", () => {
|
||||
const keptPresent = new Set<string>();
|
||||
expect(filterSkillsNeedingReinjectByKept(trackedSkills, keptPresent)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("returns empty when all tracked skills are in kept window", () => {
|
||||
const keptPresent = new Set(["alpha", "beta"]);
|
||||
expect(filterSkillsNeedingReinjectByKept(trackedSkills, keptPresent)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterSkillsNeedingReinject", () => {
|
||||
it("returns registered tracked skills absent from kept window", () => {
|
||||
const keptPresent = new Set(["alpha"]);
|
||||
const registered = new Set(["alpha", "beta"]);
|
||||
expect(filterSkillsNeedingReinject(trackedSkills, keptPresent, registered)).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("excludes unregistered skills and preserves tracked order", () => {
|
||||
const keptPresent = new Set<string>();
|
||||
const registered = new Set(["alpha", "beta"]);
|
||||
expect(filterSkillsNeedingReinject(trackedSkills, keptPresent, registered)).toEqual(["alpha", "beta"]);
|
||||
expect(filterSkillsNeedingReinject(trackedSkills, keptPresent, new Set(["beta"]))).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
it("returns empty when all tracked skills are in kept window", () => {
|
||||
const keptPresent = new Set(["alpha", "beta"]);
|
||||
const registered = new Set(["alpha", "beta"]);
|
||||
expect(filterSkillsNeedingReinject(trackedSkills, keptPresent, registered)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registeredSkillsByName } from "../src/reinject";
|
||||
|
||||
describe("registeredSkillsByName", () => {
|
||||
it("keeps the first skill when names collide", () => {
|
||||
const first = { name: "alpha", filePath: "/a/SKILL.md", baseDir: "/a" };
|
||||
const second = { name: "alpha", filePath: "/b/SKILL.md", baseDir: "/b" };
|
||||
|
||||
const byName = registeredSkillsByName([first, second]);
|
||||
|
||||
expect(byName.get("alpha")).toBe(first);
|
||||
});
|
||||
|
||||
it("warns once per duplicate name", () => {
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
const skills = [
|
||||
{ name: "alpha", filePath: "/a/SKILL.md", baseDir: "/a" },
|
||||
{ name: "alpha", filePath: "/b/SKILL.md", baseDir: "/b" },
|
||||
{ name: "alpha", filePath: "/c/SKILL.md", baseDir: "/c" },
|
||||
];
|
||||
|
||||
registeredSkillsByName(skills, ctx);
|
||||
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'skill-reinject: duplicate skill name "alpha" — using first from resourceLoader',
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { filterPendingReinjectForConsume, tryConsumeDeferredReinject } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState, trackSkill } from "../src/state";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-consume-"));
|
||||
tempDirs.push(root);
|
||||
const baseDir = join(root, name);
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const filePath = join(baseDir, "SKILL.md");
|
||||
writeFileSync(filePath, "# skill\n", "utf8");
|
||||
return { filePath, baseDir };
|
||||
}
|
||||
|
||||
describe("filterPendingReinjectForConsume", () => {
|
||||
it("keeps registered pending skills", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "alpha",
|
||||
filePath: "/skills/alpha/SKILL.md",
|
||||
baseDir: "/skills/alpha",
|
||||
source: "slash",
|
||||
});
|
||||
|
||||
expect(
|
||||
filterPendingReinjectForConsume(
|
||||
["alpha"],
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
[{ name: "alpha" }],
|
||||
),
|
||||
).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("includes unregistered skill from disk when requireRegistered is false", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("loose");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
|
||||
expect(
|
||||
filterPendingReinjectForConsume(["loose"], state, createDefaultSettings(), [], ctx),
|
||||
).toEqual(["loose"]);
|
||||
expect(notify).toHaveBeenCalledWith('skill-reinject: re-injected "loose" from disk', "info");
|
||||
});
|
||||
|
||||
it("skips unregistered skill when requireRegistered is true", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("strict");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "strict", filePath, baseDir, source: "slash" });
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
const settings = createDefaultSettings();
|
||||
settings.requireRegistered = true;
|
||||
|
||||
expect(filterPendingReinjectForConsume(["strict"], state, settings, [], ctx)).toEqual([]);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'skill-reinject: skipped "strict" — no longer registered',
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when tracked file is missing on disk", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "missing",
|
||||
filePath: "/no/such/SKILL.md",
|
||||
baseDir: "/no/such",
|
||||
source: "slash",
|
||||
});
|
||||
expect(existsSync("/no/such/SKILL.md")).toBe(false);
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
|
||||
expect(
|
||||
filterPendingReinjectForConsume(["missing"], state, createDefaultSettings(), [], ctx),
|
||||
).toEqual([]);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'skill-reinject: skipped "missing" — SKILL.md not found on disk',
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryConsumeDeferredReinject loose path", () => {
|
||||
it("injects skill block from tracked filePath when not registered", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("loose");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||
state.pendingReinject = ["loose"];
|
||||
|
||||
const result = tryConsumeDeferredReinject(state, createDefaultSettings(), [], undefined);
|
||||
|
||||
expect(result?.message?.content).toContain('<skill name="loose"');
|
||||
expect(result?.message?.content).toContain("[skill-reinject] Re-applied after compaction.");
|
||||
expect(state.pendingReinject).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCompactionRuntime } from "../src/compaction";
|
||||
import { applyPendingReinjectAfterCompact } from "../src/reinject";
|
||||
import { createInitialState } from "../src/state";
|
||||
|
||||
describe("applyPendingReinjectAfterCompact", () => {
|
||||
it("replaces pending with a new plan on each compact", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
|
||||
applyPendingReinjectAfterCompact(state, runtime, true, ["beta", "gamma"]);
|
||||
|
||||
expect(state.pendingReinject).toEqual(["beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("clears pending when reinject is skipped", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
|
||||
applyPendingReinjectAfterCompact(state, runtime, false, ["beta"]);
|
||||
|
||||
expect(state.pendingReinject).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps stale pending when manual compaction scheduled a user-prompt clear", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
runtime.clearPendingReinjectOnNextUserInput = true;
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
|
||||
applyPendingReinjectAfterCompact(state, runtime, false, ["beta"]);
|
||||
|
||||
expect(state.pendingReinject).toEqual(["alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
consumeCompactionOnSessionCompact,
|
||||
createCompactionRuntime,
|
||||
} from "../src/compaction";
|
||||
import { clearPendingReinjectOnUserPrompt, tryConsumeDeferredReinject } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState } from "../src/state";
|
||||
|
||||
describe("manual compaction defer clear", () => {
|
||||
it("schedules clear on manual compaction when reinjectOnManualCompaction is false", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
runtime.pendingCompactionSource = "manual";
|
||||
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, null, createDefaultSettings());
|
||||
|
||||
expect(shouldReinject).toBe(false);
|
||||
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(true);
|
||||
expect(state.pendingReinject).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("blocks deferred inject until user prompt clears stale pending", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
runtime.clearPendingReinjectOnNextUserInput = true;
|
||||
|
||||
expect(
|
||||
tryConsumeDeferredReinject(state, createDefaultSettings(), [], undefined, runtime),
|
||||
).toBeUndefined();
|
||||
expect(state.pendingReinject).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("clears pending on user prompt and allows deferred inject again", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
runtime.clearPendingReinjectOnNextUserInput = true;
|
||||
|
||||
expect(clearPendingReinjectOnUserPrompt(state, runtime)).toBe(true);
|
||||
expect(state.pendingReinject).toEqual([]);
|
||||
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false);
|
||||
});
|
||||
|
||||
it("does not schedule clear when manual compaction may reinject", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
state.pendingReinject = ["alpha"];
|
||||
runtime.pendingCompactionSource = "manual";
|
||||
const settings = createDefaultSettings();
|
||||
settings.reinjectOnManualCompaction = true;
|
||||
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, true, settings);
|
||||
|
||||
expect(shouldReinject).toBe(true);
|
||||
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false);
|
||||
});
|
||||
|
||||
it("clears stale manual flag when a later auto compaction enqueues reinject", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
runtime.clearPendingReinjectOnNextUserInput = true;
|
||||
state.pendingReinject = ["beta"];
|
||||
runtime.pendingCompactionSource = "auto";
|
||||
|
||||
const shouldReinject = consumeCompactionOnSessionCompact(runtime, state, true, createDefaultSettings());
|
||||
|
||||
expect(shouldReinject).toBe(true);
|
||||
expect(runtime.clearPendingReinjectOnNextUserInput).toBe(false);
|
||||
expect(clearPendingReinjectOnUserPrompt(state, runtime)).toBe(false);
|
||||
expect(state.pendingReinject).toEqual(["beta"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { maybeWarnManySkills } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
|
||||
describe("maybeWarnManySkills", () => {
|
||||
it("warns above 3 when maxSkills is unset", () => {
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
|
||||
maybeWarnManySkills(4, createDefaultSettings(), ctx);
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
"skill-reinject: re-injecting 4 tracked skills (soft warn above 3)",
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn at 3 skills when maxSkills is unset", () => {
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
|
||||
maybeWarnManySkills(3, createDefaultSettings(), ctx);
|
||||
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses configured maxSkills threshold", () => {
|
||||
const notify = vi.fn();
|
||||
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||
const settings = createDefaultSettings();
|
||||
settings.maxSkills = 2;
|
||||
|
||||
maybeWarnManySkills(3, settings, ctx);
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
"skill-reinject: re-injecting 3 skills (maxSkills warn threshold: 2)",
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCompactionRuntime } from "../src/compaction";
|
||||
import { DEFERRED_REINJECT_CUSTOM_TYPE, deliverDeferredReinjectSteer, tryConsumeDeferredReinject } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState, trackSkill } from "../src/state";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-mid-turn-"));
|
||||
tempDirs.push(root);
|
||||
const baseDir = join(root, name);
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const filePath = join(baseDir, "SKILL.md");
|
||||
writeFileSync(filePath, `---\nname: ${name}\n---\n# body\n`, "utf8");
|
||||
return { filePath, baseDir };
|
||||
}
|
||||
|
||||
describe("deliverDeferredReinjectSteer", () => {
|
||||
it("sends steer message and clears pending when skills resolve", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("alpha");
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "alpha", filePath, baseDir, source: "slash" });
|
||||
state.pendingReinject = ["alpha"];
|
||||
const sendMessage = vi.fn();
|
||||
const pi = { sendMessage } as never;
|
||||
const registered = [{ name: "alpha", filePath, baseDir }];
|
||||
|
||||
const delivered = deliverDeferredReinjectSteer(
|
||||
pi,
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
registered,
|
||||
runtime,
|
||||
"compact-1",
|
||||
);
|
||||
|
||||
expect(delivered).toBe(true);
|
||||
expect(state.pendingReinject).toEqual([]);
|
||||
expect(runtime.deferredDeliveredForCompactionId).toBe("compact-1");
|
||||
expect(sendMessage).toHaveBeenCalledOnce();
|
||||
expect(sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
customType: DEFERRED_REINJECT_CUSTOM_TYPE,
|
||||
display: true,
|
||||
content: expect.stringContaining('<skill name="alpha"'),
|
||||
}),
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false without sendMessage when pending is empty", () => {
|
||||
const runtime = createCompactionRuntime();
|
||||
const state = createInitialState();
|
||||
const sendMessage = vi.fn();
|
||||
const pi = { sendMessage } as never;
|
||||
|
||||
expect(
|
||||
deliverDeferredReinjectSteer(pi, state, createDefaultSettings(), [], runtime, "compact-1"),
|
||||
).toBe(false);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryConsumeDeferredReinject after mid-turn steer", () => {
|
||||
it("skips before_agent_start inject when steer already delivered for compaction", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("beta");
|
||||
const runtime = createCompactionRuntime();
|
||||
runtime.lastCompactionEntryId = "compact-2";
|
||||
runtime.deferredDeliveredForCompactionId = "compact-2";
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "beta", filePath, baseDir, source: "slash" });
|
||||
state.pendingReinject = ["beta"];
|
||||
|
||||
expect(
|
||||
tryConsumeDeferredReinject(
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
[{ name: "beta", filePath, baseDir }],
|
||||
undefined,
|
||||
runtime,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("consumes pending on before_agent_start when steer did not run (idle path)", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("gamma");
|
||||
const runtime = createCompactionRuntime();
|
||||
runtime.lastCompactionEntryId = "compact-3";
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "gamma", filePath, baseDir, source: "slash" });
|
||||
state.pendingReinject = ["gamma"];
|
||||
|
||||
const result = tryConsumeDeferredReinject(
|
||||
state,
|
||||
createDefaultSettings(),
|
||||
[{ name: "gamma", filePath, baseDir }],
|
||||
undefined,
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(result?.message.customType).toBe(DEFERRED_REINJECT_CUSTOM_TYPE);
|
||||
expect(state.pendingReinject).toEqual([]);
|
||||
expect(runtime.deferredDeliveredForCompactionId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { reinjectNow, resolveReinjectSkillNames } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState, trackSkill } from "../src/state";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-now-"));
|
||||
tempDirs.push(root);
|
||||
const baseDir = join(root, name);
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const filePath = join(baseDir, "SKILL.md");
|
||||
writeFileSync(filePath, "# skill\n", "utf8");
|
||||
return { filePath, baseDir };
|
||||
}
|
||||
|
||||
describe("resolveReinjectSkillNames", () => {
|
||||
it("includes loose tracked skill when file exists and requireRegistered is false", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("loose");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||
|
||||
expect(resolveReinjectSkillNames(state, createDefaultSettings(), [])).toEqual(["loose"]);
|
||||
});
|
||||
|
||||
it("excludes unregistered skills when requireRegistered is true", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("strict");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "strict", filePath, baseDir, source: "slash" });
|
||||
const settings = createDefaultSettings();
|
||||
settings.requireRegistered = true;
|
||||
|
||||
expect(resolveReinjectSkillNames(state, settings, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinjectNow loose path", () => {
|
||||
it("sends skill block from disk for unregistered tracked skill", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("loose");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||
const sendUserMessage = vi.fn();
|
||||
const pi = { sendUserMessage } as never;
|
||||
const ctx = { hasUI: false, isIdle: () => true } as never;
|
||||
|
||||
reinjectNow(pi, state, createDefaultSettings(), ctx, []);
|
||||
|
||||
expect(sendUserMessage).toHaveBeenCalledTimes(1);
|
||||
expect(sendUserMessage.mock.calls[0]?.[0]).toContain('<skill name="loose"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildReinjectBlocks, tryConsumeDeferredReinject } from "../src/reinject";
|
||||
import { createDefaultSettings } from "../src/settings";
|
||||
import { createInitialState, trackSkill } from "../src/state";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-deferred-"));
|
||||
tempDirs.push(root);
|
||||
const baseDir = join(root, name);
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
const filePath = join(baseDir, "SKILL.md");
|
||||
writeFileSync(filePath, "# skill\n", "utf8");
|
||||
return { filePath, baseDir };
|
||||
}
|
||||
|
||||
function ctxWithNotify(): { ctx: never; notify: ReturnType<typeof vi.fn> } {
|
||||
const notify = vi.fn();
|
||||
return { notify, ctx: { hasUI: true, ui: { notify } } as never };
|
||||
}
|
||||
|
||||
describe("deferred reinject loose fallback", () => {
|
||||
it("(a) unregistered on disk with requireRegistered=false → block + info notify", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("loose");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||
state.pendingReinject = ["loose"];
|
||||
const { ctx, notify } = ctxWithNotify();
|
||||
|
||||
const result = tryConsumeDeferredReinject(state, createDefaultSettings(), [], ctx);
|
||||
|
||||
expect(result?.message?.content).toContain('<skill name="loose"');
|
||||
expect(notify).toHaveBeenCalledWith('skill-reinject: re-injected "loose" from disk', "info");
|
||||
});
|
||||
|
||||
it("(b) unregistered on disk with requireRegistered=true → skip + warn", () => {
|
||||
const { filePath, baseDir } = tempSkillDir("strict");
|
||||
const state = createInitialState();
|
||||
trackSkill(state, { name: "strict", filePath, baseDir, source: "slash" });
|
||||
const settings = createDefaultSettings();
|
||||
settings.requireRegistered = true;
|
||||
const { ctx, notify } = ctxWithNotify();
|
||||
|
||||
const blocks = buildReinjectBlocks(["strict"], state, settings, [], ctx);
|
||||
|
||||
expect(blocks).toEqual([]);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'skill-reinject: skipped "strict" — no longer registered',
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("(c) missing filePath on disk → skip + warn", () => {
|
||||
const state = createInitialState();
|
||||
trackSkill(state, {
|
||||
name: "missing",
|
||||
filePath: "/no/such/SKILL.md",
|
||||
baseDir: "/no/such",
|
||||
source: "slash",
|
||||
});
|
||||
const { ctx, notify } = ctxWithNotify();
|
||||
|
||||
const blocks = buildReinjectBlocks(["missing"], state, createDefaultSettings(), [], ctx);
|
||||
|
||||
expect(blocks).toEqual([]);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
'skill-reinject: skipped "missing" — SKILL.md not found on disk',
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,16 @@ describe("parseSkillReinjectPartial", () => {
|
||||
}),
|
||||
).toEqual({ enabled: true, suffix: "custom" });
|
||||
});
|
||||
|
||||
it("parses debug flag", () => {
|
||||
expect(parseSkillReinjectPartial({ debug: true })).toEqual({ debug: true });
|
||||
});
|
||||
|
||||
it("parses requireRegistered flag", () => {
|
||||
expect(parseSkillReinjectPartial({ requireRegistered: true })).toEqual({
|
||||
requireRegistered: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSkillReinjectSettings", () => {
|
||||
|
||||
Reference in New Issue
Block a user