@usewhisper/mcp-server 2.9.0 → 2.10.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 (2) hide show
  1. package/dist/server.js +629 -252
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { z } from "zod";
7
7
  import { execSync, spawnSync } from "child_process";
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from "fs";
9
- import { join, relative, extname } from "path";
9
+ import { join, relative, extname, normalize as normalizePath, resolve as resolvePath } from "path";
10
10
  import { homedir } from "os";
11
11
  import { createHash, randomUUID } from "crypto";
12
12
 
@@ -3879,15 +3879,50 @@ function ensureStateDir() {
3879
3879
  mkdirSync(STATE_DIR, { recursive: true });
3880
3880
  }
3881
3881
  }
3882
+ function canonicalizeWorkspacePath(path, cwd = process.cwd()) {
3883
+ const basePath = path?.trim() ? path.trim() : cwd;
3884
+ const absolute = resolvePath(cwd, basePath);
3885
+ const normalized = normalizePath(absolute).replace(/\\/g, "/");
3886
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
3887
+ }
3888
+ function chooseWorkspaceProjectSource(args) {
3889
+ const explicitProject = args.explicitProject?.trim();
3890
+ if (explicitProject) {
3891
+ return { project_ref: explicitProject, resolved_by: "explicit_project" };
3892
+ }
3893
+ const workspaceProjectRef = args.workspaceProjectRef?.trim();
3894
+ if (workspaceProjectRef) {
3895
+ return { project_ref: workspaceProjectRef, resolved_by: "workspace_binding" };
3896
+ }
3897
+ const defaultProject = args.defaultProject?.trim();
3898
+ if (defaultProject) {
3899
+ return { project_ref: defaultProject, resolved_by: "env_default" };
3900
+ }
3901
+ return { project_ref: null, resolved_by: "unresolved" };
3902
+ }
3903
+ function classifyWorkspaceHealth(args) {
3904
+ if (!args.project_ref) return "unbound";
3905
+ if (!args.last_indexed_at || (args.coverage ?? 0) <= 0) return "unindexed";
3906
+ const nowMs = args.now_ms ?? Date.now();
3907
+ const lastIndexedMs = new Date(args.last_indexed_at).getTime();
3908
+ const ageHours = Number.isFinite(lastIndexedMs) ? (nowMs - lastIndexedMs) / (60 * 60 * 1e3) : Number.POSITIVE_INFINITY;
3909
+ const pendingChanges = args.pending_changes ?? 0;
3910
+ const drifted = pendingChanges > 0 || Boolean(
3911
+ args.last_indexed_commit && args.current_commit && args.last_indexed_commit !== args.current_commit
3912
+ );
3913
+ if (drifted) return "drifted";
3914
+ if (ageHours > (args.max_staleness_hours ?? 168)) return "stale";
3915
+ return "healthy";
3916
+ }
3882
3917
  function getWorkspaceId(workspaceId) {
3883
3918
  if (workspaceId?.trim()) return workspaceId.trim();
3884
- const seed = `${process.cwd()}|${DEFAULT_PROJECT || "default"}|${API_KEY.slice(0, 12)}`;
3919
+ const seed = `${canonicalizeWorkspacePath(process.cwd())}|${API_KEY.slice(0, 12) || "anon"}`;
3885
3920
  return createHash("sha256").update(seed).digest("hex").slice(0, 20);
3886
3921
  }
3887
3922
  function getWorkspaceIdForPath(path, workspaceId) {
3888
3923
  if (workspaceId?.trim()) return workspaceId.trim();
3889
3924
  if (!path) return getWorkspaceId(void 0);
3890
- const seed = `${path}|${DEFAULT_PROJECT || "default"}|${API_KEY.slice(0, 12)}`;
3925
+ const seed = `${canonicalizeWorkspacePath(path)}|${API_KEY.slice(0, 12) || "anon"}`;
3891
3926
  return createHash("sha256").update(seed).digest("hex").slice(0, 20);
3892
3927
  }
3893
3928
  function clamp012(value) {
@@ -3978,7 +4013,10 @@ function getWorkspaceState(state, workspaceId) {
3978
4013
  annotations: [],
3979
4014
  session_summaries: [],
3980
4015
  events: [],
3981
- index_metadata: {}
4016
+ index_metadata: {},
4017
+ root_path: void 0,
4018
+ project_ref: void 0,
4019
+ project_id: void 0
3982
4020
  };
3983
4021
  }
3984
4022
  return state.workspaces[workspaceId];
@@ -3988,17 +4026,124 @@ function computeChecksum(value) {
3988
4026
  }
3989
4027
  var cachedProjectRef = DEFAULT_PROJECT || void 0;
3990
4028
  var cachedMcpSessionId = process.env.WHISPER_SESSION_ID || `mcp_${randomUUID().slice(0, 12)}`;
4029
+ async function resolveProjectDescriptor(projectRef) {
4030
+ try {
4031
+ const resolved = await whisper.resolveProject(projectRef);
4032
+ const resolvedRef = resolved.slug || resolved.name || resolved.id || projectRef;
4033
+ cachedProjectRef = resolvedRef;
4034
+ return { project_ref: resolvedRef, project_id: resolved.id || null };
4035
+ } catch {
4036
+ cachedProjectRef = projectRef;
4037
+ return { project_ref: projectRef, project_id: null };
4038
+ }
4039
+ }
4040
+ function getWorkspaceRecommendedNextCalls(health) {
4041
+ if (health === "unbound") return ["index.workspace_resolve", "context.list_projects", "index.workspace_status"];
4042
+ if (health === "unindexed") return ["index.workspace_status", "index.workspace_run", "search_code", "grep"];
4043
+ if (health === "stale" || health === "drifted") return ["index.workspace_status", "index.workspace_run", "grep", "search_code"];
4044
+ return [];
4045
+ }
4046
+ function getWorkspaceWarnings(args) {
4047
+ if (args.health === "unbound") {
4048
+ return [`Workspace ${args.rootPath} is not bound to a Whisper project.`];
4049
+ }
4050
+ if (args.health === "unindexed") {
4051
+ return [`Workspace ${args.rootPath} is bound to ${args.projectRef}, but no usable local index metadata exists yet.`];
4052
+ }
4053
+ if (args.health === "stale") {
4054
+ const age = args.freshness.age_hours == null ? "unknown" : `${Math.round(args.freshness.age_hours)}h`;
4055
+ return [`Workspace index for ${args.projectRef} is stale (${age} old).`];
4056
+ }
4057
+ if (args.health === "drifted") {
4058
+ const reasons = [];
4059
+ if (args.pendingChanges && args.pendingChanges > 0) reasons.push(`${args.pendingChanges} pending local changes`);
4060
+ if (args.lastIndexedCommit && args.currentCommit && args.lastIndexedCommit !== args.currentCommit) {
4061
+ reasons.push(`indexed commit ${args.lastIndexedCommit.slice(0, 12)} differs from current ${args.currentCommit.slice(0, 12)}`);
4062
+ }
4063
+ const suffix = reasons.length ? `: ${reasons.join("; ")}` : "";
4064
+ return [`Workspace state drifted since the last index${suffix}.`];
4065
+ }
4066
+ return [];
4067
+ }
4068
+ async function resolveWorkspaceTrust(args) {
4069
+ const rootPath = canonicalizeWorkspacePath(args.path);
4070
+ const workspaceId = getWorkspaceIdForPath(rootPath, args.workspace_id);
4071
+ const state = loadState();
4072
+ const workspace = getWorkspaceState(state, workspaceId);
4073
+ let mutated = false;
4074
+ if (workspace.root_path !== rootPath) {
4075
+ workspace.root_path = rootPath;
4076
+ mutated = true;
4077
+ }
4078
+ const selected = chooseWorkspaceProjectSource({
4079
+ explicitProject: args.project,
4080
+ workspaceProjectRef: workspace.project_ref,
4081
+ defaultProject: DEFAULT_PROJECT || null
4082
+ });
4083
+ let projectRef = null;
4084
+ let projectId = workspace.project_id || null;
4085
+ if (selected.project_ref) {
4086
+ const resolved = await resolveProjectDescriptor(selected.project_ref);
4087
+ projectRef = resolved.project_ref;
4088
+ projectId = resolved.project_id;
4089
+ if (workspace.project_ref !== projectRef || (workspace.project_id || null) !== projectId) {
4090
+ workspace.project_ref = projectRef;
4091
+ workspace.project_id = projectId || void 0;
4092
+ mutated = true;
4093
+ }
4094
+ }
4095
+ if (mutated) saveState(state);
4096
+ const lastIndexedAt = workspace.index_metadata?.last_indexed_at || null;
4097
+ const ageHours = lastIndexedAt ? (Date.now() - new Date(lastIndexedAt).getTime()) / (60 * 60 * 1e3) : null;
4098
+ const currentCommit = getGitHead(rootPath) || null;
4099
+ const pendingChanges = getGitPendingCount(rootPath);
4100
+ const lastIndexedCommit = workspace.index_metadata?.last_indexed_commit || null;
4101
+ const coverage = workspace.index_metadata?.coverage ?? 0;
4102
+ const health = classifyWorkspaceHealth({
4103
+ project_ref: projectRef,
4104
+ coverage,
4105
+ last_indexed_at: lastIndexedAt,
4106
+ last_indexed_commit: lastIndexedCommit,
4107
+ current_commit: currentCommit,
4108
+ pending_changes: pendingChanges ?? null,
4109
+ max_staleness_hours: args.max_staleness_hours ?? 168
4110
+ });
4111
+ const freshness = {
4112
+ stale: health === "stale",
4113
+ age_hours: ageHours,
4114
+ last_indexed_at: lastIndexedAt
4115
+ };
4116
+ const warnings = getWorkspaceWarnings({
4117
+ health,
4118
+ rootPath,
4119
+ projectRef,
4120
+ currentCommit,
4121
+ lastIndexedCommit,
4122
+ pendingChanges: pendingChanges ?? null,
4123
+ freshness
4124
+ });
4125
+ return {
4126
+ workspace_id: workspaceId,
4127
+ root_path: rootPath,
4128
+ project_ref: projectRef,
4129
+ project_id: projectId,
4130
+ resolved_by: selected.resolved_by,
4131
+ health,
4132
+ warnings,
4133
+ recommended_next_calls: getWorkspaceRecommendedNextCalls(health),
4134
+ freshness,
4135
+ coverage,
4136
+ last_indexed_commit: lastIndexedCommit,
4137
+ current_commit: currentCommit,
4138
+ pending_changes: pendingChanges ?? null,
4139
+ grounded_to_workspace: health === "healthy"
4140
+ };
4141
+ }
3991
4142
  async function resolveProjectRef(explicit) {
3992
4143
  if (explicit?.trim()) {
3993
4144
  const requestedRef = explicit.trim();
3994
- try {
3995
- const resolved = await whisper.resolveProject(requestedRef);
3996
- cachedProjectRef = resolved.slug || resolved.name || resolved.id;
3997
- return cachedProjectRef;
3998
- } catch {
3999
- cachedProjectRef = requestedRef;
4000
- return requestedRef;
4001
- }
4145
+ const resolved = await resolveProjectDescriptor(requestedRef);
4146
+ return resolved.project_ref;
4002
4147
  }
4003
4148
  if (cachedProjectRef) return cachedProjectRef;
4004
4149
  try {
@@ -4101,7 +4246,9 @@ function buildAbstain(args) {
4101
4246
  reason: args.reason,
4102
4247
  message: args.message,
4103
4248
  closest_evidence: args.closest_evidence,
4104
- recommended_next_calls: ["index.workspace_status", "index.workspace_run", "symbol_search", "context.get_relevant"],
4249
+ warnings: args.warnings || [],
4250
+ trust_state: args.trust_state,
4251
+ recommended_next_calls: args.recommended_next_calls || ["index.workspace_status", "index.workspace_run", "grep", "context.get_relevant"],
4105
4252
  diagnostics: {
4106
4253
  claims_evaluated: args.claims_evaluated,
4107
4254
  evidence_items_found: args.evidence_items_found,
@@ -4177,6 +4324,86 @@ function formatCanonicalMemoryResults(rawResults) {
4177
4324
  };
4178
4325
  });
4179
4326
  }
4327
+ function normalizeLooseText(value) {
4328
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, " ");
4329
+ }
4330
+ function resolveForgetQueryCandidates(rawResults, query) {
4331
+ const normalizedQuery = normalizeLooseText(query);
4332
+ const candidates = normalizeCanonicalResults(rawResults).map((result) => {
4333
+ const memory = result?.memory || result;
4334
+ const metadata = memory?.metadata || {};
4335
+ return {
4336
+ id: String(memory?.id || "").trim(),
4337
+ content: normalizeLooseText(memory?.content),
4338
+ normalized_content: normalizeLooseText(metadata?.normalized_content),
4339
+ canonical_content: normalizeLooseText(metadata?.canonical_content),
4340
+ similarity: typeof result?.similarity === "number" ? result.similarity : typeof result?.score === "number" ? result.score : 0
4341
+ };
4342
+ }).filter((candidate) => candidate.id);
4343
+ const exactMatches = candidates.filter(
4344
+ (candidate) => candidate.id.toLowerCase() === normalizedQuery || candidate.content === normalizedQuery || candidate.normalized_content === normalizedQuery || candidate.canonical_content === normalizedQuery
4345
+ );
4346
+ if (exactMatches.length > 0) {
4347
+ return { memory_ids: [...new Set(exactMatches.map((candidate) => candidate.id))], resolved_by: "exact" };
4348
+ }
4349
+ const substringMatches = candidates.filter((candidate) => {
4350
+ const haystacks = [candidate.content, candidate.normalized_content, candidate.canonical_content].filter(Boolean);
4351
+ return haystacks.some(
4352
+ (value) => value.includes(normalizedQuery) || normalizedQuery.length >= 12 && normalizedQuery.includes(value)
4353
+ );
4354
+ });
4355
+ if (substringMatches.length === 1) {
4356
+ return { memory_ids: [substringMatches[0].id], resolved_by: "substring" };
4357
+ }
4358
+ if (substringMatches.length > 1) {
4359
+ return {
4360
+ memory_ids: [],
4361
+ resolved_by: "ambiguous",
4362
+ warning: "Query matched multiple memories by recognizable text. Use memory_id or a more specific query."
4363
+ };
4364
+ }
4365
+ const ranked = [...candidates].sort((a, b) => b.similarity - a.similarity);
4366
+ if (ranked[0] && ranked[0].similarity >= 0.9 && (!ranked[1] || ranked[0].similarity - ranked[1].similarity >= 0.08)) {
4367
+ return { memory_ids: [ranked[0].id], resolved_by: "high_confidence" };
4368
+ }
4369
+ return { memory_ids: [], resolved_by: "none", warning: "Query did not resolve to a reliable memory match. No memories were changed." };
4370
+ }
4371
+ async function searchMemoriesForContextQuery(args) {
4372
+ return whisper.searchMemoriesSOTA({
4373
+ project: args.project,
4374
+ query: args.query,
4375
+ user_id: args.user_id,
4376
+ session_id: args.session_id,
4377
+ top_k: args.top_k ?? 5,
4378
+ include_relations: false,
4379
+ include_pending: true
4380
+ });
4381
+ }
4382
+ async function runContextQueryMemoryRescue(args) {
4383
+ const scoped = await searchMemoriesForContextQuery(args);
4384
+ const scopedResults = formatCanonicalMemoryResults(scoped);
4385
+ if (scopedResults.length > 0) {
4386
+ return { results: scopedResults, rescue_mode: "scoped" };
4387
+ }
4388
+ if (args.user_id || args.session_id) {
4389
+ return { results: [], rescue_mode: null };
4390
+ }
4391
+ const broad = await searchMemoriesForContextQuery({
4392
+ project: args.project,
4393
+ query: args.query,
4394
+ top_k: args.top_k
4395
+ });
4396
+ return { results: formatCanonicalMemoryResults(broad), rescue_mode: formatCanonicalMemoryResults(broad).length > 0 ? "project_broad" : null };
4397
+ }
4398
+ function renderContextQueryMemoryRescue(args) {
4399
+ const lines = args.results.map(
4400
+ (result, index) => `${index + 1}. [${result.memory_type || "memory"}, score: ${result.similarity ?? "n/a"}] ${result.content}`
4401
+ );
4402
+ const scopeLabel = args.rescue_mode === "project_broad" ? `project=${args.project}, project_broad_memory_rescue=true` : `project=${args.project}, user=${args.scope.userId}, session=${args.scope.sessionId}, scoped_memory_rescue=true`;
4403
+ return `Found ${args.results.length} memory result(s) (${scopeLabel}):
4404
+
4405
+ ${lines.join("\n\n")}`;
4406
+ }
4180
4407
  function likelyEmbeddingFailure(error) {
4181
4408
  const message = String(error?.message || error || "").toLowerCase();
4182
4409
  return message.includes("embedding") || message.includes("vector") || message.includes("timeout") || message.includes("timed out") || message.includes("temporarily unavailable");
@@ -4218,6 +4445,186 @@ async function queryWithDegradedFallback(params) {
4218
4445
  };
4219
4446
  }
4220
4447
  }
4448
+ function collectCodeFiles(rootPath, allowedExts, maxFiles) {
4449
+ const files = [];
4450
+ function collect(dir) {
4451
+ if (files.length >= maxFiles) return;
4452
+ let entries;
4453
+ try {
4454
+ entries = readdirSync(dir, { withFileTypes: true });
4455
+ } catch {
4456
+ return;
4457
+ }
4458
+ for (const entry of entries) {
4459
+ if (files.length >= maxFiles) break;
4460
+ if (SKIP_DIRS.has(entry.name)) continue;
4461
+ const full = join(dir, entry.name);
4462
+ if (entry.isDirectory()) collect(full);
4463
+ else if (entry.isFile()) {
4464
+ const ext = extname(entry.name).replace(".", "");
4465
+ if (allowedExts.has(ext)) files.push(full);
4466
+ }
4467
+ }
4468
+ }
4469
+ collect(rootPath);
4470
+ return files;
4471
+ }
4472
+ function tokenizeQueryForLexicalRescue(query) {
4473
+ const stopWords = /* @__PURE__ */ new Set(["where", "what", "show", "find", "logic", "code", "file", "files", "handled", "handling"]);
4474
+ return Array.from(new Set(
4475
+ query.toLowerCase().split(/[^a-z0-9/_-]+/).map((token) => token.trim()).filter((token) => token.length >= 3 && !stopWords.has(token))
4476
+ ));
4477
+ }
4478
+ function buildLexicalRescueResults(args) {
4479
+ const tokens = tokenizeQueryForLexicalRescue(args.query);
4480
+ if (tokens.length === 0) return [];
4481
+ const scored = args.documents.map((doc) => {
4482
+ const haystack = `${doc.id}
4483
+ ${doc.content}
4484
+ ${doc.raw_content}`.toLowerCase();
4485
+ const tokenHits = tokens.filter((token) => haystack.includes(token));
4486
+ const uniqueHits = tokenHits.length;
4487
+ const exactPhraseBonus = haystack.includes(args.query.toLowerCase()) ? 0.25 : 0;
4488
+ const score = uniqueHits === 0 ? 0 : Math.min(0.95, uniqueHits / tokens.length + exactPhraseBonus);
4489
+ return {
4490
+ id: doc.id,
4491
+ score,
4492
+ content: doc.content,
4493
+ snippet: doc.content.split("\n").find((line) => line.trim().length > 10)?.slice(0, 200) || doc.id,
4494
+ search_mode: "lexical_rescue"
4495
+ };
4496
+ });
4497
+ return scored.filter((result) => result.score > 0).sort((a, b) => b.score - a.score).slice(0, args.top_k);
4498
+ }
4499
+ async function runSearchCodeSearch(args) {
4500
+ const topK = args.top_k ?? 10;
4501
+ const requestedThreshold = args.threshold ?? 0.2;
4502
+ const semanticDocuments = args.documents.map((doc) => ({ id: doc.id, content: doc.content }));
4503
+ const defaultResponse = await args.semantic_search({
4504
+ query: args.query,
4505
+ documents: semanticDocuments,
4506
+ top_k: topK,
4507
+ threshold: requestedThreshold
4508
+ });
4509
+ if (defaultResponse.results?.length) {
4510
+ return {
4511
+ results: defaultResponse.results.map((result) => ({ ...result, search_mode: "semantic" })),
4512
+ mode: "semantic",
4513
+ threshold_used: requestedThreshold,
4514
+ fallback_used: false,
4515
+ fallback_reason: null
4516
+ };
4517
+ }
4518
+ const adaptiveThreshold = Math.max(0.05, Math.min(requestedThreshold, requestedThreshold / 2));
4519
+ if (adaptiveThreshold < requestedThreshold) {
4520
+ const adaptiveResponse = await args.semantic_search({
4521
+ query: args.query,
4522
+ documents: semanticDocuments,
4523
+ top_k: topK,
4524
+ threshold: adaptiveThreshold
4525
+ });
4526
+ if (adaptiveResponse.results?.length) {
4527
+ return {
4528
+ results: adaptiveResponse.results.map((result) => ({ ...result, search_mode: "adaptive_semantic" })),
4529
+ mode: "adaptive_semantic",
4530
+ threshold_used: adaptiveThreshold,
4531
+ fallback_used: true,
4532
+ fallback_reason: `No results at threshold ${requestedThreshold}; lowered to ${adaptiveThreshold}.`
4533
+ };
4534
+ }
4535
+ }
4536
+ const lexicalResults = buildLexicalRescueResults({
4537
+ query: args.query,
4538
+ documents: args.documents,
4539
+ top_k: topK
4540
+ });
4541
+ return {
4542
+ results: lexicalResults,
4543
+ mode: "lexical_rescue",
4544
+ threshold_used: null,
4545
+ fallback_used: true,
4546
+ fallback_reason: lexicalResults.length ? "Semantic ranking returned no matches; lexical rescue over local file paths and content was used." : "Semantic ranking returned no matches and lexical rescue found no strong candidates."
4547
+ };
4548
+ }
4549
+ async function runSearchCodeTool(args) {
4550
+ const rootPath = canonicalizeWorkspacePath(args.path);
4551
+ const workspace = await resolveWorkspaceTrust({ path: rootPath });
4552
+ const allowedExts = args.file_types ? new Set(args.file_types) : CODE_EXTENSIONS;
4553
+ const files = collectCodeFiles(rootPath, allowedExts, args.max_files ?? 150);
4554
+ const sharedWarnings = [...workspace.warnings];
4555
+ if (files.length === 0) {
4556
+ return {
4557
+ tool: "search_code",
4558
+ query: args.query,
4559
+ path: rootPath,
4560
+ results: [],
4561
+ count: 0,
4562
+ warnings: sharedWarnings,
4563
+ diagnostics: {
4564
+ workspace_id: workspace.workspace_id,
4565
+ root_path: workspace.root_path,
4566
+ project_ref: workspace.project_ref,
4567
+ project_id: workspace.project_id,
4568
+ index_health: workspace.health,
4569
+ grounded_to_workspace: workspace.grounded_to_workspace,
4570
+ threshold_requested: args.threshold ?? 0.2,
4571
+ threshold_used: null,
4572
+ fallback_used: false,
4573
+ fallback_reason: null,
4574
+ search_mode: "semantic",
4575
+ warnings: sharedWarnings
4576
+ }
4577
+ };
4578
+ }
4579
+ const documents = [];
4580
+ for (const filePath of files) {
4581
+ try {
4582
+ const stat = statSync(filePath);
4583
+ if (stat.size > 500 * 1024) continue;
4584
+ const content = readFileSync(filePath, "utf-8");
4585
+ const relPath = relative(rootPath, filePath).replace(/\\/g, "/");
4586
+ documents.push({ id: relPath, content: extractSignature(relPath, content), raw_content: content });
4587
+ } catch {
4588
+ }
4589
+ }
4590
+ const searchResult = await runSearchCodeSearch({
4591
+ query: args.query,
4592
+ documents,
4593
+ top_k: args.top_k,
4594
+ threshold: args.threshold,
4595
+ semantic_search: (params) => whisper.semanticSearch(params)
4596
+ });
4597
+ if (searchResult.mode === "lexical_rescue") {
4598
+ sharedWarnings.push("Local lexical rescue was used because semantic search returned no matches.");
4599
+ } else if (searchResult.mode === "adaptive_semantic") {
4600
+ sharedWarnings.push(searchResult.fallback_reason || "Adaptive semantic thresholding was used.");
4601
+ }
4602
+ if (!workspace.grounded_to_workspace && workspace.health !== "unbound" && workspace.health !== "unindexed") {
4603
+ sharedWarnings.push("Local code search is live, but project-backed retrieval may disagree until the workspace is re-indexed.");
4604
+ }
4605
+ return {
4606
+ tool: "search_code",
4607
+ query: args.query,
4608
+ path: rootPath,
4609
+ results: searchResult.results,
4610
+ count: searchResult.results.length,
4611
+ warnings: sharedWarnings,
4612
+ diagnostics: {
4613
+ workspace_id: workspace.workspace_id,
4614
+ root_path: workspace.root_path,
4615
+ project_ref: workspace.project_ref,
4616
+ project_id: workspace.project_id,
4617
+ index_health: workspace.health,
4618
+ grounded_to_workspace: workspace.grounded_to_workspace,
4619
+ threshold_requested: args.threshold ?? 0.2,
4620
+ threshold_used: searchResult.threshold_used,
4621
+ fallback_used: searchResult.fallback_used,
4622
+ fallback_reason: searchResult.fallback_reason,
4623
+ search_mode: searchResult.mode,
4624
+ warnings: sharedWarnings
4625
+ }
4626
+ };
4627
+ }
4221
4628
  function getLocalAllowlistRoots() {
4222
4629
  const fromEnv = (process.env.WHISPER_LOCAL_ALLOWLIST || "").split(",").map((v) => v.trim()).filter(Boolean);
4223
4630
  if (fromEnv.length > 0) return fromEnv;
@@ -4297,7 +4704,8 @@ async function ingestLocalPath(params) {
4297
4704
  }
4298
4705
  collect(rootPath);
4299
4706
  const manifest = loadIngestManifest();
4300
- const workspaceId = getWorkspaceIdForPath(rootPath);
4707
+ const canonicalRootPath = canonicalizeWorkspacePath(rootPath);
4708
+ const workspaceId = getWorkspaceIdForPath(canonicalRootPath);
4301
4709
  if (!manifest[workspaceId]) manifest[workspaceId] = { last_run_at: (/* @__PURE__ */ new Date(0)).toISOString(), files: {} };
4302
4710
  const docs = [];
4303
4711
  const skipped = [];
@@ -4333,6 +4741,10 @@ async function ingestLocalPath(params) {
4333
4741
  }
4334
4742
  manifest[workspaceId].last_run_at = (/* @__PURE__ */ new Date()).toISOString();
4335
4743
  saveIngestManifest(manifest);
4744
+ const state = loadState();
4745
+ const workspace = getWorkspaceState(state, workspaceId);
4746
+ workspace.root_path = canonicalRootPath;
4747
+ saveState(state);
4336
4748
  appendFileSync(
4337
4749
  AUDIT_LOG_PATH,
4338
4750
  `${(/* @__PURE__ */ new Date()).toISOString()} local_ingest workspace=${workspaceId} root_hash=${createHash("sha256").update(rootPath).digest("hex").slice(0, 16)} files=${docs.length}
@@ -4488,22 +4900,25 @@ server.tool(
4488
4900
  },
4489
4901
  async ({ path, workspace_id, project }) => {
4490
4902
  try {
4491
- const workspaceId = getWorkspaceIdForPath(path, workspace_id);
4903
+ const rootPath = canonicalizeWorkspacePath(path);
4904
+ const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4492
4905
  const state = loadState();
4493
4906
  const existed = Boolean(state.workspaces[workspaceId]);
4494
- const workspace = getWorkspaceState(state, workspaceId);
4495
- const resolvedProject = await resolveProjectRef(project);
4496
- const resolvedBy = project?.trim() ? "explicit_project" : DEFAULT_PROJECT ? "env_default" : resolvedProject ? "auto_first_project" : "unresolved";
4497
- saveState(state);
4907
+ const trust = await resolveWorkspaceTrust({ path: rootPath, workspace_id, project });
4498
4908
  const payload = {
4499
4909
  workspace_id: workspaceId,
4500
- project_id: resolvedProject || null,
4910
+ root_path: trust.root_path,
4911
+ project_ref: trust.project_ref,
4912
+ project_id: trust.project_id,
4501
4913
  created: !existed,
4502
- resolved_by: resolvedBy,
4914
+ resolved_by: trust.resolved_by,
4915
+ health: trust.health,
4916
+ warnings: trust.warnings,
4917
+ recommended_next_calls: trust.recommended_next_calls,
4503
4918
  index_state: {
4504
- last_indexed_at: workspace.index_metadata?.last_indexed_at || null,
4505
- last_indexed_commit: workspace.index_metadata?.last_indexed_commit || null,
4506
- coverage: workspace.index_metadata?.coverage ?? 0
4919
+ last_indexed_at: trust.freshness.last_indexed_at,
4920
+ last_indexed_commit: trust.last_indexed_commit,
4921
+ coverage: trust.coverage
4507
4922
  }
4508
4923
  };
4509
4924
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
@@ -4521,24 +4936,22 @@ server.tool(
4521
4936
  },
4522
4937
  async ({ workspace_id, path }) => {
4523
4938
  try {
4524
- const rootPath = path || process.cwd();
4525
- const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4526
- const state = loadState();
4527
- const workspace = getWorkspaceState(state, workspaceId);
4528
- const lastIndexedAt = workspace.index_metadata?.last_indexed_at;
4529
- const ageHours = lastIndexedAt ? (Date.now() - new Date(lastIndexedAt).getTime()) / (60 * 60 * 1e3) : null;
4530
- const stale = ageHours === null ? true : ageHours > 168;
4939
+ const trust = await resolveWorkspaceTrust({ path, workspace_id });
4531
4940
  const payload = {
4532
- workspace_id: workspaceId,
4533
- freshness: {
4534
- stale,
4535
- age_hours: ageHours,
4536
- last_indexed_at: lastIndexedAt || null
4537
- },
4538
- coverage: workspace.index_metadata?.coverage ?? 0,
4539
- last_indexed_commit: workspace.index_metadata?.last_indexed_commit || null,
4540
- current_commit: getGitHead(rootPath) || null,
4541
- pending_changes: getGitPendingCount(rootPath)
4941
+ workspace_id: trust.workspace_id,
4942
+ root_path: trust.root_path,
4943
+ project_ref: trust.project_ref,
4944
+ project_id: trust.project_id,
4945
+ resolved_by: trust.resolved_by,
4946
+ health: trust.health,
4947
+ warnings: trust.warnings,
4948
+ recommended_next_calls: trust.recommended_next_calls,
4949
+ freshness: trust.freshness,
4950
+ coverage: trust.coverage,
4951
+ last_indexed_commit: trust.last_indexed_commit,
4952
+ current_commit: trust.current_commit,
4953
+ pending_changes: trust.pending_changes,
4954
+ grounded_to_workspace: trust.grounded_to_workspace
4542
4955
  };
4543
4956
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
4544
4957
  } catch (error) {
@@ -4557,13 +4970,14 @@ server.tool(
4557
4970
  },
4558
4971
  async ({ workspace_id, path, mode, max_files }) => {
4559
4972
  try {
4560
- const rootPath = path || process.cwd();
4973
+ const rootPath = canonicalizeWorkspacePath(path);
4561
4974
  const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
4562
4975
  const state = loadState();
4563
4976
  const workspace = getWorkspaceState(state, workspaceId);
4564
4977
  const fileStats = countCodeFiles(rootPath, max_files);
4565
4978
  const coverage = Math.max(0, Math.min(1, fileStats.total / Math.max(1, max_files)));
4566
4979
  const now = (/* @__PURE__ */ new Date()).toISOString();
4980
+ workspace.root_path = rootPath;
4567
4981
  workspace.index_metadata = {
4568
4982
  last_indexed_at: now,
4569
4983
  last_indexed_commit: getGitHead(rootPath),
@@ -4572,6 +4986,7 @@ server.tool(
4572
4986
  saveState(state);
4573
4987
  const payload = {
4574
4988
  workspace_id: workspaceId,
4989
+ root_path: rootPath,
4575
4990
  mode,
4576
4991
  indexed_files: fileStats.total,
4577
4992
  skipped_files: fileStats.skipped,
@@ -4636,23 +5051,25 @@ server.tool(
4636
5051
  },
4637
5052
  async ({ question, workspace_id, project, top_k, include_memories, include_graph, session_id, user_id }) => {
4638
5053
  try {
4639
- const workspaceId = getWorkspaceId(workspace_id);
4640
- const resolvedProject = await resolveProjectRef(project);
4641
- if (!resolvedProject) {
5054
+ const trust = await resolveWorkspaceTrust({ workspace_id, project });
5055
+ if (trust.health === "unbound" || trust.health === "unindexed" || !trust.project_ref) {
4642
5056
  const payload2 = {
4643
5057
  question,
4644
- workspace_id: workspaceId,
5058
+ workspace_id: trust.workspace_id,
5059
+ trust_state: trust,
5060
+ grounded_to_workspace: false,
4645
5061
  total_results: 0,
4646
5062
  context: "",
4647
5063
  evidence: [],
4648
5064
  used_context_ids: [],
4649
5065
  latency_ms: 0,
4650
- warning: "No project resolved. Set WHISPER_PROJECT or create one in your account."
5066
+ warnings: trust.warnings,
5067
+ recommended_next_calls: trust.recommended_next_calls
4651
5068
  };
4652
5069
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
4653
5070
  }
4654
5071
  const queryResult = await queryWithDegradedFallback({
4655
- project: resolvedProject,
5072
+ project: trust.project_ref,
4656
5073
  query: question,
4657
5074
  top_k,
4658
5075
  include_memories,
@@ -4661,10 +5078,12 @@ server.tool(
4661
5078
  user_id
4662
5079
  });
4663
5080
  const response = queryResult.response;
4664
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5081
+ const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4665
5082
  const payload = {
4666
5083
  question,
4667
- workspace_id: workspaceId,
5084
+ workspace_id: trust.workspace_id,
5085
+ trust_state: trust,
5086
+ grounded_to_workspace: trust.grounded_to_workspace,
4668
5087
  total_results: response.meta?.total || evidence.length,
4669
5088
  context: response.context || "",
4670
5089
  evidence,
@@ -4672,7 +5091,9 @@ server.tool(
4672
5091
  latency_ms: response.meta?.latency_ms || 0,
4673
5092
  degraded_mode: queryResult.degraded_mode,
4674
5093
  degraded_reason: queryResult.degraded_reason,
4675
- recommendation: queryResult.recommendation
5094
+ recommendation: queryResult.recommendation,
5095
+ warnings: trust.warnings,
5096
+ recommended_next_calls: trust.recommended_next_calls
4676
5097
  };
4677
5098
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
4678
5099
  } catch (error) {
@@ -4692,20 +5113,22 @@ server.tool(
4692
5113
  },
4693
5114
  async ({ claim, workspace_id, project, context_ids, strict }) => {
4694
5115
  try {
4695
- const workspaceId = getWorkspaceId(workspace_id);
4696
- const resolvedProject = await resolveProjectRef(project);
4697
- if (!resolvedProject) {
5116
+ const trust = await resolveWorkspaceTrust({ workspace_id, project });
5117
+ if (trust.health !== "healthy" || !trust.project_ref) {
4698
5118
  const payload2 = {
4699
5119
  verdict: "unsupported",
4700
5120
  confidence: 0,
4701
5121
  evidence: [],
4702
- missing_requirements: ["No project resolved. Set WHISPER_PROJECT or create one in your account."],
4703
- explanation: "Verifier could not run because no project is configured."
5122
+ trust_state: trust,
5123
+ warnings: trust.warnings,
5124
+ recommended_next_calls: trust.recommended_next_calls,
5125
+ missing_requirements: trust.warnings.length ? trust.warnings : ["Workspace trust requirements were not met."],
5126
+ explanation: "Verifier did not run because workspace grounding is insufficient."
4704
5127
  };
4705
5128
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
4706
5129
  }
4707
5130
  const response = await whisper.query({
4708
- project: resolvedProject,
5131
+ project: trust.project_ref,
4709
5132
  query: claim,
4710
5133
  top_k: strict ? 8 : 12,
4711
5134
  include_memories: true,
@@ -4714,7 +5137,7 @@ server.tool(
4714
5137
  const filtered = (response.results || []).filter(
4715
5138
  (r) => !context_ids || context_ids.length === 0 || context_ids.includes(String(r.id))
4716
5139
  );
4717
- const evidence = filtered.map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5140
+ const evidence = filtered.map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4718
5141
  const directEvidence = evidence.filter((e) => e.score >= (strict ? 0.7 : 0.6));
4719
5142
  const weakEvidence = evidence.filter((e) => e.score >= (strict ? 0.45 : 0.35));
4720
5143
  let verdict = "unsupported";
@@ -4724,6 +5147,9 @@ server.tool(
4724
5147
  verdict,
4725
5148
  confidence: evidence.length ? Math.max(...evidence.map((e) => e.score)) : 0,
4726
5149
  evidence: verdict === "supported" ? directEvidence : weakEvidence,
5150
+ trust_state: trust,
5151
+ warnings: trust.warnings,
5152
+ recommended_next_calls: trust.recommended_next_calls,
4727
5153
  missing_requirements: verdict === "supported" ? [] : verdict === "partial" ? ["No direct evidence spans met strict threshold."] : ["No sufficient supporting evidence found for the claim."],
4728
5154
  explanation: verdict === "supported" ? "At least one direct evidence span supports the claim." : verdict === "partial" ? "Some related evidence exists, but direct support is incomplete." : "Retrieved context did not contain sufficient support."
4729
5155
  };
@@ -4754,39 +5180,38 @@ server.tool(
4754
5180
  },
4755
5181
  async ({ question, workspace_id, project, constraints, retrieval }) => {
4756
5182
  try {
4757
- const workspaceId = getWorkspaceId(workspace_id);
4758
5183
  const requireCitations = constraints?.require_citations ?? true;
4759
5184
  const minEvidenceItems = constraints?.min_evidence_items ?? 2;
4760
5185
  const minConfidence = constraints?.min_confidence ?? 0.65;
4761
5186
  const maxStalenessHours = constraints?.max_staleness_hours ?? 168;
4762
5187
  const topK = retrieval?.top_k ?? 12;
4763
- const resolvedProject = await resolveProjectRef(project);
4764
- if (!resolvedProject) {
5188
+ const trust = await resolveWorkspaceTrust({ workspace_id, project, max_staleness_hours: maxStalenessHours });
5189
+ if (trust.health !== "healthy" || !trust.project_ref) {
5190
+ const reason = trust.health === "stale" || trust.health === "drifted" ? "stale_index" : "no_retrieval_hits";
4765
5191
  const abstain = buildAbstain({
4766
- reason: "no_retrieval_hits",
4767
- message: "No project resolved. Set WHISPER_PROJECT or create one in your account.",
5192
+ reason,
5193
+ message: trust.warnings[0] || "Workspace trust requirements were not met.",
4768
5194
  closest_evidence: [],
4769
5195
  claims_evaluated: 1,
4770
5196
  evidence_items_found: 0,
4771
5197
  min_required: minEvidenceItems,
4772
- index_fresh: true
5198
+ index_fresh: trust.health === "healthy",
5199
+ warnings: trust.warnings,
5200
+ trust_state: trust,
5201
+ recommended_next_calls: trust.recommended_next_calls
4773
5202
  });
4774
5203
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4775
5204
  }
4776
5205
  const response = await whisper.query({
4777
- project: resolvedProject,
5206
+ project: trust.project_ref,
4778
5207
  query: question,
4779
5208
  top_k: topK,
4780
5209
  include_memories: true,
4781
5210
  include_graph: true
4782
5211
  });
4783
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, workspaceId, "semantic"));
5212
+ const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
4784
5213
  const sorted = evidence.sort((a, b) => b.score - a.score);
4785
5214
  const confidence = sorted.length ? sorted[0].score : 0;
4786
- const state = loadState();
4787
- const workspace = getWorkspaceState(state, workspaceId);
4788
- const lastIndexedAt = workspace.index_metadata?.last_indexed_at;
4789
- const indexFresh = !lastIndexedAt || Date.now() - new Date(lastIndexedAt).getTime() <= maxStalenessHours * 60 * 60 * 1e3;
4790
5215
  if (sorted.length === 0) {
4791
5216
  const abstain = buildAbstain({
4792
5217
  reason: "no_retrieval_hits",
@@ -4795,24 +5220,15 @@ server.tool(
4795
5220
  claims_evaluated: 1,
4796
5221
  evidence_items_found: 0,
4797
5222
  min_required: minEvidenceItems,
4798
- index_fresh: indexFresh
5223
+ index_fresh: trust.health === "healthy",
5224
+ warnings: trust.warnings,
5225
+ trust_state: trust,
5226
+ recommended_next_calls: trust.recommended_next_calls
4799
5227
  });
4800
5228
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4801
5229
  }
4802
5230
  const supportedEvidence = sorted.filter((e) => e.score >= minConfidence);
4803
5231
  const verdict = supportedEvidence.length >= 1 ? "supported" : "partial";
4804
- if (!indexFresh) {
4805
- const abstain = buildAbstain({
4806
- reason: "stale_index",
4807
- message: "Index freshness requirement not met. Re-index before answering.",
4808
- closest_evidence: sorted.slice(0, 3),
4809
- claims_evaluated: 1,
4810
- evidence_items_found: supportedEvidence.length,
4811
- min_required: minEvidenceItems,
4812
- index_fresh: false
4813
- });
4814
- return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4815
- }
4816
5232
  if (requireCitations && (verdict !== "supported" || supportedEvidence.length < minEvidenceItems)) {
4817
5233
  const abstain = buildAbstain({
4818
5234
  reason: "insufficient_evidence",
@@ -4821,7 +5237,10 @@ server.tool(
4821
5237
  claims_evaluated: 1,
4822
5238
  evidence_items_found: supportedEvidence.length,
4823
5239
  min_required: minEvidenceItems,
4824
- index_fresh: true
5240
+ index_fresh: true,
5241
+ warnings: trust.warnings,
5242
+ trust_state: trust,
5243
+ recommended_next_calls: trust.recommended_next_calls
4825
5244
  });
4826
5245
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
4827
5246
  }
@@ -4834,6 +5253,9 @@ server.tool(
4834
5253
  answer: answerLines.join("\n"),
4835
5254
  citations,
4836
5255
  confidence,
5256
+ trust_state: trust,
5257
+ warnings: trust.warnings,
5258
+ recommended_next_calls: trust.recommended_next_calls,
4837
5259
  verification: {
4838
5260
  verdict,
4839
5261
  supported_claims: verdict === "supported" ? 1 : 0,
@@ -4867,6 +5289,7 @@ server.tool(
4867
5289
  if (!resolvedProject) {
4868
5290
  return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or pass project." }] };
4869
5291
  }
5292
+ const scope = resolveMcpScope({ project: resolvedProject, user_id, session_id });
4870
5293
  const automaticMode = include_memories !== false && include_graph !== true && !(chunk_types && chunk_types.length > 0) && max_tokens === void 0 && runtimeClient;
4871
5294
  if (automaticMode) {
4872
5295
  try {
@@ -4878,6 +5301,27 @@ server.tool(
4878
5301
  session_id
4879
5302
  });
4880
5303
  if (!prepared.items.length) {
5304
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5305
+ project: resolvedProject,
5306
+ query,
5307
+ user_id: user_id ? scope2.userId : void 0,
5308
+ session_id: session_id ? scope2.sessionId : void 0,
5309
+ top_k
5310
+ }) : { results: [], rescue_mode: null };
5311
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5312
+ return {
5313
+ content: [{
5314
+ type: "text",
5315
+ text: renderContextQueryMemoryRescue({
5316
+ project: resolvedProject,
5317
+ query,
5318
+ scope: scope2,
5319
+ results: memoryRescue.results,
5320
+ rescue_mode: memoryRescue.rescue_mode
5321
+ })
5322
+ }]
5323
+ };
5324
+ }
4881
5325
  return { content: [{ type: "text", text: "No relevant context found." }] };
4882
5326
  }
4883
5327
  const warnings = prepared.retrieval.warnings.length ? `
@@ -4912,13 +5356,36 @@ ${prepared.context}${warnings}`
4912
5356
  });
4913
5357
  const response2 = queryResult2.response;
4914
5358
  if (response2.results.length === 0) {
5359
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5360
+ project: resolvedProject,
5361
+ query,
5362
+ user_id: user_id ? scope.userId : void 0,
5363
+ session_id: session_id ? scope.sessionId : void 0,
5364
+ top_k
5365
+ }) : { results: [], rescue_mode: null };
5366
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5367
+ return {
5368
+ content: [{
5369
+ type: "text",
5370
+ text: `${renderContextQueryMemoryRescue({
5371
+ project: resolvedProject,
5372
+ query,
5373
+ scope,
5374
+ results: memoryRescue.results,
5375
+ rescue_mode: memoryRescue.rescue_mode
5376
+ })}
5377
+
5378
+ [automatic_runtime]
5379
+ ${automaticWarning}`
5380
+ }]
5381
+ };
5382
+ }
4915
5383
  return { content: [{ type: "text", text: `No relevant context found.
4916
5384
 
4917
5385
  [automatic_runtime]
4918
5386
  ${automaticWarning}` }] };
4919
5387
  }
4920
- const scope2 = resolveMcpScope({ user_id, session_id });
4921
- const header2 = `Found ${response2.meta.total} results (${response2.meta.latency_ms}ms${response2.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope2.userId}, session=${scope2.sessionId}):
5388
+ const header2 = `Found ${response2.meta.total} results (${response2.meta.latency_ms}ms${response2.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope.userId}, session=${scope.sessionId}):
4922
5389
 
4923
5390
  `;
4924
5391
  const suffix2 = queryResult2.degraded_mode ? `
@@ -4942,9 +5409,29 @@ ${automaticWarning}${suffix2}` }] };
4942
5409
  });
4943
5410
  const response = queryResult.response;
4944
5411
  if (response.results.length === 0) {
5412
+ const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
5413
+ project: resolvedProject,
5414
+ query,
5415
+ user_id: user_id ? scope.userId : void 0,
5416
+ session_id: session_id ? scope.sessionId : void 0,
5417
+ top_k
5418
+ }) : { results: [], rescue_mode: null };
5419
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5420
+ return {
5421
+ content: [{
5422
+ type: "text",
5423
+ text: renderContextQueryMemoryRescue({
5424
+ project: resolvedProject,
5425
+ query,
5426
+ scope,
5427
+ results: memoryRescue.results,
5428
+ rescue_mode: memoryRescue.rescue_mode
5429
+ })
5430
+ }]
5431
+ };
5432
+ }
4945
5433
  return { content: [{ type: "text", text: "No relevant context found." }] };
4946
5434
  }
4947
- const scope = resolveMcpScope({ user_id, session_id });
4948
5435
  const header = `Found ${response.meta.total} results (${response.meta.latency_ms}ms${response.meta.cache_hit ? ", cached" : ""}, project=${resolvedProject}, user=${scope.userId}, session=${scope.sessionId}):
4949
5436
 
4950
5437
  `;
@@ -5587,6 +6074,7 @@ server.tool(
5587
6074
  return { content: [{ type: "text", text: "Error: target.memory_id or target.query is required." }] };
5588
6075
  }
5589
6076
  const affectedIds = [];
6077
+ let queryResolution = null;
5590
6078
  const now = (/* @__PURE__ */ new Date()).toISOString();
5591
6079
  const actor = process.env.WHISPER_AGENT_ID || process.env.USERNAME || "api_key_principal";
5592
6080
  const resolvedProject = await resolveProjectRef(project);
@@ -5645,24 +6133,18 @@ server.tool(
5645
6133
  project: resolvedProject,
5646
6134
  query: target.query || "",
5647
6135
  top_k: 25,
5648
- include_relations: false
5649
- });
5650
- const normalizedQuery = String(target.query || "").trim().toLowerCase();
5651
- const exactMatches = (search.results || []).filter((r) => {
5652
- const memory = r?.memory || r;
5653
- const content = String(memory?.content || "").trim().toLowerCase();
5654
- const memoryId = String(memory?.id || "").trim().toLowerCase();
5655
- const metadata = memory?.metadata || {};
5656
- const normalizedContent = String(metadata?.normalized_content || "").trim().toLowerCase();
5657
- const canonicalContent = String(metadata?.canonical_content || "").trim().toLowerCase();
5658
- return memoryId === normalizedQuery || content === normalizedQuery || normalizedContent === normalizedQuery || canonicalContent === normalizedQuery;
6136
+ include_relations: false,
6137
+ include_pending: true
5659
6138
  });
5660
- const memoryIds = exactMatches.map((r) => String(r?.memory?.id || "")).filter(Boolean);
6139
+ const resolved = resolveForgetQueryCandidates(search, target.query || "");
6140
+ queryResolution = resolved.resolved_by;
6141
+ const memoryIds = resolved.memory_ids;
5661
6142
  if (memoryIds.length === 0) {
5662
6143
  const payload2 = {
5663
6144
  status: "completed",
5664
6145
  affected_ids: affectedIds,
5665
- warning: "Query did not resolve to an exact memory match. No memories were changed."
6146
+ warning: resolved.warning || "Query did not resolve to a reliable memory match. No memories were changed.",
6147
+ resolved_by: resolved.resolved_by
5666
6148
  };
5667
6149
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5668
6150
  }
@@ -5684,6 +6166,7 @@ server.tool(
5684
6166
  const payload = {
5685
6167
  status: "completed",
5686
6168
  affected_ids: affectedIds,
6169
+ ...queryResolution ? { resolved_by: queryResolution } : {},
5687
6170
  audit: {
5688
6171
  audit_id: audit.audit_id,
5689
6172
  actor: audit.actor,
@@ -5978,30 +6461,45 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", ".next"
5978
6461
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c", "cs", "rb", "php", "swift", "kt", "sql", "prisma", "graphql", "json", "yaml", "yml", "toml", "env"]);
5979
6462
  function extractSignature(filePath, content) {
5980
6463
  const lines = content.split("\n");
5981
- const signature = [`// File: ${filePath}`];
6464
+ const signature = /* @__PURE__ */ new Set([`// File: ${filePath}`]);
6465
+ for (const segment of filePath.split(/[\\/._-]+/).filter(Boolean)) {
6466
+ signature.add(`path:${segment}`);
6467
+ }
5982
6468
  const head = lines.slice(0, 60);
5983
6469
  for (const line of head) {
5984
6470
  const trimmed = line.trim();
5985
6471
  if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
6472
+ const urlMatches = trimmed.match(/https?:\/\/[^\s"'`]+/g);
6473
+ if (urlMatches) {
6474
+ for (const match of urlMatches.slice(0, 2)) signature.add(`url:${match.slice(0, 120)}`);
6475
+ }
6476
+ const routeMatches = trimmed.match(/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{2,}/g);
6477
+ if (routeMatches) {
6478
+ for (const match of routeMatches.slice(0, 3)) signature.add(`route:${match.slice(0, 120)}`);
6479
+ }
5986
6480
  if (/^(import|from|require|use |pub use )/.test(trimmed)) {
5987
- signature.push(trimmed.slice(0, 120));
6481
+ signature.add(trimmed.slice(0, 120));
5988
6482
  continue;
5989
6483
  }
5990
6484
  if (/^(export|async function|function|class|interface|type |const |let |def |pub fn |fn |struct |impl |enum )/.test(trimmed)) {
5991
- signature.push(trimmed.slice(0, 120));
6485
+ signature.add(trimmed.slice(0, 120));
5992
6486
  continue;
5993
6487
  }
5994
6488
  if (trimmed.startsWith("@") || trimmed.startsWith("#[")) {
5995
- signature.push(trimmed.slice(0, 80));
6489
+ signature.add(trimmed.slice(0, 80));
5996
6490
  }
5997
6491
  }
5998
6492
  for (const line of lines.slice(60)) {
5999
6493
  const trimmed = line.trim();
6000
6494
  if (/^(export (default |async )?function|export (default )?class|export const|export type|export interface|async function|function |class |def |pub fn |fn )/.test(trimmed)) {
6001
- signature.push(trimmed.slice(0, 120));
6495
+ signature.add(trimmed.slice(0, 120));
6496
+ continue;
6497
+ }
6498
+ if (/^[A-Za-z0-9_$]+\s*[:=]\s*["'`][^"'`]{3,}["'`]/.test(trimmed)) {
6499
+ signature.add(trimmed.slice(0, 120));
6002
6500
  }
6003
6501
  }
6004
- return signature.join("\n").slice(0, 2e3);
6502
+ return Array.from(signature).join("\n").slice(0, 2500);
6005
6503
  }
6006
6504
  server.tool(
6007
6505
  "code.search_semantic",
@@ -6015,91 +6513,21 @@ server.tool(
6015
6513
  max_files: z.number().optional().default(150).describe("Max files to scan. For large codebases, narrow with file_types instead of raising this.")
6016
6514
  },
6017
6515
  async ({ query, path: searchPath, file_types, top_k, threshold, max_files }) => {
6018
- const rootPath = searchPath || process.cwd();
6019
- const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
6020
- const files = [];
6021
- function collect(dir) {
6022
- if (files.length >= (max_files ?? 300)) return;
6023
- let entries;
6024
- try {
6025
- entries = readdirSync(dir, { withFileTypes: true });
6026
- } catch {
6027
- return;
6028
- }
6029
- for (const entry of entries) {
6030
- if (files.length >= (max_files ?? 300)) break;
6031
- if (SKIP_DIRS.has(entry.name)) continue;
6032
- const full = join(dir, entry.name);
6033
- if (entry.isDirectory()) {
6034
- collect(full);
6035
- } else if (entry.isFile()) {
6036
- const ext = extname(entry.name).replace(".", "");
6037
- if (allowedExts.has(ext)) files.push(full);
6038
- }
6039
- }
6040
- }
6041
- collect(rootPath);
6042
- if (files.length === 0) {
6043
- return { content: [{ type: "text", text: `No code files found in ${rootPath}` }] };
6044
- }
6045
- const documents = [];
6046
- for (const filePath of files) {
6047
- try {
6048
- const stat = statSync(filePath);
6049
- if (stat.size > 500 * 1024) continue;
6050
- const content = readFileSync(filePath, "utf-8");
6051
- const relPath = relative(rootPath, filePath);
6052
- const signature = extractSignature(relPath, content);
6053
- documents.push({ id: relPath, content: signature });
6054
- } catch {
6055
- }
6056
- }
6057
- if (documents.length === 0) {
6058
- return { content: [{ type: "text", text: "Could not read any files." }] };
6059
- }
6060
- let response;
6061
6516
  try {
6062
- response = await whisper.semanticSearch({
6063
- query,
6064
- documents,
6065
- top_k: top_k ?? 10,
6066
- threshold: threshold ?? 0.2
6517
+ return primaryToolSuccess({
6518
+ ...await runSearchCodeTool({
6519
+ query,
6520
+ path: searchPath,
6521
+ file_types,
6522
+ top_k,
6523
+ threshold,
6524
+ max_files
6525
+ }),
6526
+ tool: "code.search_semantic"
6067
6527
  });
6068
6528
  } catch (error) {
6069
- return { content: [{ type: "text", text: `Semantic search failed: ${error.message}` }] };
6070
- }
6071
- if (!response.results || response.results.length === 0) {
6072
- return { content: [{ type: "text", text: `No semantically relevant files found for: "${query}"
6073
-
6074
- Searched ${documents.length} files in ${rootPath}.
6075
-
6076
- Try lowering the threshold or rephrasing your query.` }] };
6077
- }
6078
- const lines = [
6079
- `Semantic search: "${query}"`,
6080
- `Searched ${documents.length} files \u2192 ${response.results.length} relevant (${response.latency_ms}ms)
6081
- `
6082
- ];
6083
- for (const result of response.results) {
6084
- lines.push(`\u{1F4C4} ${result.id} (score: ${result.score})`);
6085
- if (result.snippet) {
6086
- lines.push(` ${result.snippet}`);
6087
- }
6088
- if (result.score > 0.5) {
6089
- try {
6090
- const fullPath = join(rootPath, result.id);
6091
- const content = readFileSync(fullPath, "utf-8");
6092
- const excerpt = content.split("\n").slice(0, 30).join("\n");
6093
- lines.push(`
6094
- \`\`\`
6095
- ${excerpt}
6096
- \`\`\``);
6097
- } catch {
6098
- }
6099
- }
6100
- lines.push("");
6529
+ return primaryToolError(`Semantic search failed: ${error.message}`);
6101
6530
  }
6102
- return { content: [{ type: "text", text: lines.join("\n") }] };
6103
6531
  }
6104
6532
  );
6105
6533
  function* walkDir(dir, fileTypes) {
@@ -6416,72 +6844,15 @@ server.tool(
6416
6844
  max_files: z.number().optional().default(150)
6417
6845
  },
6418
6846
  async ({ query, path, file_types, top_k, threshold, max_files }) => {
6419
- const rootPath = path || process.cwd();
6420
- const allowedExts = file_types ? new Set(file_types) : CODE_EXTENSIONS;
6421
- const files = [];
6422
- function collect(dir) {
6423
- if (files.length >= (max_files ?? 150)) return;
6424
- let entries;
6425
- try {
6426
- entries = readdirSync(dir, { withFileTypes: true });
6427
- } catch {
6428
- return;
6429
- }
6430
- for (const entry of entries) {
6431
- if (files.length >= (max_files ?? 150)) break;
6432
- if (SKIP_DIRS.has(entry.name)) continue;
6433
- const full = join(dir, entry.name);
6434
- if (entry.isDirectory()) collect(full);
6435
- else if (entry.isFile()) {
6436
- const ext = extname(entry.name).replace(".", "");
6437
- if (allowedExts.has(ext)) files.push(full);
6438
- }
6439
- }
6440
- }
6441
- collect(rootPath);
6442
- if (files.length === 0) {
6443
- return primaryToolSuccess({
6444
- tool: "search_code",
6445
- query,
6446
- path: rootPath,
6447
- results: [],
6448
- count: 0
6449
- });
6450
- }
6451
- const documents = [];
6452
- for (const filePath of files) {
6453
- try {
6454
- const stat = statSync(filePath);
6455
- if (stat.size > 500 * 1024) continue;
6456
- const content = readFileSync(filePath, "utf-8");
6457
- const relPath = relative(rootPath, filePath);
6458
- documents.push({ id: relPath, content: extractSignature(relPath, content) });
6459
- } catch {
6460
- }
6461
- }
6462
6847
  try {
6463
- const response = await whisper.semanticSearch({
6848
+ return primaryToolSuccess(await runSearchCodeTool({
6464
6849
  query,
6465
- documents,
6466
- top_k: top_k ?? 10,
6467
- threshold: threshold ?? 0.2
6468
- });
6469
- if (!response.results?.length) {
6470
- return primaryToolSuccess({
6471
- tool: "search_code",
6472
- query,
6473
- path: rootPath,
6474
- results: [],
6475
- count: 0
6476
- });
6477
- }
6478
- return primaryToolSuccess({
6479
- tool: "search_code",
6480
- query,
6481
- path: rootPath,
6482
- results: response.results,
6483
- count: response.results.length
6484
- });
6850
+ path,
6851
+ file_types,
6852
+ top_k,
6853
+ threshold,
6854
+ max_files
6855
+ }));
6485
6856
  } catch (error) {
6486
6857
  return primaryToolError(`Semantic search failed: ${error.message}`);
6487
6858
  }
@@ -6916,8 +7287,14 @@ if (process.argv[1] && /server\.(mjs|cjs|js|ts)$/.test(process.argv[1])) {
6916
7287
  main().catch(console.error);
6917
7288
  }
6918
7289
  export {
7290
+ canonicalizeWorkspacePath,
7291
+ chooseWorkspaceProjectSource,
7292
+ classifyWorkspaceHealth,
6919
7293
  createMcpServer,
6920
7294
  createWhisperMcpClient,
6921
7295
  createWhisperMcpRuntimeClient,
6922
- renderScopedMcpConfig
7296
+ extractSignature,
7297
+ renderScopedMcpConfig,
7298
+ resolveForgetQueryCandidates,
7299
+ runSearchCodeSearch
6923
7300
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usewhisper/mcp-server",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "whisperContractVersion": "2026.03.10",
5
5
  "scripts": {
6
6
  "build": "tsup ../src/mcp/server.ts --format esm --out-dir dist",