@doist/doistbot-cli 1.0.6 → 1.0.7
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/dist/actions/auth.js +29 -9
- package/dist/actions/doctor.js +1 -1
- package/dist/actions/review.js +2 -10
- package/dist/config.js +49 -6
- package/package.json +1 -1
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/dist/core/pi.js +31 -1
- package/sandbox/dist/core/prompt-template.js +45 -3
- package/sandbox/dist/core/repository-map.js +24 -0
- package/sandbox/dist/core/tracing.js +9 -0
- package/sandbox/dist/providers/helpers.js +51 -0
- package/sandbox/dist/providers/pi-review.js +7 -1
- package/sandbox/dist/tasks/chat/chat.js +13 -2
- package/sandbox/dist/tasks/issue-summarize/model.js +119 -48
- package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -2
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +2 -2
- package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-triage/triage.js +2 -2
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
- package/sandbox/dist/tasks/review/engines/dedupe.js +107 -42
- package/sandbox/dist/tasks/review/engines/multi-focus.js +229 -119
- package/sandbox/dist/tasks/review/engines/shared.js +36 -1
- package/sandbox/dist/tasks/review/github-comment-format.js +12 -4
- package/sandbox/dist/tasks/review/local.js +6 -1
- package/sandbox/dist/tasks/review/review-summary.js +3 -0
- package/sandbox/dist/tasks/review/review.js +253 -186
- package/sandbox/dist/tasks/review/runner.js +10 -5
- package/sandbox/dist/tasks/review/summary-model.js +40 -18
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
package/dist/actions/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { deleteGeminiApiKey, deleteOpenRouterApiKey, getAuthStatus, loginOpenAICodex, logoutOpenAICodex, saveGeminiApiKey, saveOpenRouterApiKey, } from '../auth.js';
|
|
2
3
|
import { getEnvApiKeys } from '../env.js';
|
|
3
4
|
import { FILE_STORE_PATH } from '../file-store.js';
|
|
@@ -43,6 +44,18 @@ const AUTH_LOGOUT_PROVIDER_CONFIGS = [
|
|
|
43
44
|
];
|
|
44
45
|
const AUTH_LOGOUT_PROVIDERS = AUTH_LOGOUT_PROVIDER_CONFIGS.map(({ value }) => value);
|
|
45
46
|
export const AUTH_LOGOUT_PROVIDER_USAGE = `${AUTH_LOGOUT_PROVIDERS.join('|')}|all`;
|
|
47
|
+
const OPTIONAL_LABEL = '(optional)';
|
|
48
|
+
const SKIP_INSTRUCTION = 'press Enter to skip';
|
|
49
|
+
const API_KEY_PROMPT = 'Paste your API Key: ';
|
|
50
|
+
function highlightOptionalText(value) {
|
|
51
|
+
return chalk.bold.yellow(value);
|
|
52
|
+
}
|
|
53
|
+
function formatOptionalLabel() {
|
|
54
|
+
return highlightOptionalText(OPTIONAL_LABEL);
|
|
55
|
+
}
|
|
56
|
+
function formatSkipInstruction() {
|
|
57
|
+
return highlightOptionalText(SKIP_INSTRUCTION);
|
|
58
|
+
}
|
|
46
59
|
export async function runAuthSetup(dependencies = {}) {
|
|
47
60
|
const deps = {
|
|
48
61
|
getAuthStatus,
|
|
@@ -69,7 +82,7 @@ export async function runAuthSetup(dependencies = {}) {
|
|
|
69
82
|
deps.writeLine('✓ OpenAI configured.');
|
|
70
83
|
}
|
|
71
84
|
deps.writeLine();
|
|
72
|
-
deps.writeLine(
|
|
85
|
+
deps.writeLine(`2. Gemini API key ${formatOptionalLabel()}`);
|
|
73
86
|
deps.writeLine();
|
|
74
87
|
if (status.gemini.source === 'env') {
|
|
75
88
|
deps.writeLine('✓ Gemini already configured from GEMINI_API_KEY.');
|
|
@@ -85,8 +98,8 @@ export async function runAuthSetup(dependencies = {}) {
|
|
|
85
98
|
deps.writeLine('Gemini not configured. Skipping optional Gemini provider.');
|
|
86
99
|
}
|
|
87
100
|
else {
|
|
88
|
-
deps.writeLine(
|
|
89
|
-
const geminiApiKey = (await deps.promptHiddenInput(
|
|
101
|
+
deps.writeLine(`Doistbot can use Gemini via Google Vertex. Paste a Gemini API key, or ${formatSkipInstruction()}.`);
|
|
102
|
+
const geminiApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
|
|
90
103
|
if (!geminiApiKey) {
|
|
91
104
|
deps.writeLine();
|
|
92
105
|
deps.writeLine('Gemini skipped. You can add it later with `doistbot auth`.');
|
|
@@ -102,7 +115,7 @@ export async function runAuthSetup(dependencies = {}) {
|
|
|
102
115
|
}
|
|
103
116
|
}
|
|
104
117
|
deps.writeLine();
|
|
105
|
-
deps.writeLine(
|
|
118
|
+
deps.writeLine(`3. OpenRouter API key ${formatOptionalLabel()}`);
|
|
106
119
|
deps.writeLine();
|
|
107
120
|
if (status.openrouter.source === 'env') {
|
|
108
121
|
deps.writeLine('✓ OpenRouter already configured from OPENROUTER_API_KEY.');
|
|
@@ -117,10 +130,17 @@ export async function runAuthSetup(dependencies = {}) {
|
|
|
117
130
|
return;
|
|
118
131
|
}
|
|
119
132
|
if (!deps.isInteractive) {
|
|
120
|
-
|
|
133
|
+
deps.writeLine('OpenRouter not configured. Skipping optional OpenRouter provider.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
deps.writeLine(`Doistbot can use OpenRouter for DeepSeek, Z.ai, and other open models. Paste an OpenRouter API key, or ${formatSkipInstruction()}.`);
|
|
137
|
+
const openRouterApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
|
|
138
|
+
if (!openRouterApiKey) {
|
|
139
|
+
deps.writeLine();
|
|
140
|
+
deps.writeLine('OpenRouter skipped. You can add it later with `doistbot auth`.');
|
|
141
|
+
return;
|
|
121
142
|
}
|
|
122
|
-
|
|
123
|
-
const backend = await deps.saveOpenRouterApiKey(await deps.promptHiddenInput('OpenRouter API key: '));
|
|
143
|
+
const backend = await deps.saveOpenRouterApiKey(openRouterApiKey);
|
|
124
144
|
deps.writeLine();
|
|
125
145
|
if (backend === 'file') {
|
|
126
146
|
deps.writeLine(`Note: ${getSecureStoreLabel()} unavailable; key stored in ${FILE_STORE_PATH} with 0600 permissions.`);
|
|
@@ -130,8 +150,8 @@ export async function runAuthSetup(dependencies = {}) {
|
|
|
130
150
|
export async function runAuthStatus() {
|
|
131
151
|
const status = await getAuthStatus();
|
|
132
152
|
writeLine(`OpenAI: ${status.openai.configured ? `configured (${status.openai.source ?? 'unknown'})` : 'not configured'}`);
|
|
133
|
-
writeLine(`Gemini (
|
|
134
|
-
writeLine(`OpenRouter: ${status.openrouter.configured ? `configured (${status.openrouter.source ?? 'unknown'})` : 'not configured'}`);
|
|
153
|
+
writeLine(`Gemini ${formatOptionalLabel()}: ${status.gemini.configured ? `configured (${status.gemini.source ?? 'unknown'})` : 'not configured'}`);
|
|
154
|
+
writeLine(`OpenRouter ${formatOptionalLabel()}: ${status.openrouter.configured ? `configured (${status.openrouter.source ?? 'unknown'})` : 'not configured'}`);
|
|
135
155
|
}
|
|
136
156
|
export async function runAuthLogout(target, dependencies = {}) {
|
|
137
157
|
const deps = {
|
package/dist/actions/doctor.js
CHANGED
|
@@ -9,7 +9,7 @@ export function getConfiguredProviders({ status, }) {
|
|
|
9
9
|
? `Gemini optional (${status.gemini.source ?? 'configured'})`
|
|
10
10
|
: undefined,
|
|
11
11
|
status.openrouter.configured
|
|
12
|
-
? `OpenRouter (${status.openrouter.source ?? 'configured'})`
|
|
12
|
+
? `OpenRouter optional (${status.openrouter.source ?? 'configured'})`
|
|
13
13
|
: undefined,
|
|
14
14
|
].filter((provider) => provider !== undefined);
|
|
15
15
|
}
|
package/dist/actions/review.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatMissingRequiredCliAuthProviders, getApiKeys, getMissingRequiredCliReviewAuthProviders, } from '../config.js';
|
|
1
|
+
import { formatMissingRequiredCliAuthProviders, getApiKeys, getCliReviewRouting, getMissingRequiredCliReviewAuthProviders, } from '../config.js';
|
|
2
2
|
import { getRepositoryRoot, loadSandboxFormatModule, loadSandboxReviewModule } from '../runtime.js';
|
|
3
3
|
import { formatReviewProgress, renderAlert, renderMarkdown, withSpinner, writeLine, } from '../terminal.js';
|
|
4
4
|
const LOCAL_REVIEW_SOURCE_STAGED = 'staged';
|
|
@@ -208,15 +208,6 @@ function getMissingAuthProviderFailures(missingProviders) {
|
|
|
208
208
|
message: 'OpenAI not configured; run `doistbot auth`',
|
|
209
209
|
});
|
|
210
210
|
}
|
|
211
|
-
if (missingProviders.includes('OpenRouter')) {
|
|
212
|
-
failures.push({
|
|
213
|
-
model: 'deepseek',
|
|
214
|
-
message: 'OpenRouter not configured; run `doistbot auth`',
|
|
215
|
-
}, {
|
|
216
|
-
model: 'zai',
|
|
217
|
-
message: 'OpenRouter not configured; run `doistbot auth`',
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
211
|
return failures;
|
|
221
212
|
}
|
|
222
213
|
function formatMissingAuthForJson({ source, missingProviders, }) {
|
|
@@ -245,6 +236,7 @@ async function runLocalReviewForCli({ source, apiKeys, onProgress, }) {
|
|
|
245
236
|
workspaceDir: getRepositoryRoot(),
|
|
246
237
|
source,
|
|
247
238
|
apiKeys,
|
|
239
|
+
...getCliReviewRouting(apiKeys),
|
|
248
240
|
suppressLogs: true,
|
|
249
241
|
onProgress,
|
|
250
242
|
});
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { getStoredGeminiApiKey, getStoredOpenRouterApiKey, hasOpenAICodexAuth, PI_AUTH_API_KEY, } from './auth.js';
|
|
2
2
|
import { getEnvApiKeys } from './env.js';
|
|
3
|
-
const REQUIRED_CLI_AUTH_PROVIDERS = [
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const REQUIRED_CLI_AUTH_PROVIDERS = [{ provider: 'openai', label: 'OpenAI' }];
|
|
4
|
+
const ALL_REVIEW_FOCUSES = [
|
|
5
|
+
'general',
|
|
6
|
+
'quality',
|
|
7
|
+
'efficiency',
|
|
8
|
+
'tests',
|
|
9
|
+
'standards',
|
|
6
10
|
];
|
|
11
|
+
const OPENROUTER_REVIEW_MODELS = ['deepseek', 'zai'];
|
|
12
|
+
const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.5-flash';
|
|
13
|
+
function createCliGeminiCredential(apiKey) {
|
|
14
|
+
return {
|
|
15
|
+
apiKey,
|
|
16
|
+
modelName: CLI_GEMINI_REVIEW_MODEL,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function createCliGeminiSummaryModel(credential) {
|
|
20
|
+
if (!credential) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
provider: 'gemini',
|
|
25
|
+
credential,
|
|
26
|
+
modelName: CLI_GEMINI_REVIEW_MODEL,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
7
29
|
export async function getApiKeys(env = process.env, auth = {
|
|
8
30
|
getStoredGeminiApiKey,
|
|
9
31
|
getStoredOpenRouterApiKey,
|
|
@@ -21,7 +43,7 @@ export async function getApiKeys(env = process.env, auth = {
|
|
|
21
43
|
const apiKeys = {};
|
|
22
44
|
const geminiApiKey = envApiKeys.gemini || storedGeminiApiKey;
|
|
23
45
|
if (geminiApiKey) {
|
|
24
|
-
apiKeys.gemini = geminiApiKey;
|
|
46
|
+
apiKeys.gemini = createCliGeminiCredential(geminiApiKey);
|
|
25
47
|
}
|
|
26
48
|
if (openRouterApiKey) {
|
|
27
49
|
apiKeys.deepseek = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
|
|
@@ -40,8 +62,6 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
|
|
|
40
62
|
switch (provider) {
|
|
41
63
|
case 'openai':
|
|
42
64
|
return !apiKeys.openai;
|
|
43
|
-
case 'openrouter':
|
|
44
|
-
return !(apiKeys.deepseek && apiKeys.zai);
|
|
45
65
|
}
|
|
46
66
|
return false;
|
|
47
67
|
}).map(({ label }) => label);
|
|
@@ -49,3 +69,26 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
|
|
|
49
69
|
export function formatMissingRequiredCliAuthProviders(missingProviders) {
|
|
50
70
|
return `${missingProviders.join(' and ')} not configured; run \`doistbot auth\``;
|
|
51
71
|
}
|
|
72
|
+
export function getCliReviewRouting(apiKeys) {
|
|
73
|
+
const hasGemini = Boolean(apiKeys.gemini);
|
|
74
|
+
const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.zai);
|
|
75
|
+
if (hasGemini && hasOpenRouter) {
|
|
76
|
+
return {
|
|
77
|
+
reviewModelsEnabled: ['openai', ...OPENROUTER_REVIEW_MODELS],
|
|
78
|
+
geminiFallbackFocuses: ALL_REVIEW_FOCUSES,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const focusModels = [
|
|
82
|
+
'openai',
|
|
83
|
+
...(hasGemini ? ['gemini'] : []),
|
|
84
|
+
...(hasOpenRouter ? OPENROUTER_REVIEW_MODELS : []),
|
|
85
|
+
];
|
|
86
|
+
return {
|
|
87
|
+
reviewModelsEnabled: focusModels,
|
|
88
|
+
reviewModelsByFocus: Object.fromEntries(ALL_REVIEW_FOCUSES.map((focus) => [focus, focusModels])),
|
|
89
|
+
geminiFallbackFocuses: [],
|
|
90
|
+
...(hasGemini && !hasOpenRouter
|
|
91
|
+
? { reviewSummaryModel: createCliGeminiSummaryModel(apiKeys.gemini) }
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from 'doistbot-logging';
|
|
2
2
|
import type { Octokit } from 'octokit';
|
|
3
3
|
export declare const CONFIG_FILE_PATH = ".github/doistbot.yml";
|
|
4
|
-
export declare const DEFAULT_REVIEW_MODELS: readonly ["
|
|
4
|
+
export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "zai"];
|
|
5
5
|
export type DefaultReviewModel = (typeof DEFAULT_REVIEW_MODELS)[number];
|
|
6
6
|
export type RepoConfig = {
|
|
7
7
|
review?: {
|
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { load as yamlLoad } from 'js-yaml';
|
|
4
4
|
export const CONFIG_FILE_PATH = '.github/doistbot.yml';
|
|
5
|
-
export const DEFAULT_REVIEW_MODELS = ['
|
|
5
|
+
export const DEFAULT_REVIEW_MODELS = ['openai', 'deepseek', 'zai'];
|
|
6
6
|
function parseRepoConfigContent(content) {
|
|
7
7
|
return yamlLoad(content);
|
|
8
8
|
}
|
package/sandbox/dist/core/pi.js
CHANGED
|
@@ -13,13 +13,15 @@ export const DEFAULT_PI_MODELS = {
|
|
|
13
13
|
deepseek: 'deepseek-v4-pro',
|
|
14
14
|
gemini: 'gemini-3.1-pro-preview',
|
|
15
15
|
zai: 'glm5.2',
|
|
16
|
-
openai: 'gpt-5.
|
|
16
|
+
openai: 'gpt-5.4',
|
|
17
17
|
};
|
|
18
18
|
export const DEFAULT_OPENROUTER_MODELS = {
|
|
19
19
|
deepseek: 'deepseek/deepseek-v4-pro',
|
|
20
20
|
gemini: 'google/gemini-3.1-pro-preview',
|
|
21
21
|
zai: 'z-ai/glm-5.2',
|
|
22
22
|
};
|
|
23
|
+
// Gemini flash fallback model.
|
|
24
|
+
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.5-flash';
|
|
23
25
|
export const MAX_PI_THINKING_BY_PROVIDER = {
|
|
24
26
|
deepseek: 'high',
|
|
25
27
|
gemini: 'high',
|
|
@@ -205,6 +207,15 @@ function extractAssistantMessageResult(message) {
|
|
|
205
207
|
? { error: errorMessage.trim() }
|
|
206
208
|
: null;
|
|
207
209
|
}
|
|
210
|
+
function readStopReason(message) {
|
|
211
|
+
if (!message || typeof message !== 'object') {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
const stopReason = message.stopReason;
|
|
215
|
+
// pi's stopReason follows the PiStopReason contract; cast rather than
|
|
216
|
+
// re-validate against a runtime list that would duplicate the union.
|
|
217
|
+
return typeof stopReason === 'string' ? stopReason : undefined;
|
|
218
|
+
}
|
|
208
219
|
function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt, lastToolUpdateAt, toolStartedAt, }) {
|
|
209
220
|
if (!onProgress) {
|
|
210
221
|
return;
|
|
@@ -352,6 +363,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
352
363
|
let stderr = '';
|
|
353
364
|
let lastAssistantText = '';
|
|
354
365
|
let lastAssistantError = '';
|
|
366
|
+
let lastAssistantStopReason;
|
|
355
367
|
let sessionHeaderSeen = false;
|
|
356
368
|
const lastThinkingAt = { value: 0 };
|
|
357
369
|
const lastRespondingAt = { value: 0 };
|
|
@@ -426,6 +438,14 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
426
438
|
}
|
|
427
439
|
}
|
|
428
440
|
if (parsed.type === 'message_end') {
|
|
441
|
+
// Read the stop reason regardless of whether the
|
|
442
|
+
// message carried text or an error — a terminal
|
|
443
|
+
// "length"/"error"/"aborted" ending must surface
|
|
444
|
+
// even when there's no usable text.
|
|
445
|
+
const stopReason = readStopReason(parsed.message);
|
|
446
|
+
if (stopReason) {
|
|
447
|
+
lastAssistantStopReason = stopReason;
|
|
448
|
+
}
|
|
429
449
|
const result = extractAssistantMessageResult(parsed.message);
|
|
430
450
|
if (result?.text) {
|
|
431
451
|
lastAssistantText = result.text;
|
|
@@ -437,6 +457,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
437
457
|
}
|
|
438
458
|
if (parsed.type === 'agent_end' && Array.isArray(parsed.messages)) {
|
|
439
459
|
for (const message of parsed.messages) {
|
|
460
|
+
const stopReason = readStopReason(message);
|
|
461
|
+
if (stopReason) {
|
|
462
|
+
lastAssistantStopReason = stopReason;
|
|
463
|
+
}
|
|
440
464
|
const result = extractAssistantMessageResult(message);
|
|
441
465
|
if (result?.text) {
|
|
442
466
|
lastAssistantText = result.text;
|
|
@@ -475,6 +499,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
475
499
|
durationMs: Date.now() - startedAt,
|
|
476
500
|
timedOut,
|
|
477
501
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
502
|
+
stopReason: lastAssistantStopReason,
|
|
478
503
|
});
|
|
479
504
|
});
|
|
480
505
|
child.on('close', (code, signal) => {
|
|
@@ -489,6 +514,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
489
514
|
durationMs: Date.now() - startedAt,
|
|
490
515
|
timedOut: true,
|
|
491
516
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
517
|
+
stopReason: lastAssistantStopReason,
|
|
492
518
|
});
|
|
493
519
|
return;
|
|
494
520
|
}
|
|
@@ -499,6 +525,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
499
525
|
durationMs: Date.now() - startedAt,
|
|
500
526
|
timedOut: false,
|
|
501
527
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
528
|
+
stopReason: lastAssistantStopReason,
|
|
502
529
|
});
|
|
503
530
|
return;
|
|
504
531
|
}
|
|
@@ -509,6 +536,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
509
536
|
durationMs: Date.now() - startedAt,
|
|
510
537
|
timedOut: false,
|
|
511
538
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
539
|
+
stopReason: lastAssistantStopReason,
|
|
512
540
|
});
|
|
513
541
|
return;
|
|
514
542
|
}
|
|
@@ -522,6 +550,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
522
550
|
durationMs: Date.now() - startedAt,
|
|
523
551
|
timedOut: false,
|
|
524
552
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
553
|
+
stopReason: lastAssistantStopReason,
|
|
525
554
|
});
|
|
526
555
|
return;
|
|
527
556
|
}
|
|
@@ -530,6 +559,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
|
|
|
530
559
|
output,
|
|
531
560
|
durationMs: Date.now() - startedAt,
|
|
532
561
|
toolUsage: toSortedToolUsage(toolUsageByName),
|
|
562
|
+
stopReason: lastAssistantStopReason,
|
|
533
563
|
});
|
|
534
564
|
});
|
|
535
565
|
});
|
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
3
4
|
import { logger } from './logging.js';
|
|
5
|
+
const promptCache = new Map();
|
|
6
|
+
const resolveCache = new Map();
|
|
7
|
+
/** Swap the `dist` directory nearest to the file (not just the first `/dist/` in the path) for `src`. */
|
|
8
|
+
function distDirToSrcDir(dir) {
|
|
9
|
+
const distSeg = `${path.sep}dist${path.sep}`;
|
|
10
|
+
const idx = dir.lastIndexOf(distSeg);
|
|
11
|
+
if (idx === -1) {
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
return `${dir.slice(0, idx)}${path.sep}src${path.sep}${dir.slice(idx + distSeg.length)}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a prompt file living in `<module>/prompts/`, relative to the calling
|
|
18
|
+
* module. At runtime the module lives under `dist/`, but the prompts are shipped
|
|
19
|
+
* under `src/`, so we map `dist`->`src`. The `dist` candidate is tried first so a
|
|
20
|
+
* future build step that copies prompts into `dist/` works without code changes.
|
|
21
|
+
*/
|
|
22
|
+
export function resolvePromptFile(moduleUrl, fileName) {
|
|
23
|
+
const byFileName = resolveCache.get(moduleUrl) ?? new Map();
|
|
24
|
+
resolveCache.set(moduleUrl, byFileName);
|
|
25
|
+
const cached = byFileName.get(fileName);
|
|
26
|
+
if (cached !== undefined) {
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
const dir = path.dirname(fileURLToPath(moduleUrl));
|
|
30
|
+
const candidates = [
|
|
31
|
+
path.join(dir, 'prompts', fileName),
|
|
32
|
+
path.join(distDirToSrcDir(dir), 'prompts', fileName),
|
|
33
|
+
];
|
|
34
|
+
const resolved = candidates.find(fs.existsSync) ?? candidates[candidates.length - 1];
|
|
35
|
+
byFileName.set(fileName, resolved);
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
4
38
|
export function loadPromptTemplateFile({ fileName, promptLabel, }) {
|
|
5
|
-
|
|
39
|
+
if (!path.isAbsolute(fileName)) {
|
|
40
|
+
throw new Error(`${promptLabel} prompt template path must be absolute (resolve it with resolvePromptFile first), got: ${fileName}`);
|
|
41
|
+
}
|
|
42
|
+
const cached = promptCache.get(fileName);
|
|
43
|
+
if (cached !== undefined) {
|
|
44
|
+
return cached;
|
|
45
|
+
}
|
|
6
46
|
try {
|
|
7
|
-
|
|
47
|
+
const contents = fs.readFileSync(fileName, 'utf8');
|
|
48
|
+
promptCache.set(fileName, contents);
|
|
49
|
+
return contents;
|
|
8
50
|
}
|
|
9
51
|
catch (error) {
|
|
10
52
|
logger.error(`Failed to load ${promptLabel} prompt template`, {
|
|
11
53
|
error: error instanceof Error ? error.message : String(error),
|
|
12
|
-
promptPath,
|
|
54
|
+
promptPath: fileName,
|
|
13
55
|
});
|
|
14
56
|
throw error;
|
|
15
57
|
}
|
|
@@ -64,6 +64,16 @@ const TODOIST_REPOSITORIES = [
|
|
|
64
64
|
product: 'Todoist',
|
|
65
65
|
surfaces: ['android', 'ios'],
|
|
66
66
|
}),
|
|
67
|
+
createRepositoryTarget({
|
|
68
|
+
repo: 'todoist-mcp',
|
|
69
|
+
product: 'Todoist',
|
|
70
|
+
surfaces: ['mcp'],
|
|
71
|
+
}),
|
|
72
|
+
createRepositoryTarget({
|
|
73
|
+
repo: 'todoist-cli',
|
|
74
|
+
product: 'Todoist',
|
|
75
|
+
surfaces: ['cli'],
|
|
76
|
+
}),
|
|
67
77
|
];
|
|
68
78
|
const TWIST_REPOSITORIES = [
|
|
69
79
|
createRepositoryTarget({
|
|
@@ -126,6 +136,16 @@ const TWIST_REPOSITORIES = [
|
|
|
126
136
|
product: 'Twist',
|
|
127
137
|
surfaces: ['desktop'],
|
|
128
138
|
}),
|
|
139
|
+
createRepositoryTarget({
|
|
140
|
+
repo: 'twist-ai',
|
|
141
|
+
product: 'Twist',
|
|
142
|
+
surfaces: ['mcp'],
|
|
143
|
+
}),
|
|
144
|
+
createRepositoryTarget({
|
|
145
|
+
repo: 'twist-cli',
|
|
146
|
+
product: 'Twist',
|
|
147
|
+
surfaces: ['cli'],
|
|
148
|
+
}),
|
|
129
149
|
];
|
|
130
150
|
function dedupeRepositories(repositories) {
|
|
131
151
|
const unique = new Map();
|
|
@@ -145,8 +165,10 @@ export const APP_TO_REPO_SELECTORS = {
|
|
|
145
165
|
all: (repositories) => dedupeRepositories(repositories),
|
|
146
166
|
android: (repositories) => selectBySurface(repositories, ['android']),
|
|
147
167
|
backend: (repositories) => selectBySurface(repositories, ['backend']),
|
|
168
|
+
cli: (repositories) => selectBySurface(repositories, ['cli']),
|
|
148
169
|
desktop: (repositories) => selectBySurface(repositories, ['desktop']),
|
|
149
170
|
ios: (repositories) => selectBySurface(repositories, ['ios']),
|
|
171
|
+
mcp: (repositories) => selectBySurface(repositories, ['mcp']),
|
|
150
172
|
web: (repositories) => selectBySurface(repositories, ['web']),
|
|
151
173
|
};
|
|
152
174
|
export const TEAM_TO_REPO_SELECTORS = {
|
|
@@ -179,8 +201,10 @@ export const APP_LABEL_ALIASES = {
|
|
|
179
201
|
all: 'all',
|
|
180
202
|
android: 'android',
|
|
181
203
|
backend: 'backend',
|
|
204
|
+
cli: 'cli',
|
|
182
205
|
desktop: 'desktop',
|
|
183
206
|
ios: 'ios',
|
|
207
|
+
mcp: 'mcp',
|
|
184
208
|
web: 'web',
|
|
185
209
|
};
|
|
186
210
|
export const TEAM_LABEL_ALIASES = {
|
|
@@ -69,3 +69,12 @@ export function recordDecision(name, meta) {
|
|
|
69
69
|
export function recordEvent(name, meta) {
|
|
70
70
|
emitPointInTime('event', name, meta);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Maps a doistbot provider key to the OTel `gen_ai.provider.name` value
|
|
74
|
+
* (docs/sandbox-instrumentation.md). Only `gemini` has a standard name
|
|
75
|
+
* (`gcp.gemini`); the OpenRouter-routed keys (`zai`, `deepseek`) have none, so
|
|
76
|
+
* they pass through. Single source so every span site stays consistent.
|
|
77
|
+
*/
|
|
78
|
+
export function genAiProviderName(provider) {
|
|
79
|
+
return provider === 'gemini' ? 'gcp.gemini' : provider;
|
|
80
|
+
}
|
|
@@ -6,6 +6,57 @@ import { logger } from '../core/logging.js';
|
|
|
6
6
|
import { isRawReviewResult } from './schemas.js';
|
|
7
7
|
const RAW_MODEL_LOG_CHUNK_CHARS = 8000;
|
|
8
8
|
const REVIEW_LOG_ENGINE = 'multi_focus';
|
|
9
|
+
const PROVIDER_ERROR_DETAILS_MAX_DEPTH = 8;
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function parseJsonValue(value) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(value.trim());
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function readProviderErrorDetails(error) {
|
|
22
|
+
const rawError = error instanceof Error ? error.message : String(error);
|
|
23
|
+
const details = { rawError };
|
|
24
|
+
function visit(value, depth) {
|
|
25
|
+
if (depth > PROVIDER_ERROR_DETAILS_MAX_DEPTH) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === 'string') {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
const parsed = parseJsonValue(trimmed);
|
|
31
|
+
if (isRecord(parsed)) {
|
|
32
|
+
visit(parsed, depth + 1);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!details.reason && trimmed) {
|
|
36
|
+
details.reason = trimmed;
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!isRecord(value)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (value.error !== undefined) {
|
|
44
|
+
visit(value.error, depth + 1);
|
|
45
|
+
}
|
|
46
|
+
if (typeof value.message === 'string') {
|
|
47
|
+
visit(value.message, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
if (details.errorCode === undefined &&
|
|
50
|
+
(typeof value.code === 'number' || typeof value.code === 'string')) {
|
|
51
|
+
details.errorCode = value.code;
|
|
52
|
+
}
|
|
53
|
+
if (details.status === undefined && typeof value.status === 'string') {
|
|
54
|
+
details.status = value.status;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
visit(rawError, 0);
|
|
58
|
+
return details;
|
|
59
|
+
}
|
|
9
60
|
function redactSensitiveStrings(content) {
|
|
10
61
|
return content
|
|
11
62
|
.replace(/(sk-[A-Za-z0-9_-]{10,})/g, '[REDACTED_OPENAI_KEY]')
|
|
@@ -53,7 +53,13 @@ function isOpenRouterBackedReviewRequest(provider, config) {
|
|
|
53
53
|
PI_PROVIDER_BY_DOISTBOT_PROVIDER[provider];
|
|
54
54
|
return resolvedPiProviderId === 'openrouter';
|
|
55
55
|
}
|
|
56
|
-
export async function runPiReviewProvider(
|
|
56
|
+
export async function runPiReviewProvider(params) {
|
|
57
|
+
// Stamp the resolved model spec on every result (success or failure) so
|
|
58
|
+
// telemetry has the real model without re-deriving each provider's default.
|
|
59
|
+
const result = await runPiReviewProviderCore(params);
|
|
60
|
+
return { ...result, requestModel: params.modelName };
|
|
61
|
+
}
|
|
62
|
+
async function runPiReviewProviderCore({ provider, modelName, prompt, config, prompts = [prompt], parseOutput, }) {
|
|
57
63
|
const startTime = Date.now();
|
|
58
64
|
const timeoutMs = config.timeout ?? 0;
|
|
59
65
|
let stdout = '';
|
|
@@ -4,7 +4,7 @@ import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
|
|
|
4
4
|
import { recordTaskMetrics } from '../../core/datadog-metrics.js';
|
|
5
5
|
import { logger } from '../../core/logging.js';
|
|
6
6
|
import { invokePiPrompt } from '../../core/pi.js';
|
|
7
|
-
import { loadPromptTemplateFile } from '../../core/prompt-template.js';
|
|
7
|
+
import { loadPromptTemplateFile, resolvePromptFile } from '../../core/prompt-template.js';
|
|
8
8
|
import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
|
|
9
9
|
import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
|
|
10
10
|
import { withPhase } from '../../core/tracing.js';
|
|
@@ -148,6 +148,17 @@ export async function invokeChatModel(prompt) {
|
|
|
148
148
|
// Keep the executed model in sync with the recorded span attr.
|
|
149
149
|
modelName: CHAT_MODEL,
|
|
150
150
|
});
|
|
151
|
+
// Record why the model stopped, before the ok/failure branch so
|
|
152
|
+
// terminal "error"/"aborted"/"length" endings are reported too.
|
|
153
|
+
// stopReason values:
|
|
154
|
+
// stop Natural end — model finished or hit a stop sequence.
|
|
155
|
+
// length Hit the token cap (max_tokens or context limit).
|
|
156
|
+
// toolUse Paused to call a tool. Expected mid-agent-loop.
|
|
157
|
+
// error Provider errored mid-generation.
|
|
158
|
+
// aborted Generation was cancelled (e.g. signal/timeout).
|
|
159
|
+
if (result.stopReason) {
|
|
160
|
+
span.setAttribute('gen_ai.response.finish_reasons', [result.stopReason]);
|
|
161
|
+
}
|
|
151
162
|
if (!result.ok) {
|
|
152
163
|
// Failure returns a result rather than throwing — mark the span
|
|
153
164
|
// ERROR explicitly so it survives to phase.end (withPhase only
|
|
@@ -280,7 +291,7 @@ async function main() {
|
|
|
280
291
|
}
|
|
281
292
|
// Build prompt and invoke the chat model
|
|
282
293
|
const promptTemplate = loadPromptTemplateFile({
|
|
283
|
-
fileName: CHAT_PROMPT_FILE,
|
|
294
|
+
fileName: resolvePromptFile(import.meta.url, CHAT_PROMPT_FILE),
|
|
284
295
|
promptLabel: 'chat',
|
|
285
296
|
});
|
|
286
297
|
const prompt = buildPrompt({
|