cursor-opencode-provider 0.7.2 → 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 +16 -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 +247 -51
- 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,9 +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
|
-
: execVariant === "
|
|
1053
|
-
?
|
|
1054
|
-
:
|
|
1066
|
+
: execVariant === "write_args" || execVariant === "pi_write_args"
|
|
1067
|
+
? writeRequestResultMetadata(rawArgs, mapped.args)
|
|
1068
|
+
: execVariant === "grep_args"
|
|
1069
|
+
? grepRequestResultMetadata(rawArgs)
|
|
1070
|
+
: undefined;
|
|
1055
1071
|
if (resultMetadata
|
|
1056
1072
|
&& resultMetadata.timeout_behavior !== 2
|
|
1057
1073
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1168,7 +1184,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1168
1184
|
else {
|
|
1169
1185
|
content = stringValue(cleaned.content)
|
|
1170
1186
|
?? stringValue(cleaned.file_text)
|
|
1171
|
-
?? stringValue(cleaned.fileText)
|
|
1187
|
+
?? stringValue(cleaned.fileText)
|
|
1188
|
+
?? stringValue(cleaned.contents)
|
|
1189
|
+
?? stringValue(cleaned.text);
|
|
1172
1190
|
}
|
|
1173
1191
|
if (content !== undefined)
|
|
1174
1192
|
args.content = content;
|
|
@@ -1919,6 +1937,15 @@ function toolPathSeparator(filePath) {
|
|
|
1919
1937
|
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1920
1938
|
*/
|
|
1921
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) {
|
|
1922
1949
|
if (!isForeignAbsoluteToolPath(root))
|
|
1923
1950
|
return path.resolve(root, relative);
|
|
1924
1951
|
const sep = toolPathSeparator(root);
|
|
@@ -1988,17 +2015,169 @@ function resolveToolPath(filePath, workspaceRoot) {
|
|
|
1988
2015
|
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1989
2016
|
* drop the trailing colon only in the structured file list, not in this text.
|
|
1990
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
|
+
}
|
|
1991
2166
|
function groundSearchOutput(output, workspaceRoot) {
|
|
1992
|
-
if (!workspaceRoot)
|
|
1993
|
-
return output;
|
|
1994
2167
|
const normalized = normalizeToolText(output);
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
if (
|
|
2000
|
-
return
|
|
2001
|
-
|
|
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");
|
|
2002
2181
|
}
|
|
2003
2182
|
function isBarePathList(output) {
|
|
2004
2183
|
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
@@ -2015,8 +2194,9 @@ function isBarePathList(output) {
|
|
|
2015
2194
|
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2016
2195
|
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2017
2196
|
return line;
|
|
2018
|
-
|
|
2019
|
-
|
|
2197
|
+
const status = searchStatusMessage(line);
|
|
2198
|
+
if (status || line.startsWith("Found "))
|
|
2199
|
+
return status ?? line;
|
|
2020
2200
|
const header = /^(.*):$/.exec(line);
|
|
2021
2201
|
if (header && !header[1]?.includes("://")) {
|
|
2022
2202
|
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
@@ -2131,20 +2311,33 @@ function grepRequestResultMetadata(raw) {
|
|
|
2131
2311
|
}
|
|
2132
2312
|
function extractGroundedPaths(output, workspaceRoot) {
|
|
2133
2313
|
const normalized = normalizeToolText(output);
|
|
2134
|
-
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);
|
|
2135
2322
|
if (directory)
|
|
2136
2323
|
return directory.entries.slice(0, 2000);
|
|
2137
|
-
const first =
|
|
2138
|
-
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)) {
|
|
2139
2326
|
const paths = [];
|
|
2140
|
-
for (const raw of
|
|
2327
|
+
for (const raw of collapsed.split("\n").slice(1)) {
|
|
2141
2328
|
const line = raw.trim();
|
|
2142
|
-
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")) {
|
|
2143
2336
|
continue;
|
|
2144
2337
|
}
|
|
2145
2338
|
const header = /^(.*):$/.exec(line);
|
|
2146
2339
|
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2147
|
-
if (!candidate || candidate.includes("://"))
|
|
2340
|
+
if (!candidate || candidate.includes("://") || searchStatusMessage(candidate))
|
|
2148
2341
|
continue;
|
|
2149
2342
|
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2150
2343
|
}
|
|
@@ -2348,8 +2541,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2348
2541
|
};
|
|
2349
2542
|
}
|
|
2350
2543
|
case "write_result": {
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
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.
|
|
2353
2547
|
const remappedPath = str(resultMetadata?.path);
|
|
2354
2548
|
if (error)
|
|
2355
2549
|
return { error: { path: remappedPath ?? "", error } };
|
|
@@ -2877,10 +3071,12 @@ function readFileLineCount(filePath) {
|
|
|
2877
3071
|
}
|
|
2878
3072
|
}
|
|
2879
3073
|
function extractPathLines(output) {
|
|
2880
|
-
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2881
|
-
//
|
|
2882
|
-
|
|
2883
|
-
|
|
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);
|
|
2884
3080
|
}
|
|
2885
3081
|
function countLines(s) {
|
|
2886
3082
|
if (!s)
|
|
@@ -5,13 +5,32 @@
|
|
|
5
5
|
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
6
|
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
7
|
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* The 2.0 runtime names a session directory in two places:
|
|
10
|
+
* - request header `x-opencode-directory` (per-request; preferred in the LM)
|
|
11
|
+
* - `ctx.session.get()` → flat `info.directory`, or legacy
|
|
12
|
+
* `info.location.directory`, reachable from `session.hook("context")`
|
|
13
|
+
*
|
|
14
|
+
* This module records the session-get value by id so the language model can
|
|
15
|
+
* fall back when the header is absent, same mechanism as `compaction-marker.ts`.
|
|
12
16
|
*
|
|
13
17
|
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
18
|
*/
|
|
15
19
|
export declare function markSessionDirectory(sessionID: string, directory: string | undefined): void;
|
|
16
20
|
export declare function getSessionDirectory(sessionID: string | undefined): string | undefined;
|
|
17
21
|
export declare function clearSessionDirectories(): void;
|
|
22
|
+
/**
|
|
23
|
+
* Active session workspace directory from OpenCode 2.0 request headers.
|
|
24
|
+
* Values may be URI-encoded.
|
|
25
|
+
*/
|
|
26
|
+
export declare function opencodeDirectoryHeader(headers: Record<string, string | undefined> | undefined): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the workspace root for a model turn.
|
|
29
|
+
* Prefer the per-request header, then the session mark, then static options/cwd.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveSessionWorkspaceRoot(input: {
|
|
32
|
+
sessionKey?: string;
|
|
33
|
+
headers?: Record<string, string | undefined>;
|
|
34
|
+
workspaceRoot?: string;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
}): string;
|
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
6
|
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
7
|
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* The 2.0 runtime names a session directory in two places:
|
|
10
|
+
* - request header `x-opencode-directory` (per-request; preferred in the LM)
|
|
11
|
+
* - `ctx.session.get()` → flat `info.directory`, or legacy
|
|
12
|
+
* `info.location.directory`, reachable from `session.hook("context")`
|
|
13
|
+
*
|
|
14
|
+
* This module records the session-get value by id so the language model can
|
|
15
|
+
* fall back when the header is absent, same mechanism as `compaction-marker.ts`.
|
|
12
16
|
*
|
|
13
17
|
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
18
|
*/
|
|
19
|
+
import path from "node:path";
|
|
15
20
|
const MAX_TRACKED_SESSIONS = 256;
|
|
16
21
|
const sessionDirectories = new Map();
|
|
17
22
|
export function markSessionDirectory(sessionID, directory) {
|
|
@@ -33,3 +38,30 @@ export function getSessionDirectory(sessionID) {
|
|
|
33
38
|
export function clearSessionDirectories() {
|
|
34
39
|
sessionDirectories.clear();
|
|
35
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Active session workspace directory from OpenCode 2.0 request headers.
|
|
43
|
+
* Values may be URI-encoded.
|
|
44
|
+
*/
|
|
45
|
+
export function opencodeDirectoryHeader(headers) {
|
|
46
|
+
if (!headers)
|
|
47
|
+
return undefined;
|
|
48
|
+
const raw = headers["x-opencode-directory"] ?? headers["X-Opencode-Directory"];
|
|
49
|
+
if (typeof raw !== "string" || raw.trim().length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
const trimmed = raw.trim();
|
|
52
|
+
try {
|
|
53
|
+
return decodeURIComponent(trimmed);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return trimmed;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the workspace root for a model turn.
|
|
61
|
+
* Prefer the per-request header, then the session mark, then static options/cwd.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSessionWorkspaceRoot(input) {
|
|
64
|
+
return path.resolve(opencodeDirectoryHeader(input.headers) ??
|
|
65
|
+
getSessionDirectory(input.sessionKey) ??
|
|
66
|
+
(input.workspaceRoot || input.cwd || process.cwd()));
|
|
67
|
+
}
|