pi-soly 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +153 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. package/ask/tests/prompt.test.ts +0 -54
package/deck/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ // =============================================================================
2
+ // index.ts — pi-deck extension entry point
3
+ // =============================================================================
4
+ //
5
+ // Registers one LLM tool: `decision_deck`. Presents a single design/architecture
6
+ // decision as a full-screen stack of cards (one per option) that the user flips
7
+ // through and picks from. Each card carries a title, summary, optional code
8
+ // snippet, and pros/cons — so the user compares concrete shapes, not just labels.
9
+ //
10
+ // When to reach for it over ask_pro: the choice hinges on seeing the code/structure
11
+ // of each option side-by-side, and a thin preview column isn't enough. Native TUI
12
+ // (no browser, no server). Returns the chosen option index in one call.
13
+ // =============================================================================
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { highlightCode } from "@earendil-works/pi-coding-agent";
17
+ import { Type } from "typebox";
18
+ import { DeckComponent, type DeckResult } from "./deck.ts";
19
+
20
+ export default function piDeckExtension(pi: ExtensionAPI) {
21
+ // Usage guidance lives in the soly-framework skill + the main soly prompt
22
+ // pointer — not injected here.
23
+ pi.registerTool({
24
+ name: "decision_deck",
25
+ label: "soly · decision_deck",
26
+ description:
27
+ "Present ONE design/architecture decision as a full-screen deck of cards (one per option) the user flips through (←/→ or 1-N) and picks with Enter. Use instead of ask_pro when the choice hinges on comparing each option's concrete code shape, not a label. 2-6 options. The user can attach a free-text note (rationale, caveats) via `n`; it's returned in the result. Native TUI. Returns the chosen option.",
28
+ parameters: Type.Object({
29
+ title: Type.Optional(Type.String({ description: "Decision title." })),
30
+ prompt: Type.Optional(Type.String({ description: "Question shown above the cards." })),
31
+ options: Type.Array(
32
+ Type.Object({
33
+ title: Type.String({ description: "Card title." }),
34
+ summary: Type.Optional(Type.String({ description: "1-3 sentence explanation." })),
35
+ code: Type.Optional(
36
+ Type.String({ description: "Raw code snippet (no fences); set `lang` to highlight." }),
37
+ ),
38
+ lang: Type.Optional(Type.String({ description: "Highlight language (e.g. 'ts')." })),
39
+ pros: Type.Optional(Type.Array(Type.String(), { description: "Upsides (+)." })),
40
+ cons: Type.Optional(Type.Array(Type.String(), { description: "Downsides (−)." })),
41
+ recommended: Type.Optional(
42
+ Type.Boolean({ description: "⭐ recommended (cursor starts here, at most one)." }),
43
+ ),
44
+ }),
45
+ { description: "2-6 options to compare." },
46
+ ),
47
+ }),
48
+ async execute(_id, params, _signal, _onUpdate, ctx) {
49
+ if (!ctx.hasUI) {
50
+ return {
51
+ content: [
52
+ {
53
+ type: "text",
54
+ text: "decision_deck requires a UI-capable session (TUI). Run from the interactive pi TUI.",
55
+ },
56
+ ],
57
+ details: { error: "no_ui", mode: ctx.mode },
58
+ };
59
+ }
60
+ if (params.options.length < 2 || params.options.length > 6) {
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: `decision_deck: ${params.options.length} options, need 2-6.`,
66
+ },
67
+ ],
68
+ details: { error: "bad_option_count", count: params.options.length },
69
+ };
70
+ }
71
+ const recommended = params.options.filter((o) => o.recommended).length;
72
+ if (recommended > 1) {
73
+ return {
74
+ content: [
75
+ { type: "text", text: `decision_deck: ${recommended} recommended, at most 1 allowed.` },
76
+ ],
77
+ details: { error: "multiple_recommended" },
78
+ };
79
+ }
80
+
81
+ const result = await ctx.ui.custom<DeckResult>((_tui, theme, keybindings, done) => {
82
+ const deckTheme = theme as unknown as ConstructorParameters<typeof DeckComponent>[0]["theme"];
83
+ return new DeckComponent({
84
+ options: params.options,
85
+ theme: deckTheme,
86
+ keybindings,
87
+ done,
88
+ title: params.title ?? "decision",
89
+ prompt: params.prompt,
90
+ highlight: highlightCode,
91
+ });
92
+ });
93
+
94
+ if (result.cancelled || result.chosen === undefined) {
95
+ return {
96
+ content: [{ type: "text", text: "(user cancelled the deck — no choice made)" }],
97
+ details: { cancelled: true },
98
+ };
99
+ }
100
+
101
+ const chosen = result.chosen;
102
+ const opt = params.options[chosen];
103
+ const note = result.note?.trim() ?? "";
104
+ const text = note
105
+ ? `User chose option ${chosen + 1}: "${opt?.title ?? "?"}". // note: "${note}"`
106
+ : `User chose option ${chosen + 1}: "${opt?.title ?? "?"}".`;
107
+ return {
108
+ content: [{ type: "text", text }],
109
+ details: { chosen, title: opt?.title, note: note || undefined },
110
+ };
111
+ },
112
+ });
113
+ }
package/index.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  import * as os from "node:os";
23
23
  import * as path from "node:path";
24
24
  import * as fs from "node:fs";
25
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
26
 
27
27
  import {
28
28
  analyzeRules,
@@ -48,7 +48,6 @@ import {
48
48
  type SourceSpec,
49
49
  } from "./core.ts";
50
50
  import { buildIntegrationsSection } from "./integrations.ts";
51
- import { installSolyAssets } from "./agents-install.ts";
52
51
  import {
53
52
  DEFAULT_CONFIG,
54
53
  loadConfig,
@@ -56,6 +55,7 @@ import {
56
55
  type SolyConfig,
57
56
  } from "./config.ts";
58
57
  import { classifyTaskHeuristics, buildNudgeSection } from "./nudge.ts";
58
+ import { detectToolHints, buildToolHintSection } from "./tool-hints.ts";
59
59
  import { notifyNudge, notifyDeprecation } from "./notification.ts";
60
60
  import { registerCommands, type CommandUI } from "./commands.ts";
61
61
  import { registerTools } from "./tools.ts";
@@ -66,9 +66,35 @@ import { detectEnv, buildEnvSection, type EnvSummary } from "./env.ts";
66
66
  import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.ts";
67
67
  import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.ts";
68
68
 
69
+ import { createChrome, readWelcomeMeta } from "./visual/index.ts";
70
+ import { createContextManager } from "./context-manager.ts";
71
+
69
72
  // Built-in sub-features (merged from former pi-asked, pi-agented packages):
70
73
  import piAskExtension from "./ask/index.ts";
74
+ import piDeckExtension from "./deck/index.ts";
75
+ import piArtifactExtension from "./artifact/index.ts";
76
+ import { getArtifactServer } from "./artifact/session.ts";
77
+
78
+ /** Compact phase label for the chrome top bar, e.g. "plan 2/5". Null when idle. */
79
+ function phaseLabelFromState(s: SolyState): string | null {
80
+ if (!s.exists) return null;
81
+ const cur = s.currentPhase;
82
+ const total = s.phases.length;
83
+ if (cur) return total > 0 ? `${cur.slug || cur.name} ${cur.number}/${total}` : `${cur.slug || cur.name}`;
84
+ return null;
85
+ }
86
+
71
87
 
88
+ /** Tiny always-on pointer to soly's interactive/visual tools. The detailed
89
+ * "when / when-not / params" guidance lives in the soly-framework skill
90
+ * (loaded on demand) so it doesn't cost tokens every turn. */
91
+ const SOLY_TOOLS_POINTER = `
92
+ ## soly — interactive & visual tools
93
+
94
+ When terminal text isn't the best medium, reach for these (details + when-NOT in the soly-framework skill):
95
+ - \`ask_pro\` — multi-question picker (single/multi-select, free-text, per-option previews).
96
+ - \`decision_deck\` — full-screen cards comparing design options by their concrete code shape.
97
+ - \`html_artifact\` — render HTML to a self-contained, browseable per-project gallery.`;
72
98
 
73
99
  export default function solyExtension(pi: ExtensionAPI) {
74
100
  // ============================================================================
@@ -154,6 +180,13 @@ export default function solyExtension(pi: ExtensionAPI) {
154
180
  let intentDocs: IntentDoc[] = [];
155
181
  let lastIntentSection = "";
156
182
 
183
+ // Visual chrome (top bar + custom footer + working spinner). Config-gated.
184
+ const chrome = createChrome(() => getActiveConfig().chrome);
185
+
186
+ // Single owner of pi's `context` channel (used by the /verify fresh-context
187
+ // mode; future context strategies plug in here too).
188
+ const contextManager = createContextManager(pi);
189
+
157
190
  // ============================================================================
158
191
  // Loaders
159
192
  // ============================================================================
@@ -297,6 +330,35 @@ export default function solyExtension(pi: ExtensionAPI) {
297
330
  }
298
331
  };
299
332
 
333
+ // Refresh the chrome's live data snapshot (cwd, model, ctx%, git, phase)
334
+ // from the current context + project state, then request a re-render.
335
+ const updateChromeData = (ctx: ExtensionContext) => {
336
+ if (!getActiveConfig().chrome.enabled) return;
337
+ const d = chrome.data;
338
+ d.cwd = sessionCwd || ctx.cwd || "";
339
+ d.home = os.homedir();
340
+ const model = ctx.model as { id?: string; provider?: string; reasoning?: boolean } | undefined;
341
+ d.modelId = model?.id ?? null;
342
+ d.modelProvider = model?.provider ?? null;
343
+ d.reasoning = Boolean(model?.reasoning);
344
+ try {
345
+ d.thinkingLevel = pi.getThinkingLevel();
346
+ } catch {
347
+ d.thinkingLevel = null;
348
+ }
349
+ const usage = ctx.getContextUsage();
350
+ d.ctxPercent = usage?.percent ?? null;
351
+ d.ctxTokens = usage?.tokens ?? null;
352
+ d.contextWindow = usage?.contextWindow ?? null;
353
+ d.gitDirty = gitContext.statusShort
354
+ ? gitContext.statusShort.split(/\r?\n/).filter(Boolean).length
355
+ : 0;
356
+ d.rulesActive = combinedRules().length;
357
+ d.phaseLabel = phaseLabelFromState(state);
358
+ d.artifactCount = getArtifactServer()?.count ?? 0;
359
+ chrome.poke();
360
+ };
361
+
300
362
  // ============================================================================
301
363
  // Register sub-features
302
364
  // ============================================================================
@@ -327,6 +389,15 @@ export default function solyExtension(pi: ExtensionAPI) {
327
389
  getActiveTools: () => pi.getActiveTools(),
328
390
  getConfig: getActiveConfig,
329
391
  onWorkflowUsed: resetSolyDrift,
392
+ contextManager,
393
+ onVerifyState: (s) => {
394
+ chrome.data.verbLabel = s.active ? (s.iteration > 0 ? `verify ${s.iteration}/${s.max}` : "verify") : null;
395
+ chrome.poke();
396
+ },
397
+ setVerbLabel: (verb) => {
398
+ chrome.data.verbLabel = verb;
399
+ chrome.poke();
400
+ },
330
401
  });
331
402
 
332
403
  // ============================================================================
@@ -336,7 +407,7 @@ export default function solyExtension(pi: ExtensionAPI) {
336
407
  pi.on("session_start", async (event, ctx) => {
337
408
  // Deprecation warning: if the project still uses `.soly/`, nudge the
338
409
  // user toward `.agents/`. One-time per session.
339
- if (activeConfig.agent.useSolyWorkerSubagents && isLegacySolyDir(ctx.cwd)) {
410
+ if (isLegacySolyDir(ctx.cwd)) {
340
411
  notifyDeprecation(
341
412
  ctx.ui,
342
413
  `.soly/ (legacy)`,
@@ -387,42 +458,13 @@ export default function solyExtension(pi: ExtensionAPI) {
387
458
  }
388
459
  }
389
460
 
390
- // Auto-install soly user-scope assets (soly-framework skill only,
391
- // since 1.3.0 we don't ship a subagent) to ~/.pi/agent/skills/ on
392
- // first run. Opt-in via config `agent.useSolyWorkerSubagents`
393
- // (kept for backward compat, now a no-op for the skill install).
394
- // Idempotent — respects any existing user-customized copies.
395
- if (activeConfig.agent.useSolyWorkerSubagents) {
396
- const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
397
- const assets = installSolyAssets(extRoot);
398
- const installed = assets.skills.installed.map((s) => `skill:${s}`);
399
- if (installed.length > 0) {
400
- ctx.ui.notify(
401
- `soly: installed (${installed.join(", ")}) — pi will discover on next session`,
402
- "info",
403
- );
404
- }
405
- for (const e of assets.skills.errors) {
406
- ctx.ui.notify(`soly: install error — ${e}`, "warning");
407
- }
408
- }
461
+ // (The soly-framework skill now ships via the package `pi.skills`
462
+ // manifest pi discovers it natively, no home-dir copy needed.)
409
463
 
410
464
  // Phase-scoped rules: if a phase is currently active, load its
411
465
  // per-phase rules on top of the always-on rule set.
412
466
  reloadPhaseRules();
413
467
 
414
- // Restore agent from .soly/agent (if present) — survives session restart
415
- if (state.exists) {
416
- const agentFile = path.join(state.solyDir, "agent");
417
- try {
418
- if (fs.existsSync(agentFile)) {
419
- const raw = fs.readFileSync(agentFile, "utf-8").trim();
420
- if (raw && /^[a-zA-Z0-9_-]{1,64}$/.test(raw)) {
421
- (globalThis as { __PI_SWITCH_AGENT__?: string }).__PI_SWITCH_AGENT__ = raw;
422
- }
423
- }
424
- } catch { /* best-effort */ }
425
- }
426
468
 
427
469
  // Capture rule mtimes for the "rules changed since last session" diff
428
470
  captureLastSessionRuleMtimes();
@@ -534,19 +576,45 @@ export default function solyExtension(pi: ExtensionAPI) {
534
576
  }
535
577
 
536
578
  updateStatus(ctx);
579
+
580
+ // Install the visual chrome (top bar + custom footer + snowflake spinner).
581
+ chrome.install(ctx.ui);
582
+ updateChromeData(ctx);
583
+
584
+ // Startup header snapshot (version + recent changes + project state).
585
+ try {
586
+ const extRoot = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, "$1"));
587
+ const meta = readWelcomeMeta(extRoot);
588
+ chrome.setWelcome({
589
+ version: meta.version,
590
+ hasProject: state.exists,
591
+ phaseLabel: phaseLabelFromState(state),
592
+ nextHint: buildNextHint(state) || null,
593
+ rulesActive: combinedRules().length,
594
+ docsCount: intentDocs.length,
595
+ recent: meta.recent,
596
+ });
597
+ } catch {
598
+ /* header is best-effort; never block session start */
599
+ }
537
600
  });
538
601
 
539
- pi.on("session_shutdown", async (_event, _ctx) => {
602
+ pi.on("session_shutdown", async (_event, ctx) => {
540
603
  // Stop hot-reload watcher — fs.watch handles hold OS resources
541
604
  if (hotReload) {
542
605
  hotReload.stop();
543
606
  hotReload = null;
544
607
  }
608
+ // Restore pi's native footer/widgets/indicator before teardown
609
+ chrome.dispose(ctx.ui);
545
610
  // Persist rule mtimes so the next session can show the diff
546
611
  persistRuleMtimes();
547
612
  });
548
613
 
549
614
  pi.on("before_agent_start", async (event, ctx) => {
615
+ // Keep the chrome (ctx%, model, phase) current for the upcoming turn.
616
+ updateChromeData(ctx);
617
+
550
618
  const sections: string[] = [];
551
619
  let totalRulesTokens = 0;
552
620
 
@@ -590,6 +658,9 @@ export default function solyExtension(pi: ExtensionAPI) {
590
658
  sections.push(integrationSection);
591
659
  }
592
660
 
661
+ // 2.6. Interactive/visual tools pointer (lean — detail lives in the skill).
662
+ sections.push(SOLY_TOOLS_POINTER);
663
+
593
664
  // 3.5. Project intent (zero-point docs) — always injected when present
594
665
  if (lastIntentSection) {
595
666
  sections.push(lastIntentSection);
@@ -618,7 +689,19 @@ export default function solyExtension(pi: ExtensionAPI) {
618
689
 
619
690
  // 7. Behavioral nudge
620
691
  const heuristics = classifyTaskHeuristics(event.prompt);
621
- sections.push(buildNudgeSection(heuristics));
692
+ sections.push(
693
+ buildNudgeSection(heuristics, {
694
+ hasProject: state.exists,
695
+ confirmBeforeCode: getActiveConfig().agent.confirmBeforeCode,
696
+ }),
697
+ );
698
+
699
+ // 7.1 Interactive-tool affordance hints (examples → html_artifact, options
700
+ // → decision_deck, …) — only on turns whose wording mentions them.
701
+ if (getActiveConfig().agent.toolHints) {
702
+ const toolHint = buildToolHintSection(detectToolHints(event.prompt));
703
+ if (toolHint) sections.push(toolHint);
704
+ }
622
705
 
623
706
  // 7.5 Soly drift reminder — injected when the user has been doing
624
707
  // non-soly work for several turns. Throttled: at most once per
@@ -662,7 +745,11 @@ export default function solyExtension(pi: ExtensionAPI) {
662
745
  const angle =
663
746
  heuristics.suggestedAngles[0] ?? "want me to confirm assumptions before I start?";
664
747
 
665
- notifyNudge(ctx.ui, heuristics.researchHeavy ? "research" : "nonTrivial", angle);
748
+ // The visible chat box is opt-in (default off it was noisy). The
749
+ // system-prompt nudge still guides the LLM on every turn regardless.
750
+ if (getActiveConfig().agent.nudgeNotify) {
751
+ notifyNudge(ctx.ui, heuristics.researchHeavy ? "research" : "nonTrivial", angle);
752
+ }
666
753
  nudgeActiveForTask = true;
667
754
  lastNudgePromptKey = text.slice(0, 200);
668
755
 
@@ -719,6 +806,9 @@ export default function solyExtension(pi: ExtensionAPI) {
719
806
  updateStatus(ctx);
720
807
  }
721
808
 
809
+ // Chrome reflects fresh ctx%, session tokens, git dirty count, phase.
810
+ updateChromeData(ctx);
811
+
722
812
  // Post-work rules check: surface applicable rules for files edited
723
813
  // in this turn. Tracking is kept silent (no chat notify — user feedback:
724
814
  // "spammy") but data is preserved for /why to show last-turn rule context.
@@ -744,8 +834,34 @@ export default function solyExtension(pi: ExtensionAPI) {
744
834
  }
745
835
  });
746
836
 
837
+ // ============================================================================
838
+ // Chrome: working-indicator telemetry lifecycle + reactive data refresh
839
+ // ============================================================================
840
+ pi.on("agent_start", async (_event, ctx) => {
841
+ updateChromeData(ctx); // snapshot ctx tokens (↑) before generation starts
842
+ chrome.startWorking(ctx.ui);
843
+ });
844
+
845
+ pi.on("message_update", async (event, ctx) => {
846
+ const msg = event.message as { role?: string; usage?: { output?: number } };
847
+ if (msg.role === "assistant" && typeof msg.usage?.output === "number") {
848
+ chrome.updateWorking(ctx.ui, msg.usage.output);
849
+ }
850
+ });
851
+
852
+ pi.on("agent_end", async (_event, ctx) => {
853
+ chrome.stopWorking(ctx.ui);
854
+ updateChromeData(ctx);
855
+ });
856
+
857
+ pi.on("model_select", async (_event, ctx) => {
858
+ updateChromeData(ctx);
859
+ });
860
+
747
861
  // Mount built-in sub-features
748
862
  piAskExtension(pi);
863
+ piDeckExtension(pi);
864
+ piArtifactExtension(pi, getActiveConfig);
749
865
 
750
866
  // MCP adapter (was: separate pi-mcp-adapter plugin by nicobailon).
751
867
  // Bundled into pi-soly as of v1.11.0 with UE5 session-retry fix + framed
package/iteration.ts CHANGED
@@ -579,16 +579,8 @@ export function buildIterationContent(input: IterationInput): string {
579
579
  }
580
580
 
581
581
  if ((input.kind === "plan" || input.kind === "exec") && phaseDir) {
582
- // Section 3: Phase CONTEXT
583
- const ctxPath = findPhaseContextPath(phaseDir);
584
- sections.push("## 3. Phase CONTEXT");
585
- sections.push("");
586
- sections.push(`_Source: \`${ctxPath ? rel(projectRoot, ctxPath) : "(missing)"}\`_`);
587
- sections.push("");
588
- sections.push(loadPhaseContext(phaseDir, SECTION_BUDGETS.context));
589
- sections.push("");
590
-
591
582
  // Section 4: Phase RESEARCH
583
+ // (Section 3 Phase CONTEXT is emitted by the plan|exec|discuss block above.)
592
584
  const resPath = findPhaseResearchPath(phaseDir);
593
585
  sections.push("## 4. Phase RESEARCH");
594
586
  sections.push("");
package/mcp/commands.ts CHANGED
@@ -58,6 +58,18 @@ export async function showStatus(state: McpExtensionState, ctx: ExtensionContext
58
58
  lines.push("Run /mcp setup to adopt imports or scaffold a starter .mcp.json");
59
59
  }
60
60
 
61
+ // Tool result cache stats — appended after the per-server list. Hidden if
62
+ // the cache has not seen any traffic yet (fresh session, no calls made).
63
+ const cacheStats = state.toolCache?.stats();
64
+ if (cacheStats && (cacheStats.hits > 0 || cacheStats.misses > 0 || cacheStats.expirations > 0 || cacheStats.size > 0)) {
65
+ const total = cacheStats.hits + cacheStats.misses;
66
+ const hitRate = total > 0 ? Math.round((cacheStats.hits / total) * 100) : 0;
67
+ lines.push("");
68
+ lines.push(
69
+ `Cache: ${cacheStats.size} entries · ${cacheStats.hits} hits · ${cacheStats.misses} misses · ${cacheStats.expirations} expired (${hitRate}% hit rate)`,
70
+ );
71
+ }
72
+
61
73
  ctx.ui.notify(lines.join("\n"), "info");
62
74
  }
63
75
 
@@ -12,6 +12,7 @@ import { formatToolName, isToolExcluded } from "./types.ts";
12
12
  import { resourceNameToToolName } from "./resource-tools.ts";
13
13
  import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
14
14
  import { formatAuthRequiredMessage } from "./utils.ts";
15
+ import { ToolCache, cacheKey } from "./tool-cache.ts";
15
16
 
16
17
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
17
18
 
@@ -274,9 +275,19 @@ type DirectToolExecute = (
274
275
  export function createDirectToolExecutor(
275
276
  getState: () => McpExtensionState | null,
276
277
  getInitPromise: () => Promise<McpExtensionState> | null,
277
- spec: DirectToolSpec
278
+ spec: DirectToolSpec,
279
+ cache?: ToolCache,
278
280
  ): DirectToolExecute {
279
281
  return async function execute(_toolCallId, params) {
282
+ // Cache fast-path: skip connect/init/auth on hit. UI tools and resources
283
+ // are never cached (UI has side effects, user asked for tools only).
284
+ if (cache && !spec.resourceUri && !spec.uiResourceUri) {
285
+ const hit = cache.get(cacheKey(spec.serverName, spec.originalName, params));
286
+ if (hit !== undefined) {
287
+ return hit as Awaited<ReturnType<DirectToolExecute>>;
288
+ }
289
+ }
290
+
280
291
  let state = getState();
281
292
  const initPromise = getInitPromise();
282
293
 
@@ -402,10 +413,16 @@ export function createDirectToolExecutor(
402
413
  };
403
414
  }
404
415
 
405
- return {
416
+ const successResult: Awaited<ReturnType<DirectToolExecute>> = {
406
417
  content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }],
407
418
  details: { server: spec.serverName, tool: spec.originalName },
408
419
  };
420
+ // Cache only plain (non-UI) tool success; resources and UI tools are
421
+ // never cached (see fast-path above for the matching skip condition).
422
+ if (cache) {
423
+ cache.set(cacheKey(spec.serverName, spec.originalName, params), successResult);
424
+ }
425
+ return successResult;
409
426
  } catch (error) {
410
427
  if (error instanceof UrlElicitationRequiredError) {
411
428
  const action = await state.manager.handleUrlElicitationRequired(spec.serverName, error);