@link-assistant/hive-mind 2.11.2 ā 2.11.4
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 +12 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +33 -12
- package/src/agent-token-usage.lib.mjs +5 -9
- package/src/agent.lib.mjs +110 -152
- package/src/ai-tool-scratch.lib.mjs +143 -0
- package/src/anthropic-cost-accumulator.lib.mjs +36 -0
- package/src/auto-restart-budget.lib.mjs +107 -0
- package/src/auto-restart-exhaustion.lib.mjs +122 -0
- package/src/claude.lib.mjs +19 -15
- package/src/claude.runtime-switch.lib.mjs +4 -4
- package/src/codex.lib.mjs +46 -8
- package/src/credential-sanitization-core.lib.mjs +27 -3
- package/src/formal-ai-pricing.lib.mjs +110 -0
- package/src/gemini.lib.mjs +34 -43
- package/src/github-cost-info.lib.mjs +5 -0
- package/src/json-stream.lib.mjs +219 -0
- package/src/opencode.lib.mjs +56 -74
- package/src/pull-request-changes.lib.mjs +166 -0
- package/src/qwen.lib.mjs +33 -32
- package/src/reviewers-hive.mjs +2 -2
- package/src/solve.auto-merge.lib.mjs +68 -44
- package/src/solve.auto-pr.lib.mjs +11 -3
- package/src/solve.finalize.lib.mjs +15 -0
- package/src/solve.repository.lib.mjs +21 -4
- package/src/solve.restart-shared.lib.mjs +6 -2
- package/src/solve.results.lib.mjs +29 -16
- package/src/solve.watch.lib.mjs +47 -18
- package/src/use-m-bootstrap.lib.mjs +23 -4
- package/src/use-m-single-flight.lib.mjs +350 -0
- package/src/use-with-retry.lib.mjs +8 -2
- package/src/working-session-summary.lib.mjs +65 -0
- package/src/youtrack/youtrack-sync.mjs +2 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pricing and provider identity for the Formal AI model (issue #2119).
|
|
5
|
+
*
|
|
6
|
+
* `--model formal-ai` routes every request through a local Formal AI model
|
|
7
|
+
* server (`formal-ai with <tool> ...`). The requests never reach OpenCode Zen,
|
|
8
|
+
* OpenAI, Anthropic or Google, so:
|
|
9
|
+
*
|
|
10
|
+
* - the provider is Link.Assistant, not the provider of whichever agentic CLI
|
|
11
|
+
* happens to be driving the session;
|
|
12
|
+
* - the cost is $0.00, so an inherited `total_cost_usd` (claude reported
|
|
13
|
+
* $0.252315 for a Formal AI session) or a models.dev price lookup for an
|
|
14
|
+
* unrelated base model is a false positive.
|
|
15
|
+
*
|
|
16
|
+
* Every pricing producer funnels through here so the same numbers appear in
|
|
17
|
+
* logs, GitHub comments and budget statistics.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { FORMAL_AI_MODEL_ALIAS, isFormalAiModel } from './models/index.mjs';
|
|
21
|
+
|
|
22
|
+
export const FORMAL_AI_PROVIDER_NAME = 'Link.Assistant';
|
|
23
|
+
|
|
24
|
+
const ZERO_PRICING = Object.freeze({
|
|
25
|
+
inputPerMillion: 0,
|
|
26
|
+
outputPerMillion: 0,
|
|
27
|
+
cacheReadPerMillion: 0,
|
|
28
|
+
cacheWritePerMillion: 0,
|
|
29
|
+
reasoningPerMillion: 0,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const ZERO_BREAKDOWN = Object.freeze({
|
|
33
|
+
input: 0,
|
|
34
|
+
output: 0,
|
|
35
|
+
cacheRead: 0,
|
|
36
|
+
cacheWrite: 0,
|
|
37
|
+
reasoning: 0,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the pricing record for a Formal AI session.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|null} modelId model id as passed to the tool (alias or `formalai/formal-ai`)
|
|
44
|
+
* @param {Object|null} tokenUsage aggregated token usage, kept so token counts stay reportable
|
|
45
|
+
* @returns {Object} pricing info with a Link.Assistant provider and a $0.00 cost
|
|
46
|
+
*/
|
|
47
|
+
export const buildFormalAiPricingInfo = (modelId = FORMAL_AI_MODEL_ALIAS, tokenUsage = null) => ({
|
|
48
|
+
modelId: modelId || FORMAL_AI_MODEL_ALIAS,
|
|
49
|
+
modelName: FORMAL_AI_MODEL_ALIAS,
|
|
50
|
+
provider: FORMAL_AI_PROVIDER_NAME,
|
|
51
|
+
// No third-party price applies, so there is no base model to reference.
|
|
52
|
+
originalProvider: null,
|
|
53
|
+
baseModelName: null,
|
|
54
|
+
tokenUsage: tokenUsage || null,
|
|
55
|
+
pricing: { ...ZERO_PRICING },
|
|
56
|
+
breakdown: { ...ZERO_BREAKDOWN },
|
|
57
|
+
totalCostUSD: 0,
|
|
58
|
+
isFreeModel: true,
|
|
59
|
+
isFormalAi: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wrap a `(modelId, tokenUsage) => pricingInfo` calculator so Formal AI model
|
|
64
|
+
* ids short-circuit to the free Link.Assistant record instead of being priced
|
|
65
|
+
* against models.dev.
|
|
66
|
+
*
|
|
67
|
+
* @param {Function} calculatePricing the tool's own pricing calculator
|
|
68
|
+
* @returns {Function} wrapped calculator with the same signature
|
|
69
|
+
*/
|
|
70
|
+
export const withFormalAiPricing =
|
|
71
|
+
calculatePricing =>
|
|
72
|
+
async (modelId, tokenUsage, ...rest) => {
|
|
73
|
+
if (isFormalAiModel(modelId)) return buildFormalAiPricingInfo(modelId, tokenUsage);
|
|
74
|
+
return calculatePricing(modelId, tokenUsage, ...rest);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a tool result's pricing fields for Formal AI sessions.
|
|
79
|
+
*
|
|
80
|
+
* Tools that report a provider cost of their own (claude's `total_cost_usd`)
|
|
81
|
+
* or that build a static provider record (gemini's "Google", qwen's "Alibaba")
|
|
82
|
+
* would otherwise attribute a Formal AI session to the wrong provider at a
|
|
83
|
+
* non-zero price.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} params
|
|
86
|
+
* @param {string|null} params.model model requested on the command line
|
|
87
|
+
* @param {Object|null} [params.pricingInfo]
|
|
88
|
+
* @param {number|null} [params.publicPricingEstimate]
|
|
89
|
+
* @param {number|null} [params.anthropicTotalCostUSD]
|
|
90
|
+
* @param {Object|null} [params.tokenUsage] fallback token usage when pricingInfo carries none
|
|
91
|
+
* @returns {{pricingInfo: Object|null, publicPricingEstimate: number|null, anthropicTotalCostUSD: number|null}}
|
|
92
|
+
*/
|
|
93
|
+
export const applyFormalAiPricingOverride = ({ model, pricingInfo = null, publicPricingEstimate = null, anthropicTotalCostUSD = null, tokenUsage = null }) => {
|
|
94
|
+
if (!isFormalAiModel(model)) return { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD };
|
|
95
|
+
|
|
96
|
+
const usage = pricingInfo?.tokenUsage || tokenUsage || null;
|
|
97
|
+
// Drop provider-specific cost fields carried by the tool's own record: a
|
|
98
|
+
// Formal AI session was never billed by OpenCode Zen, so a
|
|
99
|
+
// "Calculated by OpenCode Zen" line would be a false positive.
|
|
100
|
+
const { opencodeCost: _opencodeCost, isOpencodeFreeModel: _isOpencodeFreeModel, ...carried } = pricingInfo || {};
|
|
101
|
+
return {
|
|
102
|
+
pricingInfo: { ...carried, ...buildFormalAiPricingInfo(pricingInfo?.modelId || model, usage) },
|
|
103
|
+
publicPricingEstimate: 0,
|
|
104
|
+
// The session never billed Anthropic, so any captured Anthropic cost is a
|
|
105
|
+
// false positive and must not be rendered as a second cost line.
|
|
106
|
+
anthropicTotalCostUSD: null,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export { isFormalAiModel };
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -17,12 +17,15 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
|
|
|
17
17
|
import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
|
|
18
18
|
const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
|
|
19
19
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
20
|
-
import { defaultModels, geminiModels } from './models/index.mjs';
|
|
20
|
+
import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs';
|
|
21
21
|
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
22
|
+
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
22
23
|
import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
|
|
23
24
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
24
25
|
import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
|
|
26
|
+
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
25
27
|
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
28
|
+
import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
|
|
26
29
|
|
|
27
30
|
const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
28
31
|
|
|
@@ -130,6 +133,15 @@ const pickTokenValue = (...values) => {
|
|
|
130
133
|
return 0;
|
|
131
134
|
};
|
|
132
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Issue #2119: `--model formal-ai` is served by the local Link.Assistant model
|
|
138
|
+
* server, so the session must not be attributed to Google.
|
|
139
|
+
*/
|
|
140
|
+
export const buildGeminiPricingInfo = mappedModel => {
|
|
141
|
+
if (isFormalAiModel(mappedModel)) return buildFormalAiPricingInfo(mappedModel);
|
|
142
|
+
return { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null };
|
|
143
|
+
};
|
|
144
|
+
|
|
133
145
|
export const buildGeminiResultModelUsage = (modelId, stats = null) => {
|
|
134
146
|
const modelStats = stats?.models && typeof stats.models === 'object' ? stats.models : null;
|
|
135
147
|
if (modelStats) {
|
|
@@ -221,41 +233,17 @@ export const parseGeminiJsonOutput = (output, state = {}, modelId = null) => {
|
|
|
221
233
|
partialLine: state.partialLine || '',
|
|
222
234
|
};
|
|
223
235
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const bufferedOutput = `${nextState.partialLine}${output}`;
|
|
238
|
-
nextState.partialLine = '';
|
|
239
|
-
const lines = bufferedOutput.split(/\r?\n/);
|
|
240
|
-
const hasTrailingLineBreak = /\r?\n$/.test(bufferedOutput);
|
|
241
|
-
const completeLines = hasTrailingLineBreak ? lines : lines.slice(0, -1);
|
|
242
|
-
const possiblePartialLine = hasTrailingLineBreak ? '' : lines.at(-1) || '';
|
|
243
|
-
|
|
244
|
-
for (const line of completeLines) {
|
|
245
|
-
if (!line.trim()) continue;
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
applyGeminiJsonEvent(JSON.parse(line), nextState, modelId);
|
|
249
|
-
} catch {
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (possiblePartialLine.trim()) {
|
|
255
|
-
try {
|
|
256
|
-
applyGeminiJsonEvent(JSON.parse(possiblePartialLine), nextState, modelId);
|
|
257
|
-
} catch {
|
|
258
|
-
nextState.partialLine = possiblePartialLine;
|
|
236
|
+
// Issue #2119: frame the stream by balanced JSON values instead of by lines.
|
|
237
|
+
// `formal-ai with gemini` emits pretty-printed, multi-line records, so every
|
|
238
|
+
// line failed to parse and every event - including the token usage - was
|
|
239
|
+
// dropped. Scanning for balanced values also covers records concatenated
|
|
240
|
+
// without a separator and records split across two process chunks.
|
|
241
|
+
const { records, rest } = takeJsonRecords(`${nextState.partialLine}${String(output ?? '')}`);
|
|
242
|
+
nextState.partialLine = rest;
|
|
243
|
+
|
|
244
|
+
for (const record of records) {
|
|
245
|
+
for (const event of Array.isArray(record) ? record : [record]) {
|
|
246
|
+
applyGeminiJsonEvent(event, nextState, modelId);
|
|
259
247
|
}
|
|
260
248
|
}
|
|
261
249
|
|
|
@@ -587,8 +575,8 @@ export const executeGeminiCommand = async params => {
|
|
|
587
575
|
messageCount: geminiJsonState.messageCount || 0,
|
|
588
576
|
toolUseCount: geminiJsonState.toolUseCount || 0,
|
|
589
577
|
resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
|
|
590
|
-
pricingInfo:
|
|
591
|
-
publicPricingEstimate:
|
|
578
|
+
pricingInfo: buildGeminiPricingInfo(mappedModel),
|
|
579
|
+
publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
|
|
592
580
|
resultSummary: geminiJsonState.resultSummary || null,
|
|
593
581
|
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
594
582
|
errorInfo: { message: buildToolErrorMessage({ lastMessage: errorText, exitCode, fallback: `Gemini command failed with exit code ${exitCode}`, toolLabel: 'Gemini' }), exitCode },
|
|
@@ -631,8 +619,8 @@ export const executeGeminiCommand = async params => {
|
|
|
631
619
|
messageCount: geminiJsonState.messageCount || 0,
|
|
632
620
|
toolUseCount: geminiJsonState.toolUseCount || 0,
|
|
633
621
|
resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
|
|
634
|
-
pricingInfo:
|
|
635
|
-
publicPricingEstimate:
|
|
622
|
+
pricingInfo: buildGeminiPricingInfo(mappedModel),
|
|
623
|
+
publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
|
|
636
624
|
resultSummary: geminiJsonState.resultSummary || null,
|
|
637
625
|
completionHealth,
|
|
638
626
|
incompleteSession: completionHealth.incompleteSession,
|
|
@@ -655,8 +643,8 @@ export const executeGeminiCommand = async params => {
|
|
|
655
643
|
messageCount: geminiJsonState.messageCount || 0,
|
|
656
644
|
toolUseCount: geminiJsonState.toolUseCount || 0,
|
|
657
645
|
resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
|
|
658
|
-
pricingInfo:
|
|
659
|
-
publicPricingEstimate:
|
|
646
|
+
pricingInfo: buildGeminiPricingInfo(mappedModel),
|
|
647
|
+
publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
|
|
660
648
|
resultSummary: geminiJsonState.resultSummary || null,
|
|
661
649
|
};
|
|
662
650
|
} catch (error) {
|
|
@@ -690,11 +678,14 @@ export const executeGeminiCommand = async params => {
|
|
|
690
678
|
|
|
691
679
|
export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
|
|
692
680
|
await log('\nš Checking for uncommitted changes...');
|
|
681
|
+
// Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
|
|
682
|
+
// the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
|
|
683
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
693
684
|
try {
|
|
694
685
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
695
686
|
|
|
696
687
|
if (gitStatusResult.code === 0) {
|
|
697
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
688
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
698
689
|
|
|
699
690
|
if (statusOutput) {
|
|
700
691
|
await log('š Found uncommitted changes');
|
|
@@ -51,6 +51,11 @@ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricing
|
|
|
51
51
|
}
|
|
52
52
|
costInfo += `\n- Public pricing estimate: $${publicDec.toFixed(6)}${pricingRef}`;
|
|
53
53
|
}
|
|
54
|
+
} else if (pricingInfo?.isFreeModel && !pricingInfo?.baseModelName) {
|
|
55
|
+
// Issue #2119: a free model has a known price - $0.00 - even when no
|
|
56
|
+
// usage-derived estimate was produced. Reporting "unknown" for it was a
|
|
57
|
+
// false negative (`--model formal-ai` is served free by Link.Assistant).
|
|
58
|
+
costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
|
|
54
59
|
} else if (hasPricing) {
|
|
55
60
|
costInfo += '\n- Public pricing estimate: unknown';
|
|
56
61
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Incremental JSON record scanner for agentic CLI output streams.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2119: the agent stream readers split the raw output on newlines and
|
|
7
|
+
* called `JSON.parse` on each line. That works only when the tool emits strict
|
|
8
|
+
* NDJSON on line boundaries that happen to align with process chunk
|
|
9
|
+
* boundaries. Three real-world stream shapes break it:
|
|
10
|
+
*
|
|
11
|
+
* 1. Pretty-printed records ā `formal-ai with agent --verbose` emits
|
|
12
|
+
* multi-line, indented JSON, so *every* line fails to parse. Every
|
|
13
|
+
* structured event (session id, token usage, errors, result text) is then
|
|
14
|
+
* dropped, which is how a run with 21677 input / 22834 output tokens was
|
|
15
|
+
* published as "Token usage: 0 input, 0 output".
|
|
16
|
+
* 2. Concatenated records ā `{...}{...}` arriving without a separator
|
|
17
|
+
* (issue #1250).
|
|
18
|
+
* 3. Split records ā one record spanning two process chunks.
|
|
19
|
+
*
|
|
20
|
+
* Scanning for balanced JSON values instead of relying on line framing handles
|
|
21
|
+
* all three with a single mechanism, and non-JSON output is still surfaced
|
|
22
|
+
* verbatim as text events so plain tool logs keep flowing.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// A pending fragment that never balances (for example prose that happens to
|
|
26
|
+
// start with `{`) must not grow without bound. Once the buffer exceeds this
|
|
27
|
+
// size it is released as text.
|
|
28
|
+
export const DEFAULT_MAX_PENDING_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
const isStructuralOpener = character => character === '{' || character === '[';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find the index just past the JSON value starting at `start`.
|
|
34
|
+
* @returns {number} end index (exclusive), or -1 when the value is incomplete.
|
|
35
|
+
*/
|
|
36
|
+
const findValueEnd = (buffer, start) => {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let inString = false;
|
|
39
|
+
let escaped = false;
|
|
40
|
+
|
|
41
|
+
for (let index = start; index < buffer.length; index++) {
|
|
42
|
+
const character = buffer[index];
|
|
43
|
+
|
|
44
|
+
if (inString) {
|
|
45
|
+
if (escaped) escaped = false;
|
|
46
|
+
else if (character === '\\') escaped = true;
|
|
47
|
+
else if (character === '"') inString = false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (character === '"') {
|
|
52
|
+
inString = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (character === '{' || character === '[') {
|
|
56
|
+
depth++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (character === '}' || character === ']') {
|
|
60
|
+
depth--;
|
|
61
|
+
if (depth === 0) return index + 1;
|
|
62
|
+
if (depth < 0) return -1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return -1;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a stateful scanner that turns a byte stream into JSON and text events.
|
|
71
|
+
*
|
|
72
|
+
* @param {Object} [options]
|
|
73
|
+
* @param {number} [options.maxPendingBytes] release an unbalanced buffer once it grows past this size
|
|
74
|
+
* @returns {{write: (chunk: string) => Array<Object>, flush: () => Array<Object>}}
|
|
75
|
+
*/
|
|
76
|
+
export const createJsonStreamScanner = (options = {}) => {
|
|
77
|
+
const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES;
|
|
78
|
+
let pending = '';
|
|
79
|
+
|
|
80
|
+
const emitTextLines = (text, events) => {
|
|
81
|
+
for (const line of text.split('\n')) {
|
|
82
|
+
if (line.trim()) events.push({ type: 'text', value: line });
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const scan = (final, events) => {
|
|
87
|
+
let index = 0;
|
|
88
|
+
|
|
89
|
+
while (index < pending.length) {
|
|
90
|
+
const character = pending[index];
|
|
91
|
+
|
|
92
|
+
if (character === '\n' || character === '\r' || character === ' ' || character === '\t') {
|
|
93
|
+
index++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isStructuralOpener(character)) {
|
|
98
|
+
const end = findValueEnd(pending, index);
|
|
99
|
+
if (end < 0) break; // incomplete record: wait for more input
|
|
100
|
+
const raw = pending.slice(index, end);
|
|
101
|
+
try {
|
|
102
|
+
events.push({ type: 'json', value: JSON.parse(raw), raw });
|
|
103
|
+
} catch {
|
|
104
|
+
// Balanced but not valid JSON: surface it verbatim rather than
|
|
105
|
+
// silently dropping tool output.
|
|
106
|
+
emitTextLines(raw, events);
|
|
107
|
+
}
|
|
108
|
+
index = end;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const newline = pending.indexOf('\n', index);
|
|
113
|
+
if (newline < 0) break; // incomplete text line: wait for more input
|
|
114
|
+
const line = pending.slice(index, newline).replace(/\r$/, '');
|
|
115
|
+
if (line.trim()) events.push({ type: 'text', value: line });
|
|
116
|
+
index = newline + 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
pending = pending.slice(index);
|
|
120
|
+
|
|
121
|
+
if (final) {
|
|
122
|
+
if (pending.trim()) {
|
|
123
|
+
const trimmed = pending.trim();
|
|
124
|
+
let parsed = null;
|
|
125
|
+
if (isStructuralOpener(trimmed[0])) {
|
|
126
|
+
try {
|
|
127
|
+
parsed = { type: 'json', value: JSON.parse(trimmed), raw: trimmed };
|
|
128
|
+
} catch {
|
|
129
|
+
parsed = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (parsed) events.push(parsed);
|
|
133
|
+
else emitTextLines(pending, events);
|
|
134
|
+
}
|
|
135
|
+
pending = '';
|
|
136
|
+
} else if (pending.length > maxPendingBytes) {
|
|
137
|
+
emitTextLines(pending, events);
|
|
138
|
+
pending = '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return events;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
write(chunk) {
|
|
146
|
+
pending += String(chunk ?? '');
|
|
147
|
+
return scan(false, []);
|
|
148
|
+
},
|
|
149
|
+
flush() {
|
|
150
|
+
return scan(true, []);
|
|
151
|
+
},
|
|
152
|
+
/** The unconsumed tail: an incomplete record or text line. */
|
|
153
|
+
pending() {
|
|
154
|
+
return pending;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Split a buffered stream into complete JSON records plus the unconsumed tail.
|
|
161
|
+
*
|
|
162
|
+
* Stateless counterpart of `createJsonStreamScanner`, for the parsers that keep
|
|
163
|
+
* their buffer inside a plain state object they hand back to their caller
|
|
164
|
+
* (`parseGeminiJsonOutput`, `parseQwenStreamJsonOutput`) instead of holding a
|
|
165
|
+
* closure across chunks.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} buffered carried-over tail followed by the new chunk
|
|
168
|
+
* @returns {{records: Array<Object>, rest: string}}
|
|
169
|
+
*/
|
|
170
|
+
export const takeJsonRecords = buffered => {
|
|
171
|
+
const scanner = createJsonStreamScanner();
|
|
172
|
+
const events = scanner.write(String(buffered ?? ''));
|
|
173
|
+
return {
|
|
174
|
+
records: events.filter(event => event.type === 'json').map(event => event.value),
|
|
175
|
+
rest: scanner.pending(),
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Buffer a byte stream into whole lines.
|
|
181
|
+
*
|
|
182
|
+
* Line-oriented parsers (Codex NDJSON plus its interleaved OTEL diagnostics)
|
|
183
|
+
* stay correct as long as they never see half a line. A process chunk boundary
|
|
184
|
+
* can fall anywhere, so the trailing partial line is carried over to the next
|
|
185
|
+
* chunk and released by `flush()`.
|
|
186
|
+
*
|
|
187
|
+
* @returns {{write: (chunk: string) => string, flush: () => string}}
|
|
188
|
+
*/
|
|
189
|
+
export const createLineBuffer = () => {
|
|
190
|
+
let pending = '';
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
write(chunk) {
|
|
194
|
+
pending += String(chunk ?? '');
|
|
195
|
+
const boundary = pending.lastIndexOf('\n');
|
|
196
|
+
if (boundary < 0) return '';
|
|
197
|
+
const complete = pending.slice(0, boundary + 1);
|
|
198
|
+
pending = pending.slice(boundary + 1);
|
|
199
|
+
return complete;
|
|
200
|
+
},
|
|
201
|
+
flush() {
|
|
202
|
+
const rest = pending;
|
|
203
|
+
pending = '';
|
|
204
|
+
return rest;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Extract every complete JSON record from a finished output buffer.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} output raw tool output
|
|
213
|
+
* @returns {Array<Object>} parsed JSON values in stream order
|
|
214
|
+
*/
|
|
215
|
+
export const parseJsonRecords = output => {
|
|
216
|
+
const scanner = createJsonStreamScanner();
|
|
217
|
+
const events = [...scanner.write(String(output ?? '')), ...scanner.flush()];
|
|
218
|
+
return events.filter(event => event.type === 'json').map(event => event.value);
|
|
219
|
+
};
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -23,8 +23,10 @@ import { opencodeModels, defaultModels } from './models/index.mjs';
|
|
|
23
23
|
import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
|
|
24
24
|
import { checkPlaywrightMcpPackageAvailability, getOpenCodePlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
|
|
25
25
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
|
|
26
|
+
import { createJsonStreamScanner } from './json-stream.lib.mjs';
|
|
26
27
|
import { calculateAgentPricing } from './agent.lib.mjs';
|
|
27
28
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
29
|
+
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
28
30
|
|
|
29
31
|
export { parseOpenCodeTokenUsage };
|
|
30
32
|
|
|
@@ -336,6 +338,48 @@ export const executeOpenCodeCommand = async params => {
|
|
|
336
338
|
let lastTextContent = ''; // Issue #1263: Track last text content for result summary
|
|
337
339
|
let allOutput = ''; // Collect all output for error detection
|
|
338
340
|
|
|
341
|
+
// Issue #2119: frame records by balanced JSON instead of by newlines, so
|
|
342
|
+
// pretty-printed, concatenated and chunk-split records are all counted.
|
|
343
|
+
// The previous per-chunk try/catch also aborted parsing of the whole
|
|
344
|
+
// chunk as soon as one line was not JSON.
|
|
345
|
+
const stdoutScanner = createJsonStreamScanner();
|
|
346
|
+
const stderrScanner = createJsonStreamScanner();
|
|
347
|
+
|
|
348
|
+
const handleOpenCodeRecords = events => {
|
|
349
|
+
for (const event of events) {
|
|
350
|
+
if (event.type !== 'json') continue;
|
|
351
|
+
const data = sanitizeObjectStrings(event.value);
|
|
352
|
+
// Issue #1968: a bare `null`/primitive record must not abort the
|
|
353
|
+
// rest of the chunk (data.type access would throw on null).
|
|
354
|
+
if (data === null || typeof data !== 'object') continue;
|
|
355
|
+
accumulateAgentStepFinishUsage(streamingTokenUsage, data);
|
|
356
|
+
// Track text content for result summary
|
|
357
|
+
// OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
|
|
358
|
+
if (data.type === 'text' && data.text) {
|
|
359
|
+
lastTextContent = data.text;
|
|
360
|
+
} else if (data.type === 'assistant' && data.message?.content) {
|
|
361
|
+
const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
|
|
362
|
+
for (const item of content) {
|
|
363
|
+
if (item.type === 'text' && item.text) {
|
|
364
|
+
lastTextContent = item.text;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} else if (data.type === 'message' && data.content) {
|
|
368
|
+
if (typeof data.content === 'string') {
|
|
369
|
+
lastTextContent = data.content;
|
|
370
|
+
} else if (Array.isArray(data.content)) {
|
|
371
|
+
for (const item of data.content) {
|
|
372
|
+
if (item.type === 'text' && item.text) {
|
|
373
|
+
lastTextContent = item.text;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} else if (data.type === 'result' && data.result) {
|
|
378
|
+
lastTextContent = data.result;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
339
383
|
for await (const chunk of execCommand.stream()) {
|
|
340
384
|
if (chunk.type === 'stdout') {
|
|
341
385
|
const output = chunk.data.toString();
|
|
@@ -343,44 +387,8 @@ export const executeOpenCodeCommand = async params => {
|
|
|
343
387
|
lastMessage = output;
|
|
344
388
|
allOutput += output;
|
|
345
389
|
|
|
346
|
-
// Issue #1263:
|
|
347
|
-
|
|
348
|
-
const lines = output.split('\n');
|
|
349
|
-
for (const line of lines) {
|
|
350
|
-
if (!line.trim()) continue;
|
|
351
|
-
const data = sanitizeObjectStrings(JSON.parse(line));
|
|
352
|
-
// Issue #1968: a bare `null`/primitive NDJSON line must not abort the
|
|
353
|
-
// rest of the chunk (data.type access would throw on null).
|
|
354
|
-
if (data === null || typeof data !== 'object') continue;
|
|
355
|
-
accumulateAgentStepFinishUsage(streamingTokenUsage, data);
|
|
356
|
-
// Track text content for result summary
|
|
357
|
-
// OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
|
|
358
|
-
if (data.type === 'text' && data.text) {
|
|
359
|
-
lastTextContent = data.text;
|
|
360
|
-
} else if (data.type === 'assistant' && data.message?.content) {
|
|
361
|
-
const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
|
|
362
|
-
for (const item of content) {
|
|
363
|
-
if (item.type === 'text' && item.text) {
|
|
364
|
-
lastTextContent = item.text;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
} else if (data.type === 'message' && data.content) {
|
|
368
|
-
if (typeof data.content === 'string') {
|
|
369
|
-
lastTextContent = data.content;
|
|
370
|
-
} else if (Array.isArray(data.content)) {
|
|
371
|
-
for (const item of data.content) {
|
|
372
|
-
if (item.type === 'text' && item.text) {
|
|
373
|
-
lastTextContent = item.text;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
} else if (data.type === 'result' && data.result) {
|
|
378
|
-
lastTextContent = data.result;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} catch {
|
|
382
|
-
// Not JSON, continue
|
|
383
|
-
}
|
|
390
|
+
// Issue #1263: Parse JSON output to extract text content for result summary
|
|
391
|
+
handleOpenCodeRecords(stdoutScanner.write(output));
|
|
384
392
|
}
|
|
385
393
|
|
|
386
394
|
if (chunk.type === 'stderr') {
|
|
@@ -389,47 +397,18 @@ export const executeOpenCodeCommand = async params => {
|
|
|
389
397
|
await log(errorOutput, { stream: 'stderr' });
|
|
390
398
|
allOutput += errorOutput;
|
|
391
399
|
|
|
392
|
-
// Issue #1263: Also
|
|
393
|
-
|
|
394
|
-
const lines = errorOutput.split('\n');
|
|
395
|
-
for (const line of lines) {
|
|
396
|
-
if (!line.trim()) continue;
|
|
397
|
-
const data = sanitizeObjectStrings(JSON.parse(line));
|
|
398
|
-
// Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
|
|
399
|
-
if (data === null || typeof data !== 'object') continue;
|
|
400
|
-
accumulateAgentStepFinishUsage(streamingTokenUsage, data);
|
|
401
|
-
if (data.type === 'text' && data.text) {
|
|
402
|
-
lastTextContent = data.text;
|
|
403
|
-
} else if (data.type === 'assistant' && data.message?.content) {
|
|
404
|
-
const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
|
|
405
|
-
for (const item of content) {
|
|
406
|
-
if (item.type === 'text' && item.text) {
|
|
407
|
-
lastTextContent = item.text;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
} else if (data.type === 'message' && data.content) {
|
|
411
|
-
if (typeof data.content === 'string') {
|
|
412
|
-
lastTextContent = data.content;
|
|
413
|
-
} else if (Array.isArray(data.content)) {
|
|
414
|
-
for (const item of data.content) {
|
|
415
|
-
if (item.type === 'text' && item.text) {
|
|
416
|
-
lastTextContent = item.text;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
} else if (data.type === 'result' && data.result) {
|
|
421
|
-
lastTextContent = data.result;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
} catch {
|
|
425
|
-
// Not JSON, continue
|
|
426
|
-
}
|
|
400
|
+
// Issue #1263: Also parse stderr for text content
|
|
401
|
+
handleOpenCodeRecords(stderrScanner.write(errorOutput));
|
|
427
402
|
}
|
|
428
403
|
} else if (chunk.type === 'exit') {
|
|
429
404
|
exitCode = chunk.code;
|
|
430
405
|
}
|
|
431
406
|
}
|
|
432
407
|
|
|
408
|
+
// Release any record that was still being assembled when the stream ended.
|
|
409
|
+
handleOpenCodeRecords(stdoutScanner.flush());
|
|
410
|
+
handleOpenCodeRecords(stderrScanner.flush());
|
|
411
|
+
|
|
433
412
|
// Clean up the opencode.json config file to avoid polluting the repository
|
|
434
413
|
try {
|
|
435
414
|
await fs.unlink(opencodeConfigPath);
|
|
@@ -631,11 +610,14 @@ export const executeOpenCodeCommand = async params => {
|
|
|
631
610
|
export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
|
|
632
611
|
// Similar to Claude version, check for uncommitted changes
|
|
633
612
|
await log('\nš Checking for uncommitted changes...');
|
|
613
|
+
// Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
|
|
614
|
+
// the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
|
|
615
|
+
await ensureAiToolScratchIgnored(tempDir, log);
|
|
634
616
|
try {
|
|
635
617
|
const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
|
|
636
618
|
|
|
637
619
|
if (gitStatusResult.code === 0) {
|
|
638
|
-
const statusOutput = gitStatusResult.stdout.toString().trim();
|
|
620
|
+
const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
|
|
639
621
|
|
|
640
622
|
if (statusOutput) {
|
|
641
623
|
await log('š Found uncommitted changes');
|