// ============================================================================= // index.ts — Main soly extension entry point // ============================================================================= // // Loads .agents/rules/ and .agents/ project state into the agent's system // prompt, and registers: // - slash commands /rules /soly /rulewizard /why // - LLM tools soly_read soly_log_decision soly_list_phases // - input hooks nudge (soft UI hint) + workflow verbs ("soly ...") // // All heavy logic lives in submodules: // - core.ts data types, loaders, builders // - nudge.ts behavioral nudge (pre-action gate + subagent preference) // - commands.ts /rules /soly /rulewizard /why // - tools.ts soly_read soly_log_decision soly_list_phases // - workflows/ soly execute / pause / compact (plain-text input only) // // To add a new workflow verb: edit workflows/parser.ts + workflows/.ts, // then re-export the handler in workflows/index.ts. No changes needed here. // ============================================================================= import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { analyzeRules, buildProjectStateSection, buildRulesSection, buildStatusLine, CONTEXT_WINDOW_TOKENS, DEFAULT_PROGRESS, extractFilePathsFromPrompt, formatTok, loadAllRules, builtInRulesDir, loadBuiltInRules, loadPhaseRules, loadProjectState, matchesGlob, rulesApplicableToFiles, STATUS_ID, solyDirFor, SOLY_DIRNAME, buildNextHint, buildDriftReminder, type RuleFile, type SolyState, type SourceSpec, } from "./core.ts"; import { buildIntegrationsSection } from "./integrations.ts"; import { DEFAULT_CONFIG, loadConfig, pruneOldIterations, type SolyConfig, } from "./config.ts"; import { classifyTaskHeuristics, buildNudgeSection, buildSuggestionSection, type WorkflowSituation } from "./nudge.ts"; import { registerWorkflowTool } from "./workflows/llm-tools.ts"; import { detectToolHints, buildToolHintSection } from "./tool-hints.ts"; import { notifyNudge, notifyDeprecation } from "./notification.ts"; import { registerCommands, type CommandUI } from "./commands.ts"; import { registerTools } from "./tools.ts"; import { registerWorkflows } from "./workflows/index.ts"; import { readGitContext, buildGitSection, type GitContext } from "./git.ts"; import { startHotReload, type HotReloadHandle } from "./hotreload.ts"; import { registerQuotaProvider } from "./quota/registry.ts"; import { minimaxProvider } from "./quota/minimax.ts"; import { startQuotaPoller, type QuotaPoller } from "./quota/poller.ts"; import { detectEnv, buildEnvSection, type EnvSummary } from "./env.ts"; import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.ts"; import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.ts"; import { createChrome, readWelcomeMeta } from "./visual/index.ts"; import { createContextManager } from "./context-manager.ts"; // Built-in sub-features (merged from former pi-asked, pi-agented packages): import piAskExtension from "./ask/index.ts"; import piDeckExtension from "./deck/index.ts"; import piArtifactExtension from "./artifact/index.ts"; import { getArtifactServer } from "./artifact/session.ts"; import { emit } from "./visual/event-sink.ts"; /** Compact phase label for the chrome top bar, e.g. "plan 2/5". Null when idle. */ function phaseLabelFromState(s: SolyState): string | null { if (!s.exists) return null; const cur = s.currentPhase; const total = s.phases.length; if (cur) return total > 0 ? `${cur.slug || cur.name} ${cur.number}/${total}` : `${cur.slug || cur.name}`; return null; } /** Tiny always-on pointer to soly's interactive/visual tools. The detailed * "when / when-not / params" guidance lives in the soly-framework skill * (loaded on demand) so it doesn't cost tokens every turn. */ const SOLY_TOOLS_POINTER = ` ## soly — interactive & visual tools When terminal text isn't the best medium, reach for these (details + when-NOT in the soly-framework skill): - \`ask_pro\` — multi-question picker (single/multi-select, free-text, per-option previews); every options question always offers a free-text "Other…" so the user can answer in their own words. - \`decision_deck\` — full-screen cards comparing design options by their concrete code shape. - \`html_artifact\` — render HTML to a self-contained, browseable per-project gallery.`; export default function solyExtension(pi: ExtensionAPI) { // ============================================================================ // Register built-in quota providers (MiniMax via mmx CLI). // Adding a new provider = new adapter + registerQuotaProvider() here. // ============================================================================ registerQuotaProvider(minimaxProvider); // ============================================================================ // State (module-local, lives for the duration of one extension instance) // ============================================================================ // Rules let rules: RuleFile[] = []; let rulesLoaded: string[] = []; let lastRulesTokens = 0; let ruleSources: SourceSpec[] = []; let overriddenRulePaths: string[] = []; let sessionCwd = ""; // ============================================================================ // ============================================================================ // and on each session_start (the LLM can call /soly config to view). let activeConfig: SolyConfig = DEFAULT_CONFIG; const getActiveConfig = (): SolyConfig => activeConfig; // Drift counter — tracks how many non-soly turns the user has spent // before invoking a soly verb. After the threshold, a reminder is // injected into the next system prompt so the LLM can suggest a sync // (status, pause, etc.). Resets on every parsed soly verb. let solyDrift = { turnsSinceLastSolyVerb: 0, lastReminderAt: 0, REMINDER_THRESHOLD: 5, }; function resetSolyDrift() { solyDrift.turnsSinceLastSolyVerb = 0; solyDrift.lastReminderAt = 0; } // Project state let state: SolyState = { solyDir: "", exists: false, milestone: "—", milestoneName: "", status: "unknown", lastUpdated: "", progress: { ...DEFAULT_PROGRESS }, position: null, currentPhase: null, currentPlanPath: null, stateBody: "", roadmapBody: "", phases: [], features: [], tasks: [], }; // Status line cache (anti-flicker) let lastStatusLine = ""; // Behavioral nudge state let nudgeActiveForTask = false; let editedFilesThisTurn = new Set(); let lastEditedFiles: string[] = []; let lastTurnApplicableRules: string[] = []; let lastNudgePromptKey = ""; // Git context (cached, refreshed on hot reload + before_agent_start) let gitContext: GitContext = { available: false, branch: null, statusShort: null, lastCommits: [] }; let lastGitSection = ""; // Hot reload watcher for rules let hotReload: HotReloadHandle | null = null; let quotaPoller: QuotaPoller | null = null; // Session stats (computed on demand) let sessionStats: { turns: number; tokensEstimate: number } = { turns: 0, tokensEstimate: 0 }; // Env summary (detected once at session_start, cheap to re-detect) let envSummary: EnvSummary | null = null; let lastEnvSection = ""; // Code map (built once at session_start) let codeMap: CodeMap | null = null; let lastCodeMapSection = ""; // Project intent (zero-point docs from .agents/docs/) — always loaded let intentDocs: IntentDoc[] = []; let lastIntentSection = ""; // Visual chrome (top bar + custom footer + working spinner). Config-gated. const chrome = createChrome(() => getActiveConfig().chrome); // Single owner of pi's `context` channel (used by the /verify fresh-context // mode; future context strategies plug in here too). const contextManager = createContextManager(pi); // ============================================================================ // Loaders // ============================================================================ const refreshRules = () => { const result = loadAllRules(ruleSources); alwaysOnRules = result.rules; overriddenRulePaths = result.overridden; // Also refresh phase rules — they may have changed reloadPhaseRules(); }; const refreshState = () => { if (!state.solyDir) return; state = loadProjectState(state.solyDir); }; const refreshIntent = () => { intentDocs = loadIntentDocs(sessionCwd, state.currentPhase?.number); const { section } = buildIntentSection(intentDocs); lastIntentSection = section; }; // ============================================================================ // Phase rules + last-session mtime tracking // ============================================================================ /** Always-on rules (no phase) — reloaded by refreshRules + hot reload. */ let alwaysOnRules: RuleFile[] = []; /** Phase-scoped rules for the currently active phase. */ let phaseRules: RuleFile[] = []; /** Combined view consumed by buildRulesSection / status. */ const combinedRules = (): RuleFile[] => [...alwaysOnRules, ...phaseRules]; /** Reload phase rules for the current state's currentPhase. */ const reloadPhaseRules = () => { const phase = state.currentPhase; if (!phase) { phaseRules = []; return; } phaseRules = loadPhaseRules(phase.dir, phase.number); }; /** * Persistent storage of rule mtimes from the previous session, so we can * show a "rules changed since last session" diff at startup. * Stored in /rule-mtimes.json (project) or * /.agents/rule-mtimes.json (global fallback). */ let lastSessionMtimes: Record = {}; const mtimeStorePath = (): string => { const base = state.solyDir && fs.existsSync(state.solyDir) ? state.solyDir : path.join(os.homedir(), SOLY_DIRNAME); try { fs.mkdirSync(base, { recursive: true }); } catch {} return path.join(base, "rule-mtimes.json"); }; const captureLastSessionRuleMtimes = () => { const filePath = mtimeStorePath(); try { const raw = fs.readFileSync(filePath, "utf-8"); lastSessionMtimes = JSON.parse(raw); } catch { lastSessionMtimes = {}; } }; const persistRuleMtimes = () => { const mtimes: Record = {}; for (const r of alwaysOnRules) mtimes[`${r.source}::${r.absPath}`] = r.mtimeMs; try { fs.writeFileSync(mtimeStorePath(), JSON.stringify(mtimes, null, 2)); } catch { // best effort } }; // ============================================================================ // Status // ============================================================================ const updateStatus = (ui: CommandUI | { ui: { setStatus: (id: string, text: string | undefined) => void } }) => { const setStatus = (ui as { ui: { setStatus: (id: string, text: string | undefined) => void } }).ui.setStatus; const line = buildStatusLine( combinedRules().length, rulesLoaded.length, lastRulesTokens, state, ); // Append session stats if non-zero (cheap; one short group) const sessionGroup = sessionStats.turns > 0 ? `${"\x1b[2m"}session ${sessionStats.turns}t${sessionStats.tokensEstimate > 0 ? ` ${formatTok(sessionStats.tokensEstimate)}` : ""}${"\x1b[0m"}` : ""; // Smart "next:" hint from project state (e.g. "→ next: soly execute 10") const hint = buildNextHint(state); const hintGroup = hint ? `${"\x1b[2m"}${hint}${"\x1b[0m"}` : ""; // Soly doesn't render the agent badge itself. const agentGroup = ""; // Cross-extension: show pi-todo progress if either .agents/todos.json // (soly-integration mode) OR .pi-todos.json (standalone mode) exists. // Cheap (one stat + one small JSON read); cached only for the // lifetime of one updateStatus call. let todoGroup = ""; if (state.exists) { const todoCandidates = [ path.join(state.solyDir, "todos.json"), path.join(state.solyDir, ".pi-todos.json"), ]; for (const todoFile of todoCandidates) { try { if (!fs.existsSync(todoFile)) continue; const raw = fs.readFileSync(todoFile, "utf-8"); const parsed = JSON.parse(raw) as { todos?: Array<{ status: string; activeForm?: string }> }; if (!Array.isArray(parsed.todos) || parsed.todos.length === 0) continue; const total = parsed.todos.length; const done = parsed.todos.filter((t) => t.status === "completed").length; const inProgress = parsed.todos.find((t) => t.status === "in_progress"); if (inProgress?.activeForm) { todoGroup = `${"\x1b[2m"}todos ${done}/${total} \u22ef ${inProgress.activeForm}${"\x1b[0m"}`; } else { todoGroup = `${"\x1b[2m"}todos ${done}/${total}${"\x1b[0m"}`; } break; // first match wins } catch { /* corrupt file — silently skip; pi-todo will rewrite on next update */ } } } const groups = [line, sessionGroup, todoGroup, agentGroup, hintGroup].filter((g) => g.length > 0); const fullLine = groups.join(" "); if (fullLine !== lastStatusLine) { setStatus(STATUS_ID, fullLine || undefined); lastStatusLine = fullLine; } }; // Refresh the chrome's live data snapshot (cwd, model, ctx%, git, phase) // from the current context + project state, then request a re-render. const updateChromeData = (ctx: ExtensionContext) => { if (!getActiveConfig().chrome.enabled) return; const d = chrome.data; d.cwd = sessionCwd || ctx.cwd || ""; d.home = os.homedir(); const model = ctx.model as { id?: string; provider?: string; reasoning?: boolean } | undefined; d.modelId = model?.id ?? null; d.modelProvider = model?.provider ?? null; d.reasoning = Boolean(model?.reasoning); try { d.thinkingLevel = pi.getThinkingLevel(); } catch { d.thinkingLevel = null; } const usage = ctx.getContextUsage(); d.ctxPercent = usage?.percent ?? null; d.ctxTokens = usage?.tokens ?? null; d.contextWindow = usage?.contextWindow ?? null; d.gitDirty = gitContext.statusShort ? gitContext.statusShort.split(/\r?\n/).filter(Boolean).length : 0; d.rulesActive = combinedRules().length; d.phaseLabel = phaseLabelFromState(state); d.artifactCount = getArtifactServer()?.count ?? 0; chrome.poke(); }; // ============================================================================ // Register sub-features // ============================================================================ registerCommands(pi, { getRules: () => combinedRules(), getOverridden: () => overriddenRulePaths, refreshRules: () => refreshRules(), getState: () => state, refreshState: () => refreshState(), updateStatus: (ui) => updateStatus(ui), getConfig: getActiveConfig, reloadConfig: () => { if (!sessionCwd) return; const cfgResult = loadConfig(sessionCwd); activeConfig = cfgResult.config; // Re-warn about config drift (mirrors the warnings at session_start). for (const w of cfgResult.warnings) { pi.sendUserMessage?.(`soly: ${w}`, { deliverAs: "followUp" }); } }, getIntentDocs: () => intentDocs, // Non-error soly events land in the Working sub-line (└─ prefixed). // The route goes through the chrome so the sub-line auto-clears on // the next agent_start. recordEvent: (text, level) => chrome.emit(text, level), }); registerTools(pi, { getState: () => state, refreshState: () => refreshState(), getConfig: getActiveConfig, }); registerWorkflows(pi, { recordEvent: (text, level) => chrome.emit(text, level), getState: () => state, getInteractiveRules: () => combinedRules() .filter((r) => r.interactiveOnly) .map((r) => r.relPath), getActiveTools: () => pi.getActiveTools(), getConfig: getActiveConfig, onWorkflowUsed: resetSolyDrift, contextManager, onVerifyState: (s) => { chrome.data.verbLabel = s.active ? (s.iteration > 0 ? `verify ${s.iteration}/${s.max}` : "verify") : null; chrome.poke(); }, setVerbLabel: (verb) => { chrome.data.verbLabel = verb; chrome.poke(); }, }); // First-party LLM tool that drives the same workflow verbs — lets the model // start plan/execute/etc. itself on the user's natural-language intent, // with no external subagent plugin. Reuses the workflow builders. registerWorkflowTool(pi, { getState: () => state, getInteractiveRules: () => combinedRules() .filter((r) => r.interactiveOnly) .map((r) => r.relPath), getActiveTools: () => pi.getActiveTools(), getConfig: getActiveConfig, onWorkflowUsed: resetSolyDrift, setVerbLabel: (verb) => { chrome.data.verbLabel = verb; chrome.poke(); }, }); // ============================================================================ // Events // ============================================================================ pi.on("session_start", async (event, ctx) => { // Rules sources (priority order, higher wins on relPath collision). // Project rules always beat global rules. `.agents/rules.local/` is // gitignored — for personal overrides on top of the project's rules. // `.agents/rules/` is the vendor-neutral project-level convention. // Built-in rules ship with the extension (priority=10 — highest) and // can't be overridden: a user rule at the same relPath is dropped // silently into the `overridden[]` list (see loadAllRules). ruleSources = [ { dir: builtInRulesDir(), source: "built-in", sourceLabel: "soly", priority: 10 }, { dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 }, { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 }, { dir: path.join(ctx.cwd, ".agents", "rules.local"), source: "project-agents", sourceLabel: "local", priority: 5 }, ]; refreshRules(); // Project state — soly owns .agents/ at the project root state.solyDir = solyDirFor(ctx.cwd); refreshState(); // Config: per-project overrides global overrides defaults const cfgResult = loadConfig(ctx.cwd); activeConfig = cfgResult.config; for (const w of cfgResult.warnings) { emit(`soly config: ${w}`, "warning"); } if (cfgResult.sources.global || cfgResult.sources.project) { const sources = [ cfgResult.sources.global ? `global: ${cfgResult.sources.global}` : null, cfgResult.sources.project ? `project: ${cfgResult.sources.project}` : null, ].filter(Boolean).join(", "); emit(`soly config loaded (${sources})`, "info"); } // Auto-prune old iteration files per retention config if (state.exists) { const r = pruneOldIterations(state.solyDir, activeConfig.iteration.retentionDays); if (r.pruned > 0) { emit( `soly: pruned ${r.pruned} old iteration file(s) (retention ${activeConfig.iteration.retentionDays}d)`, "info", ); } } // (The soly-framework skill now ships via the package `pi.skills` // manifest — pi discovers it natively, no home-dir copy needed.) // Phase-scoped rules: if a phase is currently active, load its // per-phase rules on top of the always-on rule set. reloadPhaseRules(); // Capture rule mtimes for the "rules changed since last session" diff captureLastSessionRuleMtimes(); // Reset derived state sessionCwd = ctx.cwd; rulesLoaded = []; lastRulesTokens = 0; nudgeActiveForTask = false; lastNudgePromptKey = ""; sessionStats = { turns: 0, tokensEstimate: 0 }; editedFilesThisTurn = new Set(); lastEditedFiles = []; lastTurnApplicableRules = []; // Read git context (best-effort, silent on failure) gitContext = await readGitContext(ctx.cwd); lastGitSection = buildGitSection(gitContext); // Detect project env (cheap; ~5 fs reads) envSummary = detectEnv(ctx.cwd); lastEnvSection = buildEnvSection(envSummary); // Build code map (walk cwd once; cap at 2 levels deep) try { codeMap = buildCodeMap(ctx.cwd); lastCodeMapSection = buildCodeMapSection(codeMap); } catch { codeMap = null; lastCodeMapSection = ""; } // Load project intent (zero-point docs from .agents/docs/) — always refreshIntent(); // Start hot-reload watcher on rules dirs if (hotReload) hotReload.stop(); hotReload = startHotReload(ruleSources, { onChange: (reason) => { refreshRules(); updateStatus({ ui: { setStatus: (id, text) => ctx.ui.setStatus(id, text) }, }); }, }); // Start the background quota poller (reads modelProvider from // ChromeData each tick, resolves the registered adapter, writes // quotaPercent/quotaResetsLabel back for the footer to render). if (quotaPoller) quotaPoller.stop(); quotaPoller = startQuotaPoller(chrome.data, () => getActiveConfig().chrome.enabled, () => chrome.poke()); // Editors save in bursts (write to .tmp, rename, touch). Coalesce // those rapid reload events into a single sub-line event under the // Working indicator (└─ reloaded 47 rules). Errors here are real // (disk I/O failed) and stay as popups. hotReload.setNotifyHandler((reason) => { chrome.emit(`reloaded rules (${reason})`); }); // Notifications (one-shot at startup) if (alwaysOnRules.length > 0) { const bySource = alwaysOnRules.reduce>((acc, r) => { acc[r.sourceLabel] = (acc[r.sourceLabel] ?? 0) + 1; return acc; }, {}); const breakdown = Object.entries(bySource) .map(([k, v]) => `${v} ${k}`) .join(" + "); let summary = `soly rules: ${alwaysOnRules.length} (${breakdown})`; if (phaseRules.length > 0) { summary += ` + ${phaseRules.length} phase-${state.currentPhase?.number}`; } emit(summary, "info"); if (overriddenRulePaths.length > 0) { emit( `soly: ${overriddenRulePaths.length} rule(s) overridden by project (${overriddenRulePaths.join(", ")})`, "info", ); } // Rules diff vs last session const currentMtimes: Record = {}; for (const r of alwaysOnRules) currentMtimes[`${r.source}::${r.absPath}`] = r.mtimeMs; const lastKeys = new Set(Object.keys(lastSessionMtimes)); const currentKeys = new Set(Object.keys(currentMtimes)); const added = [...currentKeys].filter((k) => !lastKeys.has(k)); const removed = [...lastKeys].filter((k) => !currentKeys.has(k)); const changed: string[] = []; for (const k of currentKeys) { if (lastKeys.has(k) && lastSessionMtimes[k] !== currentMtimes[k]) { changed.push(k); } } if (added.length || removed.length || changed.length) { const parts: string[] = []; if (added.length) parts.push(`+${added.length}`); if (removed.length) parts.push(`-${removed.length}`); if (changed.length) parts.push(`~${changed.length}`); emit(`soly: rules changed since last session (${parts.join(" ")})`, "info"); } // Rule budget analytics const analytics = analyzeRules(alwaysOnRules, CONTEXT_WINDOW_TOKENS); if (analytics.contextBudgetPct > 5) { emit( `soly: rules use ${analytics.contextBudgetPct.toFixed(1)}% of context window (${formatTok(analytics.totalTokens)} across ${analytics.fileCount} files)`, "info", ); } } else { emit("soly rules: none found in .agents/rules.local, .agents/rules, or ~/.agents/rules", "info"); } if (state.exists) { emit(`soly state: ${state.milestone} (${state.phases.length} phases)`, "info"); } updateStatus(ctx); // Install the visual chrome (top bar + custom footer + snowflake spinner). chrome.install(ctx.ui); updateChromeData(ctx); // Startup header snapshot (version + recent changes + project state). try { const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1")); const meta = readWelcomeMeta(extRoot); chrome.setWelcome({ version: meta.version, hasProject: state.exists, phaseLabel: phaseLabelFromState(state), nextHint: buildNextHint(state) || null, rulesActive: combinedRules().length, docsCount: intentDocs.length, recent: meta.recent, }); } catch { /* header is best-effort; never block session start */ } }); pi.on("session_shutdown", async (_event, ctx) => { // Stop hot-reload watcher — fs.watch handles hold OS resources if (hotReload) { hotReload.stop(); hotReload = null; } // Stop the background quota poller if (quotaPoller) { quotaPoller.stop(); quotaPoller = null; } // Restore pi's native footer/widgets/indicator before teardown chrome.dispose(ctx.ui); // Persist rule mtimes so the next session can show the diff persistRuleMtimes(); }); // Snapshot where the user is in the plan-branch workflow, for the proactive // "suggested next step" section. Reads git branch (cached) + the plan dir on // disk; the section builder itself is pure (nudge.ts). const computeWorkflowSituation = (): WorkflowSituation => { const branch = gitContext.branch; const onPlanBranch = !!branch && branch !== "master" && branch !== "main" && !branch.startsWith("HEAD"); const dirSlug = branch ? branch.replace(/\//g, "-") : null; let planExists = false; let planIsStub = false; if (onPlanBranch && dirSlug && state.exists) { const planFile = path.join(state.solyDir, "plans", dirSlug, "PLAN.md"); try { const body = fs.readFileSync(planFile, "utf-8"); planExists = true; planIsStub = body.includes("_Stub — fill in via"); } catch { planExists = false; } } const doneIds = new Set(state.tasks.filter((t) => t.status === "done").map((t) => t.id)); const readyTaskIds = state.tasks .filter((t) => t.status === "ready" && t.dependsOn.every((d) => doneIds.has(d))) .map((t) => t.id); return { hasProject: state.exists, branch, onPlanBranch, // Target the branch name verbatim — the workflow builders accept // `/` and `` alike. planSlug: onPlanBranch ? branch : null, planExists, planIsStub, dirty: !!gitContext.statusShort && gitContext.statusShort.trim().length > 0, readyTaskIds, }; }; pi.on("before_agent_start", async (event, ctx) => { // Keep the chrome (ctx%, model, phase) current for the upcoming turn. updateChromeData(ctx); const sections: string[] = []; let totalRulesTokens = 0; // pi's own resource paths (AGENTS.md / CLAUDE.md it already loaded) // — used to inform rule globs, not to dedup context (soly doesn't // load context files). const piPaths = (event.systemPromptOptions.contextFiles ?? []).map((c) => c.path); // 1. Rules section const allRules = combinedRules(); if (allRules.length > 0) { const promptFiles = extractFilePathsFromPrompt(event.prompt); const activeGlobs = [...new Set([...promptFiles, ...piPaths])]; const hasPhase = phaseRules.length > 0; const { section, loaded } = buildRulesSection(allRules, activeGlobs, { phaseNumber: state.currentPhase?.number, groupByPhase: hasPhase, }); rulesLoaded = loaded; if (section) { sections.push(section); totalRulesTokens = Math.ceil(section.length / 4); } } else { rulesLoaded = []; } lastRulesTokens = totalRulesTokens; // 2. Project state section if (state.exists) { const section = buildProjectStateSection(state); if (section) sections.push(section); } // 2.5. Cross-extension integrations: dynamically mention only the // sibling pi-extensions that are actually loaded. Driven by // `integrations.ts` registry — add new entries there. const integrationSection = buildIntegrationsSection(pi.getActiveTools()); if (integrationSection) { sections.push(integrationSection); } // 2.6. Interactive/visual tools pointer (lean — detail lives in the skill). sections.push(SOLY_TOOLS_POINTER); // 3.5. Project intent (zero-point docs) — always injected when present if (lastIntentSection) { sections.push(lastIntentSection); } // 3.6. Inline intent bodies (opt-in via frontmatter `inline: true`) const inlineBodies = loadInlineIntentBodies(intentDocs); for (const ib of inlineBodies) { sections.push(`\n### intent: ${ib.relPath}\n\n${ib.body}`); } // 4. Git context section (always injected when available — cheap, high signal) if (lastGitSection) { sections.push(lastGitSection); } // 5. Project env section (cheap; high signal for tool/script choice) if (lastEnvSection) { sections.push(lastEnvSection); } // 6. Project layout (code map) — always injected when available if (lastCodeMapSection) { sections.push(lastCodeMapSection); } // 7. Behavioral nudge const heuristics = classifyTaskHeuristics(event.prompt); sections.push( buildNudgeSection(heuristics, { hasProject: state.exists, confirmBeforeCode: getActiveConfig().agent.confirmBeforeCode, defaultBranchPrefix: getActiveConfig().plan.defaultBranchPrefix, }), ); // 7.05 Proactive "suggested next step" — always on when a project exists. // Lets the model OFFER the next workflow action and call `soly_workflow` // itself, so the user never has to remember a verb. if (state.exists) { const suggestion = buildSuggestionSection(computeWorkflowSituation()); if (suggestion) sections.push(suggestion); } // 7.1 Interactive-tool affordance hints (examples → html_artifact, options // → decision_deck, …) — only on turns whose wording mentions them. if (getActiveConfig().agent.toolHints) { const toolHint = buildToolHintSection(detectToolHints(event.prompt)); if (toolHint) sections.push(toolHint); } // 7.5 Soly drift reminder — injected when the user has been doing // non-soly work for several turns. Throttled: at most once per // REMINDER_THRESHOLD turns. Resets when a soly verb is parsed. if ( solyDrift.turnsSinceLastSolyVerb >= solyDrift.REMINDER_THRESHOLD && solyDrift.turnsSinceLastSolyVerb - solyDrift.lastReminderAt >= solyDrift.REMINDER_THRESHOLD ) { const reminder = buildDriftReminder(solyDrift.turnsSinceLastSolyVerb); if (reminder) { sections.push(`\n## soly drift\n\n${reminder}\n`); solyDrift.lastReminderAt = solyDrift.turnsSinceLastSolyVerb; } } if (heuristics.nonTrivial || heuristics.researchHeavy) { nudgeActiveForTask = true; lastNudgePromptKey = event.prompt.slice(0, 200); } // 7. Update status bar updateStatus(ctx); if (sections.length === 0) return; return { systemPrompt: event.systemPrompt + sections.join("\n"), }; }); pi.on("input", async (event, ctx) => { // Nudge notify — runs BEFORE workflows/* (which may transform). // Soft UI hint, never blocks the input. if (event.source !== "interactive") return; const text = event.text.trim(); if (!text || text.startsWith("/")) return; if (text.startsWith("soly ")) return; // workflow verb — let workflows handle it if (text.slice(0, 200) === lastNudgePromptKey && nudgeActiveForTask) return; const heuristics = classifyTaskHeuristics(text); if (!heuristics.nonTrivial && !heuristics.researchHeavy) return; const angle = heuristics.suggestedAngles[0] ?? "want me to confirm assumptions before I start?"; // The visible chat box is opt-in (default off — it was noisy). The // system-prompt nudge still guides the LLM on every turn regardless. if (getActiveConfig().agent.nudgeNotify) { notifyNudge(ctx.ui, heuristics.researchHeavy ? "research" : "nonTrivial", angle); } nudgeActiveForTask = true; lastNudgePromptKey = text.slice(0, 200); // Drift counter — non-soly, non-slash, non-trivial prompt. // Workflow verbs reset this via onWorkflowUsed callback. solyDrift.turnsSinceLastSolyVerb += 1; }); pi.on("turn_end", async (_event, ctx) => { const beforeRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(","); const beforeStateUpdated = state.lastUpdated; refreshRules(); refreshState(); const afterRules = rules.map((r) => `${r.source}:${r.relPath}:${r.mtimeMs}`).join(","); const rulesChanged = beforeRules !== afterRules; const stateChanged = beforeStateUpdated !== state.lastUpdated; // Update session stats — count assistant turns + rough token estimate const entries = ctx.sessionManager.getBranch(); let turns = 0; let tokens = 0; for (const entry of entries) { if (entry.type === "message" && entry.message.role === "assistant") { turns++; } } const usage = ctx.getContextUsage(); if (usage) tokens = usage.tokens ?? 0; sessionStats = { turns, tokensEstimate: tokens }; // Refresh git context (cheap; debounced naturally by turn cadence) if (sessionCwd) { const newGit = await readGitContext(sessionCwd); if ( newGit.branch !== gitContext.branch || newGit.statusShort !== gitContext.statusShort ) { gitContext = newGit; lastGitSection = buildGitSection(gitContext); } } // Refresh intent (zero-point docs) — cheap, but skip if last was recent const beforeIntentCount = intentDocs.length; refreshIntent(); if (intentDocs.length !== beforeIntentCount) { // re-render status (intentionally don't push a section — system // prompt regenerates next turn) } if (rulesChanged || stateChanged) { updateStatus(ctx); } // Chrome reflects fresh ctx%, session tokens, git dirty count, phase. updateChromeData(ctx); // Post-work rules check: surface applicable rules for files edited // in this turn. Tracking is kept silent (no chat notify — user feedback: // "spammy") but data is preserved for /why to show last-turn rule context. if (editedFilesThisTurn.size > 0) { lastEditedFiles = [...editedFilesThisTurn]; lastTurnApplicableRules = rulesApplicableToFiles( combinedRules(), lastEditedFiles, ); editedFilesThisTurn = new Set(); } }); // ============================================================================ // tool_call: track files edited in this turn. Used at turn_end to surface // applicable rules as a post-work checklist ("did the agent follow them?"). // ============================================================================ pi.on("tool_call", async (event, _ctx) => { if (event.toolName !== "edit" && event.toolName !== "write") return; const input = event.input as { path?: string }; if (input?.path) { editedFilesThisTurn.add(input.path); } }); // ============================================================================ // Chrome: working-indicator telemetry lifecycle + reactive data refresh // ============================================================================ pi.on("agent_start", async (_event, ctx) => { updateChromeData(ctx); // snapshot ctx tokens (↑) before generation starts chrome.startWorking(ctx.ui); }); pi.on("message_update", async (event, ctx) => { const msg = event.message as { role?: string; usage?: { output?: number } }; if (msg.role === "assistant" && typeof msg.usage?.output === "number") { chrome.updateWorking(ctx.ui, msg.usage.output); } }); pi.on("agent_end", async (_event, ctx) => { chrome.stopWorking(ctx.ui); updateChromeData(ctx); }); pi.on("model_select", async (_event, ctx) => { updateChromeData(ctx); }); // Mount built-in sub-features piAskExtension(pi); piDeckExtension(pi); piArtifactExtension(pi, getActiveConfig); // MCP adapter (was: separate pi-mcp-adapter plugin by nicobailon). // Bundled into pi-soly as of v1.11.0 with UE5 session-retry fix + framed // notifications + compact per-server status footer. // We import dynamically because mcp/ has heavy deps (modelcontextprotocol/sdk) // and we want soly to still load if MCP fails for any reason. The import() // itself can reject (e.g. `pi install` didn't resolve a transitive peer dep // like @modelcontextprotocol/sdk) or throw synchronously under jiti — both // must be swallowed so a broken MCP never takes down the whole agent. try { void import("./mcp/index.ts") .then((m) => { try { m.default(pi); } catch (err) { emit("[soly] MCP adapter failed to initialize:" + ": " + (err instanceof Error ? err.message : String(err)), "error"); } }) .catch((err) => { emit("[soly] MCP adapter unavailable (load failed):" + ": " + (err instanceof Error ? err.message : String(err)), "error"); }); } catch (err) { emit("[soly] MCP adapter unavailable (load threw):" + ": " + (err instanceof Error ? err.message : String(err)), "error"); } }