@juspay/yama 2.6.0 → 2.7.1
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.
- package/CHANGELOG.md +14 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +21 -1
- package/dist/v2/config/ConfigLoader.js +11 -4
- package/dist/v2/config/DefaultConfig.js +4 -2
- package/dist/v2/core/LearningOrchestrator.d.ts +11 -0
- package/dist/v2/core/LearningOrchestrator.js +57 -12
- package/dist/v2/core/MCPServerManager.js +7 -3
- package/dist/v2/core/YamaV2Orchestrator.d.ts +35 -7
- package/dist/v2/core/YamaV2Orchestrator.js +217 -112
- package/dist/v2/exploration/ContextExplorerService.js +11 -3
- package/dist/v2/prompts/ReviewSystemPrompt.js +1 -0
- package/dist/v2/providers/ProviderToolset.js +7 -3
- package/dist/v2/utils/tokenLimits.d.ts +25 -0
- package/dist/v2/utils/tokenLimits.js +32 -0
- package/dist/v2/utils/toolPolicy.d.ts +23 -0
- package/dist/v2/utils/toolPolicy.js +39 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [2.7.1](https://github.com/juspay/yama/compare/v2.7.0...v2.7.1) (2026-06-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **v2:** bump neurolink to 9.70.7 and fix review/config correctness bugs ([d0c62f7](https://github.com/juspay/yama/commit/d0c62f767bb3f38fed0bc5dcc1a64845529ec3b6))
|
|
7
|
+
|
|
8
|
+
# [2.7.0](https://github.com/juspay/yama/compare/v2.6.0...v2.7.0) (2026-06-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **setup:** add one-command GitHub integration setup script ([6a014b0](https://github.com/juspay/yama/commit/6a014b05ccf12e4ca362316e5f69c9441a93618f))
|
|
14
|
+
|
|
1
15
|
# [2.6.0](https://github.com/juspay/yama/compare/v2.5.0...v2.6.0) (2026-06-04)
|
|
2
16
|
|
|
3
17
|
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,6 @@ export { MemoryManager } from "./v2/memory/MemoryManager.js";
|
|
|
12
12
|
export type { LocalReviewFinding, LocalReviewRequest, LocalReviewResult, ReviewRequest, ReviewMode, ReviewResult, ReviewUpdate, ReviewSession, ReviewStatistics, IssuesBySeverity, TokenUsage, UnifiedReviewRequest, } from "./v2/types/v2.types.js";
|
|
13
13
|
export type { YamaConfig, YamaInitOptions, YamaConfig as YamaV2Config, AIConfig, MCPServersConfig, ReviewConfig, DescriptionEnhancementConfig, MemoryConfig, } from "./v2/types/config.types.js";
|
|
14
14
|
export type { GetPullRequestResponse, GetPullRequestDiffResponse, GetIssueResponse, SearchCodeResponse, } from "./v2/types/mcp.types.js";
|
|
15
|
-
export declare const VERSION
|
|
15
|
+
export declare const VERSION: string;
|
|
16
16
|
export { createYama as default } from "./v2/core/YamaV2Orchestrator.js";
|
|
17
17
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Yama - AI-Native Code Review
|
|
3
3
|
* Main export file
|
|
4
4
|
*/
|
|
5
|
+
import { createRequire } from "node:module";
|
|
5
6
|
// ============================================================================
|
|
6
7
|
// Core Exports
|
|
7
8
|
// ============================================================================
|
|
@@ -19,7 +20,26 @@ export { MemoryManager } from "./v2/memory/MemoryManager.js";
|
|
|
19
20
|
// ============================================================================
|
|
20
21
|
// Version Information
|
|
21
22
|
// ============================================================================
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Single source of truth for the Yama version: read from package.json at runtime
|
|
25
|
+
* (this module sits one level below package.json in both src/ and dist/, so the
|
|
26
|
+
* relative require resolves in dev and in the published build). Falls back to a
|
|
27
|
+
* sentinel if the file can't be read, instead of drifting from a hardcoded
|
|
28
|
+
* literal.
|
|
29
|
+
*/
|
|
30
|
+
function resolveVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const pkg = require("../package.json");
|
|
34
|
+
return typeof pkg.version === "string" && pkg.version.length > 0
|
|
35
|
+
? pkg.version
|
|
36
|
+
: "0.0.0";
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "0.0.0";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export const VERSION = resolveVersion();
|
|
23
43
|
// ============================================================================
|
|
24
44
|
// Default Export
|
|
25
45
|
// ============================================================================
|
|
@@ -16,12 +16,16 @@ export class ConfigLoader {
|
|
|
16
16
|
*/
|
|
17
17
|
async loadConfig(configPath, instanceOverrides) {
|
|
18
18
|
console.log("📋 Loading Yama configuration...");
|
|
19
|
+
// Final precedence (lowest → highest):
|
|
20
|
+
// defaults < file < env < instance overrides
|
|
21
|
+
// Environment variables intentionally override the committed config file
|
|
22
|
+
// (the standard expectation). Env overrides are re-applied AFTER the file
|
|
23
|
+
// merge; they are idempotent and only mutate values when the env var is
|
|
24
|
+
// actually set (see applyEnvironmentOverrides / applyMemoryOverrides), so
|
|
25
|
+
// they never clobber file values with undefined.
|
|
19
26
|
// Layer 1: Start with default config
|
|
20
27
|
let config = DefaultConfig.get();
|
|
21
|
-
// Layer 2:
|
|
22
|
-
// Lowest user-provided layer in SDK mode precedence.
|
|
23
|
-
config = this.applyEnvironmentOverrides(config);
|
|
24
|
-
// Layer 3: Load from file if provided or search for default locations
|
|
28
|
+
// Layer 2: Load from file if provided or search for default locations
|
|
25
29
|
const filePath = await this.resolveConfigPath(configPath);
|
|
26
30
|
if (filePath) {
|
|
27
31
|
console.log(` Reading config from: ${filePath}`);
|
|
@@ -32,6 +36,9 @@ export class ConfigLoader {
|
|
|
32
36
|
else {
|
|
33
37
|
console.log(" Using default configuration (no config file found)");
|
|
34
38
|
}
|
|
39
|
+
// Layer 3: Apply environment variable overrides AFTER the file so env wins
|
|
40
|
+
// over the committed config (consistently for both ai.* and memory.*).
|
|
41
|
+
config = this.applyEnvironmentOverrides(config);
|
|
35
42
|
config.memory = this.applyMemoryOverrides(config.memory);
|
|
36
43
|
// Layer 4: Apply SDK instance overrides (highest priority)
|
|
37
44
|
if (instanceOverrides) {
|
|
@@ -17,7 +17,8 @@ export class DefaultConfig {
|
|
|
17
17
|
provider: "auto",
|
|
18
18
|
model: "gemini-2.5-pro",
|
|
19
19
|
temperature: 0.2,
|
|
20
|
-
|
|
20
|
+
// Sane output ceiling (NeuroLink clamps further per-model server-side).
|
|
21
|
+
maxTokens: 32000,
|
|
21
22
|
enableAnalytics: true,
|
|
22
23
|
enableEvaluation: false,
|
|
23
24
|
timeout: "15m",
|
|
@@ -36,7 +37,8 @@ export class DefaultConfig {
|
|
|
36
37
|
provider: "auto",
|
|
37
38
|
model: "gemini-2.5-flash",
|
|
38
39
|
temperature: 0.1,
|
|
39
|
-
|
|
40
|
+
// Lighter sub-task: sane output ceiling (NeuroLink clamps further per-model server-side).
|
|
41
|
+
maxTokens: 16000,
|
|
40
42
|
timeout: "5m",
|
|
41
43
|
cacheResults: true,
|
|
42
44
|
},
|
|
@@ -34,6 +34,17 @@ export declare class LearningOrchestrator {
|
|
|
34
34
|
* Parse structured response from AI output
|
|
35
35
|
*/
|
|
36
36
|
private parseStructuredLearnings;
|
|
37
|
+
/**
|
|
38
|
+
* Validate that a candidate value (typically `response.structuredData`) is a
|
|
39
|
+
* usable learning-extraction object. Returns it typed when it carries a
|
|
40
|
+
* `learnings` array, otherwise null so callers fall back to text parsing.
|
|
41
|
+
*/
|
|
42
|
+
private coerceLearningOutput;
|
|
43
|
+
/**
|
|
44
|
+
* Fallback parser: extract the learning-extraction JSON from the raw response
|
|
45
|
+
* text (may be wrapped in a ```json code block).
|
|
46
|
+
*/
|
|
47
|
+
private parseLearningOutputFromText;
|
|
37
48
|
/**
|
|
38
49
|
* Initialize NeuroLink with observability
|
|
39
50
|
*/
|
|
@@ -11,6 +11,7 @@ import { KnowledgeBaseManager } from "../learning/KnowledgeBaseManager.js";
|
|
|
11
11
|
import { MemoryManager } from "../memory/MemoryManager.js";
|
|
12
12
|
import { getProviderToolset, } from "../providers/ProviderToolset.js";
|
|
13
13
|
import { buildObservabilityConfigFromEnv, validateObservabilityConfig, } from "../utils/ObservabilityConfig.js";
|
|
14
|
+
import { clampMaxTokens } from "../utils/tokenLimits.js";
|
|
14
15
|
export class LearningOrchestrator {
|
|
15
16
|
neurolink;
|
|
16
17
|
mcpManager;
|
|
@@ -77,7 +78,7 @@ export class LearningOrchestrator {
|
|
|
77
78
|
provider: this.config.ai.provider,
|
|
78
79
|
model: this.config.ai.model,
|
|
79
80
|
temperature: 0.1,
|
|
80
|
-
maxTokens: 50000,
|
|
81
|
+
maxTokens: clampMaxTokens(50000),
|
|
81
82
|
timeout: this.config.ai.timeout,
|
|
82
83
|
context: {
|
|
83
84
|
operation: "learning-fetch-comments",
|
|
@@ -96,7 +97,7 @@ export class LearningOrchestrator {
|
|
|
96
97
|
provider: this.config.ai.provider,
|
|
97
98
|
model: this.config.ai.model,
|
|
98
99
|
temperature: 0.2,
|
|
99
|
-
maxTokens: 10000,
|
|
100
|
+
maxTokens: clampMaxTokens(10000),
|
|
100
101
|
timeout: "2m",
|
|
101
102
|
disableTools: true, // No tools needed for extraction phase
|
|
102
103
|
context: {
|
|
@@ -164,7 +165,7 @@ export class LearningOrchestrator {
|
|
|
164
165
|
provider: this.config.ai.provider,
|
|
165
166
|
model: this.config.ai.model,
|
|
166
167
|
temperature: 0.1,
|
|
167
|
-
maxTokens: 100,
|
|
168
|
+
maxTokens: clampMaxTokens(100),
|
|
168
169
|
disableTools: true,
|
|
169
170
|
context: {
|
|
170
171
|
userId: ownerId,
|
|
@@ -377,16 +378,15 @@ Analyze the PR data above and extract learnings.
|
|
|
377
378
|
*/
|
|
378
379
|
parseStructuredLearnings(response, prId) {
|
|
379
380
|
try {
|
|
380
|
-
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
381
|
+
// Prefer the provider's structured output (NeuroLink populates
|
|
382
|
+
// `structuredData` when the model returns a parsed object) before falling
|
|
383
|
+
// back to hand-parsing the raw text. This avoids brittle regex extraction
|
|
384
|
+
// when a clean object is already available.
|
|
385
|
+
const structured = this.coerceLearningOutput(response.structuredData);
|
|
386
|
+
const parsed = structured ?? this.parseLearningOutputFromText(response);
|
|
387
|
+
if (!parsed) {
|
|
386
388
|
return [];
|
|
387
389
|
}
|
|
388
|
-
const jsonStr = jsonMatch[1] || jsonMatch[0];
|
|
389
|
-
const parsed = JSON.parse(jsonStr);
|
|
390
390
|
// Log summary if available
|
|
391
391
|
if (parsed.summary) {
|
|
392
392
|
const devComments = parsed.summary.totalIndependentDevComments || 0;
|
|
@@ -413,6 +413,51 @@ Analyze the PR data above and extract learnings.
|
|
|
413
413
|
return [];
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Validate that a candidate value (typically `response.structuredData`) is a
|
|
418
|
+
* usable learning-extraction object. Returns it typed when it carries a
|
|
419
|
+
* `learnings` array, otherwise null so callers fall back to text parsing.
|
|
420
|
+
*/
|
|
421
|
+
coerceLearningOutput(candidate) {
|
|
422
|
+
if (candidate &&
|
|
423
|
+
typeof candidate === "object" &&
|
|
424
|
+
Array.isArray(candidate.learnings)) {
|
|
425
|
+
return candidate;
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Fallback parser: extract the learning-extraction JSON from the raw response
|
|
431
|
+
* text (may be wrapped in a ```json code block).
|
|
432
|
+
*/
|
|
433
|
+
parseLearningOutputFromText(response) {
|
|
434
|
+
const content = (response.content || "");
|
|
435
|
+
// Extract JSON without backtracking-prone regexes (ReDoS-safe): locate the
|
|
436
|
+
// fenced ```json block (or the first '{' .. last '}' span) via linear string
|
|
437
|
+
// scans instead of polynomial regex matching on uncontrolled model output.
|
|
438
|
+
let jsonStr = null;
|
|
439
|
+
const fenceToken = "```json";
|
|
440
|
+
const fenceStart = content.indexOf(fenceToken);
|
|
441
|
+
if (fenceStart !== -1) {
|
|
442
|
+
const afterFence = fenceStart + fenceToken.length;
|
|
443
|
+
const fenceEnd = content.indexOf("```", afterFence);
|
|
444
|
+
if (fenceEnd !== -1) {
|
|
445
|
+
jsonStr = content.slice(afterFence, fenceEnd).trim();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!jsonStr) {
|
|
449
|
+
const objStart = content.indexOf("{");
|
|
450
|
+
const objEnd = content.lastIndexOf("}");
|
|
451
|
+
if (objStart !== -1 && objEnd > objStart) {
|
|
452
|
+
jsonStr = content.slice(objStart, objEnd + 1);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!jsonStr) {
|
|
456
|
+
console.log(" ⚠️ No JSON found in extraction response");
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
return JSON.parse(jsonStr);
|
|
460
|
+
}
|
|
416
461
|
// ============================================================================
|
|
417
462
|
// Private Methods
|
|
418
463
|
// ============================================================================
|
|
@@ -513,7 +558,7 @@ Consolidate the learnings as instructed. Return the complete updated knowledge b
|
|
|
513
558
|
provider: this.config.ai.provider,
|
|
514
559
|
model: this.config.ai.model,
|
|
515
560
|
temperature: 0.2,
|
|
516
|
-
maxTokens: 30000,
|
|
561
|
+
maxTokens: clampMaxTokens(30000),
|
|
517
562
|
});
|
|
518
563
|
// Parse and update the knowledge base
|
|
519
564
|
const newContent = (response.content || "");
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { MCPServerError } from "../types/v2.types.js";
|
|
7
|
+
import { isMutatingGitTool as isMutatingGitToolShared } from "../utils/toolPolicy.js";
|
|
7
8
|
/**
|
|
8
9
|
* Default remote HTTP endpoint for GitHub's hosted MCP server.
|
|
9
10
|
* Overridable via `mcpServers.github.url`.
|
|
@@ -146,9 +147,12 @@ export class MCPServerManager {
|
|
|
146
147
|
.filter((name) => typeof name === "string");
|
|
147
148
|
}
|
|
148
149
|
isMutatingGitTool(toolName) {
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// Delegate to the shared fail-closed allow-list policy: any git_* tool not
|
|
151
|
+
// on the read-only allow-list is treated as mutating. This closes the gaps
|
|
152
|
+
// the old regex blocklist missed (git_branch, git_mv, git_pull, git_fetch,
|
|
153
|
+
// git_restore, git_switch, git_remote). Prefix handling (local-git.git_*)
|
|
154
|
+
// is performed inside the shared helper.
|
|
155
|
+
return isMutatingGitToolShared(toolName);
|
|
152
156
|
}
|
|
153
157
|
/**
|
|
154
158
|
* Setup Bitbucket MCP server (hardcoded, always enabled)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Yama Orchestrator
|
|
3
3
|
* Main entry point for AI-native autonomous code review
|
|
4
4
|
*/
|
|
5
|
-
import { LocalReviewRequest, LocalReviewResult, ReviewRequest, ReviewResult, ReviewMode,
|
|
5
|
+
import { LocalReviewRequest, LocalReviewResult, ReviewRequest, ReviewResult, ReviewMode, UnifiedReviewRequest } from "../types/v2.types.js";
|
|
6
6
|
import { YamaInitOptions } from "../types/config.types.js";
|
|
7
7
|
export declare class YamaOrchestrator {
|
|
8
8
|
private neurolink;
|
|
@@ -40,10 +40,6 @@ export declare class YamaOrchestrator {
|
|
|
40
40
|
* Local SDK mode review from git diff (no PR/MCP dependency).
|
|
41
41
|
*/
|
|
42
42
|
reviewLocalDiff(request: LocalReviewRequest): Promise<LocalReviewResult>;
|
|
43
|
-
/**
|
|
44
|
-
* Stream review with real-time updates (for verbose mode)
|
|
45
|
-
*/
|
|
46
|
-
streamReview(request: ReviewRequest): AsyncIterableIterator<ReviewUpdate>;
|
|
47
43
|
/**
|
|
48
44
|
* Start review and then enhance description in the same session
|
|
49
45
|
* This allows the AI to use knowledge gained during review to write better descriptions
|
|
@@ -75,6 +71,21 @@ export declare class YamaOrchestrator {
|
|
|
75
71
|
private createToolContext;
|
|
76
72
|
private createLocalToolContext;
|
|
77
73
|
private setToolContext;
|
|
74
|
+
/**
|
|
75
|
+
* Run a NeuroLink generate() call with a small bounded retry on transient
|
|
76
|
+
* errors (wires up the previously-unused `ai.retryAttempts` config). Retries
|
|
77
|
+
* up to `retryAttempts` times (>=1 total attempt) with a short exponential
|
|
78
|
+
* backoff. Non-transient errors (e.g. auth/validation) fail fast on the first
|
|
79
|
+
* attempt so we don't paper over real misconfiguration.
|
|
80
|
+
*/
|
|
81
|
+
private generateWithRetry;
|
|
82
|
+
/**
|
|
83
|
+
* Heuristic transient-error classifier for retry decisions. Conservative:
|
|
84
|
+
* matches common network / rate-limit / timeout / 5xx signals and leaves
|
|
85
|
+
* everything else (auth, validation, 4xx) as non-retryable.
|
|
86
|
+
*/
|
|
87
|
+
private isTransientError;
|
|
88
|
+
private delay;
|
|
78
89
|
/**
|
|
79
90
|
* Parse AI response into structured review result
|
|
80
91
|
*/
|
|
@@ -89,6 +100,12 @@ export declare class YamaOrchestrator {
|
|
|
89
100
|
private extractJsonPayload;
|
|
90
101
|
private normalizeFindings;
|
|
91
102
|
private normalizeSeverity;
|
|
103
|
+
/**
|
|
104
|
+
* Map an arbitrary value to a known severity, or null if it is not one of the
|
|
105
|
+
* recognised levels. Unlike normalizeSeverity this does NOT default to MINOR,
|
|
106
|
+
* so callers can distinguish "unknown" from a real level.
|
|
107
|
+
*/
|
|
108
|
+
private coerceSeverity;
|
|
92
109
|
private countFindingsBySeverity;
|
|
93
110
|
private normalizeDecision;
|
|
94
111
|
/**
|
|
@@ -125,7 +142,14 @@ export declare class YamaOrchestrator {
|
|
|
125
142
|
*/
|
|
126
143
|
private extractSummary;
|
|
127
144
|
/**
|
|
128
|
-
* Calculate cost estimate from
|
|
145
|
+
* Calculate cost estimate from an AI response.
|
|
146
|
+
*
|
|
147
|
+
* Prefers NeuroLink's own analytics cost (`response.analytics.cost`, a USD
|
|
148
|
+
* number computed per-provider/model when enableAnalytics is true — Yama sets
|
|
149
|
+
* it). The previous implementation hardcoded Gemini-2.0-Flash pricing
|
|
150
|
+
* ($0.25/$1.00 per 1M) for EVERY provider, which is ~12-15x too low for Claude.
|
|
151
|
+
* Only when the analytics cost is unavailable do we fall back to a clearly
|
|
152
|
+
* labeled rough estimate based on the { input, output } token usage.
|
|
129
153
|
*/
|
|
130
154
|
private calculateCost;
|
|
131
155
|
private toSafeNumber;
|
|
@@ -137,7 +161,11 @@ export declare class YamaOrchestrator {
|
|
|
137
161
|
private getPRToolFilteringOptions;
|
|
138
162
|
/**
|
|
139
163
|
* Query-level read-only filtering for local-git MCP tools.
|
|
140
|
-
*
|
|
164
|
+
*
|
|
165
|
+
* Uses the shared, fail-closed `isMutatingGitTool` helper (any git_* tool not
|
|
166
|
+
* on the read-only allow-list is treated as mutating) so the orchestrator,
|
|
167
|
+
* explorer, and MCP manager all agree on what is read-only. The previous local
|
|
168
|
+
* regex silently missed mutating tools like git_branch / git_mv / git_pull.
|
|
141
169
|
*/
|
|
142
170
|
private getLocalToolFilteringOptions;
|
|
143
171
|
private normalizeToolName;
|
|
@@ -13,6 +13,9 @@ import { ContextExplorerService } from "../exploration/ContextExplorerService.js
|
|
|
13
13
|
import { ProviderDetector, } from "../utils/ProviderDetector.js";
|
|
14
14
|
import { getProviderToolset } from "../providers/ProviderToolset.js";
|
|
15
15
|
import { buildObservabilityConfigFromEnv, validateObservabilityConfig, } from "../utils/ObservabilityConfig.js";
|
|
16
|
+
import { clampMaxTokens } from "../utils/tokenLimits.js";
|
|
17
|
+
import { isMutatingGitTool } from "../utils/toolPolicy.js";
|
|
18
|
+
import { VERSION } from "../../index.js";
|
|
16
19
|
export class YamaOrchestrator {
|
|
17
20
|
neurolink;
|
|
18
21
|
explorer = null;
|
|
@@ -153,13 +156,14 @@ export class YamaOrchestrator {
|
|
|
153
156
|
// Execute autonomous AI review
|
|
154
157
|
console.log("🤖 Starting autonomous AI review...");
|
|
155
158
|
console.log(" AI will now make decisions and execute actions autonomously\n");
|
|
156
|
-
// Review call: RETRIEVE memory (for context), but DON'T STORE
|
|
157
|
-
|
|
159
|
+
// Review call: RETRIEVE memory (for context), but DON'T STORE.
|
|
160
|
+
// Wrapped in a bounded retry (ai.retryAttempts) for transient failures.
|
|
161
|
+
const aiResponse = await this.generateWithRetry(() => this.neurolink.generate({
|
|
158
162
|
input: { text: instructions },
|
|
159
163
|
provider: this.config.ai.provider,
|
|
160
164
|
model: this.config.ai.model,
|
|
161
165
|
temperature: this.config.ai.temperature,
|
|
162
|
-
maxTokens: this.config.ai.maxTokens,
|
|
166
|
+
maxTokens: clampMaxTokens(this.config.ai.maxTokens),
|
|
163
167
|
timeout: this.config.ai.timeout,
|
|
164
168
|
skipToolPromptInjection: true,
|
|
165
169
|
...this.getPRToolFilteringOptions(instructions),
|
|
@@ -172,7 +176,7 @@ export class YamaOrchestrator {
|
|
|
172
176
|
memory: { read: true, write: false },
|
|
173
177
|
enableAnalytics: this.config.ai.enableAnalytics,
|
|
174
178
|
enableEvaluation: this.config.ai.enableEvaluation,
|
|
175
|
-
});
|
|
179
|
+
}), "code-review");
|
|
176
180
|
this.recordToolCallsFromResponse(sessionId, aiResponse);
|
|
177
181
|
// Extract and parse results
|
|
178
182
|
const result = this.parseReviewResult(aiResponse, startTime, sessionId);
|
|
@@ -217,12 +221,12 @@ export class YamaOrchestrator {
|
|
|
217
221
|
const instructions = await this.promptBuilder.buildLocalReviewInstructions(request, this.config, diffContext);
|
|
218
222
|
const toolContext = this.createLocalToolContext(sessionId, pseudoRequest, diffContext);
|
|
219
223
|
this.setToolContext(toolContext);
|
|
220
|
-
const aiResponse = await this.neurolink.generate({
|
|
224
|
+
const aiResponse = await this.generateWithRetry(() => this.neurolink.generate({
|
|
221
225
|
input: { text: instructions },
|
|
222
226
|
provider: this.config.ai.provider,
|
|
223
227
|
model: this.config.ai.model,
|
|
224
228
|
temperature: this.config.ai.temperature,
|
|
225
|
-
maxTokens:
|
|
229
|
+
maxTokens: clampMaxTokens(this.config.ai.maxTokens),
|
|
226
230
|
timeout: this.config.ai.timeout,
|
|
227
231
|
enableAnalytics: this.config.ai.enableAnalytics,
|
|
228
232
|
enableEvaluation: this.config.ai.enableEvaluation,
|
|
@@ -242,7 +246,7 @@ export class YamaOrchestrator {
|
|
|
242
246
|
headRef: diffContext.headRef,
|
|
243
247
|
},
|
|
244
248
|
},
|
|
245
|
-
});
|
|
249
|
+
}), "local-review");
|
|
246
250
|
this.recordToolCallsFromResponse(sessionId, aiResponse);
|
|
247
251
|
const result = this.parseLocalReviewResult(aiResponse, sessionId, startTime, request, diffContext);
|
|
248
252
|
// Stored as generic session payload for debugging/export parity.
|
|
@@ -254,61 +258,6 @@ export class YamaOrchestrator {
|
|
|
254
258
|
throw error;
|
|
255
259
|
}
|
|
256
260
|
}
|
|
257
|
-
/**
|
|
258
|
-
* Stream review with real-time updates (for verbose mode)
|
|
259
|
-
*/
|
|
260
|
-
async *streamReview(request) {
|
|
261
|
-
await this.ensureInitialized("pr", request.configPath, request);
|
|
262
|
-
const sessionId = this.sessionManager.createSession(request);
|
|
263
|
-
try {
|
|
264
|
-
const bootstrapStandards = await this.getBootstrappedStandards(request, sessionId);
|
|
265
|
-
// Build instructions
|
|
266
|
-
const instructions = await this.promptBuilder.buildReviewInstructions(request, this.config, bootstrapStandards, this.detectedProvider);
|
|
267
|
-
// Create tool context
|
|
268
|
-
const toolContext = this.createToolContext(sessionId, request);
|
|
269
|
-
this.setToolContext(toolContext);
|
|
270
|
-
// Stream AI execution
|
|
271
|
-
yield {
|
|
272
|
-
type: "progress",
|
|
273
|
-
timestamp: new Date().toISOString(),
|
|
274
|
-
sessionId,
|
|
275
|
-
data: {
|
|
276
|
-
phase: "context_gathering",
|
|
277
|
-
progress: 0,
|
|
278
|
-
message: "Starting review...",
|
|
279
|
-
},
|
|
280
|
-
};
|
|
281
|
-
// Note: NeuroLink streaming implementation depends on version
|
|
282
|
-
// This is a placeholder for streaming functionality
|
|
283
|
-
const aiResponse = await this.neurolink.generate({
|
|
284
|
-
input: { text: instructions },
|
|
285
|
-
provider: this.config.ai.provider,
|
|
286
|
-
model: this.config.ai.model,
|
|
287
|
-
skipToolPromptInjection: true,
|
|
288
|
-
...this.getPRToolFilteringOptions(instructions),
|
|
289
|
-
context: {
|
|
290
|
-
sessionId,
|
|
291
|
-
userId: this.getUserId(request),
|
|
292
|
-
},
|
|
293
|
-
memory: { enabled: false },
|
|
294
|
-
enableAnalytics: true,
|
|
295
|
-
});
|
|
296
|
-
yield {
|
|
297
|
-
type: "progress",
|
|
298
|
-
timestamp: new Date().toISOString(),
|
|
299
|
-
sessionId,
|
|
300
|
-
data: {
|
|
301
|
-
phase: "decision_making",
|
|
302
|
-
progress: 100,
|
|
303
|
-
message: "Review complete",
|
|
304
|
-
},
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
catch (error) {
|
|
308
|
-
this.sessionManager.failSession(sessionId, error);
|
|
309
|
-
throw error;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
261
|
/**
|
|
313
262
|
* Start review and then enhance description in the same session
|
|
314
263
|
* This allows the AI to use knowledge gained during review to write better descriptions
|
|
@@ -345,7 +294,7 @@ export class YamaOrchestrator {
|
|
|
345
294
|
provider: this.config.ai.provider,
|
|
346
295
|
model: this.config.ai.model,
|
|
347
296
|
temperature: this.config.ai.temperature,
|
|
348
|
-
maxTokens: this.config.ai.maxTokens,
|
|
297
|
+
maxTokens: clampMaxTokens(this.config.ai.maxTokens),
|
|
349
298
|
timeout: this.config.ai.timeout,
|
|
350
299
|
skipToolPromptInjection: true,
|
|
351
300
|
...this.getPRToolFilteringOptions(reviewInstructions),
|
|
@@ -378,7 +327,7 @@ export class YamaOrchestrator {
|
|
|
378
327
|
provider: this.config.ai.provider,
|
|
379
328
|
model: this.config.ai.model,
|
|
380
329
|
temperature: this.config.ai.temperature,
|
|
381
|
-
maxTokens: this.config.ai.maxTokens,
|
|
330
|
+
maxTokens: clampMaxTokens(this.config.ai.maxTokens),
|
|
382
331
|
timeout: this.config.ai.timeout,
|
|
383
332
|
skipToolPromptInjection: true,
|
|
384
333
|
...this.getPRToolFilteringOptions(enhanceInstructions),
|
|
@@ -494,7 +443,7 @@ export class YamaOrchestrator {
|
|
|
494
443
|
branch: request.branch,
|
|
495
444
|
dryRun: request.dryRun || false,
|
|
496
445
|
metadata: {
|
|
497
|
-
yamaVersion:
|
|
446
|
+
yamaVersion: VERSION,
|
|
498
447
|
startTime: new Date().toISOString(),
|
|
499
448
|
},
|
|
500
449
|
};
|
|
@@ -511,7 +460,7 @@ export class YamaOrchestrator {
|
|
|
511
460
|
repository: request.repository || request.repo,
|
|
512
461
|
dryRun: request.dryRun || false,
|
|
513
462
|
metadata: {
|
|
514
|
-
yamaVersion:
|
|
463
|
+
yamaVersion: VERSION,
|
|
515
464
|
startTime: new Date().toISOString(),
|
|
516
465
|
repoPath: diffContext.repoPath,
|
|
517
466
|
diffSource: diffContext.diffSource,
|
|
@@ -524,6 +473,70 @@ export class YamaOrchestrator {
|
|
|
524
473
|
this.currentToolContext = context;
|
|
525
474
|
this.neurolink.setToolContext(context);
|
|
526
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Run a NeuroLink generate() call with a small bounded retry on transient
|
|
478
|
+
* errors (wires up the previously-unused `ai.retryAttempts` config). Retries
|
|
479
|
+
* up to `retryAttempts` times (>=1 total attempt) with a short exponential
|
|
480
|
+
* backoff. Non-transient errors (e.g. auth/validation) fail fast on the first
|
|
481
|
+
* attempt so we don't paper over real misconfiguration.
|
|
482
|
+
*/
|
|
483
|
+
async generateWithRetry(run, label) {
|
|
484
|
+
const configured = this.config.ai.retryAttempts;
|
|
485
|
+
const maxAttempts = typeof configured === "number" && Number.isFinite(configured)
|
|
486
|
+
? Math.max(1, Math.floor(configured))
|
|
487
|
+
: 1;
|
|
488
|
+
let lastError;
|
|
489
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
490
|
+
try {
|
|
491
|
+
return await run();
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
lastError = error;
|
|
495
|
+
if (attempt >= maxAttempts || !this.isTransientError(error)) {
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
const backoffMs = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
|
499
|
+
console.warn(` ⚠️ ${label} generate attempt ${attempt}/${maxAttempts} failed ` +
|
|
500
|
+
`(${error.message}); retrying in ${backoffMs}ms...`);
|
|
501
|
+
await this.delay(backoffMs);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Unreachable in practice (loop either returns or throws), but keeps types happy.
|
|
505
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Heuristic transient-error classifier for retry decisions. Conservative:
|
|
509
|
+
* matches common network / rate-limit / timeout / 5xx signals and leaves
|
|
510
|
+
* everything else (auth, validation, 4xx) as non-retryable.
|
|
511
|
+
*/
|
|
512
|
+
isTransientError(error) {
|
|
513
|
+
const message = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
|
|
514
|
+
if (!message) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return (message.includes("timeout") ||
|
|
518
|
+
message.includes("timed out") ||
|
|
519
|
+
message.includes("etimedout") ||
|
|
520
|
+
message.includes("econnreset") ||
|
|
521
|
+
message.includes("econnrefused") ||
|
|
522
|
+
message.includes("enotfound") ||
|
|
523
|
+
message.includes("eai_again") ||
|
|
524
|
+
message.includes("socket hang up") ||
|
|
525
|
+
message.includes("network") ||
|
|
526
|
+
message.includes("rate limit") ||
|
|
527
|
+
message.includes("rate_limit") ||
|
|
528
|
+
message.includes("too many requests") ||
|
|
529
|
+
message.includes("429") ||
|
|
530
|
+
message.includes("500") ||
|
|
531
|
+
message.includes("502") ||
|
|
532
|
+
message.includes("503") ||
|
|
533
|
+
message.includes("504") ||
|
|
534
|
+
message.includes("overloaded") ||
|
|
535
|
+
message.includes("temporarily unavailable"));
|
|
536
|
+
}
|
|
537
|
+
delay(ms) {
|
|
538
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
539
|
+
}
|
|
527
540
|
/**
|
|
528
541
|
* Parse AI response into structured review result
|
|
529
542
|
*/
|
|
@@ -544,11 +557,13 @@ export class YamaOrchestrator {
|
|
|
544
557
|
summary: this.extractSummary(aiResponse),
|
|
545
558
|
duration,
|
|
546
559
|
tokenUsage: {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
560
|
+
// NeuroLink 9.70.x usage shape is { input, output, total } — NOT
|
|
561
|
+
// inputTokens/outputTokens/totalTokens (those always resolved to 0).
|
|
562
|
+
input: this.toSafeNumber(aiResponse?.usage?.input),
|
|
563
|
+
output: this.toSafeNumber(aiResponse?.usage?.output),
|
|
564
|
+
total: this.toSafeNumber(aiResponse?.usage?.total),
|
|
550
565
|
},
|
|
551
|
-
costEstimate: this.calculateCost(aiResponse
|
|
566
|
+
costEstimate: this.calculateCost(aiResponse),
|
|
552
567
|
sessionId,
|
|
553
568
|
};
|
|
554
569
|
}
|
|
@@ -571,28 +586,52 @@ export class YamaOrchestrator {
|
|
|
571
586
|
*/
|
|
572
587
|
parseLocalReviewResult(aiResponse, sessionId, startTime, request, diffContext) {
|
|
573
588
|
const rawContent = aiResponse?.content || aiResponse?.outputs?.text || "";
|
|
574
|
-
|
|
589
|
+
// Prefer NeuroLink's already-parsed structuredData (output.format "json")
|
|
590
|
+
// before re-parsing the raw content string by hand; fall back to the
|
|
591
|
+
// hand-parse so older shapes / non-JSON outputs still work.
|
|
592
|
+
const structured = aiResponse?.structuredData &&
|
|
593
|
+
typeof aiResponse.structuredData === "object" &&
|
|
594
|
+
!Array.isArray(aiResponse.structuredData)
|
|
595
|
+
? aiResponse.structuredData
|
|
596
|
+
: null;
|
|
597
|
+
const parsed = structured ?? this.extractJsonPayload(rawContent);
|
|
598
|
+
const jsonTruncated = aiResponse?.jsonTruncated === true;
|
|
575
599
|
const usage = aiResponse?.usage || {};
|
|
576
600
|
const tokenUsage = {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
601
|
+
// NeuroLink 9.70.x usage shape is { input, output, total }.
|
|
602
|
+
input: this.toSafeNumber(usage.input),
|
|
603
|
+
output: this.toSafeNumber(usage.output),
|
|
604
|
+
total: this.toSafeNumber(usage.total),
|
|
580
605
|
};
|
|
581
606
|
if (!parsed) {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
607
|
+
// Distinguish a truncated response (model ran out of output budget mid-JSON)
|
|
608
|
+
// from a genuinely malformed one, and advise raising maxTokens in that case
|
|
609
|
+
// rather than emitting the generic format-violation guidance.
|
|
610
|
+
const fallbackIssue = jsonTruncated
|
|
611
|
+
? {
|
|
612
|
+
id: "OUTPUT_TRUNCATED",
|
|
613
|
+
severity: "MAJOR",
|
|
614
|
+
category: "review-engine",
|
|
615
|
+
title: "Model output was truncated before valid JSON completed",
|
|
616
|
+
description: "The local review response was cut off (jsonTruncated), so the JSON could not be parsed and findings cannot be trusted.",
|
|
617
|
+
suggestion: "Raise ai.maxTokens (output budget) and/or reduce review scope (smaller diff / includePaths) so the structured JSON can finish.",
|
|
618
|
+
}
|
|
619
|
+
: {
|
|
620
|
+
id: "OUTPUT_FORMAT_VIOLATION",
|
|
621
|
+
severity: "MAJOR",
|
|
622
|
+
category: "review-engine",
|
|
623
|
+
title: "Model did not return structured JSON",
|
|
624
|
+
description: "Local review response was unstructured (likely tool-call trace or partial output), so findings cannot be trusted.",
|
|
625
|
+
suggestion: "Retry with a tool-calling capable model or reduce review scope (smaller diff / includePaths) to keep responses structured.",
|
|
626
|
+
};
|
|
590
627
|
const issuesBySeverity = this.countFindingsBySeverity([fallbackIssue]);
|
|
591
628
|
return {
|
|
592
629
|
mode: "local",
|
|
593
630
|
decision: "CHANGES_REQUESTED",
|
|
594
631
|
summary: this.sanitizeLocalSummary(rawContent) ||
|
|
595
|
-
|
|
632
|
+
(jsonTruncated
|
|
633
|
+
? "Local review output was truncated before valid JSON completed."
|
|
634
|
+
: "Local review could not produce structured JSON output."),
|
|
596
635
|
issues: [fallbackIssue],
|
|
597
636
|
enhancements: [],
|
|
598
637
|
statistics: {
|
|
@@ -605,7 +644,7 @@ export class YamaOrchestrator {
|
|
|
605
644
|
},
|
|
606
645
|
duration: Math.round((Date.now() - startTime) / 1000),
|
|
607
646
|
tokenUsage,
|
|
608
|
-
costEstimate: this.calculateCost(aiResponse
|
|
647
|
+
costEstimate: this.calculateCost(aiResponse),
|
|
609
648
|
sessionId,
|
|
610
649
|
schemaVersion: request.outputSchemaVersion || "1.0",
|
|
611
650
|
metadata: {
|
|
@@ -642,7 +681,7 @@ export class YamaOrchestrator {
|
|
|
642
681
|
},
|
|
643
682
|
duration: Math.round((Date.now() - startTime) / 1000),
|
|
644
683
|
tokenUsage,
|
|
645
|
-
costEstimate: this.calculateCost(aiResponse
|
|
684
|
+
costEstimate: this.calculateCost(aiResponse),
|
|
646
685
|
sessionId,
|
|
647
686
|
schemaVersion: request.outputSchemaVersion || "1.0",
|
|
648
687
|
metadata: {
|
|
@@ -728,7 +767,11 @@ export class YamaOrchestrator {
|
|
|
728
767
|
return findings
|
|
729
768
|
.map((raw, index) => {
|
|
730
769
|
const value = (raw || {});
|
|
731
|
-
const
|
|
770
|
+
const ruleKey = (typeof value.rule === "string" && value.rule) ||
|
|
771
|
+
(typeof value.category === "string" && value.category) ||
|
|
772
|
+
(typeof value.id === "string" && value.id) ||
|
|
773
|
+
undefined;
|
|
774
|
+
const severity = this.normalizeSeverity(value.severity, ruleKey);
|
|
732
775
|
if (!severity) {
|
|
733
776
|
return null;
|
|
734
777
|
}
|
|
@@ -758,9 +801,28 @@ export class YamaOrchestrator {
|
|
|
758
801
|
})
|
|
759
802
|
.filter((item) => item !== null);
|
|
760
803
|
}
|
|
761
|
-
normalizeSeverity(severity) {
|
|
804
|
+
normalizeSeverity(severity, ruleKey) {
|
|
805
|
+
// Calibration: allow projectStandards.severityOverrides to remap a finding's
|
|
806
|
+
// severity by its rule/category key (defensive — config is optional and the
|
|
807
|
+
// override value is itself validated against the known severity set). This
|
|
808
|
+
// takes precedence over the model-reported severity.
|
|
809
|
+
const overrides = this.config.projectStandards?.severityOverrides;
|
|
810
|
+
if (overrides && ruleKey) {
|
|
811
|
+
const overridden = this.coerceSeverity(overrides[ruleKey]);
|
|
812
|
+
if (overridden) {
|
|
813
|
+
return overridden;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return this.coerceSeverity(severity) ?? "MINOR";
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Map an arbitrary value to a known severity, or null if it is not one of the
|
|
820
|
+
* recognised levels. Unlike normalizeSeverity this does NOT default to MINOR,
|
|
821
|
+
* so callers can distinguish "unknown" from a real level.
|
|
822
|
+
*/
|
|
823
|
+
coerceSeverity(severity) {
|
|
762
824
|
if (typeof severity !== "string") {
|
|
763
|
-
return
|
|
825
|
+
return null;
|
|
764
826
|
}
|
|
765
827
|
const value = severity.toUpperCase();
|
|
766
828
|
if (value === "CRITICAL" ||
|
|
@@ -769,7 +831,7 @@ export class YamaOrchestrator {
|
|
|
769
831
|
value === "SUGGESTION") {
|
|
770
832
|
return value;
|
|
771
833
|
}
|
|
772
|
-
return
|
|
834
|
+
return null;
|
|
773
835
|
}
|
|
774
836
|
countFindingsBySeverity(findings) {
|
|
775
837
|
const counts = {
|
|
@@ -821,24 +883,24 @@ export class YamaOrchestrator {
|
|
|
821
883
|
// set_pr_approval + legacy names) byte-for-byte.
|
|
822
884
|
const toolCalls = session.toolCalls || [];
|
|
823
885
|
const ts = getProviderToolset(this.detectedProvider);
|
|
824
|
-
|
|
825
|
-
|
|
886
|
+
// ORDER-AWARE: the LAST decisive review-status / approval tool call wins, so
|
|
887
|
+
// a later approval correctly overrides an earlier request-changes (and vice
|
|
888
|
+
// versa). The previous logic let any "BLOCKED" stick regardless of order,
|
|
889
|
+
// reporting BLOCKED even when the AI changed its mind and approved afterward.
|
|
890
|
+
let lastDecision;
|
|
826
891
|
for (const tc of toolCalls) {
|
|
827
892
|
const decision = ts.interpretDecision({
|
|
828
893
|
toolName: tc?.toolName,
|
|
829
894
|
args: tc?.args || {},
|
|
830
895
|
});
|
|
831
|
-
if (decision === "BLOCKED") {
|
|
832
|
-
|
|
833
|
-
}
|
|
834
|
-
else if (decision === "APPROVED") {
|
|
835
|
-
approved = true;
|
|
896
|
+
if (decision === "BLOCKED" || decision === "APPROVED") {
|
|
897
|
+
lastDecision = decision;
|
|
836
898
|
}
|
|
837
899
|
}
|
|
838
|
-
if (
|
|
900
|
+
if (lastDecision === "BLOCKED") {
|
|
839
901
|
return "BLOCKED";
|
|
840
902
|
}
|
|
841
|
-
if (
|
|
903
|
+
if (lastDecision === "APPROVED") {
|
|
842
904
|
return "APPROVED";
|
|
843
905
|
}
|
|
844
906
|
// Default to changes requested if unclear
|
|
@@ -925,7 +987,10 @@ export class YamaOrchestrator {
|
|
|
925
987
|
suggestions: 0,
|
|
926
988
|
};
|
|
927
989
|
commentCalls.forEach((call) => {
|
|
928
|
-
|
|
990
|
+
// Comment body field differs per provider: Bitbucket comment tools use
|
|
991
|
+
// `comment_text`, GitHub comment tools use `body`. Read either so severity
|
|
992
|
+
// counts work for both (GitHub previously always counted 0).
|
|
993
|
+
const text = call.args?.comment_text ?? call.args?.body ?? "";
|
|
929
994
|
if (text.includes("🔒 CRITICAL") || text.includes("CRITICAL:")) {
|
|
930
995
|
counts.critical++;
|
|
931
996
|
}
|
|
@@ -949,17 +1014,31 @@ export class YamaOrchestrator {
|
|
|
949
1014
|
return aiResponse.content || aiResponse.text || "Review completed";
|
|
950
1015
|
}
|
|
951
1016
|
/**
|
|
952
|
-
* Calculate cost estimate from
|
|
1017
|
+
* Calculate cost estimate from an AI response.
|
|
1018
|
+
*
|
|
1019
|
+
* Prefers NeuroLink's own analytics cost (`response.analytics.cost`, a USD
|
|
1020
|
+
* number computed per-provider/model when enableAnalytics is true — Yama sets
|
|
1021
|
+
* it). The previous implementation hardcoded Gemini-2.0-Flash pricing
|
|
1022
|
+
* ($0.25/$1.00 per 1M) for EVERY provider, which is ~12-15x too low for Claude.
|
|
1023
|
+
* Only when the analytics cost is unavailable do we fall back to a clearly
|
|
1024
|
+
* labeled rough estimate based on the { input, output } token usage.
|
|
953
1025
|
*/
|
|
954
|
-
calculateCost(
|
|
1026
|
+
calculateCost(aiResponse) {
|
|
1027
|
+
const analyticsCost = aiResponse?.analytics?.cost;
|
|
1028
|
+
if (typeof analyticsCost === "number" && Number.isFinite(analyticsCost)) {
|
|
1029
|
+
return Number(analyticsCost.toFixed(6));
|
|
1030
|
+
}
|
|
1031
|
+
const usage = aiResponse?.usage;
|
|
955
1032
|
if (!usage) {
|
|
956
1033
|
return 0;
|
|
957
1034
|
}
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
const
|
|
1035
|
+
// ROUGH FALLBACK ONLY — provider/model agnostic placeholder pricing used
|
|
1036
|
+
// when NeuroLink analytics cost is unavailable. Not accurate for any
|
|
1037
|
+
// specific model; treat as an order-of-magnitude estimate.
|
|
1038
|
+
const inputCostPer1M = 0.25;
|
|
1039
|
+
const outputCostPer1M = 1.0;
|
|
1040
|
+
const inputTokens = this.toSafeNumber(usage.input);
|
|
1041
|
+
const outputTokens = this.toSafeNumber(usage.output);
|
|
963
1042
|
const inputCost = (inputTokens / 1_000_000) * inputCostPer1M;
|
|
964
1043
|
const outputCost = (outputTokens / 1_000_000) * outputCostPer1M;
|
|
965
1044
|
const totalCost = inputCost + outputCost;
|
|
@@ -985,7 +1064,27 @@ export class YamaOrchestrator {
|
|
|
985
1064
|
if (mode === "off") {
|
|
986
1065
|
return {};
|
|
987
1066
|
}
|
|
988
|
-
|
|
1067
|
+
// Jira issue-key signal. The naive /\b[A-Z]{2,}-\d+\b/ over-matched common
|
|
1068
|
+
// non-Jira tokens (UTF-8, SHA-256, SHA256-..., CVE-2024, ISO-8601, RFC-3339),
|
|
1069
|
+
// wrongly keeping Jira tools enabled. Exclude a small denylist of those
|
|
1070
|
+
// prefixes so the match stays conservative (still keeps tools when a real
|
|
1071
|
+
// ABC-123 style key — or the literal word "jira" — is present).
|
|
1072
|
+
const jiraKeyPattern = /\b([A-Z]{2,})-\d+\b/g;
|
|
1073
|
+
const nonJiraKeyPrefixes = new Set([
|
|
1074
|
+
"UTF",
|
|
1075
|
+
"SHA",
|
|
1076
|
+
"SHA256",
|
|
1077
|
+
"SHA512",
|
|
1078
|
+
"MD5",
|
|
1079
|
+
"CVE",
|
|
1080
|
+
"ISO",
|
|
1081
|
+
"RFC",
|
|
1082
|
+
"UTC",
|
|
1083
|
+
"GMT",
|
|
1084
|
+
"BASE64",
|
|
1085
|
+
]);
|
|
1086
|
+
const hasJiraKey = Array.from(inputText.matchAll(jiraKeyPattern), (m) => m[1]).some((prefix) => !nonJiraKeyPrefixes.has(prefix));
|
|
1087
|
+
const hasJiraSignal = hasJiraKey || /\bjira\b/i.test(inputText);
|
|
989
1088
|
if (hasJiraSignal) {
|
|
990
1089
|
return {};
|
|
991
1090
|
}
|
|
@@ -1013,7 +1112,11 @@ export class YamaOrchestrator {
|
|
|
1013
1112
|
}
|
|
1014
1113
|
/**
|
|
1015
1114
|
* Query-level read-only filtering for local-git MCP tools.
|
|
1016
|
-
*
|
|
1115
|
+
*
|
|
1116
|
+
* Uses the shared, fail-closed `isMutatingGitTool` helper (any git_* tool not
|
|
1117
|
+
* on the read-only allow-list is treated as mutating) so the orchestrator,
|
|
1118
|
+
* explorer, and MCP manager all agree on what is read-only. The previous local
|
|
1119
|
+
* regex silently missed mutating tools like git_branch / git_mv / git_pull.
|
|
1017
1120
|
*/
|
|
1018
1121
|
getLocalToolFilteringOptions() {
|
|
1019
1122
|
try {
|
|
@@ -1021,15 +1124,17 @@ export class YamaOrchestrator {
|
|
|
1021
1124
|
if (!Array.isArray(externalTools)) {
|
|
1022
1125
|
return {};
|
|
1023
1126
|
}
|
|
1024
|
-
const mutatingGitToolPattern = /^git_(commit|push|add|checkout|create_branch|merge|rebase|cherry_pick|reset|revert|tag|rm|clean|stash|apply)\b/i;
|
|
1025
1127
|
// High-volume read operations can flood context with huge payloads.
|
|
1026
1128
|
const highVolumeGitToolPattern = /^git_(diff|diff_staged|diff_unstaged|log|show)\b/i;
|
|
1027
1129
|
const excludeTools = externalTools
|
|
1028
1130
|
.filter((tool) => tool?.serverId === "local-git")
|
|
1029
1131
|
.map((tool) => tool?.name)
|
|
1030
1132
|
.filter((name) => typeof name === "string")
|
|
1031
|
-
.filter((name) =>
|
|
1032
|
-
|
|
1133
|
+
.filter((name) => {
|
|
1134
|
+
const normalized = this.normalizeToolName(name);
|
|
1135
|
+
return (isMutatingGitTool(normalized) ||
|
|
1136
|
+
highVolumeGitToolPattern.test(normalized));
|
|
1137
|
+
});
|
|
1033
1138
|
if (excludeTools.length === 0) {
|
|
1034
1139
|
return {};
|
|
1035
1140
|
}
|
|
@@ -1239,7 +1344,7 @@ export class YamaOrchestrator {
|
|
|
1239
1344
|
console.log(`
|
|
1240
1345
|
⚔️ YAMA - AI-Native Code Review Guardian
|
|
1241
1346
|
|
|
1242
|
-
Version:
|
|
1347
|
+
Version: ${VERSION}
|
|
1243
1348
|
Mode: Autonomous AI-Powered Review
|
|
1244
1349
|
Powered by: NeuroLink + MCP Tools
|
|
1245
1350
|
`);
|
|
@@ -6,6 +6,8 @@ import { MCPServerManager } from "../core/MCPServerManager.js";
|
|
|
6
6
|
import { KnowledgeBaseManager } from "../learning/KnowledgeBaseManager.js";
|
|
7
7
|
import { buildObservabilityConfigFromEnv, validateObservabilityConfig, } from "../utils/ObservabilityConfig.js";
|
|
8
8
|
import { getProviderToolset, } from "../providers/ProviderToolset.js";
|
|
9
|
+
import { clampMaxTokens, MAX_EXTRACTION_TOKENS } from "../utils/tokenLimits.js";
|
|
10
|
+
import { isMutatingGitTool } from "../utils/toolPolicy.js";
|
|
9
11
|
import { ExplorerPromptBuilder } from "./ExplorerPromptBuilder.js";
|
|
10
12
|
import { RulesContextLoader } from "./RulesContextLoader.js";
|
|
11
13
|
export class ContextExplorerService {
|
|
@@ -78,7 +80,7 @@ export class ContextExplorerService {
|
|
|
78
80
|
provider: this.config.ai.explore.provider || this.config.ai.provider,
|
|
79
81
|
model: this.config.ai.explore.model || this.config.ai.model,
|
|
80
82
|
temperature: this.config.ai.explore.temperature ?? this.config.ai.temperature,
|
|
81
|
-
maxTokens: this.config.ai.explore.maxTokens
|
|
83
|
+
maxTokens: clampMaxTokens(this.config.ai.explore.maxTokens ?? this.config.ai.maxTokens),
|
|
82
84
|
timeout: this.config.ai.explore.timeout || this.config.ai.timeout,
|
|
83
85
|
skipToolPromptInjection: true,
|
|
84
86
|
...this.getToolFilteringOptions(runtimeContext.mode),
|
|
@@ -98,7 +100,7 @@ export class ContextExplorerService {
|
|
|
98
100
|
provider: this.config.ai.explore.provider || this.config.ai.provider,
|
|
99
101
|
model: this.config.ai.explore.model || this.config.ai.model,
|
|
100
102
|
temperature: 0.1,
|
|
101
|
-
maxTokens:
|
|
103
|
+
maxTokens: clampMaxTokens(this.config.ai.explore.maxTokens ?? this.config.ai.maxTokens, MAX_EXTRACTION_TOKENS),
|
|
102
104
|
timeout: "2m",
|
|
103
105
|
disableTools: true,
|
|
104
106
|
context: {
|
|
@@ -202,11 +204,17 @@ export class ContextExplorerService {
|
|
|
202
204
|
static MUTATION_TOOL_NAMES = new Set(["bitbucket", "github"].flatMap((provider) => getProviderToolset(provider).mutationToolNames.map((name) => name.toLowerCase())));
|
|
203
205
|
shouldExcludeTool(mode, toolName) {
|
|
204
206
|
const normalized = this.normalizeToolName(toolName);
|
|
207
|
+
// Provider (non-git) mutation tools — Bitbucket/GitHub write tools derived
|
|
208
|
+
// from each provider toolset — are always excluded for the read-only
|
|
209
|
+
// explore subagent.
|
|
205
210
|
if (ContextExplorerService.MUTATION_TOOL_NAMES.has(normalized.toLowerCase())) {
|
|
206
211
|
return true;
|
|
207
212
|
}
|
|
213
|
+
// Git tools are filtered with the shared fail-closed allow-list in local
|
|
214
|
+
// mode (any git_* tool not on the read-only list is treated as mutating),
|
|
215
|
+
// closing the gaps the old regex missed (git_branch, git_mv, git_pull, …).
|
|
208
216
|
if (mode === "local") {
|
|
209
|
-
return
|
|
217
|
+
return isMutatingGitTool(toolName);
|
|
210
218
|
}
|
|
211
219
|
return false;
|
|
212
220
|
}
|
|
@@ -50,6 +50,7 @@ ${toolUsageSection}
|
|
|
50
50
|
<level name="MAJOR" emoji="⚠️">Blocks if multiple. MUST include a real-code suggestion. Logic bugs, perf issues, broken APIs.</level>
|
|
51
51
|
<level name="MINOR" emoji="💡">Request changes. Suggestion optional. Quality, naming, duplication.</level>
|
|
52
52
|
<level name="SUGGESTION" emoji="💬">Informational. Optimizations and improvements.</level>
|
|
53
|
+
<marker-rule>EVERY posted inline comment body MUST BEGIN with its severity marker as the very first token, exactly one of: "🔒 CRITICAL:", "⚠️ MAJOR:", "💡 MINOR:", "💬 SUGGESTION:". Write the marker verbatim (emoji + level + colon) before any other text — it is how issues are counted, so an omitted or altered marker is not counted.</marker-rule>
|
|
53
54
|
</severity-levels>
|
|
54
55
|
|
|
55
56
|
<anti-patterns>
|
|
@@ -140,7 +140,9 @@ class BitbucketToolset {
|
|
|
140
140
|
history-dependent behavior — <!-- EXPLORE_BEGIN -->call explore_context with a precise
|
|
141
141
|
task and wait for its evidence before commenting<!-- EXPLORE_END --><!-- EXPLORE_DISABLED_BEGIN -->use search_code or get_file_content to verify before commenting<!-- EXPLORE_DISABLED_END -->.
|
|
142
142
|
d. For every confirmed issue, call add_comment immediately with line_number and
|
|
143
|
-
line_type from the diff JSON.
|
|
143
|
+
line_type from the diff JSON. Begin comment_text with the exact severity marker as
|
|
144
|
+
its first token — "🔒 CRITICAL:", "⚠️ MAJOR:", "💡 MINOR:", or "💬 SUGGESTION:" —
|
|
145
|
+
then the message. Include a real-code suggestion for CRITICAL/MAJOR.
|
|
144
146
|
e. Move to the next file. Never request another file's diff before finishing the
|
|
145
147
|
current one. Never request a multi-file diff.
|
|
146
148
|
|
|
@@ -330,8 +332,10 @@ class GitHubToolset {
|
|
|
330
332
|
history-dependent behavior — <!-- EXPLORE_BEGIN -->call explore_context with a precise
|
|
331
333
|
task and wait for its evidence before commenting<!-- EXPLORE_END --><!-- EXPLORE_DISABLED_BEGIN -->use search_code or get_file_contents to verify before commenting<!-- EXPLORE_DISABLED_END -->.
|
|
332
334
|
d. For every confirmed issue, call add_comment_to_pending_review immediately with
|
|
333
|
-
path, line, subjectType="LINE", and body taken from the diff.
|
|
334
|
-
|
|
335
|
+
path, line, subjectType="LINE", and body taken from the diff. Begin body with the
|
|
336
|
+
exact severity marker as its first token — "🔒 CRITICAL:", "⚠️ MAJOR:", "💡 MINOR:",
|
|
337
|
+
or "💬 SUGGESTION:" — then the message. Include a real-code suggestion in body for
|
|
338
|
+
CRITICAL/MAJOR.
|
|
335
339
|
e. Move to the next file. Never jump between files mid-review.
|
|
336
340
|
|
|
337
341
|
STEP 5 — Decision (submit the review)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized output-token limits for NeuroLink generate() calls.
|
|
3
|
+
*
|
|
4
|
+
* Yama previously passed `ai.maxTokens` (defaulting to 128000) straight to
|
|
5
|
+
* generate(). 128000 is a context-window size, not an output cap — no model
|
|
6
|
+
* emits that many output tokens, and on the non-streaming Vertex+Claude path it
|
|
7
|
+
* historically tripped the Anthropic SDK's "Streaming is required" guard
|
|
8
|
+
* (expected time > 10 min for maxTokens > ~21333). NeuroLink >= 9.70 clamps
|
|
9
|
+
* per-model server-side, but Yama still clamps to a sane ceiling so configs can
|
|
10
|
+
* never pass absurd values and behavior is consistent across every call site
|
|
11
|
+
* (previous clamps were inconsistent: 16_000 in one place, 12_000 in another,
|
|
12
|
+
* and unclamped in the main review path).
|
|
13
|
+
*/
|
|
14
|
+
/** Sane default output-token ceiling for review/generation calls. */
|
|
15
|
+
export declare const MAX_OUTPUT_TOKENS = 32000;
|
|
16
|
+
/** Smaller ceiling for lightweight structured-extraction passes. */
|
|
17
|
+
export declare const MAX_EXTRACTION_TOKENS = 12000;
|
|
18
|
+
/**
|
|
19
|
+
* Clamp a requested maxTokens value to a safe ceiling.
|
|
20
|
+
*
|
|
21
|
+
* Returns `cap` when the requested value is missing, non-finite, or non-positive
|
|
22
|
+
* so callers always end up with a usable, bounded number.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clampMaxTokens(requested: number | undefined | null, cap?: number): number;
|
|
25
|
+
//# sourceMappingURL=tokenLimits.d.ts.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized output-token limits for NeuroLink generate() calls.
|
|
3
|
+
*
|
|
4
|
+
* Yama previously passed `ai.maxTokens` (defaulting to 128000) straight to
|
|
5
|
+
* generate(). 128000 is a context-window size, not an output cap — no model
|
|
6
|
+
* emits that many output tokens, and on the non-streaming Vertex+Claude path it
|
|
7
|
+
* historically tripped the Anthropic SDK's "Streaming is required" guard
|
|
8
|
+
* (expected time > 10 min for maxTokens > ~21333). NeuroLink >= 9.70 clamps
|
|
9
|
+
* per-model server-side, but Yama still clamps to a sane ceiling so configs can
|
|
10
|
+
* never pass absurd values and behavior is consistent across every call site
|
|
11
|
+
* (previous clamps were inconsistent: 16_000 in one place, 12_000 in another,
|
|
12
|
+
* and unclamped in the main review path).
|
|
13
|
+
*/
|
|
14
|
+
/** Sane default output-token ceiling for review/generation calls. */
|
|
15
|
+
export const MAX_OUTPUT_TOKENS = 32_000;
|
|
16
|
+
/** Smaller ceiling for lightweight structured-extraction passes. */
|
|
17
|
+
export const MAX_EXTRACTION_TOKENS = 12_000;
|
|
18
|
+
/**
|
|
19
|
+
* Clamp a requested maxTokens value to a safe ceiling.
|
|
20
|
+
*
|
|
21
|
+
* Returns `cap` when the requested value is missing, non-finite, or non-positive
|
|
22
|
+
* so callers always end up with a usable, bounded number.
|
|
23
|
+
*/
|
|
24
|
+
export function clampMaxTokens(requested, cap = MAX_OUTPUT_TOKENS) {
|
|
25
|
+
if (typeof requested !== "number" ||
|
|
26
|
+
!Number.isFinite(requested) ||
|
|
27
|
+
requested <= 0) {
|
|
28
|
+
return cap;
|
|
29
|
+
}
|
|
30
|
+
return Math.min(requested, cap);
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=tokenLimits.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-safety policy helpers.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes git mutation detection so the orchestrator, the explorer, and the
|
|
5
|
+
* MCP server manager all agree on what is read-only. Uses a fail-closed
|
|
6
|
+
* allow-list: any `git_*` tool NOT on the read-only list is treated as mutating.
|
|
7
|
+
* The previous per-file blocklists silently missed mutating tools such as
|
|
8
|
+
* `git_branch`, `git_mv`, `git_pull`, `git_fetch`, `git_restore`, `git_switch`,
|
|
9
|
+
* and `git_remote`, leaving them exposed in "read-only" review modes.
|
|
10
|
+
*/
|
|
11
|
+
/** Strip server/namespace prefixes, e.g. "local-git:git_status" -> "git_status". */
|
|
12
|
+
export declare function normalizeToolName(name: string): string;
|
|
13
|
+
/** Git MCP tools that only READ repository state and are always safe to expose. */
|
|
14
|
+
export declare const READ_ONLY_GIT_TOOLS: ReadonlySet<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if the given tool is a git tool that can MUTATE repository state.
|
|
17
|
+
*
|
|
18
|
+
* Fail-closed: any `git_*` tool not on the read-only allow-list is treated as
|
|
19
|
+
* mutating. Non-git tools return false — callers handle provider-specific
|
|
20
|
+
* mutation tool lists (e.g. Bitbucket/GitHub write tools) separately.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isMutatingGitTool(toolName: string): boolean;
|
|
23
|
+
//# sourceMappingURL=toolPolicy.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-safety policy helpers.
|
|
3
|
+
*
|
|
4
|
+
* Centralizes git mutation detection so the orchestrator, the explorer, and the
|
|
5
|
+
* MCP server manager all agree on what is read-only. Uses a fail-closed
|
|
6
|
+
* allow-list: any `git_*` tool NOT on the read-only list is treated as mutating.
|
|
7
|
+
* The previous per-file blocklists silently missed mutating tools such as
|
|
8
|
+
* `git_branch`, `git_mv`, `git_pull`, `git_fetch`, `git_restore`, `git_switch`,
|
|
9
|
+
* and `git_remote`, leaving them exposed in "read-only" review modes.
|
|
10
|
+
*/
|
|
11
|
+
/** Strip server/namespace prefixes, e.g. "local-git:git_status" -> "git_status". */
|
|
12
|
+
export function normalizeToolName(name) {
|
|
13
|
+
return name.split(/[.:/]/).pop() || name;
|
|
14
|
+
}
|
|
15
|
+
/** Git MCP tools that only READ repository state and are always safe to expose. */
|
|
16
|
+
export const READ_ONLY_GIT_TOOLS = new Set([
|
|
17
|
+
"git_status",
|
|
18
|
+
"git_log",
|
|
19
|
+
"git_show",
|
|
20
|
+
"git_diff",
|
|
21
|
+
"git_diff_staged",
|
|
22
|
+
"git_diff_unstaged",
|
|
23
|
+
"git_blame",
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Returns true if the given tool is a git tool that can MUTATE repository state.
|
|
27
|
+
*
|
|
28
|
+
* Fail-closed: any `git_*` tool not on the read-only allow-list is treated as
|
|
29
|
+
* mutating. Non-git tools return false — callers handle provider-specific
|
|
30
|
+
* mutation tool lists (e.g. Bitbucket/GitHub write tools) separately.
|
|
31
|
+
*/
|
|
32
|
+
export function isMutatingGitTool(toolName) {
|
|
33
|
+
const name = normalizeToolName(toolName).toLowerCase();
|
|
34
|
+
if (!name.startsWith("git_")) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return !READ_ONLY_GIT_TOOLS.has(name);
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=toolPolicy.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/yama",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Enterprise-grade Pull Request automation toolkit with AI-powered code review and description enhancement",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pr",
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
"check:all": "npm run lint && npm run format --check && npm run validate && npm run validate:commit"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
-
"@juspay/neurolink": "^9.
|
|
93
|
+
"@juspay/neurolink": "^9.70.7",
|
|
94
|
+
"@juspay/hippocampus": "^0.1.7",
|
|
94
95
|
"langfuse": "^3.35.0",
|
|
95
96
|
"@nexus2520/bitbucket-mcp-server": "2.0.4",
|
|
96
97
|
"@nexus2520/jira-mcp-server": "^1.1.1",
|