From ee13faf285266e8b7e6682a3d779df102ddfc2b2 Mon Sep 17 00:00:00 2001 From: GRayHook Date: Wed, 17 Jun 2026 12:45:51 +0700 Subject: [PATCH] =?UTF-8?q?Phase=2010:=20rescan=20branch=20for=20tracked?= =?UTF-8?q?=20skills=20when=20state=20entry=20missing=20=E2=80=94=20SPEC?= =?UTF-8?q?=20=C2=A76.3.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.ts | 9 +++-- src/rescan.ts | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/rescan.ts diff --git a/src/index.ts b/src/index.ts index aabd442..0b46f19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { tryConsumeDeferredReinject, } from "./reinject.js"; import { readSettings } from "./settings.js"; +import { rescanSkillsFromBranch } from "./rescan.js"; import { findRegisteredSkillByName, resolveRegisteredSkills } from "./skills-registry.js"; import { applyExtensionState, @@ -99,9 +100,13 @@ export default function skillReinject(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { detectAndCachePiAutoCompact(pi, runtime); - readSettings(ctx); - const loaded = loadStateFromBranch(ctx.sessionManager.getBranch()); + 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); + } }); pi.on("session_before_compact", async () => { diff --git a/src/rescan.ts b/src/rescan.ts new file mode 100644 index 0000000..4444948 --- /dev/null +++ b/src/rescan.ts @@ -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 }; + 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, + }); + } + } + } + } +}