Compare commits
83 Commits
884fee99a5
...
7665096601
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -10,7 +10,7 @@
|
||||
|---|---|
|
||||
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
|
||||
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
|
||||
| Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
|
||||
| Статус | **Фазы 0–13 завершены** (v1); E2E compaction — см. [BACKLOG B-001](./BACKLOG.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
|
||||
|
||||
+19
-2
@@ -41,10 +41,27 @@
|
||||
|
||||
## Открыто
|
||||
|
||||
_Новые пункты — ниже (следующий id: **B-001**)._
|
||||
_Новые пункты — ниже (следующий id: **B-003**)._
|
||||
|
||||
---
|
||||
|
||||
## Закрыто
|
||||
|
||||
_Пусто._
|
||||
### 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,84 @@
|
||||
|
||||
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` + `before_agent_start`, чтобы не конкурировать с follow-up pi-auto-compact (см. [SPEC.md §16](./SPEC.md#16-совместимость-с-capyuppi-auto-compact)).
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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,6 +105,7 @@
|
||||
| 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 |
|
||||
|
||||
**Порядок:** фазы 0→13; внутри фазы — сверху вниз. Параллельно после фазы 0 можно вести 1 и 2; фазы 3 и 4 — независимы друг от друга.
|
||||
|
||||
@@ -141,112 +142,133 @@
|
||||
|
||||
### Фаза 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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
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);
|
||||
@@ -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,70 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function createCompactionRuntime(): CompactionRuntime {
|
||||
return {
|
||||
pendingCompactionSource: null,
|
||||
clearPendingReinjectOnNextUserInput: false,
|
||||
lastCompactionFirstKeptEntryId: 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";
|
||||
}
|
||||
}
|
||||
|
||||
/** session_before_compact: default to auto unless input already marked manual (SPEC §8). */
|
||||
export function markAutoCompactionBeforeCompact(runtime: CompactionRuntime): void {
|
||||
if (runtime.pendingCompactionSource !== "manual") {
|
||||
runtime.pendingCompactionSource = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { ExtensionContext, Skill } from "@earendil-works/pi-coding-agent";
|
||||
import type { SkillReinjectSettings } from "./settings.js";
|
||||
import type { ExtensionState } from "./state.js";
|
||||
|
||||
export type ReinjectDiagPhase = "session_compact" | "before_agent_start";
|
||||
|
||||
/** Filter snapshot for debug logging (Phase 14 / B-002). */
|
||||
export interface ReinjectDiagSnapshot {
|
||||
tracked: string[];
|
||||
kept: string[];
|
||||
registered: string[];
|
||||
planned: string[];
|
||||
pending: string[];
|
||||
}
|
||||
|
||||
export function buildReinjectDiagSnapshot(
|
||||
state: ExtensionState,
|
||||
registeredSkills: readonly Pick<Skill, "name">[],
|
||||
keptPresent: ReadonlySet<string>,
|
||||
planned: readonly string[],
|
||||
): ReinjectDiagSnapshot {
|
||||
return {
|
||||
tracked: state.skills.map((skill) => skill.name),
|
||||
kept: [...keptPresent],
|
||||
registered: registeredSkills.map((skill) => skill.name),
|
||||
planned: [...planned],
|
||||
pending: [...state.pendingReinject],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
+263
-3
@@ -1,7 +1,267 @@
|
||||
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,
|
||||
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,
|
||||
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;
|
||||
const trackedNames = state.skills.map((skill) => skill.name);
|
||||
const keptEntries = getKeptEntries(branch, event.compactionEntry.firstKeptEntryId);
|
||||
const keptPresent = skillsPresentInKeptWindow(keptEntries, trackedNames);
|
||||
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);
|
||||
notifyReinjectDiag(
|
||||
ctx,
|
||||
settings,
|
||||
"session_compact",
|
||||
buildReinjectDiagSnapshot(state, skills, keptPresent, planned),
|
||||
);
|
||||
|
||||
if (deliveryMode === "defer") {
|
||||
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, []),
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
||||
import { parseSkillBlocksFromText } from "./detect.js";
|
||||
import type { TrackedSkill } from "./state.js";
|
||||
|
||||
/** 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 (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) {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const text = extractUserMessageText(entry.message.content);
|
||||
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),
|
||||
);
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (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,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,86 @@
|
||||
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"],
|
||||
),
|
||||
).toEqual({
|
||||
tracked: ["alpha"],
|
||||
kept: ["gamma"],
|
||||
registered: ["beta"],
|
||||
planned: ["alpha"],
|
||||
pending: ["alpha"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,135 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
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,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