cursor-opencode-provider 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/language-model.d.ts +9 -0
- package/dist/language-model.js +20 -2
- package/dist/protocol/tools.d.ts +6 -7
- package/dist/protocol/tools.js +563 -60
- 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
|
}
|
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,12 +1047,8 @@ 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
|
-
path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
|
|
1034
|
-
...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
|
|
1035
|
-
...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
|
|
1036
|
-
}
|
|
1050
|
+
: execVariant === "read_args" || execVariant === "pi_read_args"
|
|
1051
|
+
? readRequestResultMetadata(rawArgs, mapped.args)
|
|
1037
1052
|
: undefined;
|
|
1038
1053
|
if (resultMetadata
|
|
1039
1054
|
&& resultMetadata.timeout_behavior !== 2
|
|
@@ -1415,9 +1430,11 @@ export function isUriReadTarget(requested) {
|
|
|
1415
1430
|
*/
|
|
1416
1431
|
export function resolveReadTargetPath(requested, workspaceRoot) {
|
|
1417
1432
|
const expanded = untildify(requested);
|
|
1418
|
-
if (
|
|
1419
|
-
return path.resolve(
|
|
1433
|
+
if (isAbsoluteToolPath(expanded)) {
|
|
1434
|
+
return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
|
|
1420
1435
|
}
|
|
1436
|
+
if (workspaceRoot)
|
|
1437
|
+
return joinToolPath(workspaceRoot, expanded);
|
|
1421
1438
|
return path.resolve(expanded);
|
|
1422
1439
|
}
|
|
1423
1440
|
/**
|
|
@@ -1482,6 +1499,7 @@ export function buildExecClientMessages(input) {
|
|
|
1482
1499
|
const resultField = input.resultField || "mcp_result";
|
|
1483
1500
|
const frames = [];
|
|
1484
1501
|
if (resultField === "shell_stream") {
|
|
1502
|
+
const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
|
|
1485
1503
|
// Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
|
|
1486
1504
|
frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
|
|
1487
1505
|
if (input.error) {
|
|
@@ -1489,8 +1507,8 @@ export function buildExecClientMessages(input) {
|
|
|
1489
1507
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
|
|
1490
1508
|
}
|
|
1491
1509
|
else {
|
|
1492
|
-
if (
|
|
1493
|
-
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data:
|
|
1510
|
+
if (stdout) {
|
|
1511
|
+
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
|
|
1494
1512
|
}
|
|
1495
1513
|
if (input.shellOutcome?.kind === "backgrounded") {
|
|
1496
1514
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
@@ -1700,17 +1718,396 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1700
1718
|
frames.push(buildExecStreamClose(execId));
|
|
1701
1719
|
return frames;
|
|
1702
1720
|
}
|
|
1721
|
+
/** OpenCode 2 `read` prints this when a text page does not reach EOF. */
|
|
1722
|
+
const OPENCODE2_READ_TRUNCATION = /^\[Output truncated\. Continue reading with offset:\s*(\d+)\]\s*$/;
|
|
1723
|
+
/** OpenCode 2 stops a read at 2,000 lines as well as 50 KB. */
|
|
1724
|
+
const OPENCODE2_READ_MAX_LINES = 2_000;
|
|
1725
|
+
const OPENCODE2_READ_MAX_LINE_CHARS = 2_000;
|
|
1726
|
+
const OPENCODE2_READ_LINE_TRUNCATION = `... (line truncated to ${OPENCODE2_READ_MAX_LINE_CHARS} chars)`;
|
|
1727
|
+
/**
|
|
1728
|
+
* `read-filesystem.ts` shortens each selected line to 2,000 UTF-16 code units.
|
|
1729
|
+
* One code unit encodes to at most three UTF-8 bytes. Add the 34-byte
|
|
1730
|
+
* `... (line truncated to 2000 chars)` suffix and one line separator when
|
|
1731
|
+
* proving a page was close enough for the next line not to fit.
|
|
1732
|
+
*/
|
|
1733
|
+
const OPENCODE2_MAX_RENDERED_LINE_BYTES = (OPENCODE2_READ_MAX_LINE_CHARS * 3) + Buffer.byteLength(OPENCODE2_READ_LINE_TRUNCATION, "utf8") + 1;
|
|
1734
|
+
function normalizeToolText(output) {
|
|
1735
|
+
return output.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* OpenCode 2 `ReadTool.toModelContent` for a file. The whole string must match;
|
|
1739
|
+
* a file that merely quotes the header stays a numbered body line.
|
|
1740
|
+
*
|
|
1741
|
+
* Read file <path>, lines <start>-<end>
|
|
1742
|
+
* <n>: <line>
|
|
1743
|
+
* [Output truncated. Continue reading with offset: <next>]
|
|
1744
|
+
*/
|
|
1745
|
+
function parseOpenCode2FileRead(output, resultMetadata) {
|
|
1746
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1747
|
+
if (!normalized.startsWith("Read file "))
|
|
1748
|
+
return undefined;
|
|
1749
|
+
const lines = normalized.split("\n");
|
|
1750
|
+
const headerLine = (lines[0] ?? "").trim();
|
|
1751
|
+
const empty = /^Read file (.*), 0 lines$/.exec(headerLine);
|
|
1752
|
+
if (empty) {
|
|
1753
|
+
if (lines.length !== 1)
|
|
1754
|
+
return undefined;
|
|
1755
|
+
return {
|
|
1756
|
+
path: empty[1] ?? "",
|
|
1757
|
+
content: "",
|
|
1758
|
+
totalLines: 0,
|
|
1759
|
+
hostTruncated: false,
|
|
1760
|
+
outputCapped: false,
|
|
1761
|
+
truncatedLines: [],
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
const header = /^Read file (.*), lines (\d+)-(\d+)$/.exec(headerLine);
|
|
1765
|
+
if (!header)
|
|
1766
|
+
return undefined;
|
|
1767
|
+
const start = Number(header[2]);
|
|
1768
|
+
const end = Number(header[3]);
|
|
1769
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
|
|
1770
|
+
return undefined;
|
|
1771
|
+
}
|
|
1772
|
+
let bodyEnd = lines.length;
|
|
1773
|
+
let nextOffset;
|
|
1774
|
+
const banner = OPENCODE2_READ_TRUNCATION.exec(lines[lines.length - 1] ?? "");
|
|
1775
|
+
if (banner) {
|
|
1776
|
+
nextOffset = Number(banner[1]);
|
|
1777
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset < 1)
|
|
1778
|
+
return undefined;
|
|
1779
|
+
bodyEnd -= 1;
|
|
1780
|
+
}
|
|
1781
|
+
const expected = end - start + 1;
|
|
1782
|
+
if (bodyEnd - 1 !== expected)
|
|
1783
|
+
return undefined;
|
|
1784
|
+
const raw = [];
|
|
1785
|
+
const truncatedLines = [];
|
|
1786
|
+
for (let index = 0; index < expected; index++) {
|
|
1787
|
+
const match = /^(\d+):[ \t](.*)$/.exec(lines[index + 1] ?? "");
|
|
1788
|
+
if (!match || Number(match[1]) !== start + index)
|
|
1789
|
+
return undefined;
|
|
1790
|
+
const value = match[2] ?? "";
|
|
1791
|
+
if (value.length === OPENCODE2_READ_MAX_LINE_CHARS + OPENCODE2_READ_LINE_TRUNCATION.length
|
|
1792
|
+
&& value.endsWith(OPENCODE2_READ_LINE_TRUNCATION)) {
|
|
1793
|
+
truncatedLines.push(start + index);
|
|
1794
|
+
}
|
|
1795
|
+
raw.push(value);
|
|
1796
|
+
}
|
|
1797
|
+
const content = raw.join("\n");
|
|
1798
|
+
const hostTruncated = nextOffset !== undefined;
|
|
1799
|
+
const requestedLimit = num(resultMetadata?.limit);
|
|
1800
|
+
const effectiveLineLimit = requestedLimit !== undefined && requestedLimit > 0
|
|
1801
|
+
? Math.min(requestedLimit, OPENCODE2_READ_MAX_LINES)
|
|
1802
|
+
: OPENCODE2_READ_MAX_LINES;
|
|
1803
|
+
// OpenCode 2 ends a text page only for the requested/default line limit or
|
|
1804
|
+
// the 50 KiB byte limit. Fewer lines than the effective line limit, together
|
|
1805
|
+
// with a page close enough that one maximum-size rendered line may not fit,
|
|
1806
|
+
// identifies the byte cap without the old ASCII-only 4 KiB guess.
|
|
1807
|
+
const outputCapped = hostTruncated
|
|
1808
|
+
&& expected < effectiveLineLimit
|
|
1809
|
+
&& Buffer.byteLength(content, "utf8") > OPENCODE_READ_MAX_BYTES - OPENCODE2_MAX_RENDERED_LINE_BYTES;
|
|
1810
|
+
if (hostTruncated) {
|
|
1811
|
+
return {
|
|
1812
|
+
path: header[1] ?? "",
|
|
1813
|
+
content,
|
|
1814
|
+
startLine: start,
|
|
1815
|
+
endLine: end,
|
|
1816
|
+
nextOffset,
|
|
1817
|
+
hostTruncated: true,
|
|
1818
|
+
outputCapped,
|
|
1819
|
+
truncatedLines,
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
// A page that reached EOF is complete. Record the span so a non-1 offset is
|
|
1823
|
+
// not later mistaken for a silent cap, without flagging it truncated.
|
|
1824
|
+
return {
|
|
1825
|
+
path: header[1] ?? "",
|
|
1826
|
+
content,
|
|
1827
|
+
...(start === 1
|
|
1828
|
+
? { totalLines: end }
|
|
1829
|
+
: { startLine: start, endLine: end, totalLines: end }),
|
|
1830
|
+
hostTruncated: false,
|
|
1831
|
+
outputCapped: false,
|
|
1832
|
+
truncatedLines,
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* OpenCode 2 directory reads list bare entry names. Join them onto the
|
|
1837
|
+
* directory, then onto the workspace root, so the model can copy a real path
|
|
1838
|
+
* instead of inventing an absolute prefix.
|
|
1839
|
+
*/
|
|
1840
|
+
function parseOpenCode2DirectoryListing(output, workspaceRoot) {
|
|
1841
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1842
|
+
if (!normalized.startsWith("Read directory "))
|
|
1843
|
+
return undefined;
|
|
1844
|
+
const lines = normalized.split("\n");
|
|
1845
|
+
const header = /^Read directory (.*), (?:0 entries|entries (\d+)-(\d+))$/.exec((lines[0] ?? "").trim());
|
|
1846
|
+
if (!header) {
|
|
1847
|
+
trace("parseOpenCode2DirectoryListing: OpenCode 2 directory header did not match — leaving output unchanged");
|
|
1848
|
+
return undefined;
|
|
1849
|
+
}
|
|
1850
|
+
const requested = header[1] ?? "";
|
|
1851
|
+
if (!isAbsoluteToolPath(requested) && !workspaceRoot)
|
|
1852
|
+
return undefined;
|
|
1853
|
+
let bodyEnd = lines.length;
|
|
1854
|
+
let banner;
|
|
1855
|
+
if (bodyEnd > 1 && OPENCODE2_READ_TRUNCATION.test(lines[bodyEnd - 1] ?? "")) {
|
|
1856
|
+
banner = lines[bodyEnd - 1];
|
|
1857
|
+
bodyEnd -= 1;
|
|
1858
|
+
}
|
|
1859
|
+
const rawEntries = lines.slice(1, bodyEnd);
|
|
1860
|
+
if (header[2] === undefined) {
|
|
1861
|
+
if (rawEntries.length !== 0)
|
|
1862
|
+
return undefined;
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
const start = Number(header[2]);
|
|
1866
|
+
const end = Number(header[3]);
|
|
1867
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end - start + 1 !== rawEntries.length) {
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const directory = resolveToolPath(requested, workspaceRoot);
|
|
1872
|
+
const directories = [];
|
|
1873
|
+
const files = [];
|
|
1874
|
+
const entries = rawEntries.map((entry) => {
|
|
1875
|
+
const resolved = resolveListedEntry(directory, entry);
|
|
1876
|
+
if (entry.endsWith("/") || entry.endsWith("\\"))
|
|
1877
|
+
directories.push(resolved);
|
|
1878
|
+
else
|
|
1879
|
+
files.push(resolved);
|
|
1880
|
+
return resolved;
|
|
1881
|
+
});
|
|
1882
|
+
const headerLine = header[2] === undefined
|
|
1883
|
+
? `Read directory ${directory}, 0 entries`
|
|
1884
|
+
: `Read directory ${directory}, entries ${header[2]}-${header[3]}`;
|
|
1885
|
+
return {
|
|
1886
|
+
directory,
|
|
1887
|
+
entries,
|
|
1888
|
+
directories,
|
|
1889
|
+
files,
|
|
1890
|
+
text: [headerLine, ...entries, ...(banner ? [banner] : [])].join("\n"),
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Absolute on this host, plus Windows drive and UNC paths. A posix agent must
|
|
1895
|
+
* not prefix `C:/…` or `\\server\…` just because `path.isAbsolute` rejects them.
|
|
1896
|
+
*/
|
|
1897
|
+
function isAbsoluteToolPath(filePath) {
|
|
1898
|
+
return path.isAbsolute(filePath)
|
|
1899
|
+
|| /^[A-Za-z]:[\\/]/.test(filePath)
|
|
1900
|
+
|| filePath.startsWith("\\\\");
|
|
1901
|
+
}
|
|
1902
|
+
/** Windows drive or UNC path that `path.isAbsolute` rejects on this host. */
|
|
1903
|
+
function isForeignAbsoluteToolPath(filePath) {
|
|
1904
|
+
return isAbsoluteToolPath(filePath) && !path.isAbsolute(filePath);
|
|
1905
|
+
}
|
|
1906
|
+
/** Separator already used by a drive or UNC path. Host paths keep `path.sep`. */
|
|
1907
|
+
function toolPathSeparator(filePath) {
|
|
1908
|
+
if (path.isAbsolute(filePath))
|
|
1909
|
+
return path.sep;
|
|
1910
|
+
if (filePath.startsWith("\\\\") || (filePath.includes("\\") && !filePath.includes("/")))
|
|
1911
|
+
return "\\";
|
|
1912
|
+
return "/";
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Join `relative` onto `root`. Node's `path.resolve` treats `C:/…` and
|
|
1916
|
+
* `\\server\…` as relative on posix and prefixes the process cwd. Keep those
|
|
1917
|
+
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1918
|
+
*/
|
|
1919
|
+
function joinToolPath(root, relative) {
|
|
1920
|
+
if (!isForeignAbsoluteToolPath(root))
|
|
1921
|
+
return path.resolve(root, relative);
|
|
1922
|
+
const sep = toolPathSeparator(root);
|
|
1923
|
+
const base = splitForeignAbsolute(root, sep);
|
|
1924
|
+
const parts = [...base.segments];
|
|
1925
|
+
for (const part of relative.split(/[\\/]+/)) {
|
|
1926
|
+
if (!part || part === ".")
|
|
1927
|
+
continue;
|
|
1928
|
+
if (part === "..") {
|
|
1929
|
+
if (parts.length > base.frozen)
|
|
1930
|
+
parts.pop();
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
parts.push(part);
|
|
1934
|
+
}
|
|
1935
|
+
return parts.length === 0 ? base.prefix : `${base.prefix}${parts.join(sep)}`;
|
|
1936
|
+
}
|
|
1937
|
+
function splitForeignAbsolute(filePath, sep) {
|
|
1938
|
+
if (filePath.startsWith("\\\\")) {
|
|
1939
|
+
const segments = filePath.slice(2).split(/[\\/]+/).filter(Boolean);
|
|
1940
|
+
return { prefix: "\\\\", segments, frozen: Math.min(2, segments.length) };
|
|
1941
|
+
}
|
|
1942
|
+
const rest = filePath.slice(2).replace(/^[\\/]+/, "");
|
|
1943
|
+
return {
|
|
1944
|
+
prefix: `${filePath.slice(0, 2)}${sep}`,
|
|
1945
|
+
segments: rest ? rest.split(/[\\/]+/).filter(Boolean) : [],
|
|
1946
|
+
frozen: 0,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function isRelativePathToken(token) {
|
|
1950
|
+
if (!token || isAbsoluteToolPath(token))
|
|
1951
|
+
return false;
|
|
1952
|
+
// A slash alone does not make arbitrary shell output a path. In particular,
|
|
1953
|
+
// compact JSON, quoted strings, package ids, and shell syntax must remain
|
|
1954
|
+
// byte-for-byte model-visible rather than being prefixed with the workspace.
|
|
1955
|
+
if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
|
|
1956
|
+
return false;
|
|
1957
|
+
if (token.startsWith("./")
|
|
1958
|
+
|| token.startsWith("../")
|
|
1959
|
+
|| token.startsWith(".\\")
|
|
1960
|
+
|| token.startsWith("..\\"))
|
|
1961
|
+
return true;
|
|
1962
|
+
return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
|
|
1963
|
+
}
|
|
1964
|
+
function resolveListedEntry(directory, entry) {
|
|
1965
|
+
if (!entry || isAbsoluteToolPath(entry))
|
|
1966
|
+
return entry;
|
|
1967
|
+
if (entry === "~" || entry.startsWith("~/") || entry.startsWith("~\\"))
|
|
1968
|
+
return entry;
|
|
1969
|
+
const directoryEntry = entry.endsWith("/") || entry.endsWith("\\");
|
|
1970
|
+
const resolved = joinToolPath(directory, entry);
|
|
1971
|
+
if (!directoryEntry || resolved.endsWith("/") || resolved.endsWith("\\"))
|
|
1972
|
+
return resolved;
|
|
1973
|
+
return `${resolved}${toolPathSeparator(resolved)}`;
|
|
1974
|
+
}
|
|
1975
|
+
function resolveToolPath(filePath, workspaceRoot) {
|
|
1976
|
+
if (!filePath || isAbsoluteToolPath(filePath))
|
|
1977
|
+
return filePath;
|
|
1978
|
+
if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\"))
|
|
1979
|
+
return filePath;
|
|
1980
|
+
if (!workspaceRoot)
|
|
1981
|
+
return filePath;
|
|
1982
|
+
return joinToolPath(workspaceRoot, filePath);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Rewrite OpenCode 2 grep/glob lines that are still project-relative. Absolute
|
|
1986
|
+
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1987
|
+
* drop the trailing colon only in the structured file list, not in this text.
|
|
1988
|
+
*/
|
|
1989
|
+
function groundSearchOutput(output, workspaceRoot) {
|
|
1990
|
+
if (!workspaceRoot)
|
|
1991
|
+
return output;
|
|
1992
|
+
const normalized = normalizeToolText(output);
|
|
1993
|
+
const first = normalized.split("\n", 1)[0] ?? "";
|
|
1994
|
+
const searchShaped = /^Found \d+ matches/.test(first)
|
|
1995
|
+
|| first === "No matches found"
|
|
1996
|
+
|| first === "No files found";
|
|
1997
|
+
if (!searchShaped && !isBarePathList(normalized))
|
|
1998
|
+
return output;
|
|
1999
|
+
return normalized.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
|
|
2000
|
+
}
|
|
2001
|
+
function isBarePathList(output) {
|
|
2002
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2003
|
+
if (lines.length === 0 || lines.length > 2000)
|
|
2004
|
+
return false;
|
|
2005
|
+
return lines.every((line) => {
|
|
2006
|
+
if (line.startsWith("("))
|
|
2007
|
+
return true;
|
|
2008
|
+
if (/\s/.test(line) || line.includes("://"))
|
|
2009
|
+
return false;
|
|
2010
|
+
return true;
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2014
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2015
|
+
return line;
|
|
2016
|
+
if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
|
|
2017
|
+
return line;
|
|
2018
|
+
const header = /^(.*):$/.exec(line);
|
|
2019
|
+
if (header && !header[1]?.includes("://")) {
|
|
2020
|
+
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
2021
|
+
}
|
|
2022
|
+
if (line.includes("://"))
|
|
2023
|
+
return line;
|
|
2024
|
+
return resolveToolPath(line, workspaceRoot);
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
|
|
2028
|
+
* paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
|
|
2029
|
+
* status words alone.
|
|
2030
|
+
*/
|
|
2031
|
+
function groundShellPathText(output, root) {
|
|
2032
|
+
if (!root || !output)
|
|
2033
|
+
return output;
|
|
2034
|
+
return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
|
|
2035
|
+
}
|
|
2036
|
+
function shellPathRoot(resultMetadata, workspaceRoot) {
|
|
2037
|
+
const workingDirectory = str(resultMetadata?.working_directory)?.trim();
|
|
2038
|
+
if (!workingDirectory)
|
|
2039
|
+
return workspaceRoot;
|
|
2040
|
+
if (isAbsoluteToolPath(workingDirectory))
|
|
2041
|
+
return workingDirectory;
|
|
2042
|
+
return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
|
|
2043
|
+
}
|
|
2044
|
+
function rewriteShellPathLine(line, root) {
|
|
2045
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t"))
|
|
2046
|
+
return line;
|
|
2047
|
+
const trimmed = line.trimEnd();
|
|
2048
|
+
if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
2049
|
+
return line;
|
|
2050
|
+
}
|
|
2051
|
+
const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
|
|
2052
|
+
if (located && located[1] && isRelativePathToken(located[1])) {
|
|
2053
|
+
const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
|
|
2054
|
+
return `${resolveToolPath(located[1], root)}${suffix}`;
|
|
2055
|
+
}
|
|
2056
|
+
if (/\s/.test(trimmed))
|
|
2057
|
+
return line;
|
|
2058
|
+
const header = /^(.*):$/.exec(trimmed);
|
|
2059
|
+
if (header && header[1] && isRelativePathToken(header[1])) {
|
|
2060
|
+
return `${resolveToolPath(header[1], root)}:`;
|
|
2061
|
+
}
|
|
2062
|
+
if (isRelativePathToken(trimmed))
|
|
2063
|
+
return resolveToolPath(trimmed, root);
|
|
2064
|
+
return line;
|
|
2065
|
+
}
|
|
2066
|
+
function extractGroundedPaths(output, workspaceRoot) {
|
|
2067
|
+
const normalized = normalizeToolText(output);
|
|
2068
|
+
const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
|
|
2069
|
+
if (directory)
|
|
2070
|
+
return directory.entries.slice(0, 2000);
|
|
2071
|
+
const first = normalized.split("\n", 1)[0]?.trim() ?? "";
|
|
2072
|
+
if (/^Found \d+ matches/.test(first) || first === "No matches found" || first === "No files found") {
|
|
2073
|
+
const paths = [];
|
|
2074
|
+
for (const raw of normalized.split("\n").slice(1)) {
|
|
2075
|
+
const line = raw.trim();
|
|
2076
|
+
if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const header = /^(.*):$/.exec(line);
|
|
2080
|
+
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2081
|
+
if (!candidate || candidate.includes("://"))
|
|
2082
|
+
continue;
|
|
2083
|
+
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2084
|
+
}
|
|
2085
|
+
return paths.slice(0, 2000);
|
|
2086
|
+
}
|
|
2087
|
+
if (isBarePathList(output)) {
|
|
2088
|
+
return output
|
|
2089
|
+
.split("\n")
|
|
2090
|
+
.map((line) => line.trim())
|
|
2091
|
+
.filter((line) => line && !line.startsWith("("))
|
|
2092
|
+
.map((line) => resolveToolPath(line.endsWith(":") && !line.includes("://") ? line.slice(0, -1) : line, workspaceRoot))
|
|
2093
|
+
.slice(0, 2000);
|
|
2094
|
+
}
|
|
2095
|
+
return extractPathLines(output).map((line) => {
|
|
2096
|
+
const trimmed = line.trim();
|
|
2097
|
+
const candidate = trimmed.endsWith(":") && !trimmed.includes("://") ? trimmed.slice(0, -1) : trimmed;
|
|
2098
|
+
return resolveToolPath(candidate, workspaceRoot);
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
1703
2101
|
/**
|
|
1704
2102
|
* Strip opencode's `read` envelope, leaving raw file content.
|
|
1705
2103
|
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
2104
|
+
* OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
|
|
2105
|
+
* models are trained on, but Cursor's are not:
|
|
1708
2106
|
* <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…`).
|
|
2107
|
+
* OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
|
|
2108
|
+
* the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
|
|
2109
|
+
* Forwarding either envelope verbatim made Cursor's model treat the wrapper as
|
|
2110
|
+
* literal file content and write tags or line prefixes back into files.
|
|
1714
2111
|
*
|
|
1715
2112
|
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
1716
2113
|
*
|
|
@@ -1723,6 +2120,12 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1723
2120
|
export function unwrapReadOutput(output) {
|
|
1724
2121
|
if (typeof output !== "string" || output.length === 0)
|
|
1725
2122
|
return output;
|
|
2123
|
+
const opencode2 = parseOpenCode2FileRead(output);
|
|
2124
|
+
if (opencode2)
|
|
2125
|
+
return opencode2.content;
|
|
2126
|
+
if (normalizeToolText(output).startsWith("Read file ")) {
|
|
2127
|
+
trace("unwrapReadOutput: OpenCode 2 read header did not match the page parser — leaving output unchanged");
|
|
2128
|
+
}
|
|
1726
2129
|
// Require the full opencode read-envelope skeleton *before* `<content>`
|
|
1727
2130
|
// (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
|
|
1728
2131
|
// stray "<content>" later in tool chatter can't trigger unwrapping just
|
|
@@ -1773,34 +2176,53 @@ export function unwrapReadOutput(output) {
|
|
|
1773
2176
|
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
1774
2177
|
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
1775
2178
|
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
1776
|
-
const
|
|
1777
|
-
|
|
2179
|
+
const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
|
|
2180
|
+
// A posix process must not turn `C:/…` or `\\server\…` into `<cwd>/C:/…`.
|
|
2181
|
+
const resultRoot = trimmedRoot
|
|
2182
|
+
? (isForeignAbsoluteToolPath(trimmedRoot) ? trimmedRoot : path.resolve(trimmedRoot))
|
|
1778
2183
|
: undefined;
|
|
1779
2184
|
switch (resultField) {
|
|
1780
2185
|
case "read_result": {
|
|
1781
|
-
const
|
|
2186
|
+
const parsedFile = parseOpenCode2FileRead(output, resultMetadata);
|
|
2187
|
+
const listing = error ? undefined : parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2188
|
+
const rawPath = str(resultMetadata?.path)
|
|
2189
|
+
?? parsedFile?.path
|
|
2190
|
+
?? listing?.directory
|
|
2191
|
+
?? extractPathTag(output)
|
|
2192
|
+
?? "";
|
|
2193
|
+
const readPath = resolveToolPath(rawPath, resultRoot);
|
|
1782
2194
|
if (error)
|
|
1783
2195
|
return { error: { path: readPath, error } };
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
2196
|
+
// The host may have resolved an alternate spelling after the request
|
|
2197
|
+
// (for example a Unicode-space filename). Use the result's own path for
|
|
2198
|
+
// local stat/newline recovery while preserving the requested path in the
|
|
2199
|
+
// Cursor result, matching the existing OpenCode 1 behavior.
|
|
2200
|
+
const outputPath = parsedFile?.path ?? extractPathTag(output);
|
|
2201
|
+
const statPath = resolveToolPath(outputPath ?? readPath, resultRoot);
|
|
2202
|
+
// Strip OpenCode's read envelope so Cursor's model receives raw file
|
|
2203
|
+
// content and can't echo the wrapper into subsequent writes.
|
|
1786
2204
|
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1787
2205
|
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1788
2206
|
// native channel, so this is the path that matters in practice.
|
|
1789
|
-
const
|
|
1790
|
-
const
|
|
1791
|
-
|
|
2207
|
+
const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2208
|
+
const content = listing
|
|
2209
|
+
? listing.text
|
|
2210
|
+
: restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
|
|
1792
2211
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1793
2212
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1794
2213
|
// `truncated` alone is not enough: it is set here, and models still assert
|
|
1795
2214
|
// the partial content is the whole file. Cursor's own executor puts the
|
|
1796
2215
|
// limit marker in the output text for the same reason, so append one.
|
|
1797
|
-
const
|
|
2216
|
+
const notices = [
|
|
2217
|
+
readTruncationNotice(output, resultMetadata),
|
|
2218
|
+
readLongLineTruncationNotice(output),
|
|
2219
|
+
].filter((notice) => !!notice);
|
|
1798
2220
|
return {
|
|
1799
2221
|
success: {
|
|
1800
|
-
path: readPath,
|
|
1801
|
-
content:
|
|
2222
|
+
path: listing?.directory || readPath,
|
|
2223
|
+
content: notices.length > 0 ? `${content}\n\n${notices.join("\n\n")}` : content,
|
|
1802
2224
|
total_lines: totalLines,
|
|
1803
|
-
file_size: readFileSize(statPath),
|
|
2225
|
+
file_size: listing ? 0 : readFileSize(statPath),
|
|
1804
2226
|
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
1805
2227
|
range_applied: rangeApplied,
|
|
1806
2228
|
},
|
|
@@ -1811,7 +2233,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1811
2233
|
return { error: { error } };
|
|
1812
2234
|
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
1813
2235
|
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
1814
|
-
const files =
|
|
2236
|
+
const files = extractGroundedPaths(output, resultRoot);
|
|
1815
2237
|
const cwd = resultRoot ?? "";
|
|
1816
2238
|
return {
|
|
1817
2239
|
success: {
|
|
@@ -1855,19 +2277,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1855
2277
|
// Pi results carry truncation structurally (PiReadExecSuccess field 2),
|
|
1856
2278
|
// which is how Cursor's own executors report a capped payload.
|
|
1857
2279
|
const content = unwrapReadOutput(output);
|
|
1858
|
-
const truncation = readTruncationMessage(output, content);
|
|
2280
|
+
const truncation = readTruncationMessage(output, content, resultMetadata);
|
|
1859
2281
|
return { success: { output: content, ...(truncation ? { truncation } : {}) } };
|
|
1860
2282
|
}
|
|
1861
2283
|
case "shell_result": {
|
|
1862
2284
|
const command = str(resultMetadata?.command) ?? "";
|
|
1863
2285
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2286
|
+
const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
|
|
1864
2287
|
if (error) {
|
|
1865
2288
|
return {
|
|
1866
2289
|
failure: {
|
|
1867
2290
|
command,
|
|
1868
2291
|
working_directory: workingDirectory,
|
|
1869
2292
|
exit_code: 1,
|
|
1870
|
-
stdout:
|
|
2293
|
+
stdout: stdout || "",
|
|
1871
2294
|
stderr: error,
|
|
1872
2295
|
aborted: false,
|
|
1873
2296
|
},
|
|
@@ -1888,7 +2311,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1888
2311
|
command: shellOutcome.command || command,
|
|
1889
2312
|
working_directory: shellOutcome.workingDirectory || workingDirectory,
|
|
1890
2313
|
exit_code: 0,
|
|
1891
|
-
stdout:
|
|
2314
|
+
stdout: stdout,
|
|
1892
2315
|
shell_id: shellOutcome.shellId,
|
|
1893
2316
|
pid: shellOutcome.pid,
|
|
1894
2317
|
ms_to_wait: shellOutcome.msToWait,
|
|
@@ -1906,18 +2329,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1906
2329
|
command,
|
|
1907
2330
|
working_directory: workingDirectory,
|
|
1908
2331
|
exit_code: exitCode,
|
|
1909
|
-
stdout:
|
|
2332
|
+
stdout: stdout,
|
|
1910
2333
|
},
|
|
1911
2334
|
};
|
|
1912
2335
|
}
|
|
1913
2336
|
case "pi_bash_result":
|
|
2337
|
+
if (error)
|
|
2338
|
+
return { error: { error } };
|
|
2339
|
+
return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
|
|
1914
2340
|
case "pi_edit_result":
|
|
2341
|
+
if (error)
|
|
2342
|
+
return { error: { error } };
|
|
2343
|
+
return { success: { output } };
|
|
1915
2344
|
case "pi_grep_result":
|
|
1916
2345
|
case "pi_find_result":
|
|
1917
|
-
case "pi_ls_result":
|
|
1918
2346
|
if (error)
|
|
1919
2347
|
return { error: { error } };
|
|
1920
|
-
return { success: { output } };
|
|
2348
|
+
return { success: { output: groundSearchOutput(output, resultRoot) } };
|
|
2349
|
+
case "pi_ls_result": {
|
|
2350
|
+
if (error)
|
|
2351
|
+
return { error: { error } };
|
|
2352
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2353
|
+
return { success: { output: listing?.text ?? groundSearchOutput(output, resultRoot) } };
|
|
2354
|
+
}
|
|
1921
2355
|
case "delete_result":
|
|
1922
2356
|
if (error)
|
|
1923
2357
|
return { error: { path: "", error } };
|
|
@@ -1963,17 +2397,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1963
2397
|
case "ls_result": {
|
|
1964
2398
|
if (error)
|
|
1965
2399
|
return { error: { path: "", error } };
|
|
1966
|
-
const
|
|
1967
|
-
const
|
|
2400
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2401
|
+
const rootPath = listing?.directory || resultRoot || "";
|
|
2402
|
+
const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
|
|
2403
|
+
const directories = listing?.directories ?? [];
|
|
2404
|
+
const files = listing?.files ?? entries;
|
|
1968
2405
|
return {
|
|
1969
2406
|
success: {
|
|
1970
2407
|
directory_tree_root: {
|
|
1971
2408
|
abs_path: rootPath,
|
|
1972
|
-
children_dirs:
|
|
1973
|
-
|
|
1974
|
-
|
|
2409
|
+
children_dirs: directories.map((entry) => ({
|
|
2410
|
+
abs_path: stripTrailingPathSeparator(entry),
|
|
2411
|
+
children_dirs: [],
|
|
2412
|
+
children_files: [],
|
|
2413
|
+
num_files: 0,
|
|
1975
2414
|
})),
|
|
1976
|
-
|
|
2415
|
+
children_files: files.map((entry) => ({
|
|
2416
|
+
name: listedEntryName(entry),
|
|
2417
|
+
})),
|
|
2418
|
+
num_files: files.length,
|
|
1977
2419
|
},
|
|
1978
2420
|
},
|
|
1979
2421
|
};
|
|
@@ -1985,17 +2427,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1985
2427
|
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
1986
2428
|
// "read" so a non-read MCP tool whose output merely contains a
|
|
1987
2429
|
// "<content>"-like block is never rewritten.
|
|
2430
|
+
if (toolName === "grep" || toolName === "glob") {
|
|
2431
|
+
return {
|
|
2432
|
+
success: {
|
|
2433
|
+
content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
|
|
2434
|
+
is_error: false,
|
|
2435
|
+
},
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
1988
2438
|
if (toolName !== "read") {
|
|
1989
2439
|
return { success: { content: [{ text: { text: output } }], is_error: false } };
|
|
1990
2440
|
}
|
|
2441
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2442
|
+
if (listing) {
|
|
2443
|
+
return {
|
|
2444
|
+
success: { content: [{ text: { text: listing.text } }], is_error: false },
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
1991
2447
|
// Carry the truncation notice as its own content item: the file content
|
|
1992
2448
|
// item stays byte-exact, so it can still never be echoed into a write.
|
|
1993
|
-
const
|
|
2449
|
+
const notices = [
|
|
2450
|
+
readTruncationNotice(output, resultMetadata),
|
|
2451
|
+
readLongLineTruncationNotice(output),
|
|
2452
|
+
].filter((notice) => !!notice);
|
|
1994
2453
|
return {
|
|
1995
2454
|
success: {
|
|
1996
2455
|
content: [
|
|
1997
2456
|
{ text: { text: unwrapReadOutput(output) } },
|
|
1998
|
-
...(notice
|
|
2457
|
+
...notices.map((notice) => ({ text: { text: notice } })),
|
|
1999
2458
|
],
|
|
2000
2459
|
is_error: false,
|
|
2001
2460
|
},
|
|
@@ -2072,7 +2531,19 @@ function readEnvelopeFooter(output) {
|
|
|
2072
2531
|
return output.slice(start + 2, close);
|
|
2073
2532
|
}
|
|
2074
2533
|
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
2075
|
-
function parseOpenCodeReadMetadata(output) {
|
|
2534
|
+
function parseOpenCodeReadMetadata(output, resultMetadata) {
|
|
2535
|
+
const opencode2 = parseOpenCode2FileRead(output, resultMetadata);
|
|
2536
|
+
if (opencode2) {
|
|
2537
|
+
return {
|
|
2538
|
+
...(opencode2.startLine !== undefined ? { startLine: opencode2.startLine } : {}),
|
|
2539
|
+
...(opencode2.endLine !== undefined ? { endLine: opencode2.endLine } : {}),
|
|
2540
|
+
...(opencode2.totalLines !== undefined ? { totalLines: opencode2.totalLines } : {}),
|
|
2541
|
+
...(opencode2.nextOffset !== undefined ? { nextOffset: opencode2.nextOffset } : {}),
|
|
2542
|
+
outputCapped: opencode2.outputCapped,
|
|
2543
|
+
hostTruncated: opencode2.hostTruncated,
|
|
2544
|
+
...(opencode2.truncatedLines.length > 0 ? { truncatedLines: opencode2.truncatedLines } : {}),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2076
2547
|
const footer = readEnvelopeFooter(output);
|
|
2077
2548
|
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
|
|
2078
2549
|
if (showing) {
|
|
@@ -2104,9 +2575,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
2104
2575
|
|| num(resultMetadata?.limit) !== undefined
|
|
2105
2576
|
|| content.endsWith("\n"))
|
|
2106
2577
|
return content;
|
|
2107
|
-
const absolute =
|
|
2578
|
+
const absolute = isAbsoluteToolPath(readPath)
|
|
2108
2579
|
? readPath
|
|
2109
|
-
:
|
|
2580
|
+
: joinToolPath(workspaceRoot ?? process.cwd(), readPath);
|
|
2110
2581
|
let fd;
|
|
2111
2582
|
try {
|
|
2112
2583
|
fd = fs.openSync(absolute, "r");
|
|
@@ -2153,8 +2624,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
2153
2624
|
* that, keeping the signal out of the file content itself so it still cannot be
|
|
2154
2625
|
* echoed into a write.
|
|
2155
2626
|
*/
|
|
2156
|
-
function readTruncationSummary(output) {
|
|
2157
|
-
const meta = parseOpenCodeReadMetadata(output);
|
|
2627
|
+
function readTruncationSummary(output, resultMetadata) {
|
|
2628
|
+
const meta = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2158
2629
|
if (meta.startLine === undefined || meta.endLine === undefined)
|
|
2159
2630
|
return undefined;
|
|
2160
2631
|
// A complete read reports "(End of file …)" and never reaches this shape.
|
|
@@ -2163,7 +2634,7 @@ function readTruncationSummary(output) {
|
|
|
2163
2634
|
return {
|
|
2164
2635
|
startLine: meta.startLine,
|
|
2165
2636
|
endLine: meta.endLine,
|
|
2166
|
-
nextOffset: meta.endLine + 1,
|
|
2637
|
+
nextOffset: meta.nextOffset ?? meta.endLine + 1,
|
|
2167
2638
|
capped: meta.outputCapped === true,
|
|
2168
2639
|
...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
|
|
2169
2640
|
};
|
|
@@ -2176,7 +2647,7 @@ function readTruncationSummary(output) {
|
|
|
2176
2647
|
* would cry wolf on every deliberate paged read.
|
|
2177
2648
|
*/
|
|
2178
2649
|
function readTruncationNotice(output, resultMetadata) {
|
|
2179
|
-
const summary = readTruncationSummary(output);
|
|
2650
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2180
2651
|
if (!summary)
|
|
2181
2652
|
return undefined;
|
|
2182
2653
|
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
@@ -2191,18 +2662,35 @@ function readTruncationNotice(output, resultMetadata) {
|
|
|
2191
2662
|
`acting on the whole file; writing the content above back would delete everything ` +
|
|
2192
2663
|
`after line ${summary.endLine}.]`);
|
|
2193
2664
|
}
|
|
2665
|
+
function readLongLineTruncationNotice(output) {
|
|
2666
|
+
const lines = parseOpenCodeReadMetadata(output).truncatedLines;
|
|
2667
|
+
if (!lines || lines.length === 0)
|
|
2668
|
+
return undefined;
|
|
2669
|
+
const displayed = lines.slice(0, 8).join(", ");
|
|
2670
|
+
const remainder = lines.length > 8 ? ` and ${lines.length - 8} more` : "";
|
|
2671
|
+
return (`[Partial read: OpenCode shortened ${lines.length === 1 ? "line" : "lines"} ` +
|
|
2672
|
+
`${displayed}${remainder} to ${OPENCODE2_READ_MAX_LINE_CHARS} characters. ` +
|
|
2673
|
+
"It is NOT the complete file. Use a byte-preserving read method to inspect the full " +
|
|
2674
|
+
"line content before acting on the whole file; writing the content above back would lose data.]");
|
|
2675
|
+
}
|
|
2194
2676
|
/** agent.v1.PiTruncation for a capped OpenCode read. */
|
|
2195
|
-
function readTruncationMessage(output, content) {
|
|
2196
|
-
const summary = readTruncationSummary(output);
|
|
2197
|
-
|
|
2677
|
+
function readTruncationMessage(output, content, resultMetadata) {
|
|
2678
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2679
|
+
const longLines = parseOpenCodeReadMetadata(output, resultMetadata).truncatedLines;
|
|
2680
|
+
if (!summary && (!longLines || longLines.length === 0))
|
|
2681
|
+
return undefined;
|
|
2682
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2683
|
+
if (summary && rangeRequested && !summary.capped && (!longLines || longLines.length === 0))
|
|
2198
2684
|
return undefined;
|
|
2199
2685
|
return {
|
|
2200
2686
|
truncated: true,
|
|
2201
|
-
truncated_by: summary
|
|
2202
|
-
...(summary
|
|
2203
|
-
output_lines:
|
|
2687
|
+
truncated_by: summary?.capped ? "bytes" : longLines?.length ? "characters" : "lines",
|
|
2688
|
+
...(summary?.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
|
|
2689
|
+
output_lines: summary
|
|
2690
|
+
? Math.max(0, summary.endLine - summary.startLine + 1)
|
|
2691
|
+
: countLines(content),
|
|
2204
2692
|
output_bytes: Buffer.byteLength(content, "utf8"),
|
|
2205
|
-
...(summary
|
|
2693
|
+
...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
|
|
2206
2694
|
};
|
|
2207
2695
|
}
|
|
2208
2696
|
/** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
|
|
@@ -2218,8 +2706,15 @@ function readRangeApplied(resultMetadata, totalLines) {
|
|
|
2218
2706
|
return startLine < 0 || startLine <= totalLines;
|
|
2219
2707
|
}
|
|
2220
2708
|
function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
2709
|
+
if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
|
|
2710
|
+
return true;
|
|
2221
2711
|
if (outputMetadata.outputCapped)
|
|
2222
2712
|
return true;
|
|
2713
|
+
if (outputMetadata.hostTruncated) {
|
|
2714
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2715
|
+
if (!rangeRequested)
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2223
2718
|
const returnedEnd = outputMetadata.endLine;
|
|
2224
2719
|
if (returnedEnd === undefined || totalLines === 0)
|
|
2225
2720
|
return false;
|
|
@@ -2233,6 +2728,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
|
2233
2728
|
: Math.min(totalLines, Math.max(1, startLine) + limit - 1);
|
|
2234
2729
|
return returnedEnd < expectedEnd;
|
|
2235
2730
|
}
|
|
2731
|
+
function listedEntryName(entry) {
|
|
2732
|
+
const trimmed = stripTrailingPathSeparator(entry);
|
|
2733
|
+
const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
2734
|
+
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
|
2735
|
+
}
|
|
2736
|
+
function stripTrailingPathSeparator(entry) {
|
|
2737
|
+
return entry.endsWith("/") || entry.endsWith("\\") ? entry.slice(0, -1) : entry;
|
|
2738
|
+
}
|
|
2236
2739
|
function readFileSize(filePath) {
|
|
2237
2740
|
if (!filePath)
|
|
2238
2741
|
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
|
}
|