cursor-opencode-provider 0.7.0 → 0.7.2
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/README.md +2 -2
- package/dist/language-model.d.ts +9 -0
- package/dist/language-model.js +20 -2
- package/dist/protocol/messages.js +2 -0
- package/dist/protocol/tools.d.ts +6 -7
- package/dist/protocol/tools.js +664 -66
- package/dist/protocol/workspace-grounding.d.ts +17 -1
- package/dist/protocol/workspace-grounding.js +34 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
48
48
|
- **Model discovery** — fetches available models from Cursor's API and caches them locally
|
|
49
49
|
- **Image input** — advertises vision only for supported models and forwards OpenCode image attachments to Cursor
|
|
50
50
|
- **Streaming** — bidirectional Connect-RPC Runs with stale-session rotation, health checks, semantic/read-idle deadlines, bounded replay-safe recovery, and activity-aware held tool continuations
|
|
51
|
-
- **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts using only the advertised canonical catalog. Exact advertised subagent names win; standard Cursor roles select compatible OpenCode agents, and native Task maps to OpenCode 1.x `task` or OpenCode 2.0 `subagent`. OCP translates any alternate host vocabulary before it reaches this package. The bridge also covers Cursor's typed read/bash/edit/write/grep/find/ls request/result range, mirrors finalized display-only todo/plan state, and strips OpenCode
|
|
51
|
+
- **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts using only the advertised canonical catalog. Exact advertised subagent names win; standard Cursor roles select compatible OpenCode agents, and native Task maps to OpenCode 1.x `task` or OpenCode 2.0 `subagent`. OCP translates any alternate host vocabulary before it reaches this package. The bridge also covers Cursor's typed read/bash/edit/write/grep/find/ls request/result range, mirrors finalized display-only todo/plan state, and strips OpenCode 1.x and OpenCode 2 read envelopes before returning content to Cursor.
|
|
52
52
|
- **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
|
|
53
53
|
|
|
54
54
|
## Requirements
|
|
@@ -379,7 +379,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
379
379
|
- **Conversation bindings survive provider restarts** — after each successful `TurnEnded`, the OpenCode-session binding, latest checkpoint, checkpoint-reachable KV blob graph, frozen request-context base, last real tool catalog, host agent, stable system-prompt hash, and pending post-compaction rebase marker are atomically replaced in that session's private `<host-cache>/cursor-conversations/*.pb.gz` snapshot. The protobuf stores checkpoints, blob ids/data, and request context as bytes without JSON/base64 expansion, then gzip compresses the complete record. A checkpoint alone is insufficient: Cursor requests its referenced hashes through `get_blob` when the next Run starts. Reachability follows Cursor CLI's export traversal (turns, messages, steps, images, summaries, todos, prompts, and nested subagents), pruning superseded blobs; decode failure or a missing referenced hash conservatively retains the complete blob set. The stable OpenCode session id selects the file; Cursor conversation ids may rotate during compaction, agent switches, or prompt changes and remain inside the snapshot. Independent files prevent concurrent OpenCode processes from losing unrelated sessions by replacing a shared aggregate cache. Startup restores records updated within 24 hours and safely flushes older ones without deleting a concurrently published fresh replacement. Experimental legacy JSON snapshots are discarded rather than migrated. Debug logs report hydration as `restored`, `missing`, `invalid`, or `expired`, plus the compressed and protobuf snapshot sizes. The restored catalog is a fallback only for lifecycle turns such as compaction; an ordinary restricted/no-tool turn never resurrects it. Process-global bindings and prompt/catalog state still use a 256-session LRU bound; memory eviction drops the live checkpoint, blobs, and frozen context, which can be rehydrated from the restart cache on later use.
|
|
380
380
|
- **Oversized checkpoint graphs rebase instead of flooding the Run stream** — before reusing a checkpoint, the provider measures its reachable KV graph. State above 100 MiB, or an incomplete graph with a missing/undecodable reference, rotates to a fresh Cursor conversation seeded from OpenCode's authoritative history. KV, request-context, MCP-state, interaction, and heartbeat writes are ordered per Run and await HTTP/2 drain, so a large blob reply cannot create an unbounded queue later misreported as heartbeat backpressure. Debug logs distinguish checkpoint-envelope bytes, reachable blob bytes, seed size, and encoded Run-request estimate; raw blob bytes are never presented as tokens.
|
|
381
381
|
- **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
|
|
382
|
-
- **Large reads are capped
|
|
382
|
+
- **Large reads are capped by OpenCode, and the cap is reported** — OpenCode 1.x `read` stops at `MAX_BYTES = 50 * 1024` and appends `(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)`. OpenCode 2 prints `Read file <path>, lines <start>-<end>` with `N: ` prefixes and, when the page ends before EOF, `[Output truncated. Continue reading with offset: N]` (50 KB or 2,000 lines); it also shortens each individual line after 2,000 characters. The provider strips either envelope before returning content to Cursor so the model cannot echo wrappers into a later write, but it re-states every partial result: a native read appends a `[Partial read: …]` marker after the content, an MCP read gets a separate notice content item, and a Pi read carries structured `PiReadExecSuccess.truncation`. Relative grep, glob, and directory entries are resolved against the workspace root. Shell stdout absolutizes only relative path tokens, not prose. Checkpointed turns, which do not resend the system prompt, get that same root on the user message. The structured `truncated` flag alone is not enough — verified against live `gpt-5.4-mini` and `grok-4.5` sessions, both of which asserted "highly confident" that partial content was the whole file until the textual marker was added. Deliberately paged reads (explicit `offset`/`limit`) are not marked unless the host also hits the byte cap or character-truncates a line. Whole-file writes and overwriting `Add File` patches that echo the marker are rejected before OpenCode executes them; targeted edits remain possible, including edits to source that quotes the warning text. Cursor's private legacy-edit read is handled separately as described above, so an edit can operate on a workspace file larger than 50 KB without turning the capped preview into a replacement. The cap is hardcoded in OpenCode's read tool — it is not the configurable `tool_output.max_bytes`, which governs `Truncate.Service` and not read's line accumulation — so ordinary model reads still require paging with `offset`.
|
|
383
383
|
- **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
|
|
384
384
|
|
|
385
385
|
## License
|
package/dist/language-model.d.ts
CHANGED
|
@@ -87,6 +87,15 @@ type ExtractedToolResult = {
|
|
|
87
87
|
* are ignored — they are conversation history, not replies for a held-open Run.
|
|
88
88
|
*/
|
|
89
89
|
export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
|
|
90
|
+
/**
|
|
91
|
+
* Checkpointed Runs do not resend the system prompt. Keep the workspace root
|
|
92
|
+
* on the live user message, and require absolute `path` arguments when that is
|
|
93
|
+
* the host's file-tool dialect.
|
|
94
|
+
*/
|
|
95
|
+
export declare function groundCheckpointTurnText(userText: string, checkpoint: boolean, workspaceRoot: string, tools: readonly {
|
|
96
|
+
name?: string;
|
|
97
|
+
inputSchema?: unknown;
|
|
98
|
+
}[]): string;
|
|
90
99
|
/**
|
|
91
100
|
* Cursor's native UI interactions cannot be surfaced through the AI SDK.
|
|
92
101
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
package/dist/language-model.js
CHANGED
|
@@ -9,7 +9,7 @@ import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/mes
|
|
|
9
9
|
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
|
|
10
10
|
import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
|
|
11
11
|
import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
12
|
-
import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
|
|
12
|
+
import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
|
|
13
13
|
import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
|
|
14
14
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
15
15
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
@@ -844,12 +844,15 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
844
844
|
`previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
|
|
845
845
|
}
|
|
846
846
|
const lastUser = [...prompt].reverse().find((message) => message.role === "user");
|
|
847
|
-
|
|
847
|
+
let userText = recovery?.kind === "rebase"
|
|
848
848
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
849
849
|
: (extractUserText(lastUser) || ".");
|
|
850
850
|
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
851
851
|
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
852
852
|
const startedWithCheckpoint = !!conversationState;
|
|
853
|
+
if (startedWithCheckpoint) {
|
|
854
|
+
userText = groundCheckpointTurnText(userText, true, workspaceRoot, cursorTools);
|
|
855
|
+
}
|
|
853
856
|
const activeMode = getActiveCursorMode(sessionKey);
|
|
854
857
|
const nativePlanPromptOwnsMode = hostAgent === "plan"
|
|
855
858
|
&& (activeMode === "plan" || activeMode === "spec");
|
|
@@ -2934,6 +2937,18 @@ function extractSystemPrompt(prompt) {
|
|
|
2934
2937
|
}
|
|
2935
2938
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
2936
2939
|
}
|
|
2940
|
+
/**
|
|
2941
|
+
* Checkpointed Runs do not resend the system prompt. Keep the workspace root
|
|
2942
|
+
* on the live user message, and require absolute `path` arguments when that is
|
|
2943
|
+
* the host's file-tool dialect.
|
|
2944
|
+
*/
|
|
2945
|
+
export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, tools) {
|
|
2946
|
+
if (!checkpoint)
|
|
2947
|
+
return userText;
|
|
2948
|
+
return appendCheckpointUserGrounding(userText, workspaceRoot, {
|
|
2949
|
+
requireAbsolutePathArg: hostToolDialectFromTools(tools).filePathKey === "path",
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2937
2952
|
/**
|
|
2938
2953
|
* Cursor's native UI interactions cannot be surfaced through the AI SDK.
|
|
2939
2954
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
|
@@ -3015,6 +3030,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
3015
3030
|
// leaving the turn with no file-editing guidance at all.
|
|
3016
3031
|
instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
|
|
3017
3032
|
}
|
|
3033
|
+
if (hostToolDialectFromTools(tools).filePathKey === "path") {
|
|
3034
|
+
instructions.push("- OpenCode file tools take `path` as an absolute path under the workspace root above. Do not pass a project-relative path, and do not invent a different absolute prefix.");
|
|
3035
|
+
}
|
|
3018
3036
|
if (names.has("edit") || names.has("write") || names.has("apply_patch")) {
|
|
3019
3037
|
instructions.push("- Never use a read result as complete file content when it says the output is capped, partial, or requires another offset. Read the remaining ranges first, or make a targeted edit/patch from complete context; do not pass a partial read back as a whole-file replacement.");
|
|
3020
3038
|
}
|
|
@@ -472,6 +472,8 @@ export function createMessageTypes() {
|
|
|
472
472
|
{ id: 1, name: "matches", type: "GrepFileMatch", repeated: true },
|
|
473
473
|
{ id: 2, name: "total_lines", type: "int32" },
|
|
474
474
|
{ id: 3, name: "total_matched_lines", type: "int32" },
|
|
475
|
+
{ id: 4, name: "client_truncated", type: "bool" },
|
|
476
|
+
{ id: 5, name: "ripgrep_truncated", type: "bool" },
|
|
475
477
|
]);
|
|
476
478
|
addType(root, "GrepUnionResult", [
|
|
477
479
|
{ id: 2, name: "files", type: "GrepFilesResult" },
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -290,14 +290,13 @@ export declare function buildUnsupportedExecDeny(input: {
|
|
|
290
290
|
/**
|
|
291
291
|
* Strip opencode's `read` envelope, leaving raw file content.
|
|
292
292
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
293
|
+
* OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
|
|
294
|
+
* models are trained on, but Cursor's are not:
|
|
295
295
|
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
296
|
+
* OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
|
|
297
|
+
* the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
|
|
298
|
+
* Forwarding either envelope verbatim made Cursor's model treat the wrapper as
|
|
299
|
+
* literal file content and write tags or line prefixes back into files.
|
|
301
300
|
*
|
|
302
301
|
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
303
302
|
*
|
package/dist/protocol/tools.js
CHANGED
|
@@ -860,6 +860,18 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
|
|
|
860
860
|
// the requested path for the typed Cursor result.
|
|
861
861
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
862
862
|
}
|
|
863
|
+
function readRequestResultMetadata(raw, mappedArgs) {
|
|
864
|
+
const requestedPath = str(raw.path)
|
|
865
|
+
?? str(raw.filePath)
|
|
866
|
+
?? str(raw.file_path)
|
|
867
|
+
?? opencodePathArg(mappedArgs)
|
|
868
|
+
?? "";
|
|
869
|
+
return {
|
|
870
|
+
path: requestedPath,
|
|
871
|
+
...(typeof mappedArgs.offset === "number" ? { offset: mappedArgs.offset } : {}),
|
|
872
|
+
...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
863
875
|
/**
|
|
864
876
|
* A partial-read notice is for Cursor's model, never for a file. Refuse only
|
|
865
877
|
* whole-file mutation forms that echo it. A targeted edit or Update File patch
|
|
@@ -888,12 +900,15 @@ export function rejectPartialReadMutation(parsed) {
|
|
|
888
900
|
return;
|
|
889
901
|
const filePath = opencodePathArg(parsed.args) ?? "the target file";
|
|
890
902
|
const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
|
|
903
|
+
const longLine = content.includes("Use a byte-preserving read method");
|
|
891
904
|
parsed.localError =
|
|
892
905
|
`NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
|
|
893
906
|
"because it contains the provider's partial-read notice. Do not retry the same mutation. " +
|
|
894
907
|
(nextOffset
|
|
895
908
|
? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
|
|
896
|
-
:
|
|
909
|
+
: longLine
|
|
910
|
+
? "Inspect the full line with a byte-preserving read method, then use a targeted edit or Update File patch."
|
|
911
|
+
: "Read the remaining file ranges, then use a targeted edit or Update File patch.");
|
|
897
912
|
}
|
|
898
913
|
export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
899
914
|
const id = msg.id;
|
|
@@ -986,13 +1001,17 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
986
1001
|
// tool name (Cursor's model may have shortened "opencode-read" → "read") and
|
|
987
1002
|
// decode the argument map back into JSON for opencode to execute.
|
|
988
1003
|
const m = msg.mcp_args ?? {};
|
|
989
|
-
const
|
|
1004
|
+
const rawArgs = decodeMcpArgs(m.args);
|
|
1005
|
+
const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), rawArgs, "mcp_args", dialect);
|
|
990
1006
|
return {
|
|
991
1007
|
id,
|
|
992
1008
|
execId,
|
|
993
1009
|
toolName: mapped.toolName,
|
|
994
1010
|
args: mapped.args,
|
|
995
1011
|
resultField,
|
|
1012
|
+
...(mapped.toolName === "read"
|
|
1013
|
+
? { resultMetadata: readRequestResultMetadata(rawArgs, mapped.args) }
|
|
1014
|
+
: {}),
|
|
996
1015
|
};
|
|
997
1016
|
}
|
|
998
1017
|
if (execVariant === "pi_edit_args") {
|
|
@@ -1028,13 +1047,11 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1028
1047
|
const rawArgs = msg[execVariant] ?? {};
|
|
1029
1048
|
const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
|
|
1030
1049
|
? shellStreamResultMetadata(rawArgs)
|
|
1031
|
-
: execVariant === "read_args"
|
|
1032
|
-
?
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1037
|
-
: undefined;
|
|
1050
|
+
: execVariant === "read_args" || execVariant === "pi_read_args"
|
|
1051
|
+
? readRequestResultMetadata(rawArgs, mapped.args)
|
|
1052
|
+
: execVariant === "grep_args"
|
|
1053
|
+
? grepRequestResultMetadata(rawArgs)
|
|
1054
|
+
: undefined;
|
|
1038
1055
|
if (resultMetadata
|
|
1039
1056
|
&& resultMetadata.timeout_behavior !== 2
|
|
1040
1057
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1415,9 +1432,11 @@ export function isUriReadTarget(requested) {
|
|
|
1415
1432
|
*/
|
|
1416
1433
|
export function resolveReadTargetPath(requested, workspaceRoot) {
|
|
1417
1434
|
const expanded = untildify(requested);
|
|
1418
|
-
if (
|
|
1419
|
-
return path.resolve(
|
|
1435
|
+
if (isAbsoluteToolPath(expanded)) {
|
|
1436
|
+
return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
|
|
1420
1437
|
}
|
|
1438
|
+
if (workspaceRoot)
|
|
1439
|
+
return joinToolPath(workspaceRoot, expanded);
|
|
1421
1440
|
return path.resolve(expanded);
|
|
1422
1441
|
}
|
|
1423
1442
|
/**
|
|
@@ -1482,6 +1501,7 @@ export function buildExecClientMessages(input) {
|
|
|
1482
1501
|
const resultField = input.resultField || "mcp_result";
|
|
1483
1502
|
const frames = [];
|
|
1484
1503
|
if (resultField === "shell_stream") {
|
|
1504
|
+
const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
|
|
1485
1505
|
// Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
|
|
1486
1506
|
frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
|
|
1487
1507
|
if (input.error) {
|
|
@@ -1489,8 +1509,8 @@ export function buildExecClientMessages(input) {
|
|
|
1489
1509
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
|
|
1490
1510
|
}
|
|
1491
1511
|
else {
|
|
1492
|
-
if (
|
|
1493
|
-
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data:
|
|
1512
|
+
if (stdout) {
|
|
1513
|
+
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
|
|
1494
1514
|
}
|
|
1495
1515
|
if (input.shellOutcome?.kind === "backgrounded") {
|
|
1496
1516
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
@@ -1700,17 +1720,460 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1700
1720
|
frames.push(buildExecStreamClose(execId));
|
|
1701
1721
|
return frames;
|
|
1702
1722
|
}
|
|
1723
|
+
/** OpenCode 2 `read` prints this when a text page does not reach EOF. */
|
|
1724
|
+
const OPENCODE2_READ_TRUNCATION = /^\[Output truncated\. Continue reading with offset:\s*(\d+)\]\s*$/;
|
|
1725
|
+
/** OpenCode 2 stops a read at 2,000 lines as well as 50 KB. */
|
|
1726
|
+
const OPENCODE2_READ_MAX_LINES = 2_000;
|
|
1727
|
+
const OPENCODE2_READ_MAX_LINE_CHARS = 2_000;
|
|
1728
|
+
const OPENCODE2_READ_LINE_TRUNCATION = `... (line truncated to ${OPENCODE2_READ_MAX_LINE_CHARS} chars)`;
|
|
1729
|
+
/**
|
|
1730
|
+
* `read-filesystem.ts` shortens each selected line to 2,000 UTF-16 code units.
|
|
1731
|
+
* One code unit encodes to at most three UTF-8 bytes. Add the 34-byte
|
|
1732
|
+
* `... (line truncated to 2000 chars)` suffix and one line separator when
|
|
1733
|
+
* proving a page was close enough for the next line not to fit.
|
|
1734
|
+
*/
|
|
1735
|
+
const OPENCODE2_MAX_RENDERED_LINE_BYTES = (OPENCODE2_READ_MAX_LINE_CHARS * 3) + Buffer.byteLength(OPENCODE2_READ_LINE_TRUNCATION, "utf8") + 1;
|
|
1736
|
+
function normalizeToolText(output) {
|
|
1737
|
+
return output.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* OpenCode 2 `ReadTool.toModelContent` for a file. The whole string must match;
|
|
1741
|
+
* a file that merely quotes the header stays a numbered body line.
|
|
1742
|
+
*
|
|
1743
|
+
* Read file <path>, lines <start>-<end>
|
|
1744
|
+
* <n>: <line>
|
|
1745
|
+
* [Output truncated. Continue reading with offset: <next>]
|
|
1746
|
+
*/
|
|
1747
|
+
function parseOpenCode2FileRead(output, resultMetadata) {
|
|
1748
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1749
|
+
if (!normalized.startsWith("Read file "))
|
|
1750
|
+
return undefined;
|
|
1751
|
+
const lines = normalized.split("\n");
|
|
1752
|
+
const headerLine = (lines[0] ?? "").trim();
|
|
1753
|
+
const empty = /^Read file (.*), 0 lines$/.exec(headerLine);
|
|
1754
|
+
if (empty) {
|
|
1755
|
+
if (lines.length !== 1)
|
|
1756
|
+
return undefined;
|
|
1757
|
+
return {
|
|
1758
|
+
path: empty[1] ?? "",
|
|
1759
|
+
content: "",
|
|
1760
|
+
totalLines: 0,
|
|
1761
|
+
hostTruncated: false,
|
|
1762
|
+
outputCapped: false,
|
|
1763
|
+
truncatedLines: [],
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
const header = /^Read file (.*), lines (\d+)-(\d+)$/.exec(headerLine);
|
|
1767
|
+
if (!header)
|
|
1768
|
+
return undefined;
|
|
1769
|
+
const start = Number(header[2]);
|
|
1770
|
+
const end = Number(header[3]);
|
|
1771
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
|
|
1772
|
+
return undefined;
|
|
1773
|
+
}
|
|
1774
|
+
let bodyEnd = lines.length;
|
|
1775
|
+
let nextOffset;
|
|
1776
|
+
const banner = OPENCODE2_READ_TRUNCATION.exec(lines[lines.length - 1] ?? "");
|
|
1777
|
+
if (banner) {
|
|
1778
|
+
nextOffset = Number(banner[1]);
|
|
1779
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset < 1)
|
|
1780
|
+
return undefined;
|
|
1781
|
+
bodyEnd -= 1;
|
|
1782
|
+
}
|
|
1783
|
+
const expected = end - start + 1;
|
|
1784
|
+
if (bodyEnd - 1 !== expected)
|
|
1785
|
+
return undefined;
|
|
1786
|
+
const raw = [];
|
|
1787
|
+
const truncatedLines = [];
|
|
1788
|
+
for (let index = 0; index < expected; index++) {
|
|
1789
|
+
const match = /^(\d+):[ \t](.*)$/.exec(lines[index + 1] ?? "");
|
|
1790
|
+
if (!match || Number(match[1]) !== start + index)
|
|
1791
|
+
return undefined;
|
|
1792
|
+
const value = match[2] ?? "";
|
|
1793
|
+
if (value.length === OPENCODE2_READ_MAX_LINE_CHARS + OPENCODE2_READ_LINE_TRUNCATION.length
|
|
1794
|
+
&& value.endsWith(OPENCODE2_READ_LINE_TRUNCATION)) {
|
|
1795
|
+
truncatedLines.push(start + index);
|
|
1796
|
+
}
|
|
1797
|
+
raw.push(value);
|
|
1798
|
+
}
|
|
1799
|
+
const content = raw.join("\n");
|
|
1800
|
+
const hostTruncated = nextOffset !== undefined;
|
|
1801
|
+
const requestedLimit = num(resultMetadata?.limit);
|
|
1802
|
+
const effectiveLineLimit = requestedLimit !== undefined && requestedLimit > 0
|
|
1803
|
+
? Math.min(requestedLimit, OPENCODE2_READ_MAX_LINES)
|
|
1804
|
+
: OPENCODE2_READ_MAX_LINES;
|
|
1805
|
+
// OpenCode 2 ends a text page only for the requested/default line limit or
|
|
1806
|
+
// the 50 KiB byte limit. Fewer lines than the effective line limit, together
|
|
1807
|
+
// with a page close enough that one maximum-size rendered line may not fit,
|
|
1808
|
+
// identifies the byte cap without the old ASCII-only 4 KiB guess.
|
|
1809
|
+
const outputCapped = hostTruncated
|
|
1810
|
+
&& expected < effectiveLineLimit
|
|
1811
|
+
&& Buffer.byteLength(content, "utf8") > OPENCODE_READ_MAX_BYTES - OPENCODE2_MAX_RENDERED_LINE_BYTES;
|
|
1812
|
+
if (hostTruncated) {
|
|
1813
|
+
return {
|
|
1814
|
+
path: header[1] ?? "",
|
|
1815
|
+
content,
|
|
1816
|
+
startLine: start,
|
|
1817
|
+
endLine: end,
|
|
1818
|
+
nextOffset,
|
|
1819
|
+
hostTruncated: true,
|
|
1820
|
+
outputCapped,
|
|
1821
|
+
truncatedLines,
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
// A page that reached EOF is complete. Record the span so a non-1 offset is
|
|
1825
|
+
// not later mistaken for a silent cap, without flagging it truncated.
|
|
1826
|
+
return {
|
|
1827
|
+
path: header[1] ?? "",
|
|
1828
|
+
content,
|
|
1829
|
+
...(start === 1
|
|
1830
|
+
? { totalLines: end }
|
|
1831
|
+
: { startLine: start, endLine: end, totalLines: end }),
|
|
1832
|
+
hostTruncated: false,
|
|
1833
|
+
outputCapped: false,
|
|
1834
|
+
truncatedLines,
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* OpenCode 2 directory reads list bare entry names. Join them onto the
|
|
1839
|
+
* directory, then onto the workspace root, so the model can copy a real path
|
|
1840
|
+
* instead of inventing an absolute prefix.
|
|
1841
|
+
*/
|
|
1842
|
+
function parseOpenCode2DirectoryListing(output, workspaceRoot) {
|
|
1843
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1844
|
+
if (!normalized.startsWith("Read directory "))
|
|
1845
|
+
return undefined;
|
|
1846
|
+
const lines = normalized.split("\n");
|
|
1847
|
+
const header = /^Read directory (.*), (?:0 entries|entries (\d+)-(\d+))$/.exec((lines[0] ?? "").trim());
|
|
1848
|
+
if (!header) {
|
|
1849
|
+
trace("parseOpenCode2DirectoryListing: OpenCode 2 directory header did not match — leaving output unchanged");
|
|
1850
|
+
return undefined;
|
|
1851
|
+
}
|
|
1852
|
+
const requested = header[1] ?? "";
|
|
1853
|
+
if (!isAbsoluteToolPath(requested) && !workspaceRoot)
|
|
1854
|
+
return undefined;
|
|
1855
|
+
let bodyEnd = lines.length;
|
|
1856
|
+
let banner;
|
|
1857
|
+
if (bodyEnd > 1 && OPENCODE2_READ_TRUNCATION.test(lines[bodyEnd - 1] ?? "")) {
|
|
1858
|
+
banner = lines[bodyEnd - 1];
|
|
1859
|
+
bodyEnd -= 1;
|
|
1860
|
+
}
|
|
1861
|
+
const rawEntries = lines.slice(1, bodyEnd);
|
|
1862
|
+
if (header[2] === undefined) {
|
|
1863
|
+
if (rawEntries.length !== 0)
|
|
1864
|
+
return undefined;
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
const start = Number(header[2]);
|
|
1868
|
+
const end = Number(header[3]);
|
|
1869
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end - start + 1 !== rawEntries.length) {
|
|
1870
|
+
return undefined;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
const directory = resolveToolPath(requested, workspaceRoot);
|
|
1874
|
+
const directories = [];
|
|
1875
|
+
const files = [];
|
|
1876
|
+
const entries = rawEntries.map((entry) => {
|
|
1877
|
+
const resolved = resolveListedEntry(directory, entry);
|
|
1878
|
+
if (entry.endsWith("/") || entry.endsWith("\\"))
|
|
1879
|
+
directories.push(resolved);
|
|
1880
|
+
else
|
|
1881
|
+
files.push(resolved);
|
|
1882
|
+
return resolved;
|
|
1883
|
+
});
|
|
1884
|
+
const headerLine = header[2] === undefined
|
|
1885
|
+
? `Read directory ${directory}, 0 entries`
|
|
1886
|
+
: `Read directory ${directory}, entries ${header[2]}-${header[3]}`;
|
|
1887
|
+
return {
|
|
1888
|
+
directory,
|
|
1889
|
+
entries,
|
|
1890
|
+
directories,
|
|
1891
|
+
files,
|
|
1892
|
+
text: [headerLine, ...entries, ...(banner ? [banner] : [])].join("\n"),
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Absolute on this host, plus Windows drive and UNC paths. A posix agent must
|
|
1897
|
+
* not prefix `C:/…` or `\\server\…` just because `path.isAbsolute` rejects them.
|
|
1898
|
+
*/
|
|
1899
|
+
function isAbsoluteToolPath(filePath) {
|
|
1900
|
+
return path.isAbsolute(filePath)
|
|
1901
|
+
|| /^[A-Za-z]:[\\/]/.test(filePath)
|
|
1902
|
+
|| filePath.startsWith("\\\\");
|
|
1903
|
+
}
|
|
1904
|
+
/** Windows drive or UNC path that `path.isAbsolute` rejects on this host. */
|
|
1905
|
+
function isForeignAbsoluteToolPath(filePath) {
|
|
1906
|
+
return isAbsoluteToolPath(filePath) && !path.isAbsolute(filePath);
|
|
1907
|
+
}
|
|
1908
|
+
/** Separator already used by a drive or UNC path. Host paths keep `path.sep`. */
|
|
1909
|
+
function toolPathSeparator(filePath) {
|
|
1910
|
+
if (path.isAbsolute(filePath))
|
|
1911
|
+
return path.sep;
|
|
1912
|
+
if (filePath.startsWith("\\\\") || (filePath.includes("\\") && !filePath.includes("/")))
|
|
1913
|
+
return "\\";
|
|
1914
|
+
return "/";
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* Join `relative` onto `root`. Node's `path.resolve` treats `C:/…` and
|
|
1918
|
+
* `\\server\…` as relative on posix and prefixes the process cwd. Keep those
|
|
1919
|
+
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1920
|
+
*/
|
|
1921
|
+
function joinToolPath(root, relative) {
|
|
1922
|
+
if (!isForeignAbsoluteToolPath(root))
|
|
1923
|
+
return path.resolve(root, relative);
|
|
1924
|
+
const sep = toolPathSeparator(root);
|
|
1925
|
+
const base = splitForeignAbsolute(root, sep);
|
|
1926
|
+
const parts = [...base.segments];
|
|
1927
|
+
for (const part of relative.split(/[\\/]+/)) {
|
|
1928
|
+
if (!part || part === ".")
|
|
1929
|
+
continue;
|
|
1930
|
+
if (part === "..") {
|
|
1931
|
+
if (parts.length > base.frozen)
|
|
1932
|
+
parts.pop();
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
parts.push(part);
|
|
1936
|
+
}
|
|
1937
|
+
return parts.length === 0 ? base.prefix : `${base.prefix}${parts.join(sep)}`;
|
|
1938
|
+
}
|
|
1939
|
+
function splitForeignAbsolute(filePath, sep) {
|
|
1940
|
+
if (filePath.startsWith("\\\\")) {
|
|
1941
|
+
const segments = filePath.slice(2).split(/[\\/]+/).filter(Boolean);
|
|
1942
|
+
return { prefix: "\\\\", segments, frozen: Math.min(2, segments.length) };
|
|
1943
|
+
}
|
|
1944
|
+
const rest = filePath.slice(2).replace(/^[\\/]+/, "");
|
|
1945
|
+
return {
|
|
1946
|
+
prefix: `${filePath.slice(0, 2)}${sep}`,
|
|
1947
|
+
segments: rest ? rest.split(/[\\/]+/).filter(Boolean) : [],
|
|
1948
|
+
frozen: 0,
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
function isRelativePathToken(token) {
|
|
1952
|
+
if (!token || isAbsoluteToolPath(token))
|
|
1953
|
+
return false;
|
|
1954
|
+
// A slash alone does not make arbitrary shell output a path. In particular,
|
|
1955
|
+
// compact JSON, quoted strings, package ids, and shell syntax must remain
|
|
1956
|
+
// byte-for-byte model-visible rather than being prefixed with the workspace.
|
|
1957
|
+
if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
|
|
1958
|
+
return false;
|
|
1959
|
+
if (token.startsWith("./")
|
|
1960
|
+
|| token.startsWith("../")
|
|
1961
|
+
|| token.startsWith(".\\")
|
|
1962
|
+
|| token.startsWith("..\\"))
|
|
1963
|
+
return true;
|
|
1964
|
+
return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
|
|
1965
|
+
}
|
|
1966
|
+
function resolveListedEntry(directory, entry) {
|
|
1967
|
+
if (!entry || isAbsoluteToolPath(entry))
|
|
1968
|
+
return entry;
|
|
1969
|
+
if (entry === "~" || entry.startsWith("~/") || entry.startsWith("~\\"))
|
|
1970
|
+
return entry;
|
|
1971
|
+
const directoryEntry = entry.endsWith("/") || entry.endsWith("\\");
|
|
1972
|
+
const resolved = joinToolPath(directory, entry);
|
|
1973
|
+
if (!directoryEntry || resolved.endsWith("/") || resolved.endsWith("\\"))
|
|
1974
|
+
return resolved;
|
|
1975
|
+
return `${resolved}${toolPathSeparator(resolved)}`;
|
|
1976
|
+
}
|
|
1977
|
+
function resolveToolPath(filePath, workspaceRoot) {
|
|
1978
|
+
if (!filePath || isAbsoluteToolPath(filePath))
|
|
1979
|
+
return filePath;
|
|
1980
|
+
if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\"))
|
|
1981
|
+
return filePath;
|
|
1982
|
+
if (!workspaceRoot)
|
|
1983
|
+
return filePath;
|
|
1984
|
+
return joinToolPath(workspaceRoot, filePath);
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Rewrite OpenCode 2 grep/glob lines that are still project-relative. Absolute
|
|
1988
|
+
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1989
|
+
* drop the trailing colon only in the structured file list, not in this text.
|
|
1990
|
+
*/
|
|
1991
|
+
function groundSearchOutput(output, workspaceRoot) {
|
|
1992
|
+
if (!workspaceRoot)
|
|
1993
|
+
return output;
|
|
1994
|
+
const normalized = normalizeToolText(output);
|
|
1995
|
+
const first = normalized.split("\n", 1)[0] ?? "";
|
|
1996
|
+
const searchShaped = /^Found \d+ matches/.test(first)
|
|
1997
|
+
|| first === "No matches found"
|
|
1998
|
+
|| first === "No files found";
|
|
1999
|
+
if (!searchShaped && !isBarePathList(normalized))
|
|
2000
|
+
return output;
|
|
2001
|
+
return normalized.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
|
|
2002
|
+
}
|
|
2003
|
+
function isBarePathList(output) {
|
|
2004
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2005
|
+
if (lines.length === 0 || lines.length > 2000)
|
|
2006
|
+
return false;
|
|
2007
|
+
return lines.every((line) => {
|
|
2008
|
+
if (line.startsWith("("))
|
|
2009
|
+
return true;
|
|
2010
|
+
if (/\s/.test(line) || line.includes("://"))
|
|
2011
|
+
return false;
|
|
2012
|
+
return true;
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2016
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2017
|
+
return line;
|
|
2018
|
+
if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
|
|
2019
|
+
return line;
|
|
2020
|
+
const header = /^(.*):$/.exec(line);
|
|
2021
|
+
if (header && !header[1]?.includes("://")) {
|
|
2022
|
+
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
2023
|
+
}
|
|
2024
|
+
if (line.includes("://"))
|
|
2025
|
+
return line;
|
|
2026
|
+
return resolveToolPath(line, workspaceRoot);
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
|
|
2030
|
+
* paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
|
|
2031
|
+
* status words alone.
|
|
2032
|
+
*/
|
|
2033
|
+
function groundShellPathText(output, root) {
|
|
2034
|
+
if (!root || !output)
|
|
2035
|
+
return output;
|
|
2036
|
+
return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
|
|
2037
|
+
}
|
|
2038
|
+
function shellPathRoot(resultMetadata, workspaceRoot) {
|
|
2039
|
+
const workingDirectory = str(resultMetadata?.working_directory)?.trim();
|
|
2040
|
+
if (!workingDirectory)
|
|
2041
|
+
return workspaceRoot;
|
|
2042
|
+
if (isAbsoluteToolPath(workingDirectory))
|
|
2043
|
+
return workingDirectory;
|
|
2044
|
+
return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
|
|
2045
|
+
}
|
|
2046
|
+
function rewriteShellPathLine(line, root) {
|
|
2047
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t"))
|
|
2048
|
+
return line;
|
|
2049
|
+
const trimmed = line.trimEnd();
|
|
2050
|
+
if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
2051
|
+
return line;
|
|
2052
|
+
}
|
|
2053
|
+
const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
|
|
2054
|
+
if (located && located[1] && isRelativePathToken(located[1])) {
|
|
2055
|
+
const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
|
|
2056
|
+
return `${resolveToolPath(located[1], root)}${suffix}`;
|
|
2057
|
+
}
|
|
2058
|
+
if (/\s/.test(trimmed))
|
|
2059
|
+
return line;
|
|
2060
|
+
const header = /^(.*):$/.exec(trimmed);
|
|
2061
|
+
if (header && header[1] && isRelativePathToken(header[1])) {
|
|
2062
|
+
return `${resolveToolPath(header[1], root)}:`;
|
|
2063
|
+
}
|
|
2064
|
+
if (isRelativePathToken(trimmed))
|
|
2065
|
+
return resolveToolPath(trimmed, root);
|
|
2066
|
+
return line;
|
|
2067
|
+
}
|
|
2068
|
+
const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
|
|
2069
|
+
/**
|
|
2070
|
+
* OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
|
|
2071
|
+
* and indented `Line N: text` previews. A page with no previews is not content.
|
|
2072
|
+
*/
|
|
2073
|
+
function parseOpenCodeGrepContent(output, workspaceRoot) {
|
|
2074
|
+
const lines = normalizeToolText(output).split("\n");
|
|
2075
|
+
const first = lines[0]?.trim() ?? "";
|
|
2076
|
+
if (!/^Found \d+ matches\b/.test(first))
|
|
2077
|
+
return undefined;
|
|
2078
|
+
const matches = [];
|
|
2079
|
+
let current;
|
|
2080
|
+
let truncated = /\bmore matches available\b/.test(first);
|
|
2081
|
+
let sawLine = false;
|
|
2082
|
+
for (const raw of lines.slice(1)) {
|
|
2083
|
+
if (!raw.trim())
|
|
2084
|
+
continue;
|
|
2085
|
+
const preview = OPENCODE_GREP_LINE.exec(raw);
|
|
2086
|
+
if (preview) {
|
|
2087
|
+
sawLine = true;
|
|
2088
|
+
if (!current)
|
|
2089
|
+
continue;
|
|
2090
|
+
const lineNumber = Number(preview[1]);
|
|
2091
|
+
if (!Number.isSafeInteger(lineNumber) || lineNumber < 1)
|
|
2092
|
+
continue;
|
|
2093
|
+
current.matches.push({ line_number: lineNumber, content: preview[2] ?? "" });
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
if (raw.trimStart().startsWith("(")) {
|
|
2097
|
+
if (/truncat/i.test(raw))
|
|
2098
|
+
truncated = true;
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
if (raw.startsWith(" ") || raw.startsWith("\t"))
|
|
2102
|
+
continue;
|
|
2103
|
+
const header = /^(.*):$/.exec(raw.trim());
|
|
2104
|
+
if (!header || !header[1] || header[1].includes("://"))
|
|
2105
|
+
continue;
|
|
2106
|
+
current = { file: resolveToolPath(header[1], workspaceRoot), matches: [] };
|
|
2107
|
+
matches.push(current);
|
|
2108
|
+
}
|
|
2109
|
+
if (!sawLine)
|
|
2110
|
+
return undefined;
|
|
2111
|
+
const withHits = matches.filter((file) => file.matches.length > 0);
|
|
2112
|
+
if (withHits.length === 0)
|
|
2113
|
+
return undefined;
|
|
2114
|
+
return {
|
|
2115
|
+
matches: withHits,
|
|
2116
|
+
truncated,
|
|
2117
|
+
totalMatchedLines: withHits.reduce((count, file) => count + file.matches.length, 0),
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function grepRequestResultMetadata(raw) {
|
|
2121
|
+
const pattern = str(raw.pattern);
|
|
2122
|
+
const outputMode = str(raw.output_mode);
|
|
2123
|
+
const searchPath = str(raw.path);
|
|
2124
|
+
if (!pattern && !outputMode && !searchPath)
|
|
2125
|
+
return undefined;
|
|
2126
|
+
return {
|
|
2127
|
+
...(pattern ? { pattern } : {}),
|
|
2128
|
+
...(outputMode ? { output_mode: outputMode } : {}),
|
|
2129
|
+
...(searchPath ? { path: searchPath } : {}),
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
function extractGroundedPaths(output, workspaceRoot) {
|
|
2133
|
+
const normalized = normalizeToolText(output);
|
|
2134
|
+
const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
|
|
2135
|
+
if (directory)
|
|
2136
|
+
return directory.entries.slice(0, 2000);
|
|
2137
|
+
const first = normalized.split("\n", 1)[0]?.trim() ?? "";
|
|
2138
|
+
if (/^Found \d+ matches/.test(first) || first === "No matches found" || first === "No files found") {
|
|
2139
|
+
const paths = [];
|
|
2140
|
+
for (const raw of normalized.split("\n").slice(1)) {
|
|
2141
|
+
const line = raw.trim();
|
|
2142
|
+
if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
const header = /^(.*):$/.exec(line);
|
|
2146
|
+
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2147
|
+
if (!candidate || candidate.includes("://"))
|
|
2148
|
+
continue;
|
|
2149
|
+
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2150
|
+
}
|
|
2151
|
+
return paths.slice(0, 2000);
|
|
2152
|
+
}
|
|
2153
|
+
if (isBarePathList(output)) {
|
|
2154
|
+
return output
|
|
2155
|
+
.split("\n")
|
|
2156
|
+
.map((line) => line.trim())
|
|
2157
|
+
.filter((line) => line && !line.startsWith("("))
|
|
2158
|
+
.map((line) => resolveToolPath(line.endsWith(":") && !line.includes("://") ? line.slice(0, -1) : line, workspaceRoot))
|
|
2159
|
+
.slice(0, 2000);
|
|
2160
|
+
}
|
|
2161
|
+
return extractPathLines(output).map((line) => {
|
|
2162
|
+
const trimmed = line.trim();
|
|
2163
|
+
const candidate = trimmed.endsWith(":") && !trimmed.includes("://") ? trimmed.slice(0, -1) : trimmed;
|
|
2164
|
+
return resolveToolPath(candidate, workspaceRoot);
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
1703
2167
|
/**
|
|
1704
2168
|
* Strip opencode's `read` envelope, leaving raw file content.
|
|
1705
2169
|
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
2170
|
+
* OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
|
|
2171
|
+
* models are trained on, but Cursor's are not:
|
|
1708
2172
|
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
1713
|
-
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
2173
|
+
* OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
|
|
2174
|
+
* the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
|
|
2175
|
+
* Forwarding either envelope verbatim made Cursor's model treat the wrapper as
|
|
2176
|
+
* literal file content and write tags or line prefixes back into files.
|
|
1714
2177
|
*
|
|
1715
2178
|
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
1716
2179
|
*
|
|
@@ -1723,6 +2186,12 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1723
2186
|
export function unwrapReadOutput(output) {
|
|
1724
2187
|
if (typeof output !== "string" || output.length === 0)
|
|
1725
2188
|
return output;
|
|
2189
|
+
const opencode2 = parseOpenCode2FileRead(output);
|
|
2190
|
+
if (opencode2)
|
|
2191
|
+
return opencode2.content;
|
|
2192
|
+
if (normalizeToolText(output).startsWith("Read file ")) {
|
|
2193
|
+
trace("unwrapReadOutput: OpenCode 2 read header did not match the page parser — leaving output unchanged");
|
|
2194
|
+
}
|
|
1726
2195
|
// Require the full opencode read-envelope skeleton *before* `<content>`
|
|
1727
2196
|
// (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
|
|
1728
2197
|
// stray "<content>" later in tool chatter can't trigger unwrapping just
|
|
@@ -1773,34 +2242,53 @@ export function unwrapReadOutput(output) {
|
|
|
1773
2242
|
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
1774
2243
|
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
1775
2244
|
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
1776
|
-
const
|
|
1777
|
-
|
|
2245
|
+
const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
|
|
2246
|
+
// A posix process must not turn `C:/…` or `\\server\…` into `<cwd>/C:/…`.
|
|
2247
|
+
const resultRoot = trimmedRoot
|
|
2248
|
+
? (isForeignAbsoluteToolPath(trimmedRoot) ? trimmedRoot : path.resolve(trimmedRoot))
|
|
1778
2249
|
: undefined;
|
|
1779
2250
|
switch (resultField) {
|
|
1780
2251
|
case "read_result": {
|
|
1781
|
-
const
|
|
2252
|
+
const parsedFile = parseOpenCode2FileRead(output, resultMetadata);
|
|
2253
|
+
const listing = error ? undefined : parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2254
|
+
const rawPath = str(resultMetadata?.path)
|
|
2255
|
+
?? parsedFile?.path
|
|
2256
|
+
?? listing?.directory
|
|
2257
|
+
?? extractPathTag(output)
|
|
2258
|
+
?? "";
|
|
2259
|
+
const readPath = resolveToolPath(rawPath, resultRoot);
|
|
1782
2260
|
if (error)
|
|
1783
2261
|
return { error: { path: readPath, error } };
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
2262
|
+
// The host may have resolved an alternate spelling after the request
|
|
2263
|
+
// (for example a Unicode-space filename). Use the result's own path for
|
|
2264
|
+
// local stat/newline recovery while preserving the requested path in the
|
|
2265
|
+
// Cursor result, matching the existing OpenCode 1 behavior.
|
|
2266
|
+
const outputPath = parsedFile?.path ?? extractPathTag(output);
|
|
2267
|
+
const statPath = resolveToolPath(outputPath ?? readPath, resultRoot);
|
|
2268
|
+
// Strip OpenCode's read envelope so Cursor's model receives raw file
|
|
2269
|
+
// content and can't echo the wrapper into subsequent writes.
|
|
1786
2270
|
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1787
2271
|
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1788
2272
|
// native channel, so this is the path that matters in practice.
|
|
1789
|
-
const
|
|
1790
|
-
const
|
|
1791
|
-
|
|
2273
|
+
const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2274
|
+
const content = listing
|
|
2275
|
+
? listing.text
|
|
2276
|
+
: restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
|
|
1792
2277
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1793
2278
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1794
2279
|
// `truncated` alone is not enough: it is set here, and models still assert
|
|
1795
2280
|
// the partial content is the whole file. Cursor's own executor puts the
|
|
1796
2281
|
// limit marker in the output text for the same reason, so append one.
|
|
1797
|
-
const
|
|
2282
|
+
const notices = [
|
|
2283
|
+
readTruncationNotice(output, resultMetadata),
|
|
2284
|
+
readLongLineTruncationNotice(output),
|
|
2285
|
+
].filter((notice) => !!notice);
|
|
1798
2286
|
return {
|
|
1799
2287
|
success: {
|
|
1800
|
-
path: readPath,
|
|
1801
|
-
content:
|
|
2288
|
+
path: listing?.directory || readPath,
|
|
2289
|
+
content: notices.length > 0 ? `${content}\n\n${notices.join("\n\n")}` : content,
|
|
1802
2290
|
total_lines: totalLines,
|
|
1803
|
-
file_size: readFileSize(statPath),
|
|
2291
|
+
file_size: listing ? 0 : readFileSize(statPath),
|
|
1804
2292
|
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
1805
2293
|
range_applied: rangeApplied,
|
|
1806
2294
|
},
|
|
@@ -1809,21 +2297,50 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1809
2297
|
case "grep_result": {
|
|
1810
2298
|
if (error)
|
|
1811
2299
|
return { error: { error } };
|
|
1812
|
-
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
1813
|
-
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
1814
|
-
const files = extractPathLines(output);
|
|
1815
2300
|
const cwd = resultRoot ?? "";
|
|
2301
|
+
const pattern = str(resultMetadata?.pattern) ?? "";
|
|
2302
|
+
const requestedPath = str(resultMetadata?.path) ?? cwd;
|
|
2303
|
+
const requestedMode = str(resultMetadata?.output_mode);
|
|
2304
|
+
// OpenCode always returns match previews. Cursor's native Grep defaults
|
|
2305
|
+
// to content; encoding that as files_with_matches drops the previews and
|
|
2306
|
+
// the model only sees paths. Keep files_with_matches for an explicit
|
|
2307
|
+
// files-only request, and for path lists (glob remaps, no matches).
|
|
2308
|
+
const parsedContent = parseOpenCodeGrepContent(output, resultRoot);
|
|
2309
|
+
const content = requestedMode === "files_with_matches"
|
|
2310
|
+
? undefined
|
|
2311
|
+
: parsedContent;
|
|
2312
|
+
if (content) {
|
|
2313
|
+
return {
|
|
2314
|
+
success: {
|
|
2315
|
+
pattern,
|
|
2316
|
+
path: requestedPath,
|
|
2317
|
+
output_mode: "content",
|
|
2318
|
+
workspace_results: {
|
|
2319
|
+
[cwd]: {
|
|
2320
|
+
content: {
|
|
2321
|
+
matches: content.matches,
|
|
2322
|
+
total_lines: content.totalMatchedLines,
|
|
2323
|
+
total_matched_lines: content.totalMatchedLines,
|
|
2324
|
+
client_truncated: content.truncated,
|
|
2325
|
+
ripgrep_truncated: false,
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
},
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
const files = extractGroundedPaths(output, resultRoot);
|
|
1816
2333
|
return {
|
|
1817
2334
|
success: {
|
|
1818
|
-
pattern
|
|
1819
|
-
path:
|
|
2335
|
+
pattern,
|
|
2336
|
+
path: requestedPath,
|
|
1820
2337
|
output_mode: "files_with_matches",
|
|
1821
2338
|
workspace_results: {
|
|
1822
2339
|
[cwd]: {
|
|
1823
2340
|
files: {
|
|
1824
2341
|
files,
|
|
1825
2342
|
total_files: files.length,
|
|
1826
|
-
client_truncated: false,
|
|
2343
|
+
client_truncated: parsedContent?.truncated ?? false,
|
|
1827
2344
|
},
|
|
1828
2345
|
},
|
|
1829
2346
|
},
|
|
@@ -1855,19 +2372,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1855
2372
|
// Pi results carry truncation structurally (PiReadExecSuccess field 2),
|
|
1856
2373
|
// which is how Cursor's own executors report a capped payload.
|
|
1857
2374
|
const content = unwrapReadOutput(output);
|
|
1858
|
-
const truncation = readTruncationMessage(output, content);
|
|
2375
|
+
const truncation = readTruncationMessage(output, content, resultMetadata);
|
|
1859
2376
|
return { success: { output: content, ...(truncation ? { truncation } : {}) } };
|
|
1860
2377
|
}
|
|
1861
2378
|
case "shell_result": {
|
|
1862
2379
|
const command = str(resultMetadata?.command) ?? "";
|
|
1863
2380
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2381
|
+
const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
|
|
1864
2382
|
if (error) {
|
|
1865
2383
|
return {
|
|
1866
2384
|
failure: {
|
|
1867
2385
|
command,
|
|
1868
2386
|
working_directory: workingDirectory,
|
|
1869
2387
|
exit_code: 1,
|
|
1870
|
-
stdout:
|
|
2388
|
+
stdout: stdout || "",
|
|
1871
2389
|
stderr: error,
|
|
1872
2390
|
aborted: false,
|
|
1873
2391
|
},
|
|
@@ -1888,7 +2406,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1888
2406
|
command: shellOutcome.command || command,
|
|
1889
2407
|
working_directory: shellOutcome.workingDirectory || workingDirectory,
|
|
1890
2408
|
exit_code: 0,
|
|
1891
|
-
stdout:
|
|
2409
|
+
stdout: stdout,
|
|
1892
2410
|
shell_id: shellOutcome.shellId,
|
|
1893
2411
|
pid: shellOutcome.pid,
|
|
1894
2412
|
ms_to_wait: shellOutcome.msToWait,
|
|
@@ -1906,18 +2424,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1906
2424
|
command,
|
|
1907
2425
|
working_directory: workingDirectory,
|
|
1908
2426
|
exit_code: exitCode,
|
|
1909
|
-
stdout:
|
|
2427
|
+
stdout: stdout,
|
|
1910
2428
|
},
|
|
1911
2429
|
};
|
|
1912
2430
|
}
|
|
1913
2431
|
case "pi_bash_result":
|
|
2432
|
+
if (error)
|
|
2433
|
+
return { error: { error } };
|
|
2434
|
+
return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
|
|
1914
2435
|
case "pi_edit_result":
|
|
2436
|
+
if (error)
|
|
2437
|
+
return { error: { error } };
|
|
2438
|
+
return { success: { output } };
|
|
1915
2439
|
case "pi_grep_result":
|
|
1916
2440
|
case "pi_find_result":
|
|
1917
|
-
case "pi_ls_result":
|
|
1918
2441
|
if (error)
|
|
1919
2442
|
return { error: { error } };
|
|
1920
|
-
return { success: { output } };
|
|
2443
|
+
return { success: { output: groundSearchOutput(output, resultRoot) } };
|
|
2444
|
+
case "pi_ls_result": {
|
|
2445
|
+
if (error)
|
|
2446
|
+
return { error: { error } };
|
|
2447
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2448
|
+
return { success: { output: listing?.text ?? groundSearchOutput(output, resultRoot) } };
|
|
2449
|
+
}
|
|
1921
2450
|
case "delete_result":
|
|
1922
2451
|
if (error)
|
|
1923
2452
|
return { error: { path: "", error } };
|
|
@@ -1963,17 +2492,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1963
2492
|
case "ls_result": {
|
|
1964
2493
|
if (error)
|
|
1965
2494
|
return { error: { path: "", error } };
|
|
1966
|
-
const
|
|
1967
|
-
const
|
|
2495
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2496
|
+
const rootPath = listing?.directory || resultRoot || "";
|
|
2497
|
+
const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
|
|
2498
|
+
const directories = listing?.directories ?? [];
|
|
2499
|
+
const files = listing?.files ?? entries;
|
|
1968
2500
|
return {
|
|
1969
2501
|
success: {
|
|
1970
2502
|
directory_tree_root: {
|
|
1971
2503
|
abs_path: rootPath,
|
|
1972
|
-
children_dirs:
|
|
1973
|
-
|
|
1974
|
-
|
|
2504
|
+
children_dirs: directories.map((entry) => ({
|
|
2505
|
+
abs_path: stripTrailingPathSeparator(entry),
|
|
2506
|
+
children_dirs: [],
|
|
2507
|
+
children_files: [],
|
|
2508
|
+
num_files: 0,
|
|
2509
|
+
})),
|
|
2510
|
+
children_files: files.map((entry) => ({
|
|
2511
|
+
name: listedEntryName(entry),
|
|
1975
2512
|
})),
|
|
1976
|
-
num_files:
|
|
2513
|
+
num_files: files.length,
|
|
1977
2514
|
},
|
|
1978
2515
|
},
|
|
1979
2516
|
};
|
|
@@ -1985,17 +2522,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1985
2522
|
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
1986
2523
|
// "read" so a non-read MCP tool whose output merely contains a
|
|
1987
2524
|
// "<content>"-like block is never rewritten.
|
|
2525
|
+
if (toolName === "grep" || toolName === "glob") {
|
|
2526
|
+
return {
|
|
2527
|
+
success: {
|
|
2528
|
+
content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
|
|
2529
|
+
is_error: false,
|
|
2530
|
+
},
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
1988
2533
|
if (toolName !== "read") {
|
|
1989
2534
|
return { success: { content: [{ text: { text: output } }], is_error: false } };
|
|
1990
2535
|
}
|
|
2536
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2537
|
+
if (listing) {
|
|
2538
|
+
return {
|
|
2539
|
+
success: { content: [{ text: { text: listing.text } }], is_error: false },
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
1991
2542
|
// Carry the truncation notice as its own content item: the file content
|
|
1992
2543
|
// item stays byte-exact, so it can still never be echoed into a write.
|
|
1993
|
-
const
|
|
2544
|
+
const notices = [
|
|
2545
|
+
readTruncationNotice(output, resultMetadata),
|
|
2546
|
+
readLongLineTruncationNotice(output),
|
|
2547
|
+
].filter((notice) => !!notice);
|
|
1994
2548
|
return {
|
|
1995
2549
|
success: {
|
|
1996
2550
|
content: [
|
|
1997
2551
|
{ text: { text: unwrapReadOutput(output) } },
|
|
1998
|
-
...(notice
|
|
2552
|
+
...notices.map((notice) => ({ text: { text: notice } })),
|
|
1999
2553
|
],
|
|
2000
2554
|
is_error: false,
|
|
2001
2555
|
},
|
|
@@ -2072,7 +2626,19 @@ function readEnvelopeFooter(output) {
|
|
|
2072
2626
|
return output.slice(start + 2, close);
|
|
2073
2627
|
}
|
|
2074
2628
|
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
2075
|
-
function parseOpenCodeReadMetadata(output) {
|
|
2629
|
+
function parseOpenCodeReadMetadata(output, resultMetadata) {
|
|
2630
|
+
const opencode2 = parseOpenCode2FileRead(output, resultMetadata);
|
|
2631
|
+
if (opencode2) {
|
|
2632
|
+
return {
|
|
2633
|
+
...(opencode2.startLine !== undefined ? { startLine: opencode2.startLine } : {}),
|
|
2634
|
+
...(opencode2.endLine !== undefined ? { endLine: opencode2.endLine } : {}),
|
|
2635
|
+
...(opencode2.totalLines !== undefined ? { totalLines: opencode2.totalLines } : {}),
|
|
2636
|
+
...(opencode2.nextOffset !== undefined ? { nextOffset: opencode2.nextOffset } : {}),
|
|
2637
|
+
outputCapped: opencode2.outputCapped,
|
|
2638
|
+
hostTruncated: opencode2.hostTruncated,
|
|
2639
|
+
...(opencode2.truncatedLines.length > 0 ? { truncatedLines: opencode2.truncatedLines } : {}),
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2076
2642
|
const footer = readEnvelopeFooter(output);
|
|
2077
2643
|
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
|
|
2078
2644
|
if (showing) {
|
|
@@ -2104,9 +2670,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
2104
2670
|
|| num(resultMetadata?.limit) !== undefined
|
|
2105
2671
|
|| content.endsWith("\n"))
|
|
2106
2672
|
return content;
|
|
2107
|
-
const absolute =
|
|
2673
|
+
const absolute = isAbsoluteToolPath(readPath)
|
|
2108
2674
|
? readPath
|
|
2109
|
-
:
|
|
2675
|
+
: joinToolPath(workspaceRoot ?? process.cwd(), readPath);
|
|
2110
2676
|
let fd;
|
|
2111
2677
|
try {
|
|
2112
2678
|
fd = fs.openSync(absolute, "r");
|
|
@@ -2153,8 +2719,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
2153
2719
|
* that, keeping the signal out of the file content itself so it still cannot be
|
|
2154
2720
|
* echoed into a write.
|
|
2155
2721
|
*/
|
|
2156
|
-
function readTruncationSummary(output) {
|
|
2157
|
-
const meta = parseOpenCodeReadMetadata(output);
|
|
2722
|
+
function readTruncationSummary(output, resultMetadata) {
|
|
2723
|
+
const meta = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2158
2724
|
if (meta.startLine === undefined || meta.endLine === undefined)
|
|
2159
2725
|
return undefined;
|
|
2160
2726
|
// A complete read reports "(End of file …)" and never reaches this shape.
|
|
@@ -2163,7 +2729,7 @@ function readTruncationSummary(output) {
|
|
|
2163
2729
|
return {
|
|
2164
2730
|
startLine: meta.startLine,
|
|
2165
2731
|
endLine: meta.endLine,
|
|
2166
|
-
nextOffset: meta.endLine + 1,
|
|
2732
|
+
nextOffset: meta.nextOffset ?? meta.endLine + 1,
|
|
2167
2733
|
capped: meta.outputCapped === true,
|
|
2168
2734
|
...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
|
|
2169
2735
|
};
|
|
@@ -2176,7 +2742,7 @@ function readTruncationSummary(output) {
|
|
|
2176
2742
|
* would cry wolf on every deliberate paged read.
|
|
2177
2743
|
*/
|
|
2178
2744
|
function readTruncationNotice(output, resultMetadata) {
|
|
2179
|
-
const summary = readTruncationSummary(output);
|
|
2745
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2180
2746
|
if (!summary)
|
|
2181
2747
|
return undefined;
|
|
2182
2748
|
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
@@ -2191,18 +2757,35 @@ function readTruncationNotice(output, resultMetadata) {
|
|
|
2191
2757
|
`acting on the whole file; writing the content above back would delete everything ` +
|
|
2192
2758
|
`after line ${summary.endLine}.]`);
|
|
2193
2759
|
}
|
|
2760
|
+
function readLongLineTruncationNotice(output) {
|
|
2761
|
+
const lines = parseOpenCodeReadMetadata(output).truncatedLines;
|
|
2762
|
+
if (!lines || lines.length === 0)
|
|
2763
|
+
return undefined;
|
|
2764
|
+
const displayed = lines.slice(0, 8).join(", ");
|
|
2765
|
+
const remainder = lines.length > 8 ? ` and ${lines.length - 8} more` : "";
|
|
2766
|
+
return (`[Partial read: OpenCode shortened ${lines.length === 1 ? "line" : "lines"} ` +
|
|
2767
|
+
`${displayed}${remainder} to ${OPENCODE2_READ_MAX_LINE_CHARS} characters. ` +
|
|
2768
|
+
"It is NOT the complete file. Use a byte-preserving read method to inspect the full " +
|
|
2769
|
+
"line content before acting on the whole file; writing the content above back would lose data.]");
|
|
2770
|
+
}
|
|
2194
2771
|
/** agent.v1.PiTruncation for a capped OpenCode read. */
|
|
2195
|
-
function readTruncationMessage(output, content) {
|
|
2196
|
-
const summary = readTruncationSummary(output);
|
|
2197
|
-
|
|
2772
|
+
function readTruncationMessage(output, content, resultMetadata) {
|
|
2773
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2774
|
+
const longLines = parseOpenCodeReadMetadata(output, resultMetadata).truncatedLines;
|
|
2775
|
+
if (!summary && (!longLines || longLines.length === 0))
|
|
2776
|
+
return undefined;
|
|
2777
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2778
|
+
if (summary && rangeRequested && !summary.capped && (!longLines || longLines.length === 0))
|
|
2198
2779
|
return undefined;
|
|
2199
2780
|
return {
|
|
2200
2781
|
truncated: true,
|
|
2201
|
-
truncated_by: summary
|
|
2202
|
-
...(summary
|
|
2203
|
-
output_lines:
|
|
2782
|
+
truncated_by: summary?.capped ? "bytes" : longLines?.length ? "characters" : "lines",
|
|
2783
|
+
...(summary?.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
|
|
2784
|
+
output_lines: summary
|
|
2785
|
+
? Math.max(0, summary.endLine - summary.startLine + 1)
|
|
2786
|
+
: countLines(content),
|
|
2204
2787
|
output_bytes: Buffer.byteLength(content, "utf8"),
|
|
2205
|
-
...(summary
|
|
2788
|
+
...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
|
|
2206
2789
|
};
|
|
2207
2790
|
}
|
|
2208
2791
|
/** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
|
|
@@ -2218,8 +2801,15 @@ function readRangeApplied(resultMetadata, totalLines) {
|
|
|
2218
2801
|
return startLine < 0 || startLine <= totalLines;
|
|
2219
2802
|
}
|
|
2220
2803
|
function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
2804
|
+
if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
|
|
2805
|
+
return true;
|
|
2221
2806
|
if (outputMetadata.outputCapped)
|
|
2222
2807
|
return true;
|
|
2808
|
+
if (outputMetadata.hostTruncated) {
|
|
2809
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2810
|
+
if (!rangeRequested)
|
|
2811
|
+
return true;
|
|
2812
|
+
}
|
|
2223
2813
|
const returnedEnd = outputMetadata.endLine;
|
|
2224
2814
|
if (returnedEnd === undefined || totalLines === 0)
|
|
2225
2815
|
return false;
|
|
@@ -2233,6 +2823,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
|
2233
2823
|
: Math.min(totalLines, Math.max(1, startLine) + limit - 1);
|
|
2234
2824
|
return returnedEnd < expectedEnd;
|
|
2235
2825
|
}
|
|
2826
|
+
function listedEntryName(entry) {
|
|
2827
|
+
const trimmed = stripTrailingPathSeparator(entry);
|
|
2828
|
+
const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
2829
|
+
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
|
2830
|
+
}
|
|
2831
|
+
function stripTrailingPathSeparator(entry) {
|
|
2832
|
+
return entry.endsWith("/") || entry.endsWith("\\") ? entry.slice(0, -1) : entry;
|
|
2833
|
+
}
|
|
2236
2834
|
function readFileSize(filePath) {
|
|
2237
2835
|
if (!filePath)
|
|
2238
2836
|
return 0;
|
|
@@ -1 +1,17 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type WorkspaceGroundingOptions = {
|
|
2
|
+
/** OpenCode 2 file tools take `path` and do not say it must be absolute. */
|
|
3
|
+
requireAbsolutePathArg?: boolean;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Attach the exact workspace root to a model-visible string.
|
|
7
|
+
*
|
|
8
|
+
* Idempotent: a second call does not stack another root line. The absolute-path
|
|
9
|
+
* sentence is added only when requested, and only once.
|
|
10
|
+
*/
|
|
11
|
+
export declare function appendWorkspaceRootGrounding(reason: string, workspaceRoot: string | undefined, options?: WorkspaceGroundingOptions): string;
|
|
12
|
+
/**
|
|
13
|
+
* Checkpointed Runs omit the system prompt, which is where the root normally
|
|
14
|
+
* lives. Put the same reminder on the live user message so a later turn cannot
|
|
15
|
+
* invent an absolute prefix before any tool result arrives.
|
|
16
|
+
*/
|
|
17
|
+
export declare function appendCheckpointUserGrounding(userText: string, workspaceRoot: string | undefined, options?: WorkspaceGroundingOptions): string;
|
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
|
|
1
|
+
const ABSOLUTE_PATH_ARG = "OpenCode file tools take `path` as an absolute path under this root. Do not pass a project-relative path, and do not invent a different absolute prefix.";
|
|
2
|
+
/**
|
|
3
|
+
* Attach the exact workspace root to a model-visible string.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent: a second call does not stack another root line. The absolute-path
|
|
6
|
+
* sentence is added only when requested, and only once.
|
|
7
|
+
*/
|
|
8
|
+
export function appendWorkspaceRootGrounding(reason, workspaceRoot, options) {
|
|
2
9
|
if (!workspaceRoot || !workspaceRoot.trim())
|
|
3
10
|
return reason;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
const pathSentence = options?.requireAbsolutePathArg ? ` ${ABSOLUTE_PATH_ARG}` : "";
|
|
12
|
+
if (reason.includes("Workspace root:")) {
|
|
13
|
+
if (!pathSentence || reason.includes("take `path` as an absolute path"))
|
|
14
|
+
return reason;
|
|
15
|
+
return `${reason}${pathSentence}`;
|
|
16
|
+
}
|
|
17
|
+
const note = `Workspace root: ${JSON.stringify(workspaceRoot)}. ` +
|
|
18
|
+
"Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them." +
|
|
19
|
+
pathSentence;
|
|
20
|
+
if (!reason)
|
|
21
|
+
return note;
|
|
22
|
+
return `${reason}\n${note}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Checkpointed Runs omit the system prompt, which is where the root normally
|
|
26
|
+
* lives. Put the same reminder on the live user message so a later turn cannot
|
|
27
|
+
* invent an absolute prefix before any tool result arrives.
|
|
28
|
+
*/
|
|
29
|
+
export function appendCheckpointUserGrounding(userText, workspaceRoot, options) {
|
|
30
|
+
const note = appendWorkspaceRootGrounding("", workspaceRoot, options);
|
|
31
|
+
if (!note)
|
|
32
|
+
return userText;
|
|
33
|
+
if (userText.includes("Workspace root:")) {
|
|
34
|
+
return appendWorkspaceRootGrounding(userText, workspaceRoot, options);
|
|
35
|
+
}
|
|
36
|
+
return userText ? `${userText}\n\n${note}` : note;
|
|
8
37
|
}
|