@usewhisper/mcp-server 2.10.0 → 2.11.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 +721 -144
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -3914,6 +3914,21 @@ function classifyWorkspaceHealth(args) {
3914
3914
  if (ageHours > (args.max_staleness_hours ?? 168)) return "stale";
3915
3915
  return "healthy";
3916
3916
  }
3917
+ function resolveWorkspaceIdentity(args) {
3918
+ const rootPath = canonicalizeWorkspacePath(args?.path, args?.cwd);
3919
+ const derivedWorkspaceId = getWorkspaceIdForPath(rootPath);
3920
+ const requestedWorkspaceId = args?.workspace_id?.trim();
3921
+ if (requestedWorkspaceId && requestedWorkspaceId !== derivedWorkspaceId) {
3922
+ throw new Error(
3923
+ `workspace_id '${requestedWorkspaceId}' does not match canonical workspace '${derivedWorkspaceId}' for ${rootPath}.`
3924
+ );
3925
+ }
3926
+ return {
3927
+ workspace_id: derivedWorkspaceId,
3928
+ root_path: rootPath,
3929
+ identity_source: args?.path?.trim() ? "path_canonical" : "cwd_canonical"
3930
+ };
3931
+ }
3917
3932
  function getWorkspaceId(workspaceId) {
3918
3933
  if (workspaceId?.trim()) return workspaceId.trim();
3919
3934
  const seed = `${canonicalizeWorkspacePath(process.cwd())}|${API_KEY.slice(0, 12) || "anon"}`;
@@ -3925,6 +3940,134 @@ function getWorkspaceIdForPath(path, workspaceId) {
3925
3940
  const seed = `${canonicalizeWorkspacePath(path)}|${API_KEY.slice(0, 12) || "anon"}`;
3926
3941
  return createHash("sha256").update(seed).digest("hex").slice(0, 20);
3927
3942
  }
3943
+ function normalizeLoosePath(value) {
3944
+ if (!value || !String(value).trim()) return null;
3945
+ return canonicalizeWorkspacePath(String(value));
3946
+ }
3947
+ function normalizeRepoName(value) {
3948
+ if (!value || !String(value).trim()) return null;
3949
+ return String(value).trim().replace(/\.git$/i, "").toLowerCase();
3950
+ }
3951
+ function parseGitHubRemote(remote) {
3952
+ if (!remote) return null;
3953
+ const normalized = remote.trim().replace(/\.git$/i, "");
3954
+ const httpsMatch = normalized.match(/github\.com[/:]([^/:\s]+)\/([^/\s]+)$/i);
3955
+ if (httpsMatch) {
3956
+ return { owner: httpsMatch[1].toLowerCase(), repo: httpsMatch[2].toLowerCase() };
3957
+ }
3958
+ const sshMatch = normalized.match(/git@github\.com:([^/:\s]+)\/([^/\s]+)$/i);
3959
+ if (sshMatch) {
3960
+ return { owner: sshMatch[1].toLowerCase(), repo: sshMatch[2].toLowerCase() };
3961
+ }
3962
+ return null;
3963
+ }
3964
+ function getGitRemoteUrl(searchPath) {
3965
+ const root = searchPath || process.cwd();
3966
+ const result = spawnSync("git", ["-C", root, "config", "--get", "remote.origin.url"], { encoding: "utf-8" });
3967
+ if (result.status !== 0) return void 0;
3968
+ const out = String(result.stdout || "").trim();
3969
+ return out || void 0;
3970
+ }
3971
+ function getGitBranch(searchPath) {
3972
+ const root = searchPath || process.cwd();
3973
+ const result = spawnSync("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf-8" });
3974
+ if (result.status !== 0) return void 0;
3975
+ const out = String(result.stdout || "").trim();
3976
+ return out || void 0;
3977
+ }
3978
+ function sourceStatusLooksReady(status) {
3979
+ const normalized = String(status || "").trim().toLowerCase();
3980
+ if (!normalized) return true;
3981
+ if (["ready", "indexed", "completed", "complete", "active", "synced", "success"].includes(normalized)) return true;
3982
+ if (["processing", "queued", "syncing", "pending", "creating", "indexing", "error", "failed"].includes(normalized)) {
3983
+ return false;
3984
+ }
3985
+ return !normalized.includes("error") && !normalized.includes("fail") && !normalized.includes("queue");
3986
+ }
3987
+ function sourceMatchesWorkspacePath(source, rootPath) {
3988
+ const config = source.config || {};
3989
+ const candidates = [
3990
+ config.path,
3991
+ config.root_path,
3992
+ config.workspace_path,
3993
+ config.workspacePath,
3994
+ config.local_path,
3995
+ config.file_path,
3996
+ config.directory
3997
+ ].map((value) => normalizeLoosePath(typeof value === "string" ? value : null)).filter(Boolean);
3998
+ return candidates.some((candidate) => candidate === rootPath || rootPath.startsWith(`${candidate}/`) || candidate.startsWith(`${rootPath}/`));
3999
+ }
4000
+ function sourceMatchesWorkspaceRepo(source, repoIdentity, branch) {
4001
+ if (!repoIdentity) return false;
4002
+ const config = source.config || {};
4003
+ const owner = normalizeRepoName(config.owner || config.org || config.organization || null);
4004
+ const repo = normalizeRepoName(config.repo || config.repository || config.name || null);
4005
+ const sourceUrlRepo = parseGitHubRemote(typeof config.url === "string" ? config.url : null);
4006
+ const branchValue = normalizeRepoName(config.branch || config.ref || config.default_branch || null);
4007
+ const ownerMatches = owner === repoIdentity.owner || sourceUrlRepo?.owner === repoIdentity.owner;
4008
+ const repoMatches = repo === repoIdentity.repo || sourceUrlRepo?.repo === repoIdentity.repo;
4009
+ if (!ownerMatches || !repoMatches) return false;
4010
+ if (!branchValue || !branch) return true;
4011
+ return branchValue === normalizeRepoName(branch);
4012
+ }
4013
+ function classifyProjectRepoReadiness(args) {
4014
+ const remoteIdentity = parseGitHubRemote(args.remote_url || null);
4015
+ const matchedSources = args.sources.filter(
4016
+ (source) => sourceMatchesWorkspacePath(source, args.root_path) || sourceMatchesWorkspaceRepo(source, remoteIdentity, args.branch || void 0)
4017
+ );
4018
+ if (matchedSources.length === 0) {
4019
+ return {
4020
+ retrieval_readiness: "project_bound_no_repo_source",
4021
+ matched_sources: [],
4022
+ matched_source_ids: [],
4023
+ warnings: [`No verified local or GitHub source matches ${args.root_path}.`]
4024
+ };
4025
+ }
4026
+ const readySources = matchedSources.filter((source) => sourceStatusLooksReady(source.status));
4027
+ if (readySources.length === 0) {
4028
+ return {
4029
+ retrieval_readiness: "project_repo_source_stale",
4030
+ matched_sources: matchedSources,
4031
+ matched_source_ids: matchedSources.map((source) => source.id),
4032
+ warnings: [
4033
+ `Matching repo sources are present but not ready (${matchedSources.map((source) => `${source.name}:${source.status}`).join(", ")}).`
4034
+ ]
4035
+ };
4036
+ }
4037
+ return {
4038
+ retrieval_readiness: "project_repo_source_ready",
4039
+ matched_sources: readySources,
4040
+ matched_source_ids: readySources.map((source) => source.id),
4041
+ warnings: []
4042
+ };
4043
+ }
4044
+ function classifyRepoGroundedQuery(args) {
4045
+ const query = args.query.toLowerCase();
4046
+ let score = 0;
4047
+ if (/(^|[\s"'])where is\b|which file|what file|show me|wiring|handler|middleware|implementation|route|endpoint|function|class|module|repo|workspace|code/.test(query)) {
4048
+ score += 1;
4049
+ }
4050
+ if (/\bauth\b|\burl\b|\bcookie\b|\bsession\b|\bapi\b/.test(query)) {
4051
+ score += 1;
4052
+ }
4053
+ if (/[A-Za-z0-9/_-]+\.[A-Za-z0-9]+/.test(args.query) || /\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{2,}/.test(args.query)) {
4054
+ score += 2;
4055
+ }
4056
+ if (/[A-Za-z_$][A-Za-z0-9_$-]*\(/.test(args.query) || /[A-Z][A-Za-z0-9]+/.test(args.query)) {
4057
+ score += 1;
4058
+ }
4059
+ if ((args.chunk_types || []).some((chunkType) => ["code", "function", "class", "config", "schema", "api_spec"].includes(chunkType))) {
4060
+ score += 2;
4061
+ }
4062
+ const changedTokens = new Set((args.changed_path_tokens || []).map((token) => token.toLowerCase()));
4063
+ if (changedTokens.size > 0) {
4064
+ const queryTokens = tokenizeQueryForLexicalRescue(args.query);
4065
+ if (queryTokens.some((token) => changedTokens.has(token.toLowerCase()))) {
4066
+ score += 1;
4067
+ }
4068
+ }
4069
+ return score >= 2;
4070
+ }
3928
4071
  function clamp012(value) {
3929
4072
  if (Number.isNaN(value)) return 0;
3930
4073
  if (value < 0) return 0;
@@ -4066,8 +4209,9 @@ function getWorkspaceWarnings(args) {
4066
4209
  return [];
4067
4210
  }
4068
4211
  async function resolveWorkspaceTrust(args) {
4069
- const rootPath = canonicalizeWorkspacePath(args.path);
4070
- const workspaceId = getWorkspaceIdForPath(rootPath, args.workspace_id);
4212
+ const identity = resolveWorkspaceIdentity({ path: args.path, workspace_id: args.workspace_id });
4213
+ const rootPath = identity.root_path;
4214
+ const workspaceId = identity.workspace_id;
4071
4215
  const state = loadState();
4072
4216
  const workspace = getWorkspaceState(state, workspaceId);
4073
4217
  let mutated = false;
@@ -4125,6 +4269,7 @@ async function resolveWorkspaceTrust(args) {
4125
4269
  return {
4126
4270
  workspace_id: workspaceId,
4127
4271
  root_path: rootPath,
4272
+ identity_source: identity.identity_source,
4128
4273
  project_ref: projectRef,
4129
4274
  project_id: projectId,
4130
4275
  resolved_by: selected.resolved_by,
@@ -4156,6 +4301,98 @@ async function resolveProjectRef(explicit) {
4156
4301
  return void 0;
4157
4302
  }
4158
4303
  }
4304
+ function collectGitChangedPathTokens(searchPath) {
4305
+ const root = searchPath || process.cwd();
4306
+ const result = spawnSync("git", ["-C", root, "status", "--porcelain"], { encoding: "utf-8" });
4307
+ if (result.status !== 0) return [];
4308
+ const lines = String(result.stdout || "").split("\n").map((line) => line.trim()).filter(Boolean);
4309
+ const tokens = /* @__PURE__ */ new Set();
4310
+ for (const line of lines) {
4311
+ const filePath = line.slice(3).split(" -> ").at(-1)?.trim();
4312
+ if (!filePath) continue;
4313
+ for (const token of filePath.split(/[\\/._-]+/).filter((part) => part.length >= 3)) {
4314
+ tokens.add(token.toLowerCase());
4315
+ }
4316
+ }
4317
+ return Array.from(tokens);
4318
+ }
4319
+ async function inspectProjectRepoSources(args) {
4320
+ if (!args.project_ref) {
4321
+ return {
4322
+ retrieval_readiness: "no_project",
4323
+ matched_sources: [],
4324
+ matched_source_ids: [],
4325
+ warnings: ["No Whisper project is bound to this workspace."]
4326
+ };
4327
+ }
4328
+ try {
4329
+ const sourceData = await whisper.listSources(args.project_ref);
4330
+ const classified = classifyProjectRepoReadiness({
4331
+ sources: sourceData.sources || [],
4332
+ root_path: args.root_path,
4333
+ remote_url: getGitRemoteUrl(args.root_path) || null,
4334
+ branch: getGitBranch(args.root_path) || null
4335
+ });
4336
+ if (classified.retrieval_readiness === "project_bound_no_repo_source") {
4337
+ return {
4338
+ ...classified,
4339
+ warnings: [`Project ${args.project_ref} has no verified local or GitHub source for ${args.root_path}.`]
4340
+ };
4341
+ }
4342
+ if (classified.retrieval_readiness === "project_repo_source_stale") {
4343
+ return {
4344
+ ...classified,
4345
+ warnings: [
4346
+ `Project ${args.project_ref} has matching repo sources, but none are ready (${classified.matched_sources.map((source) => `${source.name}:${source.status}`).join(", ")}).`
4347
+ ]
4348
+ };
4349
+ }
4350
+ return classified;
4351
+ } catch (error) {
4352
+ return {
4353
+ retrieval_readiness: "project_unverified",
4354
+ matched_sources: [],
4355
+ matched_source_ids: [],
4356
+ warnings: [`Could not verify project sources for ${args.project_ref}: ${error.message}`]
4357
+ };
4358
+ }
4359
+ }
4360
+ async function resolveRepoGroundingPreflight(args) {
4361
+ const trust = await resolveWorkspaceTrust({ path: args.path, workspace_id: args.workspace_id, project: args.project });
4362
+ const repoGrounded = classifyRepoGroundedQuery({
4363
+ query: args.query,
4364
+ chunk_types: args.chunk_types,
4365
+ changed_path_tokens: collectGitChangedPathTokens(trust.root_path)
4366
+ });
4367
+ const sourceVerification = await inspectProjectRepoSources({
4368
+ project_ref: trust.project_ref,
4369
+ root_path: trust.root_path
4370
+ });
4371
+ const warnings = [...trust.warnings, ...sourceVerification.warnings];
4372
+ let retrievalReadiness = sourceVerification.retrieval_readiness;
4373
+ let retrievalRoute = "none";
4374
+ if (repoGrounded) {
4375
+ retrievalRoute = sourceVerification.retrieval_readiness === "project_repo_source_ready" ? "project_repo" : "local_workspace_fallback";
4376
+ if (retrievalRoute === "local_workspace_fallback") {
4377
+ retrievalReadiness = "local_fallback";
4378
+ warnings.push("Using live local workspace retrieval because project-backed repo retrieval is unavailable or unverified.");
4379
+ }
4380
+ }
4381
+ const recommendedNextCalls = [...trust.recommended_next_calls];
4382
+ if (sourceVerification.retrieval_readiness === "project_bound_no_repo_source" || sourceVerification.retrieval_readiness === "project_unverified") {
4383
+ recommendedNextCalls.push("context.list_sources", "index.local_scan_ingest");
4384
+ }
4385
+ return {
4386
+ repo_grounded: repoGrounded,
4387
+ trust_state: trust,
4388
+ retrieval_readiness: retrievalReadiness,
4389
+ retrieval_route: retrievalRoute,
4390
+ matched_sources: sourceVerification.matched_sources,
4391
+ matched_source_ids: sourceVerification.matched_source_ids,
4392
+ warnings: Array.from(new Set(warnings)),
4393
+ recommended_next_calls: Array.from(new Set(recommendedNextCalls))
4394
+ };
4395
+ }
4159
4396
  async function ingestSessionWithSyncFallback(params) {
4160
4397
  try {
4161
4398
  return await whisper.ingestSession({
@@ -4182,7 +4419,7 @@ function resolveMcpScope(params) {
4182
4419
  project: params?.project,
4183
4420
  userId: params?.user_id?.trim() || defaultMcpUserId(),
4184
4421
  sessionId: params?.session_id?.trim() || cachedMcpSessionId,
4185
- workspacePath: process.env.WHISPER_WORKSPACE_PATH || process.cwd()
4422
+ workspacePath: canonicalizeWorkspacePath(params?.path || process.env.WHISPER_WORKSPACE_PATH || process.cwd())
4186
4423
  };
4187
4424
  }
4188
4425
  async function prepareAutomaticQuery(params) {
@@ -4418,6 +4655,7 @@ async function queryWithDegradedFallback(params) {
4418
4655
  include_graph: params.include_graph,
4419
4656
  user_id: params.user_id,
4420
4657
  session_id: params.session_id,
4658
+ source_ids: params.source_ids,
4421
4659
  hybrid: true,
4422
4660
  rerank: true
4423
4661
  });
@@ -4432,6 +4670,7 @@ async function queryWithDegradedFallback(params) {
4432
4670
  include_graph: false,
4433
4671
  user_id: params.user_id,
4434
4672
  session_id: params.session_id,
4673
+ source_ids: params.source_ids,
4435
4674
  hybrid: false,
4436
4675
  rerank: false,
4437
4676
  vector_weight: 0,
@@ -4471,9 +4710,15 @@ function collectCodeFiles(rootPath, allowedExts, maxFiles) {
4471
4710
  }
4472
4711
  function tokenizeQueryForLexicalRescue(query) {
4473
4712
  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
- ));
4713
+ const rawTokens = query.toLowerCase().split(/[^a-z0-9/._:-]+/).map((token) => token.trim()).filter(Boolean);
4714
+ const expanded = /* @__PURE__ */ new Set();
4715
+ for (const token of rawTokens) {
4716
+ if (token.length >= 3 && !stopWords.has(token)) expanded.add(token);
4717
+ for (const part of token.split(/[/:._-]+/).filter((value) => value.length >= 3 && !stopWords.has(value))) {
4718
+ expanded.add(part);
4719
+ }
4720
+ }
4721
+ return Array.from(expanded);
4477
4722
  }
4478
4723
  function buildLexicalRescueResults(args) {
4479
4724
  const tokens = tokenizeQueryForLexicalRescue(args.query);
@@ -4499,16 +4744,17 @@ ${doc.raw_content}`.toLowerCase();
4499
4744
  async function runSearchCodeSearch(args) {
4500
4745
  const topK = args.top_k ?? 10;
4501
4746
  const requestedThreshold = args.threshold ?? 0.2;
4747
+ const semanticTopK = Math.min(args.documents.length || topK, Math.max(topK * 3, topK + 5));
4502
4748
  const semanticDocuments = args.documents.map((doc) => ({ id: doc.id, content: doc.content }));
4503
4749
  const defaultResponse = await args.semantic_search({
4504
4750
  query: args.query,
4505
4751
  documents: semanticDocuments,
4506
- top_k: topK,
4752
+ top_k: semanticTopK,
4507
4753
  threshold: requestedThreshold
4508
4754
  });
4509
4755
  if (defaultResponse.results?.length) {
4510
4756
  return {
4511
- results: defaultResponse.results.map((result) => ({ ...result, search_mode: "semantic" })),
4757
+ results: defaultResponse.results.slice(0, topK).map((result) => ({ ...result, search_mode: "semantic" })),
4512
4758
  mode: "semantic",
4513
4759
  threshold_used: requestedThreshold,
4514
4760
  fallback_used: false,
@@ -4520,12 +4766,12 @@ async function runSearchCodeSearch(args) {
4520
4766
  const adaptiveResponse = await args.semantic_search({
4521
4767
  query: args.query,
4522
4768
  documents: semanticDocuments,
4523
- top_k: topK,
4769
+ top_k: semanticTopK,
4524
4770
  threshold: adaptiveThreshold
4525
4771
  });
4526
4772
  if (adaptiveResponse.results?.length) {
4527
4773
  return {
4528
- results: adaptiveResponse.results.map((result) => ({ ...result, search_mode: "adaptive_semantic" })),
4774
+ results: adaptiveResponse.results.slice(0, topK).map((result) => ({ ...result, search_mode: "adaptive_semantic" })),
4529
4775
  mode: "adaptive_semantic",
4530
4776
  threshold_used: adaptiveThreshold,
4531
4777
  fallback_used: true,
@@ -4546,36 +4792,8 @@ async function runSearchCodeSearch(args) {
4546
4792
  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
4793
  };
4548
4794
  }
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
- }
4795
+ function buildLocalWorkspaceDocuments(rootPath, allowedExts, maxFiles) {
4796
+ const files = collectCodeFiles(rootPath, allowedExts, maxFiles);
4579
4797
  const documents = [];
4580
4798
  for (const filePath of files) {
4581
4799
  try {
@@ -4587,6 +4805,38 @@ async function runSearchCodeTool(args) {
4587
4805
  } catch {
4588
4806
  }
4589
4807
  }
4808
+ return documents;
4809
+ }
4810
+ function extractRelevantSnippet(rawContent, query) {
4811
+ const lines = rawContent.split("\n");
4812
+ const tokens = tokenizeQueryForLexicalRescue(query);
4813
+ let matchIndex = -1;
4814
+ for (let index = 0; index < lines.length; index += 1) {
4815
+ const lower = lines[index].toLowerCase();
4816
+ if (tokens.some((token) => lower.includes(token))) {
4817
+ matchIndex = index;
4818
+ break;
4819
+ }
4820
+ }
4821
+ if (matchIndex < 0) {
4822
+ matchIndex = lines.findIndex((line) => line.trim().length > 0);
4823
+ }
4824
+ const start = Math.max(0, matchIndex < 0 ? 0 : matchIndex - 1);
4825
+ const end = Math.min(lines.length, start + 4);
4826
+ const snippet = lines.slice(start, end).join("\n").trim().slice(0, 500);
4827
+ return {
4828
+ snippet,
4829
+ line_start: start + 1,
4830
+ ...end > start + 1 ? { line_end: end } : {}
4831
+ };
4832
+ }
4833
+ async function runLocalWorkspaceRetrieval(args) {
4834
+ const identity = resolveWorkspaceIdentity({ path: args.path });
4835
+ const workspace = await resolveWorkspaceTrust({ path: identity.root_path, project: args.project });
4836
+ const allowedExts = args.file_types ? new Set(args.file_types) : CODE_EXTENSIONS;
4837
+ const candidateFiles = collectCodeFiles(identity.root_path, allowedExts, args.max_files ?? 150);
4838
+ const documents = buildLocalWorkspaceDocuments(identity.root_path, allowedExts, args.max_files ?? 150);
4839
+ const sharedWarnings = [...workspace.warnings];
4590
4840
  const searchResult = await runSearchCodeSearch({
4591
4841
  query: args.query,
4592
4842
  documents,
@@ -4602,29 +4852,140 @@ async function runSearchCodeTool(args) {
4602
4852
  if (!workspace.grounded_to_workspace && workspace.health !== "unbound" && workspace.health !== "unindexed") {
4603
4853
  sharedWarnings.push("Local code search is live, but project-backed retrieval may disagree until the workspace is re-indexed.");
4604
4854
  }
4855
+ const documentMap = new Map(documents.map((document) => [document.id, document]));
4856
+ const localResults = searchResult.results.map((result) => {
4857
+ const document = documentMap.get(result.id);
4858
+ const snippet = extractRelevantSnippet(document?.raw_content || result.snippet || "", args.query);
4859
+ return {
4860
+ ...result,
4861
+ snippet: snippet.snippet || result.snippet,
4862
+ raw_snippet: snippet.snippet || result.snippet,
4863
+ line_start: snippet.line_start,
4864
+ ...snippet.line_end ? { line_end: snippet.line_end } : {},
4865
+ retrieval_method: result.search_mode === "lexical_rescue" ? "lexical" : "semantic"
4866
+ };
4867
+ });
4605
4868
  return {
4606
- tool: "search_code",
4607
- query: args.query,
4608
- path: rootPath,
4609
- results: searchResult.results,
4610
- count: searchResult.results.length,
4869
+ workspace,
4870
+ documents,
4871
+ results: localResults,
4611
4872
  warnings: sharedWarnings,
4612
4873
  diagnostics: {
4613
4874
  workspace_id: workspace.workspace_id,
4614
4875
  root_path: workspace.root_path,
4615
4876
  project_ref: workspace.project_ref,
4616
4877
  project_id: workspace.project_id,
4878
+ identity_source: workspace.identity_source,
4617
4879
  index_health: workspace.health,
4618
- grounded_to_workspace: workspace.grounded_to_workspace,
4880
+ grounded_to_workspace: true,
4619
4881
  threshold_requested: args.threshold ?? 0.2,
4620
4882
  threshold_used: searchResult.threshold_used,
4621
4883
  fallback_used: searchResult.fallback_used,
4622
4884
  fallback_reason: searchResult.fallback_reason,
4623
4885
  search_mode: searchResult.mode,
4886
+ candidate_files_scanned: candidateFiles.length,
4887
+ semantic_candidate_count: documents.length,
4888
+ retrieval_route: "local_workspace",
4624
4889
  warnings: sharedWarnings
4625
4890
  }
4626
4891
  };
4627
4892
  }
4893
+ function isLikelyRepoBackedResult(result) {
4894
+ const metadata = result.metadata || {};
4895
+ const retrievalSource = String(result.retrieval_source || "").toLowerCase();
4896
+ if (retrievalSource.includes("memory")) return false;
4897
+ const pathCandidate = String(metadata.file_path || metadata.path || result.source || result.document || "");
4898
+ if (/[\\/]/.test(pathCandidate) || /\.[a-z0-9]+$/i.test(pathCandidate)) return true;
4899
+ const sourceType = String(metadata.source_type || metadata.connector_type || result.type || "").toLowerCase();
4900
+ return ["local", "github", "code", "file", "repo"].some((token) => sourceType.includes(token));
4901
+ }
4902
+ function filterProjectRepoResults(results, matchedSourceIds) {
4903
+ const scoped = results.filter((result) => {
4904
+ const sourceId = String(result.metadata?.source_id || result.metadata?.sourceId || result.metadata?.source || "");
4905
+ return matchedSourceIds.length === 0 || matchedSourceIds.includes(sourceId);
4906
+ });
4907
+ const repoResults = scoped.filter((result) => isLikelyRepoBackedResult(result));
4908
+ return repoResults.length > 0 ? repoResults : scoped;
4909
+ }
4910
+ function localHitsToEvidence(workspaceId, hits) {
4911
+ return hits.map(
4912
+ (hit) => toEvidenceRef(
4913
+ {
4914
+ id: hit.id,
4915
+ content: hit.raw_snippet,
4916
+ score: hit.score,
4917
+ retrieval_source: hit.retrieval_method,
4918
+ metadata: {
4919
+ file_path: hit.id,
4920
+ snippet: hit.raw_snippet,
4921
+ line_start: hit.line_start,
4922
+ ...hit.line_end ? { line_end: hit.line_end } : {}
4923
+ }
4924
+ },
4925
+ workspaceId,
4926
+ hit.retrieval_method
4927
+ )
4928
+ );
4929
+ }
4930
+ function renderLocalWorkspaceContext(args) {
4931
+ const lines = args.hits.map((hit, index) => {
4932
+ const location = hit.line_end && hit.line_end !== hit.line_start ? `${hit.id}:${hit.line_start}-${hit.line_end}` : `${hit.id}:${hit.line_start}`;
4933
+ return `${index + 1}. [${location}, score: ${hit.score.toFixed(2)}, mode: ${hit.search_mode}] ${hit.raw_snippet || hit.snippet || hit.id}`;
4934
+ });
4935
+ const header = `Found ${args.hits.length} local workspace result(s) (route=${args.route}, workspace=${args.diagnostics.workspace_id}, project=${args.project_ref || "none"}, user=${args.scope.userId}, session=${args.scope.sessionId}):`;
4936
+ const warnings = args.warnings.length ? `
4937
+
4938
+ [warnings]
4939
+ ${args.warnings.join("\n")}` : "";
4940
+ return `${header}
4941
+
4942
+ ${lines.join("\n\n")}${warnings}`;
4943
+ }
4944
+ async function runSearchCodeTool(args) {
4945
+ const rootPath = canonicalizeWorkspacePath(args.path);
4946
+ const allowedExts = args.file_types ? new Set(args.file_types) : CODE_EXTENSIONS;
4947
+ const files = collectCodeFiles(rootPath, allowedExts, args.max_files ?? 150);
4948
+ if (files.length === 0) {
4949
+ const workspace = await resolveWorkspaceTrust({ path: rootPath });
4950
+ const sharedWarnings = [...workspace.warnings];
4951
+ return {
4952
+ tool: "search_code",
4953
+ query: args.query,
4954
+ path: rootPath,
4955
+ results: [],
4956
+ count: 0,
4957
+ warnings: sharedWarnings,
4958
+ diagnostics: {
4959
+ workspace_id: workspace.workspace_id,
4960
+ root_path: workspace.root_path,
4961
+ project_ref: workspace.project_ref,
4962
+ project_id: workspace.project_id,
4963
+ identity_source: workspace.identity_source,
4964
+ index_health: workspace.health,
4965
+ grounded_to_workspace: workspace.grounded_to_workspace,
4966
+ threshold_requested: args.threshold ?? 0.2,
4967
+ threshold_used: null,
4968
+ fallback_used: false,
4969
+ fallback_reason: null,
4970
+ search_mode: "semantic",
4971
+ candidate_files_scanned: 0,
4972
+ semantic_candidate_count: 0,
4973
+ retrieval_route: "local_workspace",
4974
+ warnings: sharedWarnings
4975
+ }
4976
+ };
4977
+ }
4978
+ const localRetrieval = await runLocalWorkspaceRetrieval(args);
4979
+ return {
4980
+ tool: "search_code",
4981
+ query: args.query,
4982
+ path: rootPath,
4983
+ results: localRetrieval.results,
4984
+ count: localRetrieval.results.length,
4985
+ warnings: localRetrieval.warnings,
4986
+ diagnostics: localRetrieval.diagnostics
4987
+ };
4988
+ }
4628
4989
  function getLocalAllowlistRoots() {
4629
4990
  const fromEnv = (process.env.WHISPER_LOCAL_ALLOWLIST || "").split(",").map((v) => v.trim()).filter(Boolean);
4630
4991
  if (fromEnv.length > 0) return fromEnv;
@@ -4900,21 +5261,23 @@ server.tool(
4900
5261
  },
4901
5262
  async ({ path, workspace_id, project }) => {
4902
5263
  try {
4903
- const rootPath = canonicalizeWorkspacePath(path);
4904
- const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
5264
+ const identity = resolveWorkspaceIdentity({ path, workspace_id });
4905
5265
  const state = loadState();
4906
- const existed = Boolean(state.workspaces[workspaceId]);
4907
- const trust = await resolveWorkspaceTrust({ path: rootPath, workspace_id, project });
5266
+ const existed = Boolean(state.workspaces[identity.workspace_id]);
5267
+ const trust = await resolveWorkspaceTrust({ path: identity.root_path, workspace_id, project });
5268
+ const preflight = await resolveRepoGroundingPreflight({ query: "workspace status", path, workspace_id, project });
4908
5269
  const payload = {
4909
- workspace_id: workspaceId,
5270
+ workspace_id: identity.workspace_id,
4910
5271
  root_path: trust.root_path,
5272
+ identity_source: trust.identity_source,
4911
5273
  project_ref: trust.project_ref,
4912
5274
  project_id: trust.project_id,
4913
5275
  created: !existed,
4914
5276
  resolved_by: trust.resolved_by,
4915
5277
  health: trust.health,
4916
- warnings: trust.warnings,
4917
- recommended_next_calls: trust.recommended_next_calls,
5278
+ retrieval_readiness: preflight.retrieval_readiness,
5279
+ warnings: preflight.warnings,
5280
+ recommended_next_calls: preflight.recommended_next_calls,
4918
5281
  index_state: {
4919
5282
  last_indexed_at: trust.freshness.last_indexed_at,
4920
5283
  last_indexed_commit: trust.last_indexed_commit,
@@ -4937,15 +5300,23 @@ server.tool(
4937
5300
  async ({ workspace_id, path }) => {
4938
5301
  try {
4939
5302
  const trust = await resolveWorkspaceTrust({ path, workspace_id });
5303
+ const repoVerification = await inspectProjectRepoSources({ project_ref: trust.project_ref, root_path: trust.root_path });
4940
5304
  const payload = {
4941
5305
  workspace_id: trust.workspace_id,
4942
5306
  root_path: trust.root_path,
5307
+ identity_source: trust.identity_source,
4943
5308
  project_ref: trust.project_ref,
4944
5309
  project_id: trust.project_id,
4945
5310
  resolved_by: trust.resolved_by,
4946
5311
  health: trust.health,
4947
- warnings: trust.warnings,
4948
- recommended_next_calls: trust.recommended_next_calls,
5312
+ retrieval_readiness: repoVerification.retrieval_readiness,
5313
+ warnings: Array.from(/* @__PURE__ */ new Set([...trust.warnings, ...repoVerification.warnings])),
5314
+ recommended_next_calls: Array.from(
5315
+ /* @__PURE__ */ new Set([
5316
+ ...trust.recommended_next_calls,
5317
+ ...repoVerification.retrieval_readiness === "project_bound_no_repo_source" ? ["context.list_sources", "index.local_scan_ingest"] : []
5318
+ ])
5319
+ ),
4949
5320
  freshness: trust.freshness,
4950
5321
  coverage: trust.coverage,
4951
5322
  last_indexed_commit: trust.last_indexed_commit,
@@ -4970,8 +5341,9 @@ server.tool(
4970
5341
  },
4971
5342
  async ({ workspace_id, path, mode, max_files }) => {
4972
5343
  try {
4973
- const rootPath = canonicalizeWorkspacePath(path);
4974
- const workspaceId = getWorkspaceIdForPath(rootPath, workspace_id);
5344
+ const identity = resolveWorkspaceIdentity({ path, workspace_id });
5345
+ const rootPath = identity.root_path;
5346
+ const workspaceId = identity.workspace_id;
4975
5347
  const state = loadState();
4976
5348
  const workspace = getWorkspaceState(state, workspaceId);
4977
5349
  const fileStats = countCodeFiles(rootPath, max_files);
@@ -5038,9 +5410,10 @@ server.tool(
5038
5410
  );
5039
5411
  server.tool(
5040
5412
  "context.get_relevant",
5041
- "Core retrieval. Task goes in, ranked context chunks come out with structured evidence (file:line ready).",
5413
+ "Default grounded retrieval step for workspace/project questions. Call this before answering when you need ranked evidence with file:line citations instead of relying on model memory.",
5042
5414
  {
5043
5415
  question: z.string().describe("Task/question to retrieve context for"),
5416
+ path: z.string().optional().describe("Workspace path. Defaults to current working directory."),
5044
5417
  workspace_id: z.string().optional(),
5045
5418
  project: z.string().optional(),
5046
5419
  top_k: z.number().optional().default(12),
@@ -5049,51 +5422,115 @@ server.tool(
5049
5422
  session_id: z.string().optional(),
5050
5423
  user_id: z.string().optional()
5051
5424
  },
5052
- async ({ question, workspace_id, project, top_k, include_memories, include_graph, session_id, user_id }) => {
5425
+ async ({ question, path, workspace_id, project, top_k, include_memories, include_graph, session_id, user_id }) => {
5053
5426
  try {
5054
- const trust = await resolveWorkspaceTrust({ workspace_id, project });
5055
- if (trust.health === "unbound" || trust.health === "unindexed" || !trust.project_ref) {
5427
+ const preflight = await resolveRepoGroundingPreflight({ query: question, path, workspace_id, project });
5428
+ if (preflight.retrieval_route === "local_workspace_fallback") {
5429
+ const localRetrieval = await runLocalWorkspaceRetrieval({
5430
+ query: question,
5431
+ path: preflight.trust_state.root_path,
5432
+ project: preflight.trust_state.project_ref || project,
5433
+ top_k
5434
+ });
5435
+ const evidence2 = localHitsToEvidence(preflight.trust_state.workspace_id, localRetrieval.results);
5056
5436
  const payload2 = {
5057
5437
  question,
5058
- workspace_id: trust.workspace_id,
5059
- trust_state: trust,
5438
+ workspace_id: preflight.trust_state.workspace_id,
5439
+ root_path: preflight.trust_state.root_path,
5440
+ identity_source: preflight.trust_state.identity_source,
5441
+ trust_state: preflight.trust_state,
5442
+ retrieval_readiness: preflight.retrieval_readiness,
5443
+ retrieval_route: preflight.retrieval_route,
5444
+ grounded_to_workspace: true,
5445
+ total_results: evidence2.length,
5446
+ context: evidence2.map((item) => `[${renderCitation(item)}] ${item.snippet || "Relevant local workspace context found."}`).join("\n"),
5447
+ evidence: evidence2,
5448
+ used_context_ids: evidence2.map((item) => item.source_id),
5449
+ latency_ms: 0,
5450
+ warnings: Array.from(/* @__PURE__ */ new Set([...preflight.warnings, ...localRetrieval.warnings])),
5451
+ recommended_next_calls: preflight.recommended_next_calls
5452
+ };
5453
+ return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5454
+ }
5455
+ if (!preflight.trust_state.project_ref) {
5456
+ const payload2 = {
5457
+ question,
5458
+ workspace_id: preflight.trust_state.workspace_id,
5459
+ root_path: preflight.trust_state.root_path,
5460
+ identity_source: preflight.trust_state.identity_source,
5461
+ trust_state: preflight.trust_state,
5462
+ retrieval_readiness: preflight.retrieval_readiness,
5463
+ retrieval_route: "none",
5060
5464
  grounded_to_workspace: false,
5061
5465
  total_results: 0,
5062
5466
  context: "",
5063
5467
  evidence: [],
5064
5468
  used_context_ids: [],
5065
5469
  latency_ms: 0,
5066
- warnings: trust.warnings,
5067
- recommended_next_calls: trust.recommended_next_calls
5470
+ warnings: preflight.warnings,
5471
+ recommended_next_calls: preflight.recommended_next_calls
5068
5472
  };
5069
5473
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5070
5474
  }
5071
5475
  const queryResult = await queryWithDegradedFallback({
5072
- project: trust.project_ref,
5476
+ project: preflight.trust_state.project_ref,
5073
5477
  query: question,
5074
5478
  top_k,
5075
- include_memories,
5479
+ include_memories: preflight.repo_grounded ? false : include_memories,
5076
5480
  include_graph,
5077
5481
  session_id,
5078
- user_id
5482
+ user_id,
5483
+ source_ids: preflight.repo_grounded ? preflight.matched_source_ids : void 0
5079
5484
  });
5080
5485
  const response = queryResult.response;
5081
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
5486
+ const rawResults = preflight.repo_grounded ? filterProjectRepoResults(response.results || [], preflight.matched_source_ids) : response.results || [];
5487
+ if (preflight.repo_grounded && rawResults.length === 0) {
5488
+ const localRetrieval = await runLocalWorkspaceRetrieval({
5489
+ query: question,
5490
+ path: preflight.trust_state.root_path,
5491
+ project: preflight.trust_state.project_ref,
5492
+ top_k
5493
+ });
5494
+ const evidence2 = localHitsToEvidence(preflight.trust_state.workspace_id, localRetrieval.results);
5495
+ const payload2 = {
5496
+ question,
5497
+ workspace_id: preflight.trust_state.workspace_id,
5498
+ root_path: preflight.trust_state.root_path,
5499
+ identity_source: preflight.trust_state.identity_source,
5500
+ trust_state: preflight.trust_state,
5501
+ retrieval_readiness: "local_fallback",
5502
+ retrieval_route: "local_workspace_fallback",
5503
+ grounded_to_workspace: true,
5504
+ total_results: evidence2.length,
5505
+ context: evidence2.map((item) => `[${renderCitation(item)}] ${item.snippet || "Relevant local workspace context found."}`).join("\n"),
5506
+ evidence: evidence2,
5507
+ used_context_ids: evidence2.map((item) => item.source_id),
5508
+ latency_ms: 0,
5509
+ warnings: Array.from(/* @__PURE__ */ new Set([...preflight.warnings, ...localRetrieval.warnings])),
5510
+ recommended_next_calls: preflight.recommended_next_calls
5511
+ };
5512
+ return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5513
+ }
5514
+ const evidence = rawResults.map((r) => toEvidenceRef(r, preflight.trust_state.workspace_id, "semantic"));
5082
5515
  const payload = {
5083
5516
  question,
5084
- workspace_id: trust.workspace_id,
5085
- trust_state: trust,
5086
- grounded_to_workspace: trust.grounded_to_workspace,
5087
- total_results: response.meta?.total || evidence.length,
5517
+ workspace_id: preflight.trust_state.workspace_id,
5518
+ root_path: preflight.trust_state.root_path,
5519
+ identity_source: preflight.trust_state.identity_source,
5520
+ trust_state: preflight.trust_state,
5521
+ retrieval_readiness: preflight.retrieval_readiness,
5522
+ retrieval_route: preflight.repo_grounded ? "project_repo" : "none",
5523
+ grounded_to_workspace: preflight.repo_grounded ? true : preflight.trust_state.grounded_to_workspace,
5524
+ total_results: rawResults.length || response.meta?.total || evidence.length,
5088
5525
  context: response.context || "",
5089
5526
  evidence,
5090
- used_context_ids: (response.results || []).map((r) => String(r.id)),
5527
+ used_context_ids: rawResults.map((r) => String(r.id)),
5091
5528
  latency_ms: response.meta?.latency_ms || 0,
5092
5529
  degraded_mode: queryResult.degraded_mode,
5093
5530
  degraded_reason: queryResult.degraded_reason,
5094
5531
  recommendation: queryResult.recommendation,
5095
- warnings: trust.warnings,
5096
- recommended_next_calls: trust.recommended_next_calls
5532
+ warnings: preflight.warnings,
5533
+ recommended_next_calls: preflight.recommended_next_calls
5097
5534
  };
5098
5535
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
5099
5536
  } catch (error) {
@@ -5106,38 +5543,53 @@ server.tool(
5106
5543
  "Verify whether a claim is supported by retrieved context. Returns supported/partial/unsupported with evidence.",
5107
5544
  {
5108
5545
  claim: z.string().describe("Claim to verify"),
5546
+ path: z.string().optional().describe("Workspace path. Defaults to current working directory."),
5109
5547
  workspace_id: z.string().optional(),
5110
5548
  project: z.string().optional(),
5111
5549
  context_ids: z.array(z.string()).optional(),
5112
5550
  strict: z.boolean().optional().default(true)
5113
5551
  },
5114
- async ({ claim, workspace_id, project, context_ids, strict }) => {
5552
+ async ({ claim, path, workspace_id, project, context_ids, strict }) => {
5115
5553
  try {
5116
- const trust = await resolveWorkspaceTrust({ workspace_id, project });
5117
- if (trust.health !== "healthy" || !trust.project_ref) {
5554
+ const preflight = await resolveRepoGroundingPreflight({ query: claim, path, workspace_id, project });
5555
+ let evidence = [];
5556
+ if (preflight.retrieval_route === "local_workspace_fallback") {
5557
+ const localRetrieval = await runLocalWorkspaceRetrieval({
5558
+ query: claim,
5559
+ path: preflight.trust_state.root_path,
5560
+ project: preflight.trust_state.project_ref || project,
5561
+ top_k: strict ? 8 : 12
5562
+ });
5563
+ evidence = localHitsToEvidence(preflight.trust_state.workspace_id, localRetrieval.results).filter((item) => !context_ids || context_ids.length === 0 || context_ids.includes(item.source_id));
5564
+ } else if (preflight.trust_state.project_ref) {
5565
+ const response = await whisper.query({
5566
+ project: preflight.trust_state.project_ref,
5567
+ query: claim,
5568
+ top_k: strict ? 8 : 12,
5569
+ include_memories: preflight.repo_grounded ? false : true,
5570
+ include_graph: true,
5571
+ ...preflight.repo_grounded ? { source_ids: preflight.matched_source_ids } : {}
5572
+ });
5573
+ const filtered = (preflight.repo_grounded ? filterProjectRepoResults(response.results || [], preflight.matched_source_ids) : response.results || []).filter(
5574
+ (r) => !context_ids || context_ids.length === 0 || context_ids.includes(String(r.id))
5575
+ );
5576
+ evidence = filtered.map((r) => toEvidenceRef(r, preflight.trust_state.workspace_id, "semantic"));
5577
+ }
5578
+ if (evidence.length === 0) {
5118
5579
  const payload2 = {
5119
5580
  verdict: "unsupported",
5120
5581
  confidence: 0,
5121
5582
  evidence: [],
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."
5583
+ trust_state: preflight.trust_state,
5584
+ retrieval_readiness: preflight.retrieval_readiness,
5585
+ retrieval_route: preflight.retrieval_route,
5586
+ warnings: preflight.warnings,
5587
+ recommended_next_calls: preflight.recommended_next_calls,
5588
+ missing_requirements: preflight.warnings.length ? preflight.warnings : ["No repo-grounded evidence was available for verification."],
5589
+ explanation: "Verifier did not find sufficient repo-grounded evidence."
5127
5590
  };
5128
5591
  return { content: [{ type: "text", text: JSON.stringify(payload2, null, 2) }] };
5129
5592
  }
5130
- const response = await whisper.query({
5131
- project: trust.project_ref,
5132
- query: claim,
5133
- top_k: strict ? 8 : 12,
5134
- include_memories: true,
5135
- include_graph: true
5136
- });
5137
- const filtered = (response.results || []).filter(
5138
- (r) => !context_ids || context_ids.length === 0 || context_ids.includes(String(r.id))
5139
- );
5140
- const evidence = filtered.map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
5141
5593
  const directEvidence = evidence.filter((e) => e.score >= (strict ? 0.7 : 0.6));
5142
5594
  const weakEvidence = evidence.filter((e) => e.score >= (strict ? 0.45 : 0.35));
5143
5595
  let verdict = "unsupported";
@@ -5147,9 +5599,11 @@ server.tool(
5147
5599
  verdict,
5148
5600
  confidence: evidence.length ? Math.max(...evidence.map((e) => e.score)) : 0,
5149
5601
  evidence: verdict === "supported" ? directEvidence : weakEvidence,
5150
- trust_state: trust,
5151
- warnings: trust.warnings,
5152
- recommended_next_calls: trust.recommended_next_calls,
5602
+ trust_state: preflight.trust_state,
5603
+ retrieval_readiness: preflight.retrieval_readiness,
5604
+ retrieval_route: preflight.retrieval_route,
5605
+ warnings: preflight.warnings,
5606
+ recommended_next_calls: preflight.recommended_next_calls,
5153
5607
  missing_requirements: verdict === "supported" ? [] : verdict === "partial" ? ["No direct evidence spans met strict threshold."] : ["No sufficient supporting evidence found for the claim."],
5154
5608
  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."
5155
5609
  };
@@ -5164,6 +5618,7 @@ server.tool(
5164
5618
  "Answer a question only when evidence requirements are met. Fails closed with an abstain payload when not verifiable.",
5165
5619
  {
5166
5620
  question: z.string(),
5621
+ path: z.string().optional().describe("Workspace path. Defaults to current working directory."),
5167
5622
  workspace_id: z.string().optional(),
5168
5623
  project: z.string().optional(),
5169
5624
  constraints: z.object({
@@ -5178,38 +5633,51 @@ server.tool(
5178
5633
  include_recent_decisions: z.boolean().optional().default(true)
5179
5634
  }).optional()
5180
5635
  },
5181
- async ({ question, workspace_id, project, constraints, retrieval }) => {
5636
+ async ({ question, path, workspace_id, project, constraints, retrieval }) => {
5182
5637
  try {
5183
5638
  const requireCitations = constraints?.require_citations ?? true;
5184
5639
  const minEvidenceItems = constraints?.min_evidence_items ?? 2;
5185
5640
  const minConfidence = constraints?.min_confidence ?? 0.65;
5186
5641
  const maxStalenessHours = constraints?.max_staleness_hours ?? 168;
5187
5642
  const topK = retrieval?.top_k ?? 12;
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";
5643
+ const preflight = await resolveRepoGroundingPreflight({ query: question, path, workspace_id, project });
5644
+ let evidence = [];
5645
+ if (preflight.retrieval_route === "local_workspace_fallback") {
5646
+ const localRetrieval = await runLocalWorkspaceRetrieval({
5647
+ query: question,
5648
+ path: preflight.trust_state.root_path,
5649
+ project: preflight.trust_state.project_ref || project,
5650
+ top_k: topK
5651
+ });
5652
+ evidence = localHitsToEvidence(preflight.trust_state.workspace_id, localRetrieval.results);
5653
+ } else if (preflight.trust_state.project_ref) {
5654
+ const response = await whisper.query({
5655
+ project: preflight.trust_state.project_ref,
5656
+ query: question,
5657
+ top_k: topK,
5658
+ include_memories: preflight.repo_grounded ? false : true,
5659
+ include_graph: true,
5660
+ ...preflight.repo_grounded ? { source_ids: preflight.matched_source_ids } : {}
5661
+ });
5662
+ const filtered = preflight.repo_grounded ? filterProjectRepoResults(response.results || [], preflight.matched_source_ids) : response.results || [];
5663
+ evidence = filtered.map((r) => toEvidenceRef(r, preflight.trust_state.workspace_id, "semantic"));
5664
+ }
5665
+ if (evidence.length === 0) {
5666
+ const reason = preflight.trust_state.health === "stale" || preflight.trust_state.health === "drifted" ? "stale_index" : "no_retrieval_hits";
5191
5667
  const abstain = buildAbstain({
5192
5668
  reason,
5193
- message: trust.warnings[0] || "Workspace trust requirements were not met.",
5669
+ message: preflight.warnings[0] || "Workspace trust requirements were not met.",
5194
5670
  closest_evidence: [],
5195
5671
  claims_evaluated: 1,
5196
5672
  evidence_items_found: 0,
5197
5673
  min_required: minEvidenceItems,
5198
- index_fresh: trust.health === "healthy",
5199
- warnings: trust.warnings,
5200
- trust_state: trust,
5201
- recommended_next_calls: trust.recommended_next_calls
5674
+ index_fresh: preflight.trust_state.health === "healthy",
5675
+ warnings: preflight.warnings,
5676
+ trust_state: preflight.trust_state,
5677
+ recommended_next_calls: preflight.recommended_next_calls
5202
5678
  });
5203
5679
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
5204
5680
  }
5205
- const response = await whisper.query({
5206
- project: trust.project_ref,
5207
- query: question,
5208
- top_k: topK,
5209
- include_memories: true,
5210
- include_graph: true
5211
- });
5212
- const evidence = (response.results || []).map((r) => toEvidenceRef(r, trust.workspace_id, "semantic"));
5213
5681
  const sorted = evidence.sort((a, b) => b.score - a.score);
5214
5682
  const confidence = sorted.length ? sorted[0].score : 0;
5215
5683
  if (sorted.length === 0) {
@@ -5220,10 +5688,10 @@ server.tool(
5220
5688
  claims_evaluated: 1,
5221
5689
  evidence_items_found: 0,
5222
5690
  min_required: minEvidenceItems,
5223
- index_fresh: trust.health === "healthy",
5224
- warnings: trust.warnings,
5225
- trust_state: trust,
5226
- recommended_next_calls: trust.recommended_next_calls
5691
+ index_fresh: preflight.trust_state.health === "healthy",
5692
+ warnings: preflight.warnings,
5693
+ trust_state: preflight.trust_state,
5694
+ recommended_next_calls: preflight.recommended_next_calls
5227
5695
  });
5228
5696
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
5229
5697
  }
@@ -5238,9 +5706,9 @@ server.tool(
5238
5706
  evidence_items_found: supportedEvidence.length,
5239
5707
  min_required: minEvidenceItems,
5240
5708
  index_fresh: true,
5241
- warnings: trust.warnings,
5242
- trust_state: trust,
5243
- recommended_next_calls: trust.recommended_next_calls
5709
+ warnings: preflight.warnings,
5710
+ trust_state: preflight.trust_state,
5711
+ recommended_next_calls: preflight.recommended_next_calls
5244
5712
  });
5245
5713
  return { content: [{ type: "text", text: JSON.stringify(abstain, null, 2) }] };
5246
5714
  }
@@ -5253,9 +5721,11 @@ server.tool(
5253
5721
  answer: answerLines.join("\n"),
5254
5722
  citations,
5255
5723
  confidence,
5256
- trust_state: trust,
5257
- warnings: trust.warnings,
5258
- recommended_next_calls: trust.recommended_next_calls,
5724
+ trust_state: preflight.trust_state,
5725
+ retrieval_readiness: preflight.retrieval_readiness,
5726
+ retrieval_route: preflight.retrieval_route,
5727
+ warnings: preflight.warnings,
5728
+ recommended_next_calls: preflight.recommended_next_calls,
5259
5729
  verification: {
5260
5730
  verdict,
5261
5731
  supported_claims: verdict === "supported" ? 1 : 0,
@@ -5271,10 +5741,11 @@ server.tool(
5271
5741
  );
5272
5742
  server.tool(
5273
5743
  "context.query",
5274
- "Search your knowledge base for relevant context. Returns packed context ready for LLM consumption. Supports hybrid vector+keyword search, memory inclusion, and knowledge graph traversal.",
5744
+ "Use this when answering from project knowledge rather than general model memory. Retrieves packed context for a query using hybrid vector+keyword search with optional memory/graph expansion.",
5275
5745
  {
5276
5746
  project: z.string().optional().describe("Project name or slug (optional if WHISPER_PROJECT is set)"),
5277
5747
  query: z.string().describe("What are you looking for?"),
5748
+ path: z.string().optional().describe("Workspace path. Defaults to current working directory."),
5278
5749
  top_k: z.number().optional().default(10).describe("Number of results"),
5279
5750
  chunk_types: z.array(z.string()).optional().describe("Filter: code, function, class, documentation, api_spec, schema, config, text"),
5280
5751
  include_memories: z.boolean().optional().describe("Include relevant memories. Omit to use automatic runtime defaults."),
@@ -5283,14 +5754,105 @@ server.tool(
5283
5754
  session_id: z.string().optional().describe("Session ID for memory scoping"),
5284
5755
  max_tokens: z.number().optional().describe("Max tokens for packed context")
5285
5756
  },
5286
- async ({ project, query, top_k, chunk_types, include_memories, include_graph, user_id, session_id, max_tokens }) => {
5757
+ async ({ project, query, path, top_k, chunk_types, include_memories, include_graph, user_id, session_id, max_tokens }) => {
5287
5758
  try {
5288
- const resolvedProject = await resolveProjectRef(project);
5289
- if (!resolvedProject) {
5759
+ const preflight = await resolveRepoGroundingPreflight({ query, path, project, chunk_types });
5760
+ const resolvedProject = preflight.trust_state.project_ref || await resolveProjectRef(project);
5761
+ if (!resolvedProject && !preflight.repo_grounded) {
5290
5762
  return { content: [{ type: "text", text: "Error: No project resolved. Set WHISPER_PROJECT or pass project." }] };
5291
5763
  }
5292
- const scope = resolveMcpScope({ project: resolvedProject, user_id, session_id });
5293
- const automaticMode = include_memories !== false && include_graph !== true && !(chunk_types && chunk_types.length > 0) && max_tokens === void 0 && runtimeClient;
5764
+ const scope = resolveMcpScope({ project: resolvedProject, user_id, session_id, path: preflight.trust_state.root_path });
5765
+ if (preflight.repo_grounded && preflight.retrieval_route === "local_workspace_fallback") {
5766
+ const localRetrieval = await runLocalWorkspaceRetrieval({
5767
+ query,
5768
+ path: preflight.trust_state.root_path,
5769
+ project: resolvedProject || project,
5770
+ top_k
5771
+ });
5772
+ if (localRetrieval.results.length > 0) {
5773
+ return {
5774
+ content: [{
5775
+ type: "text",
5776
+ text: renderLocalWorkspaceContext({
5777
+ query,
5778
+ project_ref: resolvedProject || null,
5779
+ scope,
5780
+ route: "local_workspace_fallback",
5781
+ hits: localRetrieval.results,
5782
+ diagnostics: localRetrieval.diagnostics,
5783
+ warnings: Array.from(/* @__PURE__ */ new Set([...preflight.warnings, ...localRetrieval.warnings]))
5784
+ })
5785
+ }]
5786
+ };
5787
+ }
5788
+ }
5789
+ if (preflight.repo_grounded && resolvedProject) {
5790
+ const queryResult2 = await queryWithDegradedFallback({
5791
+ project: resolvedProject,
5792
+ query,
5793
+ top_k,
5794
+ include_memories: false,
5795
+ include_graph,
5796
+ user_id: user_id || scope.userId,
5797
+ session_id: session_id || scope.sessionId,
5798
+ source_ids: preflight.matched_source_ids
5799
+ });
5800
+ const repoResults = filterProjectRepoResults(queryResult2.response.results || [], preflight.matched_source_ids);
5801
+ if (repoResults.length > 0) {
5802
+ const scopedResponse = { ...queryResult2.response, results: repoResults };
5803
+ const header2 = `Found ${repoResults.length} repo-grounded result(s) (${scopedResponse.meta.latency_ms}ms${scopedResponse.meta.cache_hit ? ", cached" : ""}, route=project_repo, workspace=${preflight.trust_state.workspace_id}, project=${resolvedProject}, user=${scope.userId}, session=${scope.sessionId}):
5804
+
5805
+ `;
5806
+ const suffix2 = [
5807
+ `[diagnostics] identity_source=${preflight.trust_state.identity_source} retrieval_readiness=${preflight.retrieval_readiness} retrieval_route=project_repo`,
5808
+ queryResult2.degraded_mode ? `[degraded_mode=true] ${queryResult2.degraded_reason}
5809
+ Recommendation: ${queryResult2.recommendation}` : "",
5810
+ preflight.warnings.length ? `[warnings]
5811
+ ${preflight.warnings.join("\n")}` : ""
5812
+ ].filter(Boolean).join("\n\n");
5813
+ return { content: [{ type: "text", text: `${header2}${scopedResponse.context}${suffix2 ? `
5814
+
5815
+ ${suffix2}` : ""}` }] };
5816
+ }
5817
+ }
5818
+ if (preflight.repo_grounded) {
5819
+ if (resolvedProject && include_memories !== false) {
5820
+ const memoryRescue = await runContextQueryMemoryRescue({
5821
+ project: resolvedProject,
5822
+ query,
5823
+ user_id: user_id ? scope.userId : void 0,
5824
+ session_id: session_id ? scope.sessionId : void 0,
5825
+ top_k
5826
+ });
5827
+ if (memoryRescue.results.length && memoryRescue.rescue_mode) {
5828
+ return {
5829
+ content: [{
5830
+ type: "text",
5831
+ text: `${renderContextQueryMemoryRescue({
5832
+ project: resolvedProject,
5833
+ query,
5834
+ scope,
5835
+ results: memoryRescue.results,
5836
+ rescue_mode: memoryRescue.rescue_mode
5837
+ })}
5838
+
5839
+ [diagnostics]
5840
+ retrieval_route=memory_only retrieval_readiness=${preflight.retrieval_readiness} workspace=${preflight.trust_state.workspace_id}`
5841
+ }]
5842
+ };
5843
+ }
5844
+ }
5845
+ return {
5846
+ content: [{
5847
+ type: "text",
5848
+ text: `No relevant repo-grounded context found.
5849
+
5850
+ [diagnostics]
5851
+ workspace=${preflight.trust_state.workspace_id} identity_source=${preflight.trust_state.identity_source} retrieval_readiness=${preflight.retrieval_readiness} retrieval_route=${preflight.retrieval_route}`
5852
+ }]
5853
+ };
5854
+ }
5855
+ const automaticMode = !preflight.repo_grounded && include_memories !== false && include_graph !== true && !(chunk_types && chunk_types.length > 0) && max_tokens === void 0 && runtimeClient;
5294
5856
  if (automaticMode) {
5295
5857
  try {
5296
5858
  const prepared = await prepareAutomaticQuery({
@@ -5298,7 +5860,8 @@ server.tool(
5298
5860
  query,
5299
5861
  top_k,
5300
5862
  user_id,
5301
- session_id
5863
+ session_id,
5864
+ path: preflight.trust_state.root_path
5302
5865
  });
5303
5866
  if (!prepared.items.length) {
5304
5867
  const memoryRescue = include_memories !== false ? await runContextQueryMemoryRescue({
@@ -5351,8 +5914,8 @@ ${prepared.context}${warnings}`
5351
5914
  top_k,
5352
5915
  include_memories: include_memories === true,
5353
5916
  include_graph,
5354
- user_id: user_id || resolveMcpScope({ user_id }).userId,
5355
- session_id: session_id || resolveMcpScope({ session_id }).sessionId
5917
+ user_id: user_id || scope.userId,
5918
+ session_id: session_id || scope.sessionId
5356
5919
  });
5357
5920
  const response2 = queryResult2.response;
5358
5921
  if (response2.results.length === 0) {
@@ -5404,8 +5967,8 @@ ${automaticWarning}${suffix2}` }] };
5404
5967
  top_k,
5405
5968
  include_memories: include_memories === true,
5406
5969
  include_graph,
5407
- user_id: user_id || resolveMcpScope({ user_id }).userId,
5408
- session_id: session_id || resolveMcpScope({ session_id }).sessionId
5970
+ user_id: user_id || scope.userId,
5971
+ session_id: session_id || scope.sessionId
5409
5972
  });
5410
5973
  const response = queryResult.response;
5411
5974
  if (response.results.length === 0) {
@@ -5483,7 +6046,7 @@ server.tool(
5483
6046
  );
5484
6047
  server.tool(
5485
6048
  "memory.search",
5486
- "Search stored memories by semantic similarity. Recall facts, preferences, past decisions from previous interactions.",
6049
+ "Call this before answering questions about user history (preferences, prior decisions, past tasks, or 'what did we discuss/search'). Returns memory context you would not otherwise know.",
5487
6050
  {
5488
6051
  project: z.string().optional().describe("Project name or slug"),
5489
6052
  query: z.string().describe("What to search for"),
@@ -6462,6 +7025,7 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "py", "
6462
7025
  function extractSignature(filePath, content) {
6463
7026
  const lines = content.split("\n");
6464
7027
  const signature = /* @__PURE__ */ new Set([`// File: ${filePath}`]);
7028
+ signature.add(`relative_path:${filePath.replace(/\\/g, "/").toLowerCase()}`);
6465
7029
  for (const segment of filePath.split(/[\\/._-]+/).filter(Boolean)) {
6466
7030
  signature.add(`path:${segment}`);
6467
7031
  }
@@ -6477,29 +7041,39 @@ function extractSignature(filePath, content) {
6477
7041
  if (routeMatches) {
6478
7042
  for (const match of routeMatches.slice(0, 3)) signature.add(`route:${match.slice(0, 120)}`);
6479
7043
  }
7044
+ const envKeyMatches = trimmed.match(/[A-Z][A-Z0-9_]{2,}/g);
7045
+ if (envKeyMatches) {
7046
+ for (const match of envKeyMatches.slice(0, 3)) signature.add(`env:${match}`);
7047
+ }
6480
7048
  if (/^(import|from|require|use |pub use )/.test(trimmed)) {
6481
7049
  signature.add(trimmed.slice(0, 120));
6482
7050
  continue;
6483
7051
  }
6484
7052
  if (/^(export|async function|function|class|interface|type |const |let |def |pub fn |fn |struct |impl |enum )/.test(trimmed)) {
6485
7053
  signature.add(trimmed.slice(0, 120));
7054
+ const exportMatch = trimmed.match(/export\s+(?:const|function|class|type|interface)\s+([A-Za-z0-9_$]+)/);
7055
+ if (exportMatch?.[1]) signature.add(`export:${exportMatch[1]}`);
6486
7056
  continue;
6487
7057
  }
6488
7058
  if (trimmed.startsWith("@") || trimmed.startsWith("#[")) {
6489
7059
  signature.add(trimmed.slice(0, 80));
6490
7060
  }
6491
7061
  }
6492
- for (const line of lines.slice(60)) {
6493
- const trimmed = line.trim();
7062
+ for (let index = 60; index < lines.length; index += 1) {
7063
+ const trimmed = lines[index].trim();
6494
7064
  if (/^(export (default |async )?function|export (default )?class|export const|export type|export interface|async function|function |class |def |pub fn |fn )/.test(trimmed)) {
6495
7065
  signature.add(trimmed.slice(0, 120));
7066
+ const surrounding = lines.slice(index, Math.min(lines.length, index + 3)).map((line) => line.trim()).filter(Boolean);
7067
+ for (const line of surrounding) signature.add(line.slice(0, 120));
6496
7068
  continue;
6497
7069
  }
6498
7070
  if (/^[A-Za-z0-9_$]+\s*[:=]\s*["'`][^"'`]{3,}["'`]/.test(trimmed)) {
6499
7071
  signature.add(trimmed.slice(0, 120));
6500
7072
  }
7073
+ const exportMatch = trimmed.match(/export\s+(?:const|function|class|type|interface)\s+([A-Za-z0-9_$]+)/);
7074
+ if (exportMatch?.[1]) signature.add(`export:${exportMatch[1]}`);
6501
7075
  }
6502
- return Array.from(signature).join("\n").slice(0, 2500);
7076
+ return Array.from(signature).join("\n").slice(0, 3200);
6503
7077
  }
6504
7078
  server.tool(
6505
7079
  "code.search_semantic",
@@ -6766,7 +7340,7 @@ server.tool(
6766
7340
  );
6767
7341
  server.tool(
6768
7342
  "search",
6769
- "Search retrievable context by query, exact id, or both. Use `id` for exact fetch and `query` for semantic retrieval.",
7343
+ "Primary retrieval alias. Call this whenever the user asks to find or recall context: use `query` for semantic retrieval, `id` for exact memory fetch, or both for hybrid recall.",
6770
7344
  {
6771
7345
  project: z.string().optional().describe("Project name or slug"),
6772
7346
  query: z.string().optional().describe("Semantic retrieval query"),
@@ -6996,7 +7570,7 @@ server.tool(
6996
7570
  );
6997
7571
  server.tool(
6998
7572
  "index",
6999
- "Index a new source or refresh a workspace. Use action='source' to add GitHub/web/pdf/local/slack/video. Use action='workspace' to refresh local workspace metadata.",
7573
+ "Administrative indexing tool. Call this when retrieval is stale/missing or the user asks to connect new data. Use action='source' to add GitHub/web/pdf/local/slack/video, action='workspace' to refresh local workspace metadata.",
7000
7574
  {
7001
7575
  action: z.enum(["source", "workspace"]).default("source"),
7002
7576
  project: z.string().optional(),
@@ -7077,7 +7651,7 @@ server.tool(
7077
7651
  );
7078
7652
  server.tool(
7079
7653
  "remember",
7080
- "Store something the agent should keep across sessions: a fact, decision, preference, or instruction.",
7654
+ "Call this whenever the user states a durable preference, decision, instruction, or personal/project fact that should persist across sessions. Save proactively without waiting for an explicit 'remember this'.",
7081
7655
  {
7082
7656
  project: z.string().optional(),
7083
7657
  content: z.string().describe("Memory content"),
@@ -7156,7 +7730,7 @@ server.tool(
7156
7730
  );
7157
7731
  server.tool(
7158
7732
  "learn",
7159
- "Unified learning tool for conversation memory, text ingestion, and source indexing. Prefer this over the older learning-adjacent compatibility tools.",
7733
+ "Unified ingestion entrypoint. Call this when the user asks to import knowledge: mode='conversation' for chat logs, mode='text' for raw text, mode='source' for external sources to index. Prefer this over legacy compatibility tools.",
7160
7734
  {
7161
7735
  mode: z.enum(["conversation", "text", "source"]).describe("What kind of learning to perform"),
7162
7736
  project: z.string().optional(),
@@ -7289,6 +7863,8 @@ if (process.argv[1] && /server\.(mjs|cjs|js|ts)$/.test(process.argv[1])) {
7289
7863
  export {
7290
7864
  canonicalizeWorkspacePath,
7291
7865
  chooseWorkspaceProjectSource,
7866
+ classifyProjectRepoReadiness,
7867
+ classifyRepoGroundedQuery,
7292
7868
  classifyWorkspaceHealth,
7293
7869
  createMcpServer,
7294
7870
  createWhisperMcpClient,
@@ -7296,5 +7872,6 @@ export {
7296
7872
  extractSignature,
7297
7873
  renderScopedMcpConfig,
7298
7874
  resolveForgetQueryCandidates,
7875
+ resolveWorkspaceIdentity,
7299
7876
  runSearchCodeSearch
7300
7877
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usewhisper/mcp-server",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "whisperContractVersion": "2026.03.10",
5
5
  "scripts": {
6
6
  "build": "tsup ../src/mcp/server.ts --format esm --out-dir dist",