braintrust-lite 0.1.7 → 0.1.8

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.
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // Known section tags in both Chinese and English variants
4
+ const KNOWN_TAGS = [
5
+ '核心结论', '详细方案', '关键假设', '风险与不确定性',
6
+ 'Key Claims', 'Details', 'Assumptions', 'Risks',
7
+ ];
8
+
9
+ // Build a regex that matches any known tag, with optional markdown decoration
10
+ // e.g. [核心结论], **[核心结论]**, **核心结论**, ## 核心结论, ### Key Claims
11
+ const TAG_PATTERN = (() => {
12
+ const escaped = KNOWN_TAGS.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
13
+ return new RegExp(
14
+ `(?:\\*{1,2})?\\[?(${escaped.join('|')})\\]?(?:\\*{1,2})?`,
15
+ 'g'
16
+ );
17
+ })();
18
+
19
+ /**
20
+ * Find all tag positions in text, returning [{tag, start}] sorted by start.
21
+ */
22
+ function findTagPositions(text) {
23
+ const positions = [];
24
+ const re = new RegExp(TAG_PATTERN.source, 'gi');
25
+ let m;
26
+ while ((m = re.exec(text)) !== null) {
27
+ // Only record first occurrence of each tag
28
+ const tag = m[1];
29
+ if (!positions.find(p => p.tag === tag)) {
30
+ positions.push({ tag, start: m.index, end: m.index + m[0].length });
31
+ }
32
+ }
33
+ return positions.sort((a, b) => a.start - b.start);
34
+ }
35
+
36
+ /**
37
+ * Clean a single line: strip markdown noise without losing content.
38
+ */
39
+ function cleanLine(line) {
40
+ return line
41
+ .replace(/^[-─—]{3,}\s*$/, '') // pure separator lines → empty
42
+ .replace(/^#+\s+/, '') // markdown headings prefix
43
+ .replace(/^\*{1,2}(.*?)\*{1,2}$/, '$1') // **bold** wrappers
44
+ .replace(/^[-*•]\s+/, '') // list bullets
45
+ .trim();
46
+ }
47
+
48
+ /**
49
+ * Return true if a line looks like structured content (list item, numbered,
50
+ * contains a colon, or starts with a bracket). Used to detect when trailing
51
+ * conversational prose begins after a blank gap in the last section.
52
+ */
53
+ function isStructuredLine(line) {
54
+ return /^[-*•\d]/.test(line) || // list/numbered
55
+ line.includes(':') || line.includes(':') || // has colon
56
+ /^[[\((【]/.test(line); // starts with bracket
57
+ }
58
+
59
+ /**
60
+ * Extract lines from a named section of the text.
61
+ * Handles:
62
+ * - Chinese tags: [核心结论], **核心结论**, **[核心结论]**
63
+ * - English tags: [Key Claims], **Key Claims**
64
+ * - Markdown headings: ## 核心结论
65
+ * - Separator noise: --- lines removed
66
+ * - Bold list items: **item** stripped to plain text
67
+ * - Trailing conversational prose (codex): stops at blank + non-structured line
68
+ *
69
+ * @param {string} text
70
+ * @param {string} tag - One of KNOWN_TAGS
71
+ * @returns {string[]} Non-empty lines in that section
72
+ */
73
+ function extractSection(text, tag) {
74
+ const positions = findTagPositions(text);
75
+ const entry = positions.find(p => p.tag === tag);
76
+ if (!entry) return [];
77
+
78
+ // Section runs from after the tag header to the start of the next known tag
79
+ const nextEntry = positions.find(p => p.start > entry.start);
80
+ const sectionText = nextEntry
81
+ ? text.slice(entry.end, nextEntry.start)
82
+ : text.slice(entry.end);
83
+
84
+ const isLastSection = !nextEntry;
85
+ const result = [];
86
+ let seenContent = false;
87
+ let afterBlankGap = false;
88
+
89
+ for (const raw of sectionText.split('\n')) {
90
+ const line = cleanLine(raw);
91
+
92
+ if (!line) {
93
+ if (seenContent) afterBlankGap = true;
94
+ continue;
95
+ }
96
+
97
+ // For the last section: stop when we encounter prose after a blank gap.
98
+ // This prevents codex trailing dialogue ("如果你需要更多帮助") from leaking in.
99
+ if (isLastSection && afterBlankGap && !isStructuredLine(line)) {
100
+ break;
101
+ }
102
+
103
+ result.push(line);
104
+ seenContent = true;
105
+ afterBlankGap = false;
106
+ }
107
+
108
+ return result;
109
+ }
110
+
111
+ /**
112
+ * Compute a parse quality score for a normalized result.
113
+ * Each known output section (key_claims, assumptions, risks) worth 0.25.
114
+ * Full content present worth 0.25. Fallback mode penalizes -0.2.
115
+ * Result clipped to [0, 1].
116
+ *
117
+ * @param {object} r - normalized result object
118
+ * @returns {number} score in [0, 1]
119
+ */
120
+ function parseScore(r) {
121
+ let score = 0;
122
+ if (r.key_claims && r.key_claims.length > 0) score += 0.25;
123
+ if (r.assumptions && r.assumptions.length > 0) score += 0.25;
124
+ if (r.risks && r.risks.length > 0) score += 0.25;
125
+ if (r.content && r.content.length > 50) score += 0.25;
126
+ if (r.parse_mode === 'fallback') score -= 0.2;
127
+ return Math.max(0, Math.min(1, score));
128
+ }
129
+
130
+ /**
131
+ * Normalize raw provider output into a structured result.
132
+ */
133
+ function normalize(provider, raw, adapted, durationMs) {
134
+ const { content, model, parse_mode } = adapted;
135
+ const r = {
136
+ provider,
137
+ model,
138
+ content,
139
+ key_claims: extractSection(content, '核心结论'),
140
+ detailed: extractSection(content, '详细方案'),
141
+ assumptions: extractSection(content, '关键假设'),
142
+ risks: extractSection(content, '风险与不确定性'),
143
+ duration_ms: durationMs,
144
+ parse_mode,
145
+ error_type: raw.error_type || null,
146
+ error: raw.error_type === 'enoent' ? 'not installed'
147
+ : raw.error_type === 'timeout' ? 'timeout'
148
+ : raw.error_type === 'nonzero' ? `exit ${raw.code}`
149
+ : raw.error_type ? raw.error_type
150
+ : null,
151
+ judge_score: null,
152
+ lessons: [],
153
+ };
154
+ r.parse_score = parseScore(r);
155
+ return r;
156
+ }
157
+
158
+ /**
159
+ * Build a token-efficient summary of a normalized result for the judge prompt.
160
+ */
161
+ function summarize(r) {
162
+ const claims = r.key_claims.length ? r.key_claims.slice(0, 5).join('\n') : r.content.slice(0, 600);
163
+ const risks = r.risks.slice(0, 3).join('\n');
164
+ const assumptions = r.assumptions.slice(0, 3).join('\n');
165
+ return [
166
+ `【核心结论】\n${claims}`,
167
+ risks ? `【风险】\n${risks}` : '',
168
+ assumptions ? `【假设】\n${assumptions}` : '',
169
+ ].filter(Boolean).join('\n\n');
170
+ }
171
+
172
+ module.exports = { extractSection, normalize, summarize, parseScore, KNOWN_TAGS };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { extractSection } = require('./normalize.js');
6
+
7
+ // ─── Fixture 1: Gemini bold headers ──────────────────────────────────────────
8
+ // Gemini often wraps section tags in ** bold **
9
+
10
+ test('Fixture 1: gemini **bold** tag format', () => {
11
+ const text = `
12
+ **[核心结论]**
13
+ 结论一:使用 Redis Cluster
14
+ 结论二:读写分离
15
+
16
+ **[详细方案]**
17
+ 1. 部署 3 主 3 从
18
+
19
+ **[关键假设]**
20
+ 假设一:QPS < 100k
21
+
22
+ **[风险与不确定性]**
23
+ 风险:网络分区问题
24
+ `;
25
+
26
+ const claims = extractSection(text, '核心结论');
27
+ assert.ok(claims.length >= 2, `Expected ≥2 claims, got ${claims.length}: ${JSON.stringify(claims)}`);
28
+ assert.ok(claims.some(c => c.includes('Redis')), 'Should contain Redis claim');
29
+ assert.ok(claims.some(c => c.includes('读写分离')), 'Should contain 读写分离 claim');
30
+
31
+ const risks = extractSection(text, '风险与不确定性');
32
+ assert.ok(risks.length >= 1, `Expected ≥1 risk, got ${risks.length}`);
33
+ assert.ok(risks.some(r => r.includes('网络分区')), 'Should contain 网络分区 risk');
34
+ });
35
+
36
+ // ─── Fixture 2: Codex dialogue tail contamination ─────────────────────────────
37
+ // Codex sometimes includes conversational text after the structured output
38
+
39
+ test('Fixture 2: codex trailing dialogue noise', () => {
40
+ const text = `
41
+ [核心结论]
42
+ 结论一:采用微服务架构
43
+ 结论二:使用 Kubernetes
44
+
45
+ [详细方案]
46
+ 详细内容在这里
47
+
48
+ [关键假设]
49
+ 假设:团队有 K8s 经验
50
+
51
+ [风险与不确定性]
52
+ 风险:运维复杂度高
53
+
54
+ 如果你需要更多帮助,请告诉我!
55
+ 我可以进一步解释任何部分。
56
+ `;
57
+
58
+ const claims = extractSection(text, '核心结论');
59
+ assert.ok(claims.length >= 2, `Expected ≥2 claims, got ${claims.length}`);
60
+ assert.ok(!claims.some(c => c.includes('告诉我')), 'Should not include trailing dialogue');
61
+
62
+ const risks = extractSection(text, '风险与不确定性');
63
+ assert.ok(risks.length >= 1, `Expected ≥1 risk, got ${risks.length}`);
64
+ assert.ok(!risks.some(r => r.includes('告诉我')), 'Trailing dialogue should not leak into risks');
65
+ });
66
+
67
+ // ─── Fixture 3: --- separator contamination ───────────────────────────────────
68
+ // Some models include --- separator lines inside sections
69
+
70
+ test('Fixture 3: --- separator noise removal', () => {
71
+ const text = `
72
+ [核心结论]
73
+ ---
74
+ 结论一:选择 PostgreSQL
75
+ ---
76
+ 结论二:使用连接池
77
+ ---
78
+
79
+ [关键假设]
80
+ ---
81
+ 假设:单机即可满足需求
82
+
83
+ [风险与不确定性]
84
+ ---
85
+ 风险一:数据量超预期
86
+ 风险二:并发瓶颈
87
+ `;
88
+
89
+ const claims = extractSection(text, '核心结论');
90
+ assert.ok(claims.length >= 2, `Expected ≥2 claims after stripping ---, got ${claims.length}: ${JSON.stringify(claims)}`);
91
+ assert.ok(!claims.some(c => c === '---'), 'Should not contain raw --- separators');
92
+ assert.ok(claims.some(c => c.includes('PostgreSQL')), 'Should contain PostgreSQL claim');
93
+
94
+ const risks = extractSection(text, '风险与不确定性');
95
+ assert.ok(risks.length >= 2, `Expected ≥2 risks, got ${risks.length}`);
96
+ assert.ok(!risks.some(r => r === '---'), 'Should not contain raw --- separators in risks');
97
+ });
98
+
99
+ // ─── Fixture 4: English tags ──────────────────────────────────────────────────
100
+
101
+ test('Fixture 4: English tag variants', () => {
102
+ const text = `
103
+ [Key Claims]
104
+ Claim 1: Use distributed caching
105
+ Claim 2: Implement rate limiting
106
+
107
+ [Assumptions]
108
+ Assumption: Traffic < 10k RPS
109
+
110
+ [Risks]
111
+ Risk: Cache invalidation complexity
112
+ `;
113
+
114
+ const claims = extractSection(text, 'Key Claims');
115
+ assert.ok(claims.length >= 2, `Expected ≥2 English claims, got ${claims.length}`);
116
+ assert.ok(claims.some(c => c.includes('caching')), 'Should contain caching claim');
117
+ });
118
+
119
+ // ─── Fixture 5: Empty / missing section ───────────────────────────────────────
120
+
121
+ test('Fixture 5: missing section returns empty array', () => {
122
+ const text = '[核心结论]\n结论一:这是结论\n';
123
+ const risks = extractSection(text, '风险与不确定性');
124
+ assert.deepEqual(risks, [], 'Missing section should return []');
125
+ });
@@ -0,0 +1,21 @@
1
+ 你是一个系统架构师,专注于权衡分析和长期可维护性。请给出有深度的架构建议。
2
+
3
+ 要求:
4
+ 1. 先识别约束条件,再给方案
5
+ 2. 对比多个候选方案的优劣
6
+ 3. 明确指出方案的适用场景和前提
7
+ 4. 考虑长期演进和技术债务
8
+
9
+ 请按以下结构回答(用中文标签分隔):
10
+
11
+ [核心结论]
12
+ (推荐方案,1-2条主要结论)
13
+
14
+ [详细方案]
15
+ (包含约束识别、候选方案对比、推荐方案的具体设计)
16
+
17
+ [关键假设]
18
+ (团队规模、性能要求、一致性要求、预算等约束)
19
+
20
+ [风险与不确定性]
21
+ (架构决策的主要风险点、需要进一步验证的假设)
@@ -0,0 +1,21 @@
1
+ 你是一个资深工程师,专注于代码质量和可维护性。请给出可以直接使用的解决方案。
2
+
3
+ 要求:
4
+ 1. 优先给出可运行的代码示例
5
+ 2. 解释关键设计决策的理由
6
+ 3. 指出潜在的边界情况和性能问题
7
+ 4. 如果有更好的替代方案,请对比说明
8
+
9
+ 请按以下结构回答(用中文标签分隔):
10
+
11
+ [核心结论]
12
+ (主要解决方案,1-3条核心要点)
13
+
14
+ [详细方案]
15
+ (完整可运行的代码,带必要注释)
16
+
17
+ [关键假设]
18
+ (运行环境、依赖版本、前提条件)
19
+
20
+ [风险与不确定性]
21
+ (边界情况、性能瓶颈、已知限制)
@@ -0,0 +1,22 @@
1
+ 你是一个独立思考的高级专家。请基于自己的判断给出高质量、可执行、可审查的回答。
2
+
3
+ 要求:
4
+ 1. 独立思考,不要假设其他专家会补充你遗漏的部分
5
+ 2. 优先给出清晰、可执行的内容
6
+ 3. 明确区分结论、依据、假设、风险
7
+ 4. 输出简洁但完整,避免废话
8
+ 5. 如有不确定点,直接说明
9
+
10
+ 请按以下结构回答(用中文标签分隔):
11
+
12
+ [核心结论]
13
+ (简洁的主要结论,2-5条)
14
+
15
+ [详细方案]
16
+ (可执行的具体内容)
17
+
18
+ [关键假设]
19
+ (你的回答依赖哪些假设)
20
+
21
+ [风险与不确定性]
22
+ (需要注意的风险或你不确定的点)
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync } = require('fs');
4
+ const { join } = require('path');
5
+
6
+ const TEMPLATES_DIR = __dirname;
7
+
8
+ // Supported variants and their template files
9
+ const VARIANTS = {
10
+ general: 'general.md',
11
+ code: 'code.md',
12
+ architecture: 'architecture.md',
13
+ writing: 'writing.md',
14
+ };
15
+
16
+ // Cache loaded templates
17
+ const _cache = new Map();
18
+
19
+ function loadTemplate(name) {
20
+ if (_cache.has(name)) return _cache.get(name);
21
+ const file = VARIANTS[name] || VARIANTS.general;
22
+ const content = readFileSync(join(TEMPLATES_DIR, file), 'utf8').trim();
23
+ _cache.set(name, content);
24
+ return content;
25
+ }
26
+
27
+ /**
28
+ * Build the system prompt for a generator.
29
+ * @param {string} variant - One of general|code|architecture|writing
30
+ * @param {string[]} [lessons] - Injected lesson strings from memory
31
+ * @param {string[]} [skills] - Injected skill template strings from memory
32
+ * @returns {string}
33
+ */
34
+ function buildGeneratorSystem(variant = 'general', lessons = [], skills = []) {
35
+ const base = loadTemplate(variant);
36
+ const parts = [base];
37
+
38
+ if (lessons.length > 0) {
39
+ parts.push(`\n<past-lessons>\n${lessons.slice(0, 5).join('\n')}\n</past-lessons>`);
40
+ }
41
+
42
+ if (skills.length > 0) {
43
+ parts.push(`\n<skills>\n${skills.join('\n\n')}\n</skills>`);
44
+ }
45
+
46
+ return parts.join('');
47
+ }
48
+
49
+ module.exports = { buildGeneratorSystem, VARIANTS };
@@ -0,0 +1,21 @@
1
+ 你是一个专业写作顾问,擅长清晰表达和说服性写作。请给出高质量的写作建议或内容。
2
+
3
+ 要求:
4
+ 1. 内容准确、逻辑清晰、表达简洁
5
+ 2. 根据目标受众调整语气和风格
6
+ 3. 如果是修改建议,说明改动理由
7
+ 4. 避免空话和套话
8
+
9
+ 请按以下结构回答(用中文标签分隔):
10
+
11
+ [核心结论]
12
+ (主要写作建议或关键改进点)
13
+
14
+ [详细方案]
15
+ (具体的写作内容或修改后的版本)
16
+
17
+ [关键假设]
18
+ (目标受众、使用场景、风格要求)
19
+
20
+ [风险与不确定性]
21
+ (可能需要根据实际情况调整的部分)
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Get CLI args for invoking claude as a generator.
5
+ * @param {string} fullPrompt - System + user prompt combined
6
+ * @returns {string[]}
7
+ */
8
+ function getArgs(fullPrompt) {
9
+ return ['-p', fullPrompt, '--output-format', 'json', '--no-session-persistence'];
10
+ }
11
+
12
+ /**
13
+ * Parse claude's JSON stdout into { content, model, parse_mode }.
14
+ * @param {{ stdout: string, stderr: string, code: number|string }} raw
15
+ * @returns {{ content: string, model: string, parse_mode: string }}
16
+ */
17
+ function adapt(raw) {
18
+ try {
19
+ const j = JSON.parse(raw.stdout);
20
+ const content = j.result || j.content || '';
21
+ const model = Object.keys(j.modelUsage || {})[0] || 'claude';
22
+ return { content, model, parse_mode: 'json' };
23
+ } catch {
24
+ return fallback(raw.stdout);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Extract text from claude judge output.
30
+ * @param {{ stdout: string }} raw
31
+ * @returns {string}
32
+ */
33
+ function extractJudgeText(raw) {
34
+ try {
35
+ return JSON.parse(raw.stdout).result || raw.stdout.trim();
36
+ } catch {
37
+ return raw.stdout.trim();
38
+ }
39
+ }
40
+
41
+ function fallback(stdout) {
42
+ return { content: stdout.slice(-2000).trim() || '[no output]', model: 'claude', parse_mode: 'fallback' };
43
+ }
44
+
45
+ module.exports = { getArgs, adapt, extractJudgeText };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Get CLI args for invoking codex as a generator.
5
+ * NOTE: --json MUST come before the prompt argument — codex's parser treats
6
+ * args after the prompt text as [COMMAND] positional, not as options.
7
+ * @param {string} fullPrompt - System + user prompt combined
8
+ * @returns {string[]}
9
+ */
10
+ function getArgs(fullPrompt) {
11
+ return ['exec', '--json', '--skip-git-repo-check', '--ephemeral', fullPrompt];
12
+ }
13
+
14
+ /**
15
+ * Parse codex's JSONL stdout into { content, model, parse_mode }.
16
+ * Codex streams newline-delimited JSON events. We look for the last
17
+ * item.completed event with an agent_message type.
18
+ * @param {{ stdout: string, stderr: string, code: number|string }} raw
19
+ * @returns {{ content: string, model: string, parse_mode: string }}
20
+ */
21
+ function adapt(raw) {
22
+ try {
23
+ const lines = raw.stdout.trim().split('\n');
24
+ const events = [];
25
+ for (const l of lines) {
26
+ try { events.push(JSON.parse(l)); } catch { /* skip non-JSON lines */ }
27
+ }
28
+
29
+ // Prefer agent_message events
30
+ const agentMsg = events
31
+ .filter(e => e.type === 'item.completed' && e.item?.type === 'agent_message')
32
+ .pop();
33
+ if (agentMsg?.item?.text) {
34
+ return { content: agentMsg.item.text, model: 'codex', parse_mode: 'jsonl' };
35
+ }
36
+
37
+ // Fallback: last completed event with any text
38
+ const lastWithText = events
39
+ .filter(e => e.type === 'item.completed' && e.item?.text)
40
+ .pop();
41
+ if (lastWithText?.item?.text) {
42
+ return { content: lastWithText.item.text, model: 'codex', parse_mode: 'jsonl' };
43
+ }
44
+ } catch { /* fall through */ }
45
+
46
+ return fallback(raw.stdout);
47
+ }
48
+
49
+ /**
50
+ * Extract text from codex judge output (same JSONL format).
51
+ * @param {{ stdout: string }} raw
52
+ * @returns {string}
53
+ */
54
+ function extractJudgeText(raw) {
55
+ const lines = raw.stdout.trim().split('\n').reverse();
56
+ for (const l of lines) {
57
+ try {
58
+ const e = JSON.parse(l);
59
+ if (e.item?.text) return e.item.text;
60
+ } catch { /* skip */ }
61
+ }
62
+ return raw.stdout.trim();
63
+ }
64
+
65
+ function fallback(stdout) {
66
+ return { content: stdout.slice(-2000).trim() || '[no output]', model: 'codex', parse_mode: 'fallback' };
67
+ }
68
+
69
+ module.exports = { getArgs, adapt, extractJudgeText };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Get CLI args for invoking gemini as a generator.
5
+ * --allowed-mcp-server-names skips the broken feishu-mcp server that
6
+ * causes ~15s startup delay and connection errors.
7
+ * @param {string} fullPrompt - System + user prompt combined
8
+ * @returns {string[]}
9
+ */
10
+ function getArgs(fullPrompt) {
11
+ return ['-p', fullPrompt, '-o', 'json', '--allowed-mcp-server-names', 'sequential-thinking'];
12
+ }
13
+
14
+ /**
15
+ * Parse gemini's JSON stdout into { content, model, parse_mode }.
16
+ * Gemini prepends an MCP status line before the JSON output:
17
+ * "MCP issues detected. Run /mcp list for status."
18
+ * We skip to the first '{' to handle this.
19
+ * @param {{ stdout: string, stderr: string, code: number|string }} raw
20
+ * @returns {{ content: string, model: string, parse_mode: string }}
21
+ */
22
+ function adapt(raw) {
23
+ try {
24
+ const response = parseGeminiResponse(raw.stdout);
25
+ if (response) return { content: response, model: 'gemini', parse_mode: 'json' };
26
+ } catch { /* fall through */ }
27
+ return fallback(raw.stdout);
28
+ }
29
+
30
+ /**
31
+ * Extract the response text from gemini's JSON output.
32
+ * Handles potential JSON prefix noise using brace counter for robustness.
33
+ * @param {string} stdout
34
+ * @returns {string|null}
35
+ */
36
+ function parseGeminiResponse(stdout) {
37
+ const jsonStart = stdout.indexOf('{');
38
+ if (jsonStart === -1) return null;
39
+
40
+ // Use brace counter to find the complete JSON object
41
+ let depth = 0;
42
+ let jsonEnd = -1;
43
+ for (let i = jsonStart; i < stdout.length; i++) {
44
+ if (stdout[i] === '{') depth++;
45
+ else if (stdout[i] === '}') {
46
+ depth--;
47
+ if (depth === 0) { jsonEnd = i + 1; break; }
48
+ }
49
+ }
50
+
51
+ const jsonStr = jsonEnd !== -1 ? stdout.slice(jsonStart, jsonEnd) : stdout.slice(jsonStart);
52
+ const j = JSON.parse(jsonStr);
53
+
54
+ if (j.response) return j.response;
55
+
56
+ // Handle nested response object
57
+ for (const v of Object.values(j)) {
58
+ if (v && typeof v === 'object' && typeof v.response === 'string') return v.response;
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Extract text from gemini judge output.
66
+ * @param {{ stdout: string }} raw
67
+ * @returns {string}
68
+ */
69
+ function extractJudgeText(raw) {
70
+ try {
71
+ const response = parseGeminiResponse(raw.stdout);
72
+ if (response) return response;
73
+ } catch { /* fall through */ }
74
+ return raw.stdout.trim();
75
+ }
76
+
77
+ function fallback(stdout) {
78
+ return { content: stdout.slice(-2000).trim() || '[no output]', model: 'gemini', parse_mode: 'fallback' };
79
+ }
80
+
81
+ module.exports = { getArgs, adapt, extractJudgeText, parseGeminiResponse };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ const claude = require('./claude.js');
4
+ const codex = require('./codex.js');
5
+ const gemini = require('./gemini.js');
6
+
7
+ const PROVIDERS = {
8
+ claude: { name: 'claude', cmd: 'claude', ...claude },
9
+ codex: { name: 'codex', cmd: 'codex', ...codex },
10
+ gemini: { name: 'gemini', cmd: 'gemini', ...gemini },
11
+ };
12
+
13
+ /**
14
+ * Get the list of providers to run, excluding skipped ones.
15
+ * @param {string[]} skip - Provider names to skip
16
+ * @returns {Array<{name, cmd, getArgs, adapt, extractJudgeText}>}
17
+ */
18
+ function getActiveProviders(skip = []) {
19
+ return Object.values(PROVIDERS).filter(p => !skip.includes(p.name));
20
+ }
21
+
22
+ module.exports = { PROVIDERS, getActiveProviders };