diff --git a/ClaudeRevit/Services/BenchmarkRunner.cs b/ClaudeRevit/Services/BenchmarkRunner.cs index 1c25475..f811d3e 100644 --- a/ClaudeRevit/Services/BenchmarkRunner.cs +++ b/ClaudeRevit/Services/BenchmarkRunner.cs @@ -46,6 +46,8 @@ public static async Task RunAsync( bool resetBetweenTasks, int maxRoundsPerTask, int maxSecondsPerTask, + bool judgeViaClaudeCode, + bool runViaSubscription, Action onStatus, Action onResult, CancellationToken ct) @@ -55,15 +57,24 @@ public static async Task RunAsync( ToolDispatcher.ForceSuppress = true; // "claudecode" tests the MCP path end to end: the local `claude` CLI (on the subscription) - // drives the Revit tools through our MCP server. One impartial judge chat for the whole run. - var isClaudeCode = modelTag == "claudecode"; + // drives the Revit tools through our MCP server. runViaSubscription does the same for any + // chosen model — the picked model is passed to the CLI via --model. + var isClaudeCode = modelTag == "claudecode" || runViaSubscription; + // The picked model → Claude Code --model alias (null = the CLI's default subscription model). + var ccModelAlias = modelTag == "claudecode" ? null : ClaudeCodeBackend.ModelAlias(modelTag); + // The judge can run on the subscription too (same claude.exe, no tools) so grading costs + // nothing on the API — needed since the account may sit at $0. It still grades from the + // objective probe only, so it stays impartial. string ccConfig = "", ccWorkDir = "", ccExe = ""; + if (isClaudeCode || judgeViaClaudeCode) + { + ccExe = SettingsStore.ClaudeCodeExe; + ccWorkDir = McpServer.ClientWorkDir(); + } if (isClaudeCode) { McpServer.Start(); ccConfig = McpServer.WriteClientConfig(); - ccWorkDir = McpServer.ClientWorkDir(); - ccExe = SettingsStore.ClaudeCodeExe; } var judgeChat = new ChatService(ephemeral: true); @@ -85,6 +96,7 @@ public static async Task RunAsync( // The model runs the task ONCE and recovers from any errors on its own — no judge hints. string finalText = ""; string? error = null; + string? ccDiag = null; var budgetStopped = false; long inTok = 0, outTok = 0; var rounds = 0; @@ -105,11 +117,19 @@ public static async Task RunAsync( "mcp__clauderevit__*", onText: t => sb.Append(t), onTool: name => onStatus($"{task.Id} · {task.Title} · {name}"), - taskCts.Token); + taskCts.Token, model: ccModelAlias); finalText = !string.IsNullOrEmpty(res.Text) ? res.Text : sb.ToString(); error = res.Error; inTok = res.InputTokens; outTok = res.OutputTokens; rounds = res.NumTurns; - modelUsed = "claude-code"; + modelUsed = ccModelAlias != null ? $"claude-code:{ccModelAlias}" : "claude-code"; + // Surface WHY a run did nothing: MCP connection status + result flag. A launch + // with no tool calls and no tokens usually means MCP never connected. + ccDiag = $"MCP: {res.McpStatus ?? "no init event"}" + + (res.IsError ? "; result=ERROR" : res.Subtype is { } st ? $"; result={st}" : ""); + if ((res.McpStatus == null || + res.McpStatus.IndexOf("connect", StringComparison.OrdinalIgnoreCase) < 0) && + outTok == 0) + ccDiag += " — no Revit tools reached, nothing built"; } catch (OperationCanceledException) { @@ -152,9 +172,12 @@ public static async Task RunAsync( onStatus($"{task.Id} · {task.Title} · grading…"); var verdict = error != null ? new Verdict(false, 0, "Run error: " + Truncate(error, 200), true) - : await JudgeAsync(judgeChat, judgeModel, task, before, after, finalText, ct); + : await JudgeAsync(judgeChat, judgeModel, task, before, after, finalText, + judgeViaClaudeCode, ccExe, ccWorkDir, ct); if (budgetStopped) verdict = verdict with { Reason = "[stopped — task budget hit] " + verdict.Reason }; + if (ccDiag != null) + verdict = verdict with { Reason = "[" + ccDiag + "] " + verdict.Reason }; if (resetBetweenTasks) await ResetAsync(baselineIds, ct); @@ -208,7 +231,8 @@ private static async Task StatsAsync(CancellationToken ct) private static async Task JudgeAsync( ChatService chat, string judgeModel, BenchmarkTask task, - string before, string after, string finalText, CancellationToken ct) + string before, string after, string finalText, + bool viaClaudeCode, string ccExe, string ccWorkDir, CancellationToken ct) { const string sys = "You are an impartial QA grader for a Revit modelling agent. Grade ONLY from the objective " + @@ -226,7 +250,11 @@ private static async Task JudgeAsync( $"AGENT'S CLAIMED RESULT (unverified):\n{Truncate(finalText, 1000)}\n\nGrade now."; try { - var raw = await chat.RawCompleteAsync(judgeModel, sys, user, ct); + // On the subscription (no API cost) the CLI has no system-prompt flag in the same shape, + // so fold the grader instructions into the prompt; ParseVerdict tolerates surrounding prose. + var raw = viaClaudeCode + ? await ClaudeCodeBackend.CompleteAsync(ccExe, sys + "\n\n" + user, ccWorkDir, ct) + : await chat.RawCompleteAsync(judgeModel, sys, user, ct); return ParseVerdict(raw); } catch (Exception ex) diff --git a/ClaudeRevit/Services/ChatService.cs b/ClaudeRevit/Services/ChatService.cs index c0b30ef..0d7d7a2 100644 --- a/ClaudeRevit/Services/ChatService.cs +++ b/ClaudeRevit/Services/ChatService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -181,7 +182,14 @@ public ChatService() : this(false) { } public ChatService(bool ephemeral) { _ephemeral = ephemeral; - if (!ephemeral) _history.AddRange(HistoryStore.LoadApiHistory()); + if (!ephemeral) + { + _history.AddRange(HistoryStore.LoadApiHistory()); + // Restore the Claude Code (subscription) session so the conversation continues after a + // Revit restart, matching how the API history persists. + try { if (File.Exists(ClaudeCodeSessionFile)) _claudeCodeSessionId = File.ReadAllText(ClaudeCodeSessionFile).Trim(); } + catch { /* non-fatal */ } + } // A restored long history must be eligible for compaction on the very FIRST send // after a restart — otherwise an oversized persisted conversation is replayed @@ -206,6 +214,37 @@ public ChatService(bool ephemeral) // Set by the chat pane: progress ping each tool-call round (current, max) for the status line. public Action? OnRound; + // Claude Code (subscription) mode keeps the CLI's session id so follow-up messages continue the + // same conversation via --resume. Reset on ClearHistory. + private string? _claudeCodeSessionId; + + // Set by the chat pane: when true, the selected model runs through the Claude Code CLI on the + // subscription (via --model) instead of the pay-per-token API. The advisor/auto-escalation does + // NOT apply here — Claude Code runs its own loop with the one chosen model. + public bool SubscriptionMode; + + private static string ClaudeCodeSessionFile => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ClaudeRevit", "claudecode-session.txt"); + + private void PersistClaudeCodeSession() + { + if (_ephemeral) return; + try + { + if (string.IsNullOrEmpty(_claudeCodeSessionId)) + { + if (File.Exists(ClaudeCodeSessionFile)) File.Delete(ClaudeCodeSessionFile); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(ClaudeCodeSessionFile)!); + File.WriteAllText(ClaudeCodeSessionFile, _claudeCodeSessionId); + } + } + catch { /* non-fatal */ } + } + public void RecreateClient() => _client = null; public void ClearHistory() @@ -219,6 +258,8 @@ public void ClearHistory() _execCsharpOk = 0; _promoteNudged = false; _revealedCategories.Clear(); + _claudeCodeSessionId = null; + PersistClaudeCodeSession(); if (!_ephemeral) HistoryStore.Clear(); } @@ -260,6 +301,89 @@ public async Task RawCompleteAsync(string modelTag, string systemPrompt, return string.Concat(msg.Content.Select(b => b.TryPickText(out var t) ? t.Text : "")); } + // Subscription mode: run the local Claude Code CLI headless, letting it drive the Revit tools + // through our in-process MCP server. Streams its narration into the pane and keeps the CLI's + // session id so follow-up messages continue the same conversation (--resume). Costs nothing on + // the API — the work runs on the user's Claude Pro/Max subscription. + private async Task SendViaClaudeCodeAsync( + ObservableCollection conversation, string prompt, Dispatcher ui, + string? modelAlias, CancellationToken ct) + { + if (!McpServer.IsRunning) + { + try { McpServer.Start(); } + catch (Exception ex) + { + throw new InvalidOperationException( + "Couldn't start the MCP server that Claude Code needs — free the port in " + + "Settings → MCP and try again. (" + ex.Message + ")"); + } + } + var config = McpServer.WriteClientConfig(); + var workDir = McpServer.ClientWorkDir(); + var exe = SettingsStore.ClaudeCodeExe; + + // Parity with the API path: give Claude Code the current document + selection so "this" / + // "the selected walls" resolve. The MCP session is long-lived, so we prepend this fresh each + // message (instructions, which carry memory/standards, are sent once at connect). + var contextedPrompt = prompt; + try + { + var contextJson = await ToolDispatcher.Instance.GetProjectContextAsync(ct); + var ctxHeader = "CURRENT DOCUMENT:\n" + contextJson; + var sel = SelectionService.Current; + if (sel.Ids.Count > 0) + { + var idList = sel.Ids.Count > 30 + ? string.Join(", ", sel.Ids.Take(30)) + $", … +{sel.Ids.Count - 30} more" + : string.Join(", ", sel.Ids); + ctxHeader += $"\n\nCURRENT SELECTION: {sel.Description}. Element IDs: [{idList}]"; + } + contextedPrompt = ctxHeader + "\n\n---\n\nUSER REQUEST:\n" + prompt; + } + catch { /* context is best-effort — fall back to the bare prompt */ } + + ChatMessage? bubble = null; + void Append(string piece) + { + if (string.IsNullOrEmpty(piece)) return; + ui.InvokeAsync(() => + { + if (bubble == null) { bubble = new ChatMessage { Role = "assistant", Text = "" }; conversation.Add(bubble); } + bubble.Text += piece; + }); + } + + var toolCount = 0; + var res = await ClaudeCodeBackend.RunAsync( + exe, contextedPrompt, workDir, config, resumeSessionId: _claudeCodeSessionId, + allowedToolsGlob: "mcp__clauderevit__*", + onText: Append, + onTool: _ => { toolCount++; OnRound?.Invoke(toolCount, toolCount); }, + ct, model: modelAlias); + + _claudeCodeSessionId = res.SessionId ?? _claudeCodeSessionId; + PersistClaudeCodeSession(); + + if (!string.IsNullOrEmpty(res.Error)) + { + Append((bubble == null ? "" : "\n\n") + "⚠ " + res.Error); + return; + } + // Nothing streamed (e.g. a short answer delivered only in the final result event) — show it. + if (bubble == null && !string.IsNullOrEmpty(res.Text)) + Append(res.Text); + + if (SettingsStore.ShowTaskDiagnostics && bubble != null) + { + var tok = res.InputTokens + res.OutputTokens; + Append($"\n\n— claude-code (subscription — no API charge) · {res.NumTurns} turns · " + + $"{res.DurationMs / 1000.0:0.0}s" + + (tok > 0 ? $" · {tok:N0} tokens" : "") + + (res.McpStatus != null ? $" · MCP {res.McpStatus}" : "")); + } + } + public async Task SendAsync( ObservableCollection conversation, string model, @@ -268,6 +392,20 @@ public async Task SendAsync( string? imageMime = null) { var ui = Dispatcher.CurrentDispatcher; + + // Subscription path: the local Claude Code CLI drives the Revit tools through our MCP server. + // It runs its OWN agent loop, so bypass the whole Anthropic/alt pipeline (no API key, no tool + // schemas, no history compaction here) and just stream its output into the pane. + if (model == "claudecode" || SubscriptionMode) + { + var userText = conversation.LastOrDefault(m => m.Role == "user")?.Text ?? ""; + if (string.IsNullOrWhiteSpace(userText)) return; + // "claudecode" = the CLI's default subscription model; any other pick maps to --model. + var alias = model == "claudecode" ? null : ClaudeCodeBackend.ModelAlias(model); + await SendViaClaudeCodeAsync(conversation, userText, ui, alias, ct); + return; + } + bool alt = IsAlt(model); if (alt && !OpenAIBackend.IsConfigured) throw new InvalidOperationException( diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index 29486b4..d8b9892 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -31,21 +32,54 @@ public sealed class Result public int NumTurns; public double CostUsd; public long DurationMs; + // Diagnostics: MCP server connection status from the init event ("clauderevit=connected"), + // and whether the final result was flagged an error. Explains a run that launched but did + // nothing (e.g. MCP failed to connect → no Revit tools → no work). + public string? McpStatus; + public bool IsError; + public string? Subtype; } + // Map our internal model tag to a Claude Code `--model` alias. Returns null for "auto"/"fable"/ + // the default subscription model — where we let the CLI pick — since the advisor-escalation that + // "auto" means on the API doesn't exist inside Claude Code's own loop. + public static string? ModelAlias(string? tag) => tag switch + { + "opus-4-8" or "opus-4-7" or "opus-4-6" => "opus", + "sonnet-5" or "sonnet-4-6" => "sonnet", + "haiku-4-5" => "haiku", + _ => null + }; + public static async Task RunAsync( string exe, string prompt, string workDir, string mcpConfigPath, string? resumeSessionId, - string allowedToolsGlob, Action onText, Action onTool, CancellationToken ct) + string allowedToolsGlob, Action onText, Action onTool, CancellationToken ct, + string? model = null) { var args = new List { "-p", "--output-format", "stream-json", "--verbose", - "--include-partial-messages", - "--mcp-config", mcpConfigPath, - "--allowedTools", allowedToolsGlob + "--include-partial-messages" }; + if (!string.IsNullOrWhiteSpace(model)) + { + args.Add("--model"); + args.Add(model!); + } + // MCP + tools are for the "drive Revit" path. The judge runs with neither (empty) — a pure + // text-grading call — so skip the flags; an empty allowedTools glob denies every tool. + if (!string.IsNullOrWhiteSpace(mcpConfigPath)) + { + args.Add("--mcp-config"); + args.Add(mcpConfigPath); + } + if (!string.IsNullOrWhiteSpace(allowedToolsGlob)) + { + args.Add("--allowedTools"); + args.Add(allowedToolsGlob); + } if (!string.IsNullOrWhiteSpace(resumeSessionId)) { args.Add("--resume"); @@ -53,30 +87,42 @@ public static async Task RunAsync( } var result = new Result(); - Process proc; - try + + // Revit is a GUI process — its PATH is often narrower than the user's shell, so a bare + // "claude" from an npm/native install frequently isn't found. Resolve to a full path across + // the common install locations FIRST; that also avoids cmd.exe's localized (and, on a Russian + // Windows, mojibaked) "'claude' is not recognized as a command" error leaking into results. + var resolved = Resolve(exe); + if (resolved == null) { - proc = Start(exe, args, workDir); + result.Error = + $"Claude Code CLI not found ('{exe}'). Install it (npm i -g @anthropic-ai/claude-code), " + + "run 'claude login' once, then set the full path to claude.cmd/claude.exe in Settings."; + return result; } - catch (Win32Exception) + + var viaCmd = resolved.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) || + resolved.EndsWith(".bat", StringComparison.OrdinalIgnoreCase); + + Process proc; + try { - // npm-installed `claude` is a .cmd shim — not directly launchable; go through cmd.exe. - try + if (viaCmd) { - var viaCmd = new List { "/c", exe }; - viaCmd.AddRange(args); - proc = Start("cmd.exe", viaCmd, workDir); + // .cmd/.bat shims aren't PE images — must run through cmd.exe. The path is real, so + // cmd won't print a "not recognized" error. + var cmdArgs = new List { "/c", resolved }; + cmdArgs.AddRange(args); + proc = Start("cmd.exe", cmdArgs, workDir); } - catch (Exception ex) + else { - result.Error = $"Can't launch Claude Code ('{exe}'). Install it and run 'claude login', " + - $"or set the full path in Settings. ({ex.Message})"; - return result; + proc = Start(resolved, args, workDir); } } catch (Exception ex) { - result.Error = $"Can't launch Claude Code ('{exe}'): {ex.Message}"; + result.Error = $"Can't launch Claude Code ('{resolved}'): {ex.Message}"; return result; } @@ -112,6 +158,20 @@ public static async Task RunAsync( return result; } + // A one-shot, no-tools text completion on the subscription — used for the impartial benchmark + // judge so grading costs nothing on the API. The bogus allowedTools glob matches no tool, so in + // headless (-p) mode every built-in tool is auto-denied and the model just returns text. + public static async Task CompleteAsync(string exe, string prompt, string workDir, CancellationToken ct) + { + var res = await RunAsync( + exe, prompt, workDir, mcpConfigPath: "", resumeSessionId: null, + allowedToolsGlob: "__deny_all_tools__", + onText: _ => { }, onTool: _ => { }, ct); + if (string.IsNullOrEmpty(res.Text) && !string.IsNullOrEmpty(res.Error)) + throw new InvalidOperationException(res.Error); + return res.Text; + } + // Each stdout line is one JSON event. We pull: the session id (to resume the conversation next // turn), streamed assistant text deltas, tool-call names, and the final result text. private static void ParseLine(string line, Result result, Action onText, Action onTool) @@ -128,10 +188,36 @@ private static void ParseLine(string line, Result result, Action onText, var type = root.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null; + // The init event lists the MCP servers Claude Code tried to attach and whether each + // connected — the single most useful signal when a run launches but does no work. + if (type == "system" && root.TryGetProperty("mcp_servers", out var servers) && + servers.ValueKind == JsonValueKind.Array) + { + // Report ONLY our own server — the user's Claude Code may have many unrelated + // connectors (Gmail, Drive, Booking.com…) whose statuses would otherwise flood the + // diagnostic line. + var parts = new List(); + foreach (var s in servers.EnumerateArray()) + { + var name = s.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? "" : ""; + if (name.IndexOf("clauderevit", StringComparison.OrdinalIgnoreCase) < 0) continue; + var status = s.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.String + ? st.GetString() : "?"; + parts.Add($"{name}={status}"); + } + if (parts.Count > 0) result.McpStatus = string.Join(", ", parts); + else if (result.McpStatus == null) result.McpStatus = "clauderevit=absent"; + } + if (type == "result") { if (root.TryGetProperty("result", out var res) && res.ValueKind == JsonValueKind.String) result.Text = res.GetString() ?? result.Text; + if (root.TryGetProperty("is_error", out var ie) && ie.ValueKind == JsonValueKind.True) + result.IsError = true; + if (root.TryGetProperty("subtype", out var sub) && sub.ValueKind == JsonValueKind.String) + result.Subtype = sub.GetString(); if (root.TryGetProperty("num_turns", out var nt) && nt.TryGetInt32(out var ntv)) result.NumTurns = ntv; if (root.TryGetProperty("total_cost_usd", out var c) && c.TryGetDouble(out var cv)) result.CostUsd = cv; if (root.TryGetProperty("duration_ms", out var dm) && dm.TryGetInt64(out var dmv)) result.DurationMs = dmv; @@ -168,6 +254,79 @@ private static void ParseLine(string line, Result result, Action onText, catch { /* non-JSON or partial line — ignore */ } } + // Find the `claude` executable. Honours an explicit path, then PATH (+ Windows extensions), + // then the well-known npm-global and native-install locations that Revit's PATH usually misses. + // Returns a full path, or null if nothing exists. + private static string? Resolve(string exe) + { + if (string.IsNullOrWhiteSpace(exe)) exe = "claude"; + + // Explicit path (has a directory separator) — trust it if it exists. + if (exe.IndexOf(Path.DirectorySeparatorChar) >= 0 || exe.IndexOf('/') >= 0) + return File.Exists(exe) ? exe : null; + + var exts = new[] { "", ".cmd", ".exe", ".bat", ".ps1" }; + + // Search each PATH entry. + var pathVar = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathVar.Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + foreach (var ext in exts) + { + try { var p = Path.Combine(dir, exe + ext); if (File.Exists(p)) return p; } + catch { /* bad PATH entry */ } + } + } + + // Common install locations the GUI PATH tends to omit. + string? Env(string v) => Environment.GetEnvironmentVariable(v); + var candidates = new List + { + // npm global (default prefix) + Env("APPDATA") is { } ad ? Path.Combine(ad, "npm", exe + ".cmd") : null, + Env("APPDATA") is { } ad2 ? Path.Combine(ad2, "npm", exe + ".ps1") : null, + // native installer (irm https://claude.ai/install.ps1 | iex) → %USERPROFILE%\.local\bin\claude.exe + Env("USERPROFILE") is { } upn ? Path.Combine(upn, ".local", "bin", exe + ".exe") : null, + Env("USERPROFILE") is { } up3 ? Path.Combine(up3, ".local", "bin", exe) : null, + // other local installs + Env("LOCALAPPDATA") is { } la ? Path.Combine(la, "Programs", "claude", exe + ".exe") : null, + Env("USERPROFILE") is { } up ? Path.Combine(up, ".claude", "local", exe + ".exe") : null, + Env("USERPROFILE") is { } up2 ? Path.Combine(up2, ".claude", "local", exe) : null, + // unix-y (in case Revit ever runs elsewhere) + "/usr/local/bin/" + exe, + "/usr/bin/" + exe, + }; + foreach (var c in candidates) + { + if (c != null) { try { if (File.Exists(c)) return c; } catch { } } + } + + // Claude Desktop (the MSIX Store app) BUNDLES the Claude Code CLI under its package dir at + // %LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\claude-code\\claude.exe + // Users who only have the desktop app still have a working headless claude.exe here — it's just + // not on PATH. Glob for it and take the newest version folder. + try + { + var packages = Env("LOCALAPPDATA") is { } lad ? Path.Combine(lad, "Packages") : null; + if (packages != null && Directory.Exists(packages)) + { + var best = Directory.EnumerateDirectories(packages, "Claude_*") + .Select(pkg => Path.Combine(pkg, "LocalCache", "Roaming", "Claude", "claude-code")) + .Where(Directory.Exists) + .SelectMany(cc => Directory.EnumerateDirectories(cc)) + .Select(ver => Path.Combine(ver, "claude.exe")) + .Where(File.Exists) + .OrderByDescending(p => p, StringComparer.OrdinalIgnoreCase) // newest version last-sorts first + .FirstOrDefault(); + if (best != null) return best; + } + } + catch { /* enumeration raced or access denied */ } + + return null; + } + private static Process Start(string file, IEnumerable args, string workDir) { var psi = new ProcessStartInfo @@ -179,7 +338,8 @@ private static Process Start(string file, IEnumerable args, string workD RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, - StandardOutputEncoding = Encoding.UTF8 + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 }; foreach (var a in args) psi.ArgumentList.Add(a); return Process.Start(psi) ?? throw new InvalidOperationException("Process.Start returned null."); diff --git a/ClaudeRevit/Services/McpServer.cs b/ClaudeRevit/Services/McpServer.cs index 5cdd8df..3757ab2 100644 --- a/ClaudeRevit/Services/McpServer.cs +++ b/ClaudeRevit/Services/McpServer.cs @@ -209,6 +209,23 @@ private static async Task HandleRequest(HttpListenerContext ctx, CancellationTok } } + // The static driving rules PLUS the user's saved memory (project standards) and the proven-script + // digest — so a subscription/MCP session gets the same accumulated knowledge the API path injects + // into its system prompt. Instructions are sent once at initialize, so memory saved mid-session + // appears on the next reconnect. + private static string BuildInstructions() + { + var sb = new StringBuilder(Instructions); + var memory = MemoryStore.Load(); + if (!string.IsNullOrWhiteSpace(memory)) + sb.Append("\n\nSAVED MEMORY — user preferences and project standards; apply them:\n") + .Append(memory.Trim()); + var experience = ExperienceStore.Digest(); + if (!string.IsNullOrWhiteSpace(experience)) + sb.Append("\n\n").Append(experience!.Trim()); + return sb.ToString(); + } + private static async Task<(JsonNode? value, JsonObject? error)> Dispatch(string? method, JsonNode? prms, CancellationToken ct) { switch (method) @@ -220,8 +237,10 @@ private static async Task HandleRequest(HttpListenerContext ctx, CancellationTok ["protocolVersion"] = clientVer ?? "2025-06-18", ["capabilities"] = new JsonObject { ["tools"] = new JsonObject() }, ["serverInfo"] = new JsonObject { ["name"] = "ClaudeRevit", ["version"] = "1.0" }, - // Surfaced to the model by the client — the hard-won rules for driving Revit well. - ["instructions"] = Instructions + // Surfaced to the model by the client — the hard-won rules for driving Revit well, + // plus the user's saved standards and proven-script digest (parity with the API path, + // whose system prompt carries the same). Built at session start. + ["instructions"] = BuildInstructions() }, null); case "ping": diff --git a/ClaudeRevit/UI/BenchmarkWindow.xaml b/ClaudeRevit/UI/BenchmarkWindow.xaml index e5e9624..791b4d5 100644 --- a/ClaudeRevit/UI/BenchmarkWindow.xaml +++ b/ClaudeRevit/UI/BenchmarkWindow.xaml @@ -43,6 +43,12 @@ + + { diff --git a/ClaudeRevit/UI/ChatPaneView.xaml b/ClaudeRevit/UI/ChatPaneView.xaml index 9d01a75..cdb25b7 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml +++ b/ClaudeRevit/UI/ChatPaneView.xaml @@ -51,8 +51,13 @@ Haiku 4.5 (fastest) Sonnet 4.6 Opus 4.7 + Claude Code (subscription) Alt model (set up in ⚙) +