import { runPiSubagent } from "../util/subagent.ts"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { MEMORY_DB_DIR, SKILL_FEED_CURSOR_FILE, SKILL_FEED_DIR } from "../memory/paths.ts"; import { loadCursor, saveCursor } from "../memory/review/review-cursor.ts"; import { render as renderPrompt } from "../prompts/registry.ts"; /** * Skill 审查子代理调度模块。 * 在 agent_settled 中计数,达到阈值时启动 skill-reviewer 后台审查。 * 注册 /learn 命令。 * * 数据源:从 L0 分片(2-role)改为读 skill-feed(5-role,含 tool_call/tool_result), * 子代理通过 read_skill_feed_increment 工具一次读取游标后全量增量, * 保留工具决策链供 skill 提取(M4,2026-08-19 改造)。 */ export const SKILL_REVIEW_INTERVAL = 20; const CURSOR_KEY = "skillReviewer"; // 注:原 SUBAGENT_TIMEOUT_MS(10 分钟)已随自建 spawn 移除——它是传给 executor 的 // timeoutMs,而上游异步路径根本不接(runAsyncPath 解构的 16 个字段里既没有 signal 也没有 // timeoutMs),那个值从来没生效过。审查子代理是 fire-and-forget,不进 jobs 队列, // 没有活性门控可等;真要限制时长得在任务文本里写明。 export function setupSkillReviewer( pi: ExtensionAPI, getProfilePath: () => string | undefined, onStatusChange: (ctx?: ExtensionContext) => void = () => {}, ): () => string { let turnCount = 0; let reviewScheduled = false; let waitingForReply = false; function prepareReviewPaths(ctx: ExtensionContext): { sessionPath: string; profilePath: string } | null { const sessionFile = ctx.sessionManager.getSessionFile(); if (!sessionFile || !existsSync(sessionFile)) return null; const profilePath = getProfilePath(); if (!profilePath) return null; return { sessionPath: sessionFile, profilePath }; } /** * 构建审查任务:列 skill-feed 文件路径 + reviewer 游标,传给子代理用 read_skill_feed_increment 读增量。 */ function buildReviewTask(ctx: ExtensionContext): { task: string; paths: { profilePath: string }; cursor: string } | null { const paths = prepareReviewPaths(ctx); if (!paths) return null; // 枚举 skill-feed 文件({memoryDB}/skill/feed/.jsonl,按 session 一文件) // 勘误:按 session 过滤,仅含当前 session 的 feed 文件,不回溯跨 session 未落库分片(修复 c790680d) const feedDir = join(paths.profilePath, MEMORY_DB_DIR, SKILL_FEED_DIR); if (!existsSync(feedDir)) return null; const sessionKey = basename(paths.sessionPath).replace(/\.jsonl$/, ""); let feedFiles: string[] = []; try { feedFiles = readdirSync(feedDir) .filter((f) => f === `${sessionKey}.jsonl`) .map((f) => join(feedDir, f)); } catch { return null; } if (feedFiles.length === 0) return null; const cursor = loadCursor(paths.profilePath, CURSOR_KEY); // 未完成检测(简单版:读最后一个 skill-feed 文件末尾看是否有完整 assistant 回复; // tool_result/tool_call 结尾视为轮次进行中) const lastFile = feedFiles[feedFiles.length - 1]; if (existsSync(lastFile)) { const raw = readFileSync(lastFile, "utf-8"); const lines = raw.split(/\r?\n/).filter((l) => l.trim()); const tail = lines.slice(-50); let hasCompleteReply = false; for (const line of tail) { try { const e = JSON.parse(line); if (e.role === "tool_result" || e.role === "tool_call") hasCompleteReply = false; else if (e.role === "user") hasCompleteReply = false; else if (e.role === "assistant" && typeof e.content === "string" && e.content.trim()) hasCompleteReply = true; } catch { continue; } } if (!hasCompleteReply) { waitingForReply = true; return null; } } waitingForReply = false; const reviewerCursor = cursor && cursor.sessionPath ? cursor.sessionPath : ""; const task = renderPrompt("tasks/skill-review#main", { skillFeedFiles: feedFiles.join(","), cursorPath: join(paths.profilePath, MEMORY_DB_DIR, SKILL_FEED_CURSOR_FILE), reviewerCursor, profilePath: paths.profilePath, }); return { task, paths, cursor: reviewerCursor }; } pi.on("input", (event) => { if (event.source !== "interactive") return; if (!reviewScheduled) { turnCount++; waitingForReply = false; onStatusChange(); } }); pi.on("agent_settled", async (_event, ctx) => { if (turnCount >= SKILL_REVIEW_INTERVAL && !reviewScheduled) { turnCount = 0; const built = buildReviewTask(ctx); if (!built) { onStatusChange(ctx); return; } reviewScheduled = true; onStatusChange(ctx); try { await runPiSubagent("skill-reviewer", built.task, ctx); saveCursor(built.paths.profilePath, CURSOR_KEY, new Date().toISOString(), Date.now()); } finally { reviewScheduled = false; onStatusChange(ctx); } } }); pi.registerCommand("learn", { description: "Learn a reusable skill from source code, docs, or conversation. Usage: /learn [source]", handler: async (args, ctx) => { if (reviewScheduled) { ctx.ui.notify("Skill review already in progress", "info"); return; } reviewScheduled = true; onStatusChange(ctx); const extra = (args || "").trim(); const built = buildReviewTask(ctx); if (!built) { const paths = prepareReviewPaths(ctx); if (paths) { const task = renderPrompt("tasks/skill-review#learn-fallback", { sessionPath: paths.sessionPath, profilePath: paths.profilePath, extra: extra ? `\nExtra context: ${extra}` : "", }); try { await runPiSubagent("skill-reviewer", task, ctx); } finally { reviewScheduled = false; onStatusChange(ctx); } } else { reviewScheduled = false; onStatusChange(ctx); } ctx.ui.notify("Learning skill from your request...", "info"); return; } try { await runPiSubagent("skill-reviewer", built.task + (extra ? `\nExtra context: ${extra}` : ""), ctx); saveCursor(built.paths.profilePath, CURSOR_KEY, new Date().toISOString(), Date.now()); } finally { reviewScheduled = false; onStatusChange(ctx); } ctx.ui.notify("Learning skill from your request...", "info"); }, }); return () => reviewScheduled ? "⚙审查" : waitingForReply ? "等待完整回复" : `${SKILL_REVIEW_INTERVAL - turnCount}t`; }