Compare commits

..

83 Commits

Author SHA1 Message Date
grayhook 7665096601 TODO: mark Phase 14 complete — B-002 --skill reinject fix
All checklist items done: requireRegistered, deferred filter stages, loose disk fallback, tests, README, backlog close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:39:32 +07:00
grayhook fcf9283fe1 BACKLOG: close B-002 — Phase 14 loose reinject for --skill paths
Defer planning, consume filter, disk fallback, and unit regression gate; short RPC E2E documented in docs/e2e-b002-post-fix.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:39:32 +07:00
grayhook e3873d765d Phase 14: document requireRegistered and --skill reinject paths — README
Explain default loose disk fallback for CLI --skill skills and opt-in strict requireRegistered mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:38:48 +07:00
grayhook 1e5cd07784 Phase 14: B-002 post-fix RPC E2E — unit pass, compact kept-window limit
Post-fix script and doc: automated tests cover loose defer path; short RPC session keeps skill in kept so compact reinject not triggered.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:38:46 +07:00
grayhook 2f6f5477c5 Phase 14: deferred reinject regression tests — loose, strict, missing disk
test/reinject.test.ts gates B-002 defer-path filter and build behavior for requireRegistered and missing SKILL.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:35:51 +07:00
grayhook 459b8775f4 Phase 14: reinjectNow loose fallback for --skill paths — B-002 now command
resolveReinjectSkillNames includes tracked skills on disk when requireRegistered is false so /skill-reinject now works without resourceLoader entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:35:19 +07:00
grayhook ebc169c91f Phase 14: build reinject blocks from tracked paths — loose skill disk fallback
buildReinjectBlocks uses tracked filePath/baseDir when resourceLoader has no entry and requireRegistered is false, completing defer-path B-002 injection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:34:43 +07:00
grayhook e63041bfc5 Phase 14: filter deferred pending on before_agent_start — registry + disk fallback
filterPendingReinjectForConsume applies fresh registered check at consume time; loose skills pass when requireRegistered is false and tracked SKILL.md exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:34:06 +07:00
grayhook a81337c08e Phase 14: defer reinject plan without registry at compaction — B-002 stage 1
planDeferredReinject locks pending by kept-window only; defer path in index uses it while immediate keeps registered filter at compact time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:31:43 +07:00
grayhook fe25e606b8 Phase 14: split kept-window filter from registry gate — defer planning stage
filterSkillsNeedingReinjectByKept tracks absent skills without registeredNames; immediate path keeps the combined filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:28:32 +07:00
grayhook aca68e73ee Phase 14: add requireRegistered setting — opt-out for strict registry filter
Default false so CLI --skill paths can be re-injected from disk in later Phase 14 items; explicit true restores registered-only behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:27:09 +07:00
grayhook 6c82594392 TODO: mark Phase 14 diag + pre-fix repro complete
Reflect completed checklist items for Phase 14 so the phase state matches
already-committed implementation and repro evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:04:54 +07:00
grayhook ef9b7a8c30 Phase 14: B-002 pre-fix RPC repro — filter snapshots and readSettings fix
RPC E2E with debug shows registered present at session_compact but planned=[]
because kept still contains the skill block; registered=[] still drops skills
absent from kept. Sync file readSettings avoids RPC hook deadlock on
SettingsManager/isProjectTrusted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 15:21:53 +07:00
grayhook 8f48040eac Phase 14: add debug reinject diag logging — B-002 filter visibility
Expose settings.debug snapshots on session_compact and before_agent_start
so Phase 14 can see which filter stage drops --skill paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 14:29:56 +07:00
grayhook 2894ed751d TODO: add Phase 14 — re-inject for --skill / non-discovery skills
Закрывает план для BACKLOG B-002: отложенная registered-фильтрация в defer-path, loose fallback по filePath на диске, settings.requireRegistered как opt-out. Чеклист включает диагностический подпункт перед фиксом, чтобы подтвердить гипотезу о фильтре.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 14:25:42 +07:00
grayhook ab4c133a9c BACKLOG: close B-001 (LLM available), open B-002 reinject E2E.
LiteLLM works via pi-provider-litellm; remaining gap is skill
registration for planReinject when skills load only via --skill CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 14:05:19 +07:00
grayhook b819b4bed3 TODO: mark phase 13 complete — README, E2E partial, §13 review.
E2E compaction scenarios remain open in BACKLOG B-001 pending LLM access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:28:43 +07:00
grayhook 496d7478df Phase 13: §13 acceptance review — unit pass, E2E gaps in B-001.
1 default off + session on: PASS (settings.test, RPC)
2 global on persists: PASS (settings.test, RPC)
3 auto compact reinject: PARTIAL (kept/reinject units; E2E blocked)
4 manual /compact skip: PASS (reinject-manual-defer.test)
5 tracked sources: PASS (detect.test)
6 state /resume: PASS code (loadStateFromBranch); no integration test
7 footer on·N: PASS code (updateSkillReinjectStatusLine); TUI not exercised
8 no duplicate in kept: PASS (kept-window.test)
9 pi-auto-compact defer: PARTIAL (auto-compact.test; race E2E blocked)
10 manual /compact coexist: PARTIAL (manual-defer units; E2E blocked)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:27:23 +07:00
grayhook 074fcdaae5 Phase 13: manual E2E pi-auto-compact partial — detect OK, flow blocked.
§12.3 p.1–2: pi-auto-compact loads with extension; auto-compact command
registered. delivery defer covered by unit tests (auto-compact.test.ts).
§12.3 p.3–7 blocked without LLM — see BACKLOG B-001.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:26:46 +07:00
grayhook 82dc8a7126 Phase 13: manual E2E standalone partial — RPC smoke, backlog B-001.
§12.2 RPC smoke (pi -e, --mode rpc): PASS register skill-reinject/sr/skills-reinject;
PASS /skill-reinject on|list|global on|off|reset|integration defer.
BLOCKED §12.2 p.2–5: no LLM (API key / llama server unreachable) for /skill:name,
auto compaction, and post-compact re-inject verification. Logged as B-001.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:26:26 +07:00
grayhook 08b997848f Phase 13: update README for v1 — install, commands, pi-auto-compact.
Document implemented status, pi -e installation, /skill-reinject usage,
and coexistence guidance per SPEC §9.1 and §16.7.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:20:50 +07:00
grayhook 7d99ab8f1e TODO: mark phase 12 complete — edge cases for manual compact, collisions, and RPC mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:15:22 +07:00
grayhook 09619d9dd8 Phase 12: recalculate pending on each compact — SPEC §16.6.
Every session_compact replans pendingReinject in defer mode; skipped reinject clears the queue unless manual compaction is waiting for the next user prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:15:13 +07:00
grayhook 502ca39b3e Phase 12: RPC no-ui command safety — SPEC §11.
Slash commands persist state changes in hasUI=false mode without calling notify; export handler for regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:13:42 +07:00
grayhook 66d9a39a18 Phase 12: maxSkills soft warn — SPEC §15.
Optional maxSkills setting sets the warn threshold; when unset, re-injecting more than three tracked skills emits a one-time UI warning without blocking delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:12:21 +07:00
grayhook d92c5f827d Phase 12: skill name collision warn — SPEC §11.
Duplicate names in resourceLoader resolve to the first skill with a one-time UI warning during re-inject expansion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:11:32 +07:00
grayhook c071f240d3 Phase 12: manual compaction defer clear — SPEC §16.5, §12.3.
Stale pendingReinject from auto compaction is blocked until the next user prompt after manual /compact with default settings; a later auto compaction resets the clear flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 13:10:56 +07:00
grayhook 7ff7529957 TODO: mark phase 11 complete — /skill-reinject commands, status, and footer line.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:57:51 +07:00
grayhook 5d902349d1 Phase 11: footer status line on·N — SPEC §7.2.
Update skill-reinject status after toggles, clear, skill tracking, and session restore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:57:38 +07:00
grayhook 00ed5c6253 Phase 11: register /sr and /skills-reinject aliases — SPEC §7.1.
Expose the same handler under optional shorthand command names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:56:57 +07:00
grayhook 0534093f2c Phase 11: /skill-reinject now delegates to reinjectNow — SPEC §7.1.
Wire debug force re-inject through the existing immediate delivery path with registered skills from the session.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:56:36 +07:00
grayhook 03dcdb22de Phase 11: integration session override — SPEC §7.1, §16.4.
Persist autoCompactIntegration override in state and wire resolveDeliveryMode plus status delivery line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:56:19 +07:00
grayhook 435c5b3289 Phase 11: list and clear tracked skills — SPEC §7.1.
Show tracked skill names with sources on list; clear skills array without resetting session toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:55:47 +07:00
grayhook 5c0eb4d039 Phase 11: global on/off toggle — SPEC §7.1, §7.3.
Write skillReinject.enabled to ~/.pi/agent/settings.json via merge write without clobbering other keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:55:19 +07:00
grayhook e55a14e469 Phase 11: session on/off/reset toggle — SPEC §5.1, §7.1.
Persist sessionOverride via saveState and confirm the effective enabled layer after each toggle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:55:07 +07:00
grayhook 7d1c4f031f Phase 11: show /skill-reinject status output — SPEC §7.2.
Format enabled layer, delivery mode, tracked skills, pending queue, and last compaction source on bare command.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:54:47 +07:00
grayhook dc07e516af Phase 11: register /skill-reinject command — SPEC §7.
Wire pi.registerCommand with a minimal handler stub so later subcommands can plug into commands.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:52:58 +07:00
grayhook b22ee7fefc TODO: mark phase 10 complete — session restore, branch rescan, and shutdown flush.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:47:18 +07:00
grayhook ecddaf5752 Phase 10: flush persisted state on session_shutdown — SPEC §11.
Append skill-reinject:state before extension teardown so the latest tracked
skills and session override survive quit, reload, and session replacement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:47:05 +07:00
grayhook a5448b4002 Phase 10: restore session state on session_tree for branch switch — SPEC §6.3, §11.
Extract restoreSessionState for reload/resume/startup and reuse on session_tree
so /tree branch navigation reloads persisted state or rescans like session_start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:46:44 +07:00
grayhook ee13faf285 Phase 10: rescan branch for tracked skills when state entry missing — SPEC §6.3.
Walk user messages and read tool calls on session_start to rebuild tracked
skills from history when no skill-reinject:state entry exists on the branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:45:51 +07:00
grayhook 1a690f921f Phase 10: load persisted state and settings on session_start — SPEC §5.1, §6.3, §16.4.
Restore skill-reinject:state from branch on startup/resume, read merged settings,
and detect pi-auto-compact; reset to initial state when no entry exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:40:33 +07:00
grayhook b764acd974 TODO: mark phase 9 complete — tracking hooks, persist, and session_compact reinject wiring.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:35:07 +07:00
grayhook 0e32a498ee Phase 9: wire session_compact to defer and immediate reinject — SPEC §5.2, §8.
Connect compaction source gate with plan/enqueue/send paths and consume deferred queue on before_agent_start for end-to-end auto compaction trigger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:35:01 +07:00
grayhook 0d274881dd Phase 9: persist state after tracked skill changes — SPEC §6.1.
Call saveState via appendEntry after slash, skill-block, and read-path tracking so session state survives resume.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:31:36 +07:00
grayhook edc01d1079 Phase 9: track read tool paths to SKILL.md on tool_call — SPEC §6.2 #3.
Match read tool paths against registered skills when trackReadPaths is enabled and upsert with source read.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:30:57 +07:00
grayhook 2021ee1293 Phase 9: track skill blocks on message_end for user messages — SPEC §6.2 #2.
Scan finalized user message text for expanded skill XML blocks and upsert tracked skills using registered metadata when available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:30:17 +07:00
grayhook 86c6837351 Phase 9: track slash /skill:name on input hook — SPEC §6.2 #1.
Wire input handler to detect slash skills and upsert into session state using registered skill metadata from before_agent_start cache or loadSkills fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:28:22 +07:00
grayhook eb911ab7e3 TODO: mark phase 8 complete — compaction source detection state machine and gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:04:58 +07:00
grayhook 0f06e0e45b Phase 8: add shouldReinject gate and consume on session_compact — SPEC §8.
Evaluates enabled layer and compaction source, records lastCompactionSource, clears pending flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:04:47 +07:00
grayhook 18dd600d2d Phase 8: mark auto compaction before_compact — default source unless manual.
session_before_compact sets pendingCompactionSource to auto when input did not mark /compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:03:33 +07:00
grayhook 7bbe2370d7 Phase 8: mark manual compaction from /compact input — SPEC §8 input hook.
Sets pendingCompactionSource before expansion when user text starts with /compact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:02:48 +07:00
grayhook cf2eedb85b Phase 8: add compaction runtime state — pendingCompactionSource container for §8.
Transient CompactionRuntime holds pending source between input, before_compact, and session_compact hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 12:01:36 +07:00
grayhook 3bab1f802b TODO: mark phase 7 complete — reinject orchestration plan, defer, immediate, skip missing, now.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:59:32 +07:00
grayhook d637722ea5 Phase 7: add reinjectNow — force immediate re-inject of all tracked skills for debug.
Uses idle vs followUp delivery based on ctx.isIdle(); skips unregistered skills via existing buildReinjectBlocks warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:59:24 +07:00
grayhook 9a197aee10 Phase 7: skip missing skills with ui.notify warning on expand.
buildReinjectBlocks checks registration and file presence before expandSkill; warns via ctx.ui when hasUI, no-op in RPC/print mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:59:12 +07:00
grayhook dc73ea9747 Phase 7: add sendImmediateReinjectAllFollowUp — queue all blocks when streaming or willRetry.
Uses deliverAs followUp for every skill block so re-inject does not interrupt an active agent turn or overflow recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:58:29 +07:00
grayhook 446a186431 Phase 7: add sendImmediateReinjectIdle — first skill triggers turn, rest as followUp.
Extracts buildReinjectBlocks for shared expand logic; immediate delivery when the agent is idle uses sendUserMessage without deliverAs for the first block only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:58:15 +07:00
grayhook 2059f6033b Phase 7: add defer inject on before_agent_start — combined skill blocks, clear queue.
Builds one injected custom message from pendingReinject via expandSkill, clears the queue only after content is built successfully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:57:48 +07:00
grayhook e0daa50cce Phase 7: add enqueueDeferredReinjectFromCompact — queue plan on session_compact without sendUserMessage.
Defer delivery stores planned skill names in pendingReinject for injection on the next before_agent_start, avoiding races with pi-auto-compact follow-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:55:13 +07:00
grayhook 23d580b6d2 Phase 7: add planReinject — kept-window and registration filter for post-compaction skills.
Computes which tracked skills need re-inject after compaction by slicing the kept branch and excluding skills still present in kept user messages or unregistered on disk.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:54:01 +07:00
grayhook ab315d899b TODO: mark phase 6 complete — pi-auto-compact detect, delivery mode, constants, hint.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:47:38 +07:00
grayhook bf862656ae Phase 6: add compaction coexistence hint — one-time notify when both compactors run.
Shows ui.notify once when pi-auto-compact is detected while Pi compaction.enabled stays true.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:47:27 +07:00
grayhook 2e6d36a855 Phase 6: add PI_AUTO_COMPACT_FOLLOW_UP_PREFIXES — document pi-auto-compact phrases.
Constants match SPEC §16.9 for tests and docs; runtime v1 does not match follow-up text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:46:38 +07:00
grayhook e56f81d25c Phase 6: add resolveDeliveryMode — defer vs immediate from integration table.
Wraps effectiveIntegration with RuntimeFlags.autoCompactDetected so reinject wiring reads delivery mode from one place.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:46:19 +07:00
grayhook 776345a238 Phase 6: add detectPiAutoCompact — detect pi-auto-compact via getCommands.
Uses the public ExtensionAPI command list and caches the result in RuntimeFlags for session_start wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:45:22 +07:00
grayhook b54b8f98bf TODO: mark phase 5 complete — kept-window helpers and tests.
Phase 5 delivers getKeptEntries, presence scan, filter, and kept-window.test.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:21:55 +07:00
grayhook a8e07fdd6f Phase 5: add kept-window tests — slice, presence, filter, empty kept.
Covers kept-window dedup helpers per SPEC §6.4 and §12.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:21:38 +07:00
grayhook a0e6d204a6 Phase 5: add filterSkillsNeedingReinject — kept-window dedup input for pendingReinject.
Returns tracked registered skills absent from kept user messages per SPEC §5.2 and §6.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:20:11 +07:00
grayhook 9896e7efa6 Phase 5: add skillsPresentInKeptWindow — detect skill blocks in kept user messages.
Scans kept entries for expanded skill blocks so re-inject skips duplicates per SPEC §6.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:19:05 +07:00
grayhook 6ccb580ca1 Phase 5: add getKeptEntries — branch slice from firstKeptEntryId.
Implements kept-window boundary per SPEC §6.4 for dedup before re-inject.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:17:30 +07:00
grayhook 1296090909 TODO: mark phase 4 complete — skill block expand helpers and tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:32:32 +07:00
grayhook 1382e3f66f Phase 4: add expand tests — frontmatter strip, paths, suffix.
Cover readSkillBody, formatBlock, appendSuffix, and expandSkill per SPEC §12.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:32:23 +07:00
grayhook 9d32cdffb1 Phase 4: add expandSkill — skill meta to injectable user text.
Compose readBody, formatBlock, and appendSuffix for reinject and /skill-reinject now (SPEC §5.3).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:31:45 +07:00
grayhook 049a11a7d5 Phase 4: add appendSuffix — optional reinject message suffix.
Configurable trailing note after skill block per settings.suffix (SPEC §5.3).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:31:37 +07:00
grayhook 68b7d018cc Phase 4: add formatBlock — XML skill block matching Pi expand.
Mirror _expandSkillCommand output shape with name, location, and baseDir hint (SPEC §5.3).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:31:21 +07:00
grayhook 584a8fa342 Phase 4: add readSkillBody — SKILL.md read with frontmatter strip.
Mirror agent-session _expandSkillCommand body extraction via public stripFrontmatter API (SPEC §5.3, §10).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:30:58 +07:00
grayhook 69611685d4 TODO: mark phase 3 complete — skill detection helpers and tests.
Phases 0–3 done; next up is expand.ts (phase 4).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:25:09 +07:00
grayhook 6e55990bfb Phase 3: add detect tests — slash, blocks, read match, trackReadPaths gate.
Cover detection helpers from SPEC §6.2 for regression safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:24:49 +07:00
grayhook cc5ffc47bf Phase 3: add trackReadPaths gate — skip read-path detection when disabled.
Honor skillReinject.trackReadPaths before matching read tool paths to skills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:24:49 +07:00
grayhook 9cd60a2534 Phase 3: add matchReadPathToSkill — read tool path to skill mapping.
Match read paths against registered SKILL.md filePath for the read tracking source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:23:51 +07:00
grayhook ccb39c413d Phase 3: add parseSkillBlocksFromText — skill-block scan per SPEC §6.2.
Mirror agent-session parseSkillBlock regex for detecting expanded skill blocks in user messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:21:00 +07:00
grayhook 2d7392f5ed Phase 3: add detectSlashSkill — slash command detection per SPEC §6.2.
Detect /skill:name at the start of raw user input for the slash tracking source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:18:04 +07:00
36 changed files with 3355 additions and 87 deletions
+2 -1
View File
@@ -10,7 +10,7 @@
|---|---|
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
| Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
| Статус | **Фазы 013 завершены** (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
View File
@@ -41,10 +41,27 @@
## Открыто
_Новые пункты — ниже (следующий id: **B-001**)._
_Новые пункты — ниже (следующий id: **B-003**)._
---
## Закрыто
_Пусто._
### B-002 · done · e2e · 2026-06-17 (закрыт 2026-06-17)
- **Сценарий:** Manual E2E §12.2 п.25, §12.3 п.37, §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
+63 -14
View File
@@ -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)
+80 -58
View File
@@ -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 §56) |
| Re-inject после auto compaction | Auto compaction → re-inject tracked skills (SPEC §56) |
---
@@ -105,6 +105,7 @@
| 11 | Команды и UI | 1, 2, 6, 7 | §7 |
| 12 | Edge cases и полировка | 711 | §11 |
| 13 | Приёмка и документация | 0–12 | §1213 |
| 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 п.45
- [ ] **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 п.45
- [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** — связать §78: 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** — связать §78: 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`
---
+21
View File
@@ -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`).
+22
View File
@@ -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.
+263
View File
@@ -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);
+222
View File
@@ -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);
+90
View File
@@ -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
View File
@@ -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());
}
+70
View File
@@ -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;
}
+95
View File
@@ -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
View File
@@ -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");
}
}
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
};
}
+99
View File
@@ -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
View File
@@ -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> {
+27
View File
@@ -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
View File
@@ -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,
};
}
+108
View File
@@ -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);
});
});
+98
View File
@@ -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"]);
});
});
+83
View File
@@ -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();
});
});
+85
View File
@@ -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");
});
});
+86
View File
@@ -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();
});
});
+117
View File
@@ -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.`,
);
});
});
+135
View File
@@ -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([]);
});
});
+31
View File
@@ -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",
);
});
});
+111
View File
@@ -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([]);
});
});
+37
View File
@@ -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"]);
});
});
+75
View File
@@ -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"]);
});
});
+40
View File
@@ -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",
);
});
});
+61
View File
@@ -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"');
});
});
+81
View File
@@ -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",
);
});
});
+10
View File
@@ -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", () => {