cursor-opencode-provider 0.7.1 → 0.7.3
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 +8 -5
- package/dist/context/agents.js +1 -1
- package/dist/context/build.d.ts +2 -0
- package/dist/context/build.js +22 -10
- package/dist/context/epoch.d.ts +59 -0
- package/dist/context/epoch.js +224 -0
- package/dist/context/frozen.d.ts +4 -2
- package/dist/context/frozen.js +31 -11
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/overlay.d.ts +44 -0
- package/dist/context/overlay.js +96 -0
- package/dist/debug.d.ts +9 -1
- package/dist/debug.js +46 -5
- package/dist/host-event-bridge.d.ts +22 -0
- package/dist/host-event-bridge.js +22 -0
- package/dist/image-input.js +36 -11
- package/dist/index.d.ts +3 -0
- package/dist/language-model.d.ts +51 -3
- package/dist/language-model.js +569 -140
- package/dist/model-config.js +5 -1
- package/dist/plugin-opencode2.js +10 -4
- package/dist/plugin.js +26 -15
- package/dist/pricing-data.d.ts +40 -0
- package/dist/pricing-data.js +40 -0
- package/dist/protocol/ask-question.d.ts +8 -9
- package/dist/protocol/ask-question.js +8 -9
- package/dist/protocol/conversation-bind.js +18 -8
- package/dist/protocol/create-plan.d.ts +6 -7
- package/dist/protocol/create-plan.js +4 -5
- package/dist/protocol/messages.js +18 -1
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/switch-mode.d.ts +3 -4
- package/dist/protocol/switch-mode.js +21 -12
- package/dist/protocol/tool-call-bridge.d.ts +4 -1
- package/dist/protocol/tool-call-bridge.js +27 -5
- package/dist/protocol/tools.d.ts +4 -2
- package/dist/protocol/tools.js +346 -55
- package/dist/session-directory.d.ts +23 -4
- package/dist/session-directory.js +36 -4
- package/dist/session.d.ts +23 -3
- package/dist/session.js +68 -8
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/transport/connect.js +78 -10
- package/dist/transport/https-proxy.d.ts +56 -0
- package/dist/transport/https-proxy.js +348 -0
- package/dist/usage.d.ts +11 -0
- package/dist/usage.js +33 -7
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
|
|
1
|
+
import { OPENCODE_1_TOOL_DIALECT, mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
|
|
2
2
|
import { decodeStructEntriesToJson } from "./struct.js";
|
|
3
3
|
import { mapSwitchModeTarget } from "./switch-mode.js";
|
|
4
4
|
const TODO_STATUS = {
|
|
@@ -50,11 +50,22 @@ const TOOL_CALL_VARIANTS = Object.keys(VARIANT_TO_OPENCODE);
|
|
|
50
50
|
// Cursor todo merges). Data-returning, interactive, and side-effecting calls
|
|
51
51
|
// must use an exec/interaction request channel where Cursor can receive their
|
|
52
52
|
// actual result.
|
|
53
|
+
//
|
|
54
|
+
// `get_mcp_tools_tool_call` is Cursor-native discovery (prompt name
|
|
55
|
+
// GetDynamicTools when dynamic namespaces are on). The agent executes it
|
|
56
|
+
// server-side after mcp_state; oversized catalogs spill through write_args.
|
|
53
57
|
const DISPLAY_STATE_MIRROR_VARIANTS = new Set([
|
|
54
58
|
"update_todos_tool_call",
|
|
55
59
|
"read_todos_tool_call",
|
|
56
60
|
"create_plan_tool_call",
|
|
57
61
|
]);
|
|
62
|
+
const NATIVE_DISPLAY_VARIANTS = new Set([
|
|
63
|
+
"get_mcp_tools_tool_call",
|
|
64
|
+
]);
|
|
65
|
+
/** Display variants Cursor executes itself (mcp_state + optional write spill). */
|
|
66
|
+
export function isNativeDisplayToolCall(variant) {
|
|
67
|
+
return NATIVE_DISPLAY_VARIANTS.has(variant);
|
|
68
|
+
}
|
|
58
69
|
function asRecord(v) {
|
|
59
70
|
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
60
71
|
}
|
|
@@ -291,7 +302,13 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
|
|
|
291
302
|
if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
|
|
292
303
|
const result = asRecord(payload.result);
|
|
293
304
|
const success = asRecord(result?.success);
|
|
294
|
-
|
|
305
|
+
// Cursor's completed frame may materialize proto-default `todos: []` even
|
|
306
|
+
// when the real final snapshot is present on args.todos. An empty default
|
|
307
|
+
// must not erase that named completion snapshot. A non-empty completed
|
|
308
|
+
// payload remains authoritative (especially for merge calls).
|
|
309
|
+
const completedTodos = Array.isArray(success?.todos) && success.todos.length > 0
|
|
310
|
+
? success.todos
|
|
311
|
+
: undefined;
|
|
295
312
|
const isMerge = variant === "update_todos_tool_call" && args.merge === true;
|
|
296
313
|
// OpenCode todowrite replaces the whole list. Prefer the completed payload;
|
|
297
314
|
// otherwise a non-merge args list; otherwise merge against the Run snapshot.
|
|
@@ -311,7 +328,10 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
|
|
|
311
328
|
todos.unshift({
|
|
312
329
|
id: "plan",
|
|
313
330
|
content: [name && `Plan: ${name}`, overview, plan].filter(Boolean).join("\n").slice(0, 2000),
|
|
314
|
-
|
|
331
|
+
// The plan file is already written by the time this display mirror
|
|
332
|
+
// runs. Leaving it `pending` parks a forever-open row on hosts that
|
|
333
|
+
// keep todowrite as the user-visible checklist.
|
|
334
|
+
status: "completed",
|
|
315
335
|
priority: "high",
|
|
316
336
|
});
|
|
317
337
|
}
|
|
@@ -454,7 +474,9 @@ function shellQuote(s) {
|
|
|
454
474
|
* Pick an advertised OpenCode tool for a display call, remapping args as needed.
|
|
455
475
|
* Returns undefined when nothing compatible is advertised this turn.
|
|
456
476
|
*/
|
|
457
|
-
export function resolveBridgedOpenCodeToolCall(display, advertised) {
|
|
477
|
+
export function resolveBridgedOpenCodeToolCall(display, advertised, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
478
|
+
if (isNativeDisplayToolCall(display.variant))
|
|
479
|
+
return undefined;
|
|
458
480
|
if (!DISPLAY_STATE_MIRROR_VARIANTS.has(display.variant))
|
|
459
481
|
return undefined;
|
|
460
482
|
if (display.bridgeable === false)
|
|
@@ -466,7 +488,7 @@ export function resolveBridgedOpenCodeToolCall(display, advertised) {
|
|
|
466
488
|
for (const toolName of candidates) {
|
|
467
489
|
if (!names.has(toolName))
|
|
468
490
|
continue;
|
|
469
|
-
const mapped = mapCursorArgsToOpencode(toolName, display.args);
|
|
491
|
+
const mapped = mapCursorArgsToOpencode(toolName, display.args, undefined, dialect);
|
|
470
492
|
if (toolName === "todowrite") {
|
|
471
493
|
mapped.args = {
|
|
472
494
|
todos: mapTodos(display.args.todos ?? mapped.args.todos),
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type HostToolDialect = {
|
|
|
17
17
|
shellTool: HostShellTool;
|
|
18
18
|
};
|
|
19
19
|
export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
|
|
20
|
+
export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
|
|
20
21
|
/** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
|
|
21
22
|
export declare function opencodePathArg(args: Record<string, unknown> | undefined): string | undefined;
|
|
22
23
|
/**
|
|
@@ -26,7 +27,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
|
|
|
26
27
|
export declare function hostToolDialectFromTools(tools: readonly {
|
|
27
28
|
name?: string;
|
|
28
29
|
inputSchema?: unknown;
|
|
29
|
-
}[]): HostToolDialect;
|
|
30
|
+
}[], defaultDialect?: HostToolDialect): HostToolDialect;
|
|
30
31
|
export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
|
|
31
32
|
export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
|
|
32
33
|
export declare const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
|
|
@@ -62,7 +63,8 @@ export declare function resolveToolServerIdentity(opencodeName: string, defaultS
|
|
|
62
63
|
* default server (`opencode`). Tools whose prefixes match configured MCP
|
|
63
64
|
* servers keep those server ids (`github`, …). Composite `name` is
|
|
64
65
|
* `<server>-<bareTool>`; local execution still uses the full OpenCode id
|
|
65
|
-
* reconstructed in `mcpRealToolName`.
|
|
66
|
+
* reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
|
|
67
|
+
* catalog owns canonicalization; sorting here would insert on grow.
|
|
66
68
|
*/
|
|
67
69
|
export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
|
|
68
70
|
/**
|
package/dist/protocol/tools.js
CHANGED
|
@@ -17,6 +17,10 @@ export const OPENCODE_1_TOOL_DIALECT = {
|
|
|
17
17
|
filePathKey: "filePath",
|
|
18
18
|
shellTool: "bash",
|
|
19
19
|
};
|
|
20
|
+
export const OPENCODE_2_TOOL_DIALECT = {
|
|
21
|
+
filePathKey: "path",
|
|
22
|
+
shellTool: "shell",
|
|
23
|
+
};
|
|
20
24
|
function jsonSchemaProperties(schema) {
|
|
21
25
|
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
22
26
|
return undefined;
|
|
@@ -24,11 +28,11 @@ function jsonSchemaProperties(schema) {
|
|
|
24
28
|
if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
|
|
25
29
|
return obj.properties;
|
|
26
30
|
}
|
|
27
|
-
const nested = obj.jsonSchema;
|
|
31
|
+
const nested = obj.jsonSchema ?? obj.parameters ?? obj.schema;
|
|
28
32
|
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
33
|
+
const nestedProps = nested.properties;
|
|
34
|
+
if (nestedProps && typeof nestedProps === "object" && !Array.isArray(nestedProps)) {
|
|
35
|
+
return nestedProps;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
return undefined;
|
|
@@ -43,7 +47,7 @@ export function opencodePathArg(args) {
|
|
|
43
47
|
* Infer the host tool dialect from advertised AI SDK schemas.
|
|
44
48
|
* OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
|
|
45
49
|
*/
|
|
46
|
-
export function hostToolDialectFromTools(tools) {
|
|
50
|
+
export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL_DIALECT) {
|
|
47
51
|
let filePathKey;
|
|
48
52
|
for (const name of ["read", "write", "edit"]) {
|
|
49
53
|
const tool = tools.find((candidate) => candidate.name === name);
|
|
@@ -60,10 +64,12 @@ export function hostToolDialectFromTools(tools) {
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
const names = new Set(tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
|
|
63
|
-
const shellTool = names.has("shell") && !names.has("bash")
|
|
67
|
+
const shellTool = names.has("shell") && !names.has("bash")
|
|
68
|
+
? "shell"
|
|
69
|
+
: (names.has("bash") ? "bash" : defaultDialect.shellTool);
|
|
64
70
|
if (!filePathKey) {
|
|
65
71
|
// OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
|
|
66
|
-
filePathKey = shellTool === "shell" ? "path" :
|
|
72
|
+
filePathKey = shellTool === "shell" ? "path" : defaultDialect.filePathKey;
|
|
67
73
|
}
|
|
68
74
|
return { filePathKey, shellTool };
|
|
69
75
|
}
|
|
@@ -115,12 +121,14 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
|
|
|
115
121
|
* default server (`opencode`). Tools whose prefixes match configured MCP
|
|
116
122
|
* servers keep those server ids (`github`, …). Composite `name` is
|
|
117
123
|
* `<server>-<bareTool>`; local execution still uses the full OpenCode id
|
|
118
|
-
* reconstructed in `mcpRealToolName`.
|
|
124
|
+
* reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
|
|
125
|
+
* catalog owns canonicalization; sorting here would insert on grow.
|
|
119
126
|
*/
|
|
120
127
|
export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
// Advertised order is the prefix. The epoch catalog sorts once on first
|
|
129
|
+
// freeze and appends later names at the tail; re-sorting here would insert
|
|
130
|
+
// a new tool in front of `z` and invalidate the tools bytes.
|
|
131
|
+
return tools.map((t) => {
|
|
124
132
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
125
133
|
const collisionSafeAlias = COLLISION_SAFE_ALIASES.has(t.name);
|
|
126
134
|
return {
|
|
@@ -233,11 +241,10 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
|
|
|
233
241
|
if (tools.length === 0)
|
|
234
242
|
return [];
|
|
235
243
|
const byServer = new Map();
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
for (const t of [...tools].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
244
|
+
// Walk advertised order: first-seen server, then append tools onto that
|
|
245
|
+
// server. Same-set host reorder is resolved by `resolveTurnToolState`
|
|
246
|
+
// before encode so this walk stays epoch-stable.
|
|
247
|
+
for (const t of tools) {
|
|
241
248
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
242
249
|
let list = byServer.get(id.server);
|
|
243
250
|
if (!list) {
|
|
@@ -250,14 +257,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
|
|
|
250
257
|
input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
|
|
251
258
|
});
|
|
252
259
|
}
|
|
253
|
-
|
|
254
|
-
if (left === providerIdentifier)
|
|
255
|
-
return right === providerIdentifier ? 0 : -1;
|
|
256
|
-
if (right === providerIdentifier)
|
|
257
|
-
return 1;
|
|
258
|
-
return left.localeCompare(right);
|
|
259
|
-
});
|
|
260
|
-
return orderedServers.map((server) => ({
|
|
260
|
+
return [...byServer.keys()].map((server) => ({
|
|
261
261
|
server_name: server,
|
|
262
262
|
server_identifier: server,
|
|
263
263
|
tools: byServer.get(server),
|
|
@@ -872,6 +872,20 @@ function readRequestResultMetadata(raw, mappedArgs) {
|
|
|
872
872
|
...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
|
|
873
873
|
};
|
|
874
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Cursor's native write executor echoes `WriteArgs.path` on `WriteSuccess.path`.
|
|
877
|
+
* Large GetMcpTools / GetDynamicTools catalogs spill through `write_args` into
|
|
878
|
+
* `agent-tools/<uuid>.txt` and then tell the model `filePath` from that success
|
|
879
|
+
* path. Host write output is free-form prose (often no `<path>` tag), so the
|
|
880
|
+
* request path is the only host-neutral identity we can report.
|
|
881
|
+
*/
|
|
882
|
+
function writeRequestResultMetadata(raw, mappedArgs) {
|
|
883
|
+
const requestedPath = str(raw.path)
|
|
884
|
+
?? str(raw.filePath)
|
|
885
|
+
?? str(raw.file_path)
|
|
886
|
+
?? opencodePathArg(mappedArgs);
|
|
887
|
+
return requestedPath ? { path: requestedPath } : undefined;
|
|
888
|
+
}
|
|
875
889
|
/**
|
|
876
890
|
* A partial-read notice is for Cursor's model, never for a file. Refuse only
|
|
877
891
|
* whole-file mutation forms that echo it. A targeted edit or Update File patch
|
|
@@ -1049,7 +1063,11 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1049
1063
|
? shellStreamResultMetadata(rawArgs)
|
|
1050
1064
|
: execVariant === "read_args" || execVariant === "pi_read_args"
|
|
1051
1065
|
? readRequestResultMetadata(rawArgs, mapped.args)
|
|
1052
|
-
:
|
|
1066
|
+
: execVariant === "write_args" || execVariant === "pi_write_args"
|
|
1067
|
+
? writeRequestResultMetadata(rawArgs, mapped.args)
|
|
1068
|
+
: execVariant === "grep_args"
|
|
1069
|
+
? grepRequestResultMetadata(rawArgs)
|
|
1070
|
+
: undefined;
|
|
1053
1071
|
if (resultMetadata
|
|
1054
1072
|
&& resultMetadata.timeout_behavior !== 2
|
|
1055
1073
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1166,7 +1184,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1166
1184
|
else {
|
|
1167
1185
|
content = stringValue(cleaned.content)
|
|
1168
1186
|
?? stringValue(cleaned.file_text)
|
|
1169
|
-
?? stringValue(cleaned.fileText)
|
|
1187
|
+
?? stringValue(cleaned.fileText)
|
|
1188
|
+
?? stringValue(cleaned.contents)
|
|
1189
|
+
?? stringValue(cleaned.text);
|
|
1170
1190
|
}
|
|
1171
1191
|
if (content !== undefined)
|
|
1172
1192
|
args.content = content;
|
|
@@ -1917,6 +1937,15 @@ function toolPathSeparator(filePath) {
|
|
|
1917
1937
|
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1918
1938
|
*/
|
|
1919
1939
|
function joinToolPath(root, relative) {
|
|
1940
|
+
// `path.resolve` drops a trailing separator. Glob and find use that separator
|
|
1941
|
+
// to tell a directory from a file; putting it back keeps the marker.
|
|
1942
|
+
const directory = relative.endsWith("/") || relative.endsWith("\\");
|
|
1943
|
+
const joined = joinResolvedToolPath(root, relative);
|
|
1944
|
+
if (!directory || joined.endsWith("/") || joined.endsWith("\\"))
|
|
1945
|
+
return joined;
|
|
1946
|
+
return `${joined}${toolPathSeparator(joined)}`;
|
|
1947
|
+
}
|
|
1948
|
+
function joinResolvedToolPath(root, relative) {
|
|
1920
1949
|
if (!isForeignAbsoluteToolPath(root))
|
|
1921
1950
|
return path.resolve(root, relative);
|
|
1922
1951
|
const sep = toolPathSeparator(root);
|
|
@@ -1986,17 +2015,169 @@ function resolveToolPath(filePath, workspaceRoot) {
|
|
|
1986
2015
|
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1987
2016
|
* drop the trailing colon only in the structured file list, not in this text.
|
|
1988
2017
|
*/
|
|
2018
|
+
/**
|
|
2019
|
+
* Longer phrases first. "No files found" is a prefix of the pattern sentence,
|
|
2020
|
+
* and a suffix check must not stop on the shorter one.
|
|
2021
|
+
*/
|
|
2022
|
+
const SEARCH_STATUS_MESSAGES = [
|
|
2023
|
+
"No files found matching pattern",
|
|
2024
|
+
"No matches before timeout (scan incomplete)",
|
|
2025
|
+
"No files found",
|
|
2026
|
+
"No matches found",
|
|
2027
|
+
];
|
|
2028
|
+
/**
|
|
2029
|
+
* A glob/find miss is prose, not a path. Hosts sometimes still carry it as a
|
|
2030
|
+
* path segment (`../../workspace/No files found matching pattern`) or fold that
|
|
2031
|
+
* segment into a `# dir/` header plus a bare sentence. Either shape must stay
|
|
2032
|
+
* the sentence: joining it onto the workspace is what lists a fake file.
|
|
2033
|
+
*/
|
|
2034
|
+
function searchStatusMessage(line) {
|
|
2035
|
+
const trimmed = line.trim().replace(/^#+\s+/, "");
|
|
2036
|
+
for (const message of SEARCH_STATUS_MESSAGES) {
|
|
2037
|
+
if (trimmed === message || trimmed.endsWith(`/${message}`) || trimmed.endsWith(`\\${message}`)) {
|
|
2038
|
+
return message;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return undefined;
|
|
2042
|
+
}
|
|
2043
|
+
function collapseSearchStatus(output) {
|
|
2044
|
+
return output.split("\n").map((line) => searchStatusMessage(line) ?? line).join("\n");
|
|
2045
|
+
}
|
|
2046
|
+
function isSearchStatusReport(output) {
|
|
2047
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2048
|
+
return lines.length > 0 && lines.every((line) => searchStatusMessage(line) !== undefined);
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Cursor's find/glob executor shows one path per line. A directory ends with
|
|
2052
|
+
* `/`; a file does not. Some hosts instead print a folded tree:
|
|
2053
|
+
*
|
|
2054
|
+
* # src/
|
|
2055
|
+
* a.ts
|
|
2056
|
+
* ## components/
|
|
2057
|
+
* button.tsx
|
|
2058
|
+
* # tests/
|
|
2059
|
+
*
|
|
2060
|
+
* A `# dir/` line is a grouping header, not another match. Emitting it lists
|
|
2061
|
+
* the directory beside its own children (and the shared search prefix shows up
|
|
2062
|
+
* as a blank entry once that prefix is the directory being searched). Expand
|
|
2063
|
+
* the tree to the files underneath only. A header with no child is still just
|
|
2064
|
+
* an empty directory in the walk — Cursor find reports files, so leave it out
|
|
2065
|
+
* (same as targeting that empty directory and getting a miss).
|
|
2066
|
+
*/
|
|
2067
|
+
function parseGroupedPathListing(output) {
|
|
2068
|
+
const lines = output.replace(/\n+$/, "").split("\n");
|
|
2069
|
+
const events = [];
|
|
2070
|
+
const stack = [];
|
|
2071
|
+
let sawHeader = false;
|
|
2072
|
+
let inNotes = false;
|
|
2073
|
+
for (const line of lines) {
|
|
2074
|
+
if (!line.trim()) {
|
|
2075
|
+
if (sawHeader)
|
|
2076
|
+
inNotes = true;
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (inNotes) {
|
|
2080
|
+
events.push({ kind: "note", text: line });
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
const status = searchStatusMessage(line);
|
|
2084
|
+
if (status) {
|
|
2085
|
+
events.push({ kind: "note", text: status });
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
const header = /^(#+)\s+(\S.*?)\s*$/.exec(line);
|
|
2089
|
+
if (header) {
|
|
2090
|
+
const rawName = header[2] ?? "";
|
|
2091
|
+
if (!rawName.endsWith("/") && !rawName.endsWith("\\"))
|
|
2092
|
+
return undefined;
|
|
2093
|
+
const depth = header[1].length - 1;
|
|
2094
|
+
if (depth > stack.length)
|
|
2095
|
+
return undefined;
|
|
2096
|
+
const name = rawName.slice(0, -1);
|
|
2097
|
+
if (!name || name === "." || name === "..")
|
|
2098
|
+
return undefined;
|
|
2099
|
+
stack.length = depth;
|
|
2100
|
+
const parent = depth === 0 ? "" : (stack[depth - 1] ?? "");
|
|
2101
|
+
if (depth > 0 && !parent)
|
|
2102
|
+
return undefined;
|
|
2103
|
+
const full = parent ? joinDisplayPath(parent, name) : name;
|
|
2104
|
+
stack.push(full);
|
|
2105
|
+
events.push({ kind: "dir", depth, path: full });
|
|
2106
|
+
sawHeader = true;
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
if (line.startsWith(" ") || line.startsWith("\t") || /\s/.test(line) || line.includes("://")) {
|
|
2110
|
+
return undefined;
|
|
2111
|
+
}
|
|
2112
|
+
if (line.includes("/") || line.includes("\\"))
|
|
2113
|
+
return undefined;
|
|
2114
|
+
if (!sawHeader) {
|
|
2115
|
+
events.push({ kind: "file", path: line });
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
const parent = stack[stack.length - 1];
|
|
2119
|
+
if (!parent)
|
|
2120
|
+
return undefined;
|
|
2121
|
+
events.push({ kind: "file", path: joinDisplayPath(parent, line) });
|
|
2122
|
+
}
|
|
2123
|
+
if (!sawHeader)
|
|
2124
|
+
return undefined;
|
|
2125
|
+
const paths = [];
|
|
2126
|
+
const notes = [];
|
|
2127
|
+
let sawFile = false;
|
|
2128
|
+
for (const event of events) {
|
|
2129
|
+
if (event.kind === "note")
|
|
2130
|
+
notes.push(event.text);
|
|
2131
|
+
else if (event.kind === "file") {
|
|
2132
|
+
sawFile = true;
|
|
2133
|
+
paths.push(event.path);
|
|
2134
|
+
}
|
|
2135
|
+
// Directory headers are structure only — never emit them as matches.
|
|
2136
|
+
}
|
|
2137
|
+
// The header existed only to hold the miss sentence. Emitting it lists a
|
|
2138
|
+
// directory that was never a match, usually the walk back to the workspace.
|
|
2139
|
+
if (!sawFile && notes.some((note) => searchStatusMessage(note)))
|
|
2140
|
+
return { paths: [], notes };
|
|
2141
|
+
return { paths, notes };
|
|
2142
|
+
}
|
|
2143
|
+
function joinDisplayPath(parent, child) {
|
|
2144
|
+
if (!parent || parent === ".")
|
|
2145
|
+
return child;
|
|
2146
|
+
if (parent === "/")
|
|
2147
|
+
return `/${child}`;
|
|
2148
|
+
const sep = parent.includes("\\") && !parent.includes("/") ? "\\" : "/";
|
|
2149
|
+
if (parent.endsWith("/") || parent.endsWith("\\"))
|
|
2150
|
+
return `${parent}${child}`;
|
|
2151
|
+
return `${parent}${sep}${child}`;
|
|
2152
|
+
}
|
|
2153
|
+
function renderGroupedPathListing(listing, workspaceRoot) {
|
|
2154
|
+
const paths = listing.paths.map((entry) => resolveToolPath(entry, workspaceRoot));
|
|
2155
|
+
if (paths.length === 0) {
|
|
2156
|
+
// Grouped output that only had directory headers (empty dirs) is a miss,
|
|
2157
|
+
// same as targeting that empty directory directly.
|
|
2158
|
+
if (listing.notes.length === 0)
|
|
2159
|
+
return "No files found";
|
|
2160
|
+
return listing.notes.join("\n");
|
|
2161
|
+
}
|
|
2162
|
+
if (listing.notes.length === 0)
|
|
2163
|
+
return paths.join("\n");
|
|
2164
|
+
return [...paths, "", ...listing.notes].join("\n");
|
|
2165
|
+
}
|
|
1989
2166
|
function groundSearchOutput(output, workspaceRoot) {
|
|
1990
|
-
if (!workspaceRoot)
|
|
1991
|
-
return output;
|
|
1992
2167
|
const normalized = normalizeToolText(output);
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
if (
|
|
1998
|
-
return
|
|
1999
|
-
|
|
2168
|
+
const grouped = parseGroupedPathListing(normalized);
|
|
2169
|
+
if (grouped)
|
|
2170
|
+
return renderGroupedPathListing(grouped, workspaceRoot);
|
|
2171
|
+
const collapsed = collapseSearchStatus(normalized);
|
|
2172
|
+
if (isSearchStatusReport(collapsed))
|
|
2173
|
+
return collapsed;
|
|
2174
|
+
if (!workspaceRoot)
|
|
2175
|
+
return collapsed;
|
|
2176
|
+
const first = collapsed.split("\n", 1)[0] ?? "";
|
|
2177
|
+
const searchShaped = /^Found \d+ matches/.test(first) || searchStatusMessage(first) !== undefined;
|
|
2178
|
+
if (!searchShaped && !isBarePathList(collapsed))
|
|
2179
|
+
return collapsed;
|
|
2180
|
+
return collapsed.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
|
|
2000
2181
|
}
|
|
2001
2182
|
function isBarePathList(output) {
|
|
2002
2183
|
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
@@ -2013,8 +2194,9 @@ function isBarePathList(output) {
|
|
|
2013
2194
|
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2014
2195
|
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2015
2196
|
return line;
|
|
2016
|
-
|
|
2017
|
-
|
|
2197
|
+
const status = searchStatusMessage(line);
|
|
2198
|
+
if (status || line.startsWith("Found "))
|
|
2199
|
+
return status ?? line;
|
|
2018
2200
|
const header = /^(.*):$/.exec(line);
|
|
2019
2201
|
if (header && !header[1]?.includes("://")) {
|
|
2020
2202
|
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
@@ -2063,22 +2245,99 @@ function rewriteShellPathLine(line, root) {
|
|
|
2063
2245
|
return resolveToolPath(trimmed, root);
|
|
2064
2246
|
return line;
|
|
2065
2247
|
}
|
|
2248
|
+
const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
|
|
2249
|
+
/**
|
|
2250
|
+
* OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
|
|
2251
|
+
* and indented `Line N: text` previews. A page with no previews is not content.
|
|
2252
|
+
*/
|
|
2253
|
+
function parseOpenCodeGrepContent(output, workspaceRoot) {
|
|
2254
|
+
const lines = normalizeToolText(output).split("\n");
|
|
2255
|
+
const first = lines[0]?.trim() ?? "";
|
|
2256
|
+
if (!/^Found \d+ matches\b/.test(first))
|
|
2257
|
+
return undefined;
|
|
2258
|
+
const matches = [];
|
|
2259
|
+
let current;
|
|
2260
|
+
let truncated = /\bmore matches available\b/.test(first);
|
|
2261
|
+
let sawLine = false;
|
|
2262
|
+
for (const raw of lines.slice(1)) {
|
|
2263
|
+
if (!raw.trim())
|
|
2264
|
+
continue;
|
|
2265
|
+
const preview = OPENCODE_GREP_LINE.exec(raw);
|
|
2266
|
+
if (preview) {
|
|
2267
|
+
sawLine = true;
|
|
2268
|
+
if (!current)
|
|
2269
|
+
continue;
|
|
2270
|
+
const lineNumber = Number(preview[1]);
|
|
2271
|
+
if (!Number.isSafeInteger(lineNumber) || lineNumber < 1)
|
|
2272
|
+
continue;
|
|
2273
|
+
current.matches.push({ line_number: lineNumber, content: preview[2] ?? "" });
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
if (raw.trimStart().startsWith("(")) {
|
|
2277
|
+
if (/truncat/i.test(raw))
|
|
2278
|
+
truncated = true;
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2281
|
+
if (raw.startsWith(" ") || raw.startsWith("\t"))
|
|
2282
|
+
continue;
|
|
2283
|
+
const header = /^(.*):$/.exec(raw.trim());
|
|
2284
|
+
if (!header || !header[1] || header[1].includes("://"))
|
|
2285
|
+
continue;
|
|
2286
|
+
current = { file: resolveToolPath(header[1], workspaceRoot), matches: [] };
|
|
2287
|
+
matches.push(current);
|
|
2288
|
+
}
|
|
2289
|
+
if (!sawLine)
|
|
2290
|
+
return undefined;
|
|
2291
|
+
const withHits = matches.filter((file) => file.matches.length > 0);
|
|
2292
|
+
if (withHits.length === 0)
|
|
2293
|
+
return undefined;
|
|
2294
|
+
return {
|
|
2295
|
+
matches: withHits,
|
|
2296
|
+
truncated,
|
|
2297
|
+
totalMatchedLines: withHits.reduce((count, file) => count + file.matches.length, 0),
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
function grepRequestResultMetadata(raw) {
|
|
2301
|
+
const pattern = str(raw.pattern);
|
|
2302
|
+
const outputMode = str(raw.output_mode);
|
|
2303
|
+
const searchPath = str(raw.path);
|
|
2304
|
+
if (!pattern && !outputMode && !searchPath)
|
|
2305
|
+
return undefined;
|
|
2306
|
+
return {
|
|
2307
|
+
...(pattern ? { pattern } : {}),
|
|
2308
|
+
...(outputMode ? { output_mode: outputMode } : {}),
|
|
2309
|
+
...(searchPath ? { path: searchPath } : {}),
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2066
2312
|
function extractGroundedPaths(output, workspaceRoot) {
|
|
2067
2313
|
const normalized = normalizeToolText(output);
|
|
2068
|
-
const
|
|
2314
|
+
const grouped = parseGroupedPathListing(normalized);
|
|
2315
|
+
if (grouped) {
|
|
2316
|
+
return grouped.paths.map((entry) => resolveToolPath(entry, workspaceRoot)).slice(0, 2000);
|
|
2317
|
+
}
|
|
2318
|
+
const collapsed = collapseSearchStatus(normalized);
|
|
2319
|
+
if (isSearchStatusReport(collapsed))
|
|
2320
|
+
return [];
|
|
2321
|
+
const directory = parseOpenCode2DirectoryListing(collapsed, workspaceRoot);
|
|
2069
2322
|
if (directory)
|
|
2070
2323
|
return directory.entries.slice(0, 2000);
|
|
2071
|
-
const first =
|
|
2072
|
-
if (/^Found \d+ matches/.test(first) || first
|
|
2324
|
+
const first = collapsed.split("\n", 1)[0]?.trim() ?? "";
|
|
2325
|
+
if (/^Found \d+ matches/.test(first) || searchStatusMessage(first)) {
|
|
2073
2326
|
const paths = [];
|
|
2074
|
-
for (const raw of
|
|
2327
|
+
for (const raw of collapsed.split("\n").slice(1)) {
|
|
2075
2328
|
const line = raw.trim();
|
|
2076
|
-
if (!line
|
|
2329
|
+
if (!line
|
|
2330
|
+
|| line.startsWith("(")
|
|
2331
|
+
|| line.startsWith("Line ")
|
|
2332
|
+
|| line.startsWith("#")
|
|
2333
|
+
|| searchStatusMessage(line)
|
|
2334
|
+
|| raw.startsWith(" ")
|
|
2335
|
+
|| raw.startsWith("\t")) {
|
|
2077
2336
|
continue;
|
|
2078
2337
|
}
|
|
2079
2338
|
const header = /^(.*):$/.exec(line);
|
|
2080
2339
|
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2081
|
-
if (!candidate || candidate.includes("://"))
|
|
2340
|
+
if (!candidate || candidate.includes("://") || searchStatusMessage(candidate))
|
|
2082
2341
|
continue;
|
|
2083
2342
|
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2084
2343
|
}
|
|
@@ -2231,21 +2490,50 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2231
2490
|
case "grep_result": {
|
|
2232
2491
|
if (error)
|
|
2233
2492
|
return { error: { error } };
|
|
2234
|
-
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
2235
|
-
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
2236
|
-
const files = extractGroundedPaths(output, resultRoot);
|
|
2237
2493
|
const cwd = resultRoot ?? "";
|
|
2494
|
+
const pattern = str(resultMetadata?.pattern) ?? "";
|
|
2495
|
+
const requestedPath = str(resultMetadata?.path) ?? cwd;
|
|
2496
|
+
const requestedMode = str(resultMetadata?.output_mode);
|
|
2497
|
+
// OpenCode always returns match previews. Cursor's native Grep defaults
|
|
2498
|
+
// to content; encoding that as files_with_matches drops the previews and
|
|
2499
|
+
// the model only sees paths. Keep files_with_matches for an explicit
|
|
2500
|
+
// files-only request, and for path lists (glob remaps, no matches).
|
|
2501
|
+
const parsedContent = parseOpenCodeGrepContent(output, resultRoot);
|
|
2502
|
+
const content = requestedMode === "files_with_matches"
|
|
2503
|
+
? undefined
|
|
2504
|
+
: parsedContent;
|
|
2505
|
+
if (content) {
|
|
2506
|
+
return {
|
|
2507
|
+
success: {
|
|
2508
|
+
pattern,
|
|
2509
|
+
path: requestedPath,
|
|
2510
|
+
output_mode: "content",
|
|
2511
|
+
workspace_results: {
|
|
2512
|
+
[cwd]: {
|
|
2513
|
+
content: {
|
|
2514
|
+
matches: content.matches,
|
|
2515
|
+
total_lines: content.totalMatchedLines,
|
|
2516
|
+
total_matched_lines: content.totalMatchedLines,
|
|
2517
|
+
client_truncated: content.truncated,
|
|
2518
|
+
ripgrep_truncated: false,
|
|
2519
|
+
},
|
|
2520
|
+
},
|
|
2521
|
+
},
|
|
2522
|
+
},
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
const files = extractGroundedPaths(output, resultRoot);
|
|
2238
2526
|
return {
|
|
2239
2527
|
success: {
|
|
2240
|
-
pattern
|
|
2241
|
-
path:
|
|
2528
|
+
pattern,
|
|
2529
|
+
path: requestedPath,
|
|
2242
2530
|
output_mode: "files_with_matches",
|
|
2243
2531
|
workspace_results: {
|
|
2244
2532
|
[cwd]: {
|
|
2245
2533
|
files: {
|
|
2246
2534
|
files,
|
|
2247
2535
|
total_files: files.length,
|
|
2248
|
-
client_truncated: false,
|
|
2536
|
+
client_truncated: parsedContent?.truncated ?? false,
|
|
2249
2537
|
},
|
|
2250
2538
|
},
|
|
2251
2539
|
},
|
|
@@ -2253,8 +2541,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2253
2541
|
};
|
|
2254
2542
|
}
|
|
2255
2543
|
case "write_result": {
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2544
|
+
// Prefer the path recorded from WriteArgs / a remapped apply_patch write.
|
|
2545
|
+
// Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
|
|
2546
|
+
// spill copies WriteSuccess.path into the model-visible filePath.
|
|
2258
2547
|
const remappedPath = str(resultMetadata?.path);
|
|
2259
2548
|
if (error)
|
|
2260
2549
|
return { error: { path: remappedPath ?? "", error } };
|
|
@@ -2782,10 +3071,12 @@ function readFileLineCount(filePath) {
|
|
|
2782
3071
|
}
|
|
2783
3072
|
}
|
|
2784
3073
|
function extractPathLines(output) {
|
|
2785
|
-
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2786
|
-
//
|
|
2787
|
-
|
|
2788
|
-
|
|
3074
|
+
const lines = collapseSearchStatus(output).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
3075
|
+
// A folded `# dir/` header is not itself a path. Prefer absolute / relative
|
|
3076
|
+
// path-looking lines; fall back to remaining non-status lines.
|
|
3077
|
+
const usable = lines.filter((line) => !searchStatusMessage(line) && !/^#+\s+\S/.test(line));
|
|
3078
|
+
const paths = usable.filter((l) => l.startsWith("/") || l.startsWith("./") || l.includes("/"));
|
|
3079
|
+
return (paths.length > 0 ? paths : usable).slice(0, 2000);
|
|
2789
3080
|
}
|
|
2790
3081
|
function countLines(s) {
|
|
2791
3082
|
if (!s)
|