@juspay/yama 2.4.1 → 2.5.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.
@@ -10,6 +10,8 @@ import { SessionManager } from "./SessionManager.js";
10
10
  import { MemoryManager } from "../memory/MemoryManager.js";
11
11
  import { LocalDiffSource } from "./LocalDiffSource.js";
12
12
  import { ContextExplorerService } from "../exploration/ContextExplorerService.js";
13
+ import { ProviderDetector, } from "../utils/ProviderDetector.js";
14
+ import { getProviderToolset } from "../providers/ProviderToolset.js";
13
15
  import { buildObservabilityConfigFromEnv, validateObservabilityConfig, } from "../utils/ObservabilityConfig.js";
14
16
  export class YamaOrchestrator {
15
17
  neurolink;
@@ -23,11 +25,16 @@ export class YamaOrchestrator {
23
25
  config;
24
26
  initialized = false;
25
27
  mcpInitialized = false;
28
+ // Provider the PR-mode MCP servers were last set up for. Lets one orchestrator
29
+ // instance switch providers mid-process (Bitbucket PR then GitHub PR) by
30
+ // re-running setup when the detected provider no longer matches.
31
+ mcpProvider = null;
26
32
  localGitMcpInitialized = false;
27
33
  exploreToolRegistered = false;
28
34
  currentToolContext = null;
29
35
  initOptions;
30
36
  bootstrapStandardsCache = new Map();
37
+ detectedProvider = "bitbucket";
31
38
  constructor(options = {}) {
32
39
  this.initOptions = options;
33
40
  this.configLoader = new ConfigLoader();
@@ -39,7 +46,7 @@ export class YamaOrchestrator {
39
46
  /**
40
47
  * Initialize Yama with configuration and MCP servers
41
48
  */
42
- async initialize(configPath, mode = "pr") {
49
+ async initialize(configPath, mode = "pr", request) {
43
50
  try {
44
51
  if (!this.initialized) {
45
52
  console.log("🚀 Initializing Yama...\n");
@@ -47,6 +54,22 @@ export class YamaOrchestrator {
47
54
  const resolvedConfigPath = configPath || this.initOptions.configPath;
48
55
  this.config = await this.configLoader.loadConfig(resolvedConfigPath, this.initOptions.configOverrides);
49
56
  this.showBanner();
57
+ // Step 1.5: Detect provider BEFORE MCP setup so the correct provider's
58
+ // MCP server is wired up. When the real request is threaded in, detect
59
+ // from it; otherwise fall back to a preliminary detection (for logging
60
+ // and env/config-default driven runs).
61
+ if (request) {
62
+ this.detectedProvider = ProviderDetector.detect(request, process.env, this.config.defaultProvider);
63
+ }
64
+ else {
65
+ const preliminaryRequest = {
66
+ mode: "pr",
67
+ workspace: "",
68
+ repository: "",
69
+ };
70
+ this.detectedProvider = ProviderDetector.detect(preliminaryRequest, process.env, this.config.defaultProvider);
71
+ }
72
+ console.log(`🔌 Provider detected: ${this.detectedProvider}\n`);
50
73
  // Step 2: Initialize
51
74
  if (this.config.memory?.enabled) {
52
75
  this.memoryManager = new MemoryManager(this.config.memory, this.config.ai.provider, this.config.ai.model);
@@ -61,12 +84,26 @@ export class YamaOrchestrator {
61
84
  this.initialized = true;
62
85
  }
63
86
  // Step 4: Mode-specific setup
64
- if (mode === "pr" && !this.mcpInitialized) {
65
- await this.mcpManager.setupMCPServers(this.neurolink, this.config.mcpServers);
66
- if (this.explorer && this.config.ai.explore.enabled) {
67
- await this.explorer.initialize("pr");
87
+ if (mode === "pr") {
88
+ // If MCP was already set up for a DIFFERENT provider (one instance
89
+ // reviewing a Bitbucket PR then a GitHub PR), tear down the previous
90
+ // provider's server and re-register for the new one. Single-provider
91
+ // runs never hit this branch, so their behaviour is unchanged.
92
+ if (this.mcpInitialized &&
93
+ this.mcpProvider !== null &&
94
+ this.mcpProvider !== this.detectedProvider) {
95
+ console.log(`🔄 Provider changed (${this.mcpProvider} → ${this.detectedProvider}); re-registering MCP servers...`);
96
+ await this.mcpManager.resetForProviderSwitch(this.neurolink, this.mcpProvider);
97
+ this.mcpInitialized = false;
98
+ }
99
+ if (!this.mcpInitialized) {
100
+ await this.mcpManager.setupMCPServers(this.neurolink, this.config.mcpServers, this.detectedProvider);
101
+ if (this.explorer && this.config.ai.explore.enabled) {
102
+ await this.explorer.initialize("pr");
103
+ }
104
+ this.mcpInitialized = true;
105
+ this.mcpProvider = this.detectedProvider;
68
106
  }
69
- this.mcpInitialized = true;
70
107
  }
71
108
  else if (mode === "local" && !this.localGitMcpInitialized) {
72
109
  await this.mcpManager.setupLocalGitMCPServer(this.neurolink);
@@ -75,8 +112,9 @@ export class YamaOrchestrator {
75
112
  }
76
113
  this.localGitMcpInitialized = true;
77
114
  }
78
- // Step 5: Mode-specific validation
79
- await this.configLoader.validate(mode);
115
+ // Step 5: Mode-specific validation (provider-aware: GitHub runs skip the
116
+ // Bitbucket env requirement, Bitbucket runs are unchanged)
117
+ await this.configLoader.validate(mode, this.detectedProvider);
80
118
  console.log("✅ Yama initialized successfully\n");
81
119
  console.log("═".repeat(60) + "\n");
82
120
  }
@@ -89,14 +127,16 @@ export class YamaOrchestrator {
89
127
  * Start autonomous AI review
90
128
  */
91
129
  async startReview(request) {
92
- await this.ensureInitialized("pr", request.configPath);
130
+ await this.ensureInitialized("pr", request.configPath, request);
131
+ // Detect the provider based on request and environment
132
+ this.detectedProvider = ProviderDetector.detect(request, process.env, this.config.defaultProvider);
93
133
  const startTime = Date.now();
94
134
  const sessionId = this.sessionManager.createSession(request);
95
135
  this.logReviewStart(request, sessionId);
96
136
  try {
97
137
  const bootstrapStandards = await this.getBootstrappedStandards(request, sessionId);
98
138
  // Build comprehensive AI instructions
99
- const instructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards);
139
+ const instructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards, this.detectedProvider);
100
140
  if (this.config.display.verboseToolCalls) {
101
141
  console.log("\n📝 AI Instructions built:");
102
142
  console.log(` Instruction length: ${instructions.length} characters\n`);
@@ -218,12 +258,12 @@ export class YamaOrchestrator {
218
258
  * Stream review with real-time updates (for verbose mode)
219
259
  */
220
260
  async *streamReview(request) {
221
- await this.ensureInitialized("pr", request.configPath);
261
+ await this.ensureInitialized("pr", request.configPath, request);
222
262
  const sessionId = this.sessionManager.createSession(request);
223
263
  try {
224
264
  const bootstrapStandards = await this.getBootstrappedStandards(request, sessionId);
225
265
  // Build instructions
226
- const instructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards);
266
+ const instructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards, this.detectedProvider);
227
267
  // Create tool context
228
268
  const toolContext = this.createToolContext(sessionId, request);
229
269
  this.setToolContext(toolContext);
@@ -274,7 +314,7 @@ export class YamaOrchestrator {
274
314
  * This allows the AI to use knowledge gained during review to write better descriptions
275
315
  */
276
316
  async startReviewAndEnhance(request) {
277
- await this.ensureInitialized("pr", request.configPath);
317
+ await this.ensureInitialized("pr", request.configPath, request);
278
318
  const startTime = Date.now();
279
319
  const sessionId = this.sessionManager.createSession(request);
280
320
  this.logReviewStart(request, sessionId);
@@ -284,7 +324,7 @@ export class YamaOrchestrator {
284
324
  // ========================================================================
285
325
  const bootstrapStandards = await this.getBootstrappedStandards(request, sessionId);
286
326
  // Build review instructions
287
- const reviewInstructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards);
327
+ const reviewInstructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards, this.detectedProvider);
288
328
  if (this.config.display.verboseToolCalls) {
289
329
  console.log("\n📝 Review instructions built:");
290
330
  console.log(` Instruction length: ${reviewInstructions.length} characters\n`);
@@ -331,7 +371,7 @@ export class YamaOrchestrator {
331
371
  if (this.config.descriptionEnhancement.enabled) {
332
372
  console.log("📝 Phase 2: Enhancing PR description...");
333
373
  console.log(" AI will use review insights to write description\n");
334
- const enhanceInstructions = await this.promptBuilder.buildDescriptionEnhancementInstructions(request, this.config);
374
+ const enhanceInstructions = await this.promptBuilder.buildDescriptionEnhancementInstructions(request, this.config, this.detectedProvider);
335
375
  // Continue the SAME session - AI remembers everything from review
336
376
  const enhanceResponse = await this.neurolink.generate({
337
377
  input: { text: enhanceInstructions },
@@ -376,11 +416,11 @@ export class YamaOrchestrator {
376
416
  * Enhance PR description only (without full review)
377
417
  */
378
418
  async enhanceDescription(request) {
379
- await this.ensureInitialized("pr", request.configPath);
419
+ await this.ensureInitialized("pr", request.configPath, request);
380
420
  const sessionId = this.sessionManager.createSession(request);
381
421
  try {
382
422
  console.log("\n📝 Enhancing PR description...\n");
383
- const instructions = await this.promptBuilder.buildDescriptionEnhancementInstructions(request, this.config);
423
+ const instructions = await this.promptBuilder.buildDescriptionEnhancementInstructions(request, this.config, this.detectedProvider);
384
424
  const toolContext = this.createToolContext(sessionId, request);
385
425
  this.setToolContext(toolContext);
386
426
  const aiResponse = await this.neurolink.generate({
@@ -429,15 +469,28 @@ export class YamaOrchestrator {
429
469
  return this.sessionManager.exportSession(sessionId);
430
470
  }
431
471
  getUserId(request) {
432
- return `${request.workspace}-${request.repository}`.toLowerCase();
472
+ // Coalesce provider-specific identifiers: Bitbucket uses workspace/repository,
473
+ // GitHub uses owner/repo. Bitbucket behavior is unchanged when those are set.
474
+ const workspace = request.workspace || request.owner || "";
475
+ const repository = request.repository || request.repo || "";
476
+ return `${workspace}-${repository}`.toLowerCase();
433
477
  }
434
478
  createToolContext(sessionId, request) {
435
479
  return {
436
480
  sessionId,
437
481
  mode: "pr",
438
- workspace: request.workspace,
439
- repository: request.repository,
440
- pullRequestId: request.pullRequestId,
482
+ // Carry the detected provider so explore_context / tool runtime context
483
+ // knows which provider's toolset to use (GitHub reviews otherwise saw
484
+ // provider=undefined and defaulted to Bitbucket).
485
+ provider: this.detectedProvider,
486
+ // Coalesce provider-specific identifiers (GitHub owner/repo, Bitbucket
487
+ // workspace/repository) into the existing context fields. No-op for
488
+ // Bitbucket where workspace/repository are already set.
489
+ workspace: request.workspace || request.owner,
490
+ repository: request.repository || request.repo,
491
+ // GitHub passes the PR number via prNumber; coalesce so the runtime
492
+ // context has a PR number for both providers.
493
+ pullRequestId: request.pullRequestId ?? request.prNumber,
441
494
  branch: request.branch,
442
495
  dryRun: request.dryRun || false,
443
496
  metadata: {
@@ -450,8 +503,12 @@ export class YamaOrchestrator {
450
503
  return {
451
504
  sessionId,
452
505
  mode: "local",
453
- workspace: request.workspace,
454
- repository: request.repository,
506
+ // Carry the detected provider for explore_context toolset selection.
507
+ provider: this.detectedProvider,
508
+ // Coalesce provider-specific identifiers (GitHub owner/repo, Bitbucket
509
+ // workspace/repository). No-op for Bitbucket where they are already set.
510
+ workspace: request.workspace || request.owner,
511
+ repository: request.repository || request.repo,
455
512
  dryRun: request.dryRun || false,
456
513
  metadata: {
457
514
  yamaVersion: "2.2.1",
@@ -479,7 +536,9 @@ export class YamaOrchestrator {
479
536
  const statistics = this.calculateStatistics(session);
480
537
  return {
481
538
  mode: "pr",
482
- prId: session.request.pullRequestId || 0,
539
+ // GitHub passes the PR number via prNumber; coalesce so GitHub reviews
540
+ // report the real PR number instead of 0.
541
+ prId: session.request.pullRequestId ?? session.request.prNumber ?? 0,
483
542
  decision,
484
543
  statistics,
485
544
  summary: this.extractSummary(aiResponse),
@@ -756,36 +815,25 @@ export class YamaOrchestrator {
756
815
  * Extract decision from AI response
757
816
  */
758
817
  extractDecision(aiResponse, session) {
759
- // Derive final review state from tool calls.
760
- // Supports both current Bitbucket MCP tools and legacy names.
818
+ // Derive final review state from tool calls, delegating provider-specific
819
+ // signal interpretation to the detected provider's toolset. The Bitbucket
820
+ // toolset replicates the previous hardcoded logic (set_review_status /
821
+ // set_pr_approval + legacy names) byte-for-byte.
761
822
  const toolCalls = session.toolCalls || [];
823
+ const ts = getProviderToolset(this.detectedProvider);
762
824
  let requestedChanges;
763
825
  let approved;
764
826
  for (const tc of toolCalls) {
765
- const name = tc?.toolName;
766
- const args = tc?.args || {};
767
- if (name === "set_review_status") {
768
- if (typeof args.request_changes === "boolean") {
769
- requestedChanges = args.request_changes;
770
- }
771
- }
772
- else if (name === "request_changes") {
827
+ const decision = ts.interpretDecision({
828
+ toolName: tc?.toolName,
829
+ args: tc?.args || {},
830
+ });
831
+ if (decision === "BLOCKED") {
773
832
  requestedChanges = true;
774
833
  }
775
- else if (name === "remove_requested_changes") {
776
- requestedChanges = false;
777
- }
778
- else if (name === "set_pr_approval") {
779
- if (typeof args.approved === "boolean") {
780
- approved = args.approved;
781
- }
782
- }
783
- else if (name === "approve_pull_request") {
834
+ else if (decision === "APPROVED") {
784
835
  approved = true;
785
836
  }
786
- else if (name === "unapprove_pull_request") {
787
- approved = false;
788
- }
789
837
  }
790
838
  if (requestedChanges === true) {
791
839
  return "BLOCKED";
@@ -801,10 +849,12 @@ export class YamaOrchestrator {
801
849
  */
802
850
  calculateStatistics(session) {
803
851
  const toolCalls = session.toolCalls || [];
804
- // Count file diffs read
805
- const filesReviewed = toolCalls.filter((tc) => tc.toolName === "get_pull_request_diff").length;
806
- // Try to extract issue counts from comments
807
- const commentCalls = toolCalls.filter((tc) => tc.toolName === "add_comment");
852
+ const ts = getProviderToolset(this.detectedProvider);
853
+ // Count the changed files actually reviewed. Provider-specific because the
854
+ // diff-tool semantics differ.
855
+ const filesReviewed = this.countFilesReviewed(toolCalls);
856
+ // Try to extract issue counts from comments (provider-specific comment tools)
857
+ const commentCalls = toolCalls.filter((tc) => ts.commentToolNames.includes(tc.toolName));
808
858
  const issuesFound = this.extractIssueCountsFromComments(commentCalls);
809
859
  return {
810
860
  filesReviewed,
@@ -816,6 +866,54 @@ export class YamaOrchestrator {
816
866
  totalComments: commentCalls.length,
817
867
  };
818
868
  }
869
+ /**
870
+ * Count the changed files actually reviewed, from the session tool calls.
871
+ *
872
+ * Bitbucket: get_pull_request_diff is called once per file by design, so the
873
+ * raw count of diff-tool calls already equals the files reviewed. This path is
874
+ * byte-for-byte identical to the previous behaviour.
875
+ *
876
+ * GitHub: pull_request_read is overloaded — the SAME tool name serves
877
+ * method="get" (PR shell), "get_files" (changed-file list) and "get_diff"
878
+ * (unified diff). Counting every pull_request_read call therefore inflates the
879
+ * number. Instead, count DISTINCT file paths the review actually touched, taken
880
+ * from the inline pending-review comments (add_comment_to_pending_review →
881
+ * args.path). Fall back to the number of distinct get_files/get_diff reads when
882
+ * no inline comments were posted (e.g. a clean approval), so a reviewed-but-
883
+ * clean PR is not reported as 0 files.
884
+ */
885
+ countFilesReviewed(toolCalls) {
886
+ const ts = getProviderToolset(this.detectedProvider);
887
+ if (this.detectedProvider !== "github") {
888
+ // Bitbucket (and any non-GitHub provider): one diff fetch per file.
889
+ return toolCalls.filter((tc) => ts.diffToolNames.includes(tc.toolName)).length;
890
+ }
891
+ // GitHub: prefer distinct paths from inline comments.
892
+ const commentedPaths = new Set();
893
+ for (const tc of toolCalls) {
894
+ if (tc?.toolName !== "add_comment_to_pending_review") {
895
+ continue;
896
+ }
897
+ const path = tc?.args?.path;
898
+ if (typeof path === "string" && path.length > 0) {
899
+ commentedPaths.add(path);
900
+ }
901
+ }
902
+ if (commentedPaths.size > 0) {
903
+ return commentedPaths.size;
904
+ }
905
+ // Fallback for clean PRs with no inline comments: count the distinct
906
+ // changed-file / diff reads (pull_request_read with method get_files/get_diff)
907
+ // rather than every pull_request_read (which includes the single "get").
908
+ const diffReads = toolCalls.filter((tc) => {
909
+ if (!ts.diffToolNames.includes(tc?.toolName)) {
910
+ return false;
911
+ }
912
+ const method = tc?.args?.method;
913
+ return method === "get_files" || method === "get_diff";
914
+ }).length;
915
+ return diffReads;
916
+ }
819
917
  /**
820
918
  * Extract issue counts from comment tool calls
821
919
  */
@@ -1020,6 +1118,9 @@ export class YamaOrchestrator {
1020
1118
  mode: runtimeMode,
1021
1119
  workspace: String(mergedContext.workspace || "local"),
1022
1120
  repository: String(mergedContext.repository || "repository"),
1121
+ provider: typeof mergedContext.provider === "string"
1122
+ ? mergedContext.provider
1123
+ : undefined,
1023
1124
  pullRequestId: typeof mergedContext.pullRequestId === "number"
1024
1125
  ? mergedContext.pullRequestId
1025
1126
  : undefined,
@@ -1063,20 +1164,29 @@ export class YamaOrchestrator {
1063
1164
  if (!this.config.ai.explore.enabled || !this.explorer) {
1064
1165
  return "";
1065
1166
  }
1066
- const cacheKey = `${request.workspace}/${request.repository}`.toLowerCase();
1167
+ // Validate required parameters for bootstrap. Coalesce provider-specific
1168
+ // identifiers so GitHub (owner/repo) also gets bootstrap standards; no-op
1169
+ // for Bitbucket where workspace/repository are already set.
1170
+ const workspace = (request.workspace || request.owner || "").trim();
1171
+ const repository = (request.repository || request.repo || "").trim();
1172
+ if (workspace.length === 0 || repository.length === 0) {
1173
+ return "";
1174
+ }
1175
+ const cacheKey = `${workspace}/${repository}`.toLowerCase();
1067
1176
  const cached = this.bootstrapStandardsCache.get(cacheKey);
1068
1177
  if (cached !== undefined) {
1069
1178
  return cached;
1070
1179
  }
1071
- const task = `Bootstrap repo review standards for ${request.workspace}/${request.repository}. Inspect the last 15 merged pull requests on this repository. For each, collect inline and summary comments left by HUMAN reviewers (ignore bot authors like "Yama", "yama-bot", "yama-review", "euler.bot"). Focus on comments that asked for code changes, flagged bugs, pushed back on an approach, or cited a convention. Distill the recurring patterns and anti-patterns the human reviewers care about — especially things that a static config rule set would miss (naming, error-handling idioms, module boundaries, test expectations, migration rules, review etiquette). Return a concise bullet list of 10-20 patterns. Each bullet: one sentence stating the pattern, optionally one sentence of rationale. Do NOT include PR numbers, author names, or raw quotes — just distilled patterns.`;
1180
+ const task = `Bootstrap repo review standards for ${workspace}/${repository}. Inspect the last 15 merged pull requests on this repository. For each, collect inline and summary comments left by HUMAN reviewers (ignore bot authors like "Yama", "yama-bot", "yama-review", "euler.bot"). Focus on comments that asked for code changes, flagged bugs, pushed back on an approach, or cited a convention. Distill the recurring patterns and anti-patterns the human reviewers care about — especially things that a static config rule set would miss (naming, error-handling idioms, module boundaries, test expectations, migration rules, review etiquette). Return a concise bullet list of 10-20 patterns. Each bullet: one sentence stating the pattern, optionally one sentence of rationale. Do NOT include PR numbers, author names, or raw quotes — just distilled patterns.`;
1072
1181
  try {
1073
1182
  console.log(` 🧭 Bootstrapping repo standards from recent PRs (one-time per process)...`);
1074
- const { result } = await this.explorer.explore({ task, focus: [request.repository] }, {
1183
+ const { result } = await this.explorer.explore({ task, focus: [repository] }, {
1075
1184
  sessionId,
1076
1185
  mode: "pr",
1077
- workspace: request.workspace,
1078
- repository: request.repository,
1079
- pullRequestId: request.pullRequestId,
1186
+ provider: this.detectedProvider,
1187
+ workspace,
1188
+ repository,
1189
+ pullRequestId: request.pullRequestId ?? request.prNumber,
1080
1190
  branch: request.branch,
1081
1191
  dryRun: request.dryRun || false,
1082
1192
  metadata: { bootstrap: true },
@@ -1115,8 +1225,8 @@ export class YamaOrchestrator {
1115
1225
  /**
1116
1226
  * Ensure orchestrator is initialized
1117
1227
  */
1118
- async ensureInitialized(mode = "pr", configPath) {
1119
- await this.initialize(configPath, mode);
1228
+ async ensureInitialized(mode = "pr", configPath, request) {
1229
+ await this.initialize(configPath, mode, request);
1120
1230
  }
1121
1231
  /**
1122
1232
  * Show Yama banner
@@ -18,7 +18,16 @@ export declare class ContextExplorerService {
18
18
  explore(input: ExploreContextInput, runtimeContext: ExploreRuntimeContext): Promise<ExploreExecutionResult>;
19
19
  private normalizeInput;
20
20
  private buildCacheKey;
21
+ private getUserId;
21
22
  private getToolFilteringOptions;
23
+ /**
24
+ * Provider-agnostic mutation block list for the read-only explore subagent.
25
+ * Derived once from each provider toolset's mutationToolNames (Bitbucket +
26
+ * GitHub) so the source of truth stays in ProviderToolset, not re-hardcoded
27
+ * here. Names are lower-cased for case-insensitive matching against the
28
+ * normalized tool name.
29
+ */
30
+ private static readonly MUTATION_TOOL_NAMES;
22
31
  private shouldExcludeTool;
23
32
  private normalizeToolName;
24
33
  private normalizeResult;
@@ -5,6 +5,7 @@ import { NeuroLink } from "@juspay/neurolink";
5
5
  import { MCPServerManager } from "../core/MCPServerManager.js";
6
6
  import { KnowledgeBaseManager } from "../learning/KnowledgeBaseManager.js";
7
7
  import { buildObservabilityConfigFromEnv, validateObservabilityConfig, } from "../utils/ObservabilityConfig.js";
8
+ import { getProviderToolset } from "../providers/ProviderToolset.js";
8
9
  import { ExplorerPromptBuilder } from "./ExplorerPromptBuilder.js";
9
10
  import { RulesContextLoader } from "./RulesContextLoader.js";
10
11
  export class ContextExplorerService {
@@ -72,7 +73,7 @@ export class ContextExplorerService {
72
73
  ...this.getToolFilteringOptions(runtimeContext.mode),
73
74
  context: {
74
75
  sessionId: runtimeContext.sessionId,
75
- userId: `${runtimeContext.workspace}-${runtimeContext.repository}`.toLowerCase(),
76
+ userId: this.getUserId(runtimeContext),
76
77
  operation: "explore-context-research",
77
78
  metadata: runtimeContext.metadata || {},
78
79
  },
@@ -91,7 +92,7 @@ export class ContextExplorerService {
91
92
  disableTools: true,
92
93
  context: {
93
94
  sessionId: runtimeContext.sessionId,
94
- userId: `${runtimeContext.workspace}-${runtimeContext.repository}`.toLowerCase(),
95
+ userId: this.getUserId(runtimeContext),
95
96
  operation: "explore-context-extraction",
96
97
  metadata: runtimeContext.metadata || {},
97
98
  },
@@ -122,16 +123,48 @@ export class ContextExplorerService {
122
123
  };
123
124
  }
124
125
  buildCacheKey(input, runtimeContext) {
126
+ // Validate required string parameters
127
+ const task = typeof input.task === "string" ? input.task.trim().toLowerCase() : "";
128
+ const workspace = typeof runtimeContext.workspace === "string"
129
+ ? runtimeContext.workspace.trim()
130
+ : "";
131
+ const repository = typeof runtimeContext.repository === "string"
132
+ ? runtimeContext.repository.trim()
133
+ : "";
134
+ if (!task) {
135
+ throw new Error("Invalid cache key: task must be a non-empty string");
136
+ }
137
+ if (!workspace || !repository) {
138
+ throw new Error("Invalid cache key: workspace and repository must be non-empty strings");
139
+ }
125
140
  return JSON.stringify({
126
141
  mode: runtimeContext.mode,
127
- workspace: runtimeContext.workspace,
128
- repository: runtimeContext.repository,
142
+ provider: typeof runtimeContext.provider === "string"
143
+ ? runtimeContext.provider.trim().toLowerCase()
144
+ : "bitbucket",
145
+ workspace,
146
+ repository,
129
147
  pullRequestId: runtimeContext.pullRequestId || null,
130
- branch: runtimeContext.branch || null,
131
- task: input.task.toLowerCase(),
132
- focus: (input.focus || []).map((item) => item.toLowerCase()),
148
+ branch: typeof runtimeContext.branch === "string"
149
+ ? runtimeContext.branch.trim()
150
+ : null,
151
+ task,
152
+ focus: Array.isArray(input.focus)
153
+ ? input.focus
154
+ .filter((item) => typeof item === "string")
155
+ .map((item) => item.trim().toLowerCase())
156
+ : [],
133
157
  });
134
158
  }
159
+ getUserId(runtimeContext) {
160
+ const workspace = typeof runtimeContext.workspace === "string"
161
+ ? runtimeContext.workspace.trim()
162
+ : "unknown";
163
+ const repository = typeof runtimeContext.repository === "string"
164
+ ? runtimeContext.repository.trim()
165
+ : "unknown";
166
+ return `${workspace}-${repository}`.toLowerCase();
167
+ }
135
168
  getToolFilteringOptions(mode) {
136
169
  try {
137
170
  const externalTools = this.neurolink.getExternalMCPTools?.();
@@ -148,9 +181,17 @@ export class ContextExplorerService {
148
181
  return {};
149
182
  }
150
183
  }
184
+ /**
185
+ * Provider-agnostic mutation block list for the read-only explore subagent.
186
+ * Derived once from each provider toolset's mutationToolNames (Bitbucket +
187
+ * GitHub) so the source of truth stays in ProviderToolset, not re-hardcoded
188
+ * here. Names are lower-cased for case-insensitive matching against the
189
+ * normalized tool name.
190
+ */
191
+ static MUTATION_TOOL_NAMES = new Set(["bitbucket", "github"].flatMap((provider) => getProviderToolset(provider).mutationToolNames.map((name) => name.toLowerCase())));
151
192
  shouldExcludeTool(mode, toolName) {
152
193
  const normalized = this.normalizeToolName(toolName);
153
- if (/^(add_comment|set_pr_approval|set_review_status|approve_pull_request|unapprove_pull_request|request_changes|remove_requested_changes|update_pull_request|merge_pull_request|delete_branch)$/i.test(normalized)) {
194
+ if (ContextExplorerService.MUTATION_TOOL_NAMES.has(normalized.toLowerCase())) {
154
195
  return true;
155
196
  }
156
197
  if (mode === "local") {
@@ -430,7 +471,7 @@ Rules:
430
471
  if (!this.memoryManager) {
431
472
  return null;
432
473
  }
433
- return this.memoryManager.readRepositoryMemory(runtimeContext.workspace, runtimeContext.repository);
474
+ return this.memoryManager.readRepositoryMemory(runtimeContext.workspace, runtimeContext.repository, runtimeContext.provider || "bitbucket");
434
475
  }
435
476
  initializeNeurolink() {
436
477
  const observabilityConfig = buildObservabilityConfigFromEnv();
@@ -8,6 +8,7 @@ export interface ExploreRuntimeContext {
8
8
  mode: "pr" | "local";
9
9
  workspace: string;
10
10
  repository: string;
11
+ provider?: string;
11
12
  pullRequestId?: number;
12
13
  branch?: string;
13
14
  dryRun?: boolean;
@@ -2,6 +2,7 @@
2
2
  * Learning Types
3
3
  * Type definitions for the knowledge base and learning extraction system
4
4
  */
5
+ import type { VCSProviderName } from "../providers/ProviderToolset.js";
5
6
  /**
6
7
  * Categories for extracted learnings
7
8
  * Maps to sections in the knowledge base file
@@ -69,6 +70,7 @@ export interface LearnRequest {
69
70
  workspace: string;
70
71
  repository: string;
71
72
  pullRequestId: number;
73
+ provider?: VCSProviderName;
72
74
  dryRun?: boolean;
73
75
  /** @deprecated Use commitMode instead */
74
76
  commit?: boolean;
@@ -36,18 +36,34 @@ export declare class MemoryManager {
36
36
  */
37
37
  buildNeuroLinkMemoryConfig(): Record<string, unknown>;
38
38
  /**
39
- * Build a deterministic owner ID from workspace and repository.
39
+ * Build a deterministic owner ID from provider, workspace, and repository.
40
+ * Isolates memory per platform to prevent collisions between same repo names
41
+ * on different platforms (e.g., "repo" on GitHub vs Bitbucket).
42
+ *
43
+ * Format:
44
+ * - GitHub: github-{owner}-{repo}
45
+ * - Bitbucket: bitbucket-{workspace}-{repo}
46
+ *
40
47
  * This value is passed as `context.userId` in generate() calls.
41
48
  */
42
- static buildOwnerId(workspace: string, repository: string): string;
49
+ static buildOwnerId(workspace: string, repository: string, provider?: string): string;
50
+ /**
51
+ * Build the legacy (unprefixed) owner ID used before provider isolation.
52
+ *
53
+ * Format: {workspace}-{repository}
54
+ *
55
+ * Used as a read-time fallback so memory written before the provider-prefix
56
+ * upgrade keeps working. New writes always use the provider-prefixed key.
57
+ */
58
+ static buildLegacyOwnerId(workspace: string, repository: string): string;
43
59
  /**
44
60
  * Read persisted condensed memory for a repository owner ID.
45
61
  */
46
62
  readMemory(ownerId: string): Promise<string | null>;
47
63
  /**
48
- * Read persisted condensed memory for a workspace/repository pair.
64
+ * Read persisted condensed memory for a workspace/repository pair on a specific provider.
49
65
  */
50
- readRepositoryMemory(workspace: string, repository: string): Promise<string | null>;
66
+ readRepositoryMemory(workspace: string, repository: string, provider?: string): Promise<string | null>;
51
67
  /**
52
68
  * Commit memory files to the repository if autoCommit is enabled.
53
69
  *
@@ -123,10 +123,29 @@ export class MemoryManager {
123
123
  };
124
124
  }
125
125
  /**
126
- * Build a deterministic owner ID from workspace and repository.
126
+ * Build a deterministic owner ID from provider, workspace, and repository.
127
+ * Isolates memory per platform to prevent collisions between same repo names
128
+ * on different platforms (e.g., "repo" on GitHub vs Bitbucket).
129
+ *
130
+ * Format:
131
+ * - GitHub: github-{owner}-{repo}
132
+ * - Bitbucket: bitbucket-{workspace}-{repo}
133
+ *
127
134
  * This value is passed as `context.userId` in generate() calls.
128
135
  */
129
- static buildOwnerId(workspace, repository) {
136
+ static buildOwnerId(workspace, repository, provider = "bitbucket") {
137
+ const providerPrefix = provider.toLowerCase();
138
+ return `${providerPrefix}-${workspace}-${repository}`.toLowerCase();
139
+ }
140
+ /**
141
+ * Build the legacy (unprefixed) owner ID used before provider isolation.
142
+ *
143
+ * Format: {workspace}-{repository}
144
+ *
145
+ * Used as a read-time fallback so memory written before the provider-prefix
146
+ * upgrade keeps working. New writes always use the provider-prefixed key.
147
+ */
148
+ static buildLegacyOwnerId(workspace, repository) {
130
149
  return `${workspace}-${repository}`.toLowerCase();
131
150
  }
132
151
  /**
@@ -146,10 +165,16 @@ export class MemoryManager {
146
165
  }
147
166
  }
148
167
  /**
149
- * Read persisted condensed memory for a workspace/repository pair.
168
+ * Read persisted condensed memory for a workspace/repository pair on a specific provider.
150
169
  */
151
- async readRepositoryMemory(workspace, repository) {
152
- return this.readMemory(MemoryManager.buildOwnerId(workspace, repository));
170
+ async readRepositoryMemory(workspace, repository, provider = "bitbucket") {
171
+ const prefixed = await this.readMemory(MemoryManager.buildOwnerId(workspace, repository, provider));
172
+ if (prefixed !== null) {
173
+ return prefixed;
174
+ }
175
+ // Fall back to the legacy unprefixed key so memory written before the
176
+ // provider-prefix upgrade is not silently dropped after upgrading.
177
+ return this.readMemory(MemoryManager.buildLegacyOwnerId(workspace, repository));
153
178
  }
154
179
  /**
155
180
  * Commit memory files to the repository if autoCommit is enabled.