cursor-opencode-provider 0.7.4 → 0.7.5
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 -0
- package/dist/context/build.d.ts +6 -0
- package/dist/context/build.js +4 -2
- package/dist/context/dynamic-catalog.d.ts +43 -0
- package/dist/context/dynamic-catalog.js +183 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/context/overlay.d.ts +5 -0
- package/dist/context/overlay.js +7 -0
- package/dist/context/rules.d.ts +2 -1
- package/dist/context/rules.js +3 -2
- package/dist/image-save.d.ts +1 -1
- package/dist/image-save.js +3 -3
- package/dist/image-staging.js +6 -1
- package/dist/language-model.d.ts +5 -1
- package/dist/language-model.js +78 -7
- package/dist/opencode2/mcp-direct.d.ts +7 -2
- package/dist/opencode2/mcp-direct.js +7 -2
- package/dist/opencode2/types.d.ts +25 -1
- package/dist/plugin-opencode2.js +36 -10
- package/dist/pricing-data.d.ts +35 -0
- package/dist/pricing-data.js +35 -0
- package/dist/protocol/generate-image.d.ts +2 -2
- package/dist/protocol/generate-image.js +14 -2
- package/dist/protocol/tools.d.ts +4 -0
- package/dist/protocol/tools.js +30 -1
- package/dist/session-directory.d.ts +6 -3
- package/dist/session-directory.js +13 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,6 +293,8 @@ OpenCode
|
|
|
293
293
|
|
|
294
294
|
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, preferring OpenCode `todowrite` / `todoread` over Cursor TodoWrite when those tools are advertised without inviting catalog-comparison narration, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
|
|
295
295
|
|
|
296
|
+
Cursor keeps OpenCode `skill` and configured MCP tools off its native top-level function list on **both** OpenCode 1.x and OpenCode 2.0 — they appear as MCP descriptors and are reached through GetDynamicTools / CallDynamicTool. When the host advertises `skill` and/or tools of MCP servers configured in `opencode.json`, the provider names those routes in system guidance. When the host permits `skill` this turn and the user message names a discovered skill id, matches its description, or asks which skills are available, it also appends a Mid-Conversation reminder with those skill ids, so models prefer `skill` / MCP over Grep/Shell (and do not narrate that those tools are unavailable). OpenCode 2 still moves MCP servers that did not set `"codemode": true` onto the direct AI SDK catalog first; without that placement they never reach Cursor's MCP ads at all (see [OpenCode 2 MCP tools](docs/opencode-2.md#mcp-tools)).
|
|
297
|
+
|
|
296
298
|
Native Cursor todo display updates (`update_todos_tool_call` / `read_todos_tool_call`) are mirrored into `todowrite` / `todoread` when those host tools are advertised. OpenCode 2 dropped the host builtins; set `CURSOR_OPENCODE2_TODOS=1` (or `true`) to register the same canonical names when the catalog omits them (in-memory per session, cleared on `session.deleted` and process restart). With the flag unset, the plugin registers no todo tools. Enabled plugin tools are **direct** catalog tools (`options.codemode: false` plus an `output` schema), not Code Mode helpers. The provider retains the last known full list per OpenCode session — refreshed by bridged writes, direct host `todowrite` calls, and `todoread` results, surviving Run close, checkpoint resume, and later turns — so `merge: true` updates that omit Cursor's completed list still become a host replace-all snapshot (matched by id, then by content across id spaces). This snapshot is in-memory only and never enters the prompt, RequestContext, or persisted restart state, so Cursor's prompt-cache prefix is unaffected; a provider process restart clears it until the next write/read (intentional — see `tasks/lessons.md`).
|
|
297
299
|
|
|
298
300
|
If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
|
package/dist/context/build.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { type OpencodeToolDef } from "../protocol/tools.js";
|
|
2
|
+
import { type OpencodeJson } from "./rules.js";
|
|
2
3
|
export type BuildRequestContextInput = {
|
|
3
4
|
workspaceRoot: string;
|
|
4
5
|
tools?: OpencodeToolDef[];
|
|
5
6
|
providerIdentifier?: string;
|
|
6
7
|
/** When set, skills/subagents/plugins are epoch-held for this conversation. */
|
|
7
8
|
conversationId?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Preloaded merged `opencode.json` from the same Run (e.g. for interaction
|
|
11
|
+
* guidance MCP server ids). Skips a second `loadMergedConfig` disk read.
|
|
12
|
+
*/
|
|
13
|
+
mergedConfig?: OpencodeJson;
|
|
8
14
|
};
|
|
9
15
|
export declare const DYNAMIC_REQUEST_CONTEXT_KEYS: readonly ["tools", "agent_skills", "custom_subagents", "mcp_file_system_options", "mcp_meta_tool_options", "web_search_enabled", "web_fetch_enabled", "agent_skills_info_complete", "custom_subagents_info_complete", "mcp_file_system_info_complete", "mcp_info_complete", "hooks_additional_context"];
|
|
10
16
|
export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[number];
|
package/dist/context/build.js
CHANGED
|
@@ -67,7 +67,7 @@ function buildAdvertisedSubagentCatalog(hostSubagents, discoveredAgents) {
|
|
|
67
67
|
*/
|
|
68
68
|
export async function buildRequestContext(input) {
|
|
69
69
|
const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
|
|
70
|
-
const { rules, config, worktree } = await collectRules(workspaceRoot);
|
|
70
|
+
const { rules, config, worktree } = await collectRules(workspaceRoot, input.mergedConfig);
|
|
71
71
|
const [dynamic, git, layout] = await Promise.all([
|
|
72
72
|
buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config),
|
|
73
73
|
collectGit(workspaceRoot),
|
|
@@ -176,7 +176,9 @@ export async function buildDynamicRequestContext(input) {
|
|
|
176
176
|
const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
|
|
177
177
|
const [worktree, config] = await Promise.all([
|
|
178
178
|
findGitWorktree(workspaceRoot),
|
|
179
|
-
|
|
179
|
+
input.mergedConfig
|
|
180
|
+
? Promise.resolve(input.mergedConfig)
|
|
181
|
+
: loadMergedConfig(workspaceRoot),
|
|
180
182
|
]);
|
|
181
183
|
return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config);
|
|
182
184
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type AgentSkillLike = {
|
|
2
|
+
/** OpenCode skill id (frontmatter `name`, else directory name). */
|
|
3
|
+
id?: unknown;
|
|
4
|
+
full_path?: unknown;
|
|
5
|
+
description?: unknown;
|
|
6
|
+
content?: unknown;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Configured MCP servers that own at least one advertised tool. Uses the same
|
|
10
|
+
* server resolution as the RequestContext descriptors, so guidance never names
|
|
11
|
+
* a server Cursor was not given. `toolNames` are OpenCode ids (alias
|
|
12
|
+
* `sourceName` when present).
|
|
13
|
+
*/
|
|
14
|
+
export declare function listAdvertisedMcpServers(toolNames: Iterable<string>, knownMcpServers?: Iterable<string>): string[];
|
|
15
|
+
/** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
|
|
16
|
+
export declare function skillNameFromAgentSkill(skill: AgentSkillLike): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Shared system-guidance line: name advertised dynamic-catalog tools and prefer
|
|
19
|
+
* them over Grep/Shell when skills or project rules apply. Concrete skill ids
|
|
20
|
+
* are deliberately absent: this line is part of the frozen baseline system
|
|
21
|
+
* context, and per-turn ids go through `buildSkillCatalogNudge` instead.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildDynamicCatalogRoutingInstruction(options: {
|
|
24
|
+
toolNames: Iterable<string>;
|
|
25
|
+
knownMcpServers?: Iterable<string>;
|
|
26
|
+
}): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Mid-Conversation nudge after RequestContext skills are known. Prefer skills
|
|
29
|
+
* that match the user turn; otherwise stay quiet unless the user asked about
|
|
30
|
+
* skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
|
|
31
|
+
* `hasSkillTool` must reflect what the host permits this turn, not the
|
|
32
|
+
* epoch-held advertisement.
|
|
33
|
+
*/
|
|
34
|
+
export declare function buildSkillCatalogNudge(options: {
|
|
35
|
+
hasSkillTool: boolean;
|
|
36
|
+
skills: readonly AgentSkillLike[];
|
|
37
|
+
userText?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Host skill parameter key from the advertised schema.
|
|
40
|
+
* OpenCode 1.x: `name`. OpenCode 2.0: `id`.
|
|
41
|
+
*/
|
|
42
|
+
skillArgKey?: "name" | "id";
|
|
43
|
+
}): string | undefined;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { resolveToolServerIdentity } from "../protocol/tools.js";
|
|
2
|
+
/**
|
|
3
|
+
* Issue #29: Cursor keeps OpenCode `skill` and MCP tools off its native
|
|
4
|
+
* top-level function list. Both OpenCode 1.x and OpenCode 2.0 reach the model
|
|
5
|
+
* as MCP-shaped RequestContext ads, then GetDynamicTools / CallDynamicTool
|
|
6
|
+
* (exec `mcp_args`). This module builds the shared routing guidance and the
|
|
7
|
+
* skill-aware Mid-Conversation nudge used on both hosts.
|
|
8
|
+
*
|
|
9
|
+
* OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
|
|
10
|
+
* and enter the AI SDK catalog; without that step they never reach this
|
|
11
|
+
* advertisement path at all.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_NAMED_SKILLS = 8;
|
|
14
|
+
const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
|
|
15
|
+
/** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
|
|
16
|
+
const DEFAULT_TOOL_SERVER = "opencode";
|
|
17
|
+
/** Common words that carry no skill-matching signal. */
|
|
18
|
+
const STOPWORDS = new Set([
|
|
19
|
+
"about", "all", "and", "any", "are", "but", "can", "for", "from", "has", "have",
|
|
20
|
+
"how", "into", "its", "not", "one", "only", "that", "the", "their", "them",
|
|
21
|
+
"then", "there", "these", "this", "use", "used", "uses", "using", "via", "was",
|
|
22
|
+
"what", "when", "which", "who", "why", "will", "with", "you", "your",
|
|
23
|
+
// Host/MCP instruction boilerplate — otherwise a server-instruction dump
|
|
24
|
+
// matches every firecrawl/search skill on "search"/"tools"/"server".
|
|
25
|
+
"tool", "tools", "server", "servers", "call", "calls", "fetch", "search",
|
|
26
|
+
"documentation", "library", "framework", "query", "please", "through",
|
|
27
|
+
]);
|
|
28
|
+
/** An explicit question/request about skills, not incidental prose ("has the skills to"). */
|
|
29
|
+
const SKILL_INQUIRY = /\b(?:which|what|list|show|available|any|use|using|call|invoke|load|run)\b[^.?!\n]{0,40}\bskills?\b|\bskills?\b[^.?!\n]{0,40}\b(?:available|installed|loaded|exist|do you have|can you)\b/i;
|
|
30
|
+
/** Description-only hits above this look like instruction-dump false positives. */
|
|
31
|
+
const MAX_DESCRIPTION_ONLY_MATCHES = 2;
|
|
32
|
+
/**
|
|
33
|
+
* Match against the live user utterance only. Mid-conversation / MCP instruction
|
|
34
|
+
* injections and prior reminders must not re-trigger skill dumps.
|
|
35
|
+
*/
|
|
36
|
+
function skillMatchCorpus(userText) {
|
|
37
|
+
return userText
|
|
38
|
+
.replace(/<system_reminder\b[^>]*>[\s\S]*?<\/system_reminder>/gi, " ")
|
|
39
|
+
.replace(/<system-update\b[^>]*>[\s\S]*?<\/system-update>/gi, " ")
|
|
40
|
+
.replace(/New MCP server instructions[\s\S]*?(?=\n[A-Z]|\n\n|$)/gi, " ");
|
|
41
|
+
}
|
|
42
|
+
function listWithOverflow(items, limit) {
|
|
43
|
+
const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
|
|
44
|
+
const hidden = items.length - limit;
|
|
45
|
+
return hidden > 0 ? `${shown} (+${hidden} more)` : shown;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Configured MCP servers that own at least one advertised tool. Uses the same
|
|
49
|
+
* server resolution as the RequestContext descriptors, so guidance never names
|
|
50
|
+
* a server Cursor was not given. `toolNames` are OpenCode ids (alias
|
|
51
|
+
* `sourceName` when present).
|
|
52
|
+
*/
|
|
53
|
+
export function listAdvertisedMcpServers(toolNames, knownMcpServers = []) {
|
|
54
|
+
const known = [...knownMcpServers];
|
|
55
|
+
if (known.length === 0)
|
|
56
|
+
return [];
|
|
57
|
+
const servers = new Set();
|
|
58
|
+
for (const name of toolNames) {
|
|
59
|
+
const { server } = resolveToolServerIdentity(name, DEFAULT_TOOL_SERVER, known);
|
|
60
|
+
if (server !== DEFAULT_TOOL_SERVER)
|
|
61
|
+
servers.add(server);
|
|
62
|
+
}
|
|
63
|
+
return [...servers];
|
|
64
|
+
}
|
|
65
|
+
/** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
|
|
66
|
+
export function skillNameFromAgentSkill(skill) {
|
|
67
|
+
const id = typeof skill.id === "string" ? skill.id.trim() : "";
|
|
68
|
+
if (id)
|
|
69
|
+
return id;
|
|
70
|
+
const fullPath = typeof skill.full_path === "string" ? skill.full_path.trim() : "";
|
|
71
|
+
const parts = fullPath.split(/[\\/]+/).filter(Boolean);
|
|
72
|
+
if (parts.length < 2 || parts.at(-1).toLowerCase() !== "skill.md")
|
|
73
|
+
return undefined;
|
|
74
|
+
return parts.at(-2);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Shared system-guidance line: name advertised dynamic-catalog tools and prefer
|
|
78
|
+
* them over Grep/Shell when skills or project rules apply. Concrete skill ids
|
|
79
|
+
* are deliberately absent: this line is part of the frozen baseline system
|
|
80
|
+
* context, and per-turn ids go through `buildSkillCatalogNudge` instead.
|
|
81
|
+
*/
|
|
82
|
+
export function buildDynamicCatalogRoutingInstruction(options) {
|
|
83
|
+
const names = [...options.toolNames];
|
|
84
|
+
const hasSkill = names.includes("skill");
|
|
85
|
+
const mcpServers = listAdvertisedMcpServers(names, options.knownMcpServers);
|
|
86
|
+
if (!hasSkill && mcpServers.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
const extras = [];
|
|
89
|
+
if (hasSkill)
|
|
90
|
+
extras.push("`skill`");
|
|
91
|
+
if (mcpServers.length > 0) {
|
|
92
|
+
extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
|
|
93
|
+
}
|
|
94
|
+
return (`- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
|
|
95
|
+
"are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
|
|
96
|
+
"When a skill matches or project rules name an MCP server, discover and call those tools that way " +
|
|
97
|
+
"before Grep/Shell fallbacks. Do not narrate that they are unavailable.");
|
|
98
|
+
}
|
|
99
|
+
function wordTokens(text) {
|
|
100
|
+
return new Set(text
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.split(/[^a-z0-9]+/)
|
|
103
|
+
.filter((token) => token.length >= 3 && !STOPWORDS.has(token)));
|
|
104
|
+
}
|
|
105
|
+
function descriptionMatchesUserText(description, userTokens) {
|
|
106
|
+
let hits = 0;
|
|
107
|
+
let distinctive = false;
|
|
108
|
+
for (const token of wordTokens(description)) {
|
|
109
|
+
if (!userTokens.has(token))
|
|
110
|
+
continue;
|
|
111
|
+
hits += 1;
|
|
112
|
+
if (token.length >= 8)
|
|
113
|
+
distinctive = true;
|
|
114
|
+
if (hits >= 2)
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// Single distinctive token (library names, skill ids) is enough.
|
|
118
|
+
return distinctive;
|
|
119
|
+
}
|
|
120
|
+
function nameMatchesUserText(name, userText) {
|
|
121
|
+
const haystack = userText.toLowerCase();
|
|
122
|
+
const candidates = [name.toLowerCase(), ...name.toLowerCase().split(/[-_]+/).filter((part) => part.length >= 5)];
|
|
123
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
124
|
+
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
125
|
+
if (new RegExp(`(?<![a-z0-9_-])${escaped}(?![a-z0-9_-])`).test(haystack))
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Mid-Conversation nudge after RequestContext skills are known. Prefer skills
|
|
132
|
+
* that match the user turn; otherwise stay quiet unless the user asked about
|
|
133
|
+
* skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
|
|
134
|
+
* `hasSkillTool` must reflect what the host permits this turn, not the
|
|
135
|
+
* epoch-held advertisement.
|
|
136
|
+
*/
|
|
137
|
+
export function buildSkillCatalogNudge(options) {
|
|
138
|
+
if (!options.hasSkillTool || options.skills.length === 0)
|
|
139
|
+
return undefined;
|
|
140
|
+
const userText = skillMatchCorpus(options.userText ?? "");
|
|
141
|
+
const userTokens = wordTokens(userText);
|
|
142
|
+
const skillInquiry = SKILL_INQUIRY.test(userText);
|
|
143
|
+
const named = options.skills
|
|
144
|
+
.map((skill) => {
|
|
145
|
+
const name = skillNameFromAgentSkill(skill);
|
|
146
|
+
if (!name)
|
|
147
|
+
return undefined;
|
|
148
|
+
const description = typeof skill.description === "string" ? skill.description : "";
|
|
149
|
+
const nameHit = nameMatchesUserText(name, userText);
|
|
150
|
+
const descHit = !!description && descriptionMatchesUserText(description, userTokens);
|
|
151
|
+
return { name, nameHit, descHit };
|
|
152
|
+
})
|
|
153
|
+
.filter((row) => !!row);
|
|
154
|
+
if (named.length === 0)
|
|
155
|
+
return undefined;
|
|
156
|
+
const nameMatched = named.filter((row) => row.nameHit);
|
|
157
|
+
const descOnly = named.filter((row) => row.descHit && !row.nameHit);
|
|
158
|
+
// Mass description-only hits are almost always instruction-dump noise
|
|
159
|
+
// (MCP server blurbs sharing "search"/"tools" with firecrawl skills).
|
|
160
|
+
const descMatched = descOnly.length <= MAX_DESCRIPTION_ONLY_MATCHES ? descOnly : [];
|
|
161
|
+
const selected = nameMatched.length > 0
|
|
162
|
+
? [...nameMatched, ...descMatched]
|
|
163
|
+
: skillInquiry
|
|
164
|
+
? named
|
|
165
|
+
: descMatched;
|
|
166
|
+
if (selected.length === 0)
|
|
167
|
+
return undefined;
|
|
168
|
+
const ids = [...new Set(selected.map((row) => row.name))];
|
|
169
|
+
// Prefer the advertised schema key; fall back to naming both so a stale
|
|
170
|
+
// Mid-Conversation hint cannot contradict GetDynamicTools on either host.
|
|
171
|
+
const argKey = options.skillArgKey;
|
|
172
|
+
const callHint = argKey
|
|
173
|
+
? `Call \`skill\` with \`{ "${argKey}": "<skill-id>" }\` (host schema key is \`${argKey}\`) `
|
|
174
|
+
: "Call `skill` with the skill identifier using the parameter key from GetDynamicTools " +
|
|
175
|
+
"(`name` on OpenCode 1.x, `id` on OpenCode 2.0) ";
|
|
176
|
+
return ("<system_reminder>\n" +
|
|
177
|
+
"OpenCode skills are invoked with the host `skill` tool through GetDynamicTools / CallDynamicTool " +
|
|
178
|
+
`(MCP server \`${DEFAULT_TOOL_SERVER}\`, tool \`skill\`), not by Grep/Read of SKILL.md. ` +
|
|
179
|
+
`Relevant skill id(s) this turn: ${listWithOverflow(ids, MAX_NAMED_SKILLS)}. ` +
|
|
180
|
+
callHint +
|
|
181
|
+
"before answering from memory or falling back to shell/search tools.\n" +
|
|
182
|
+
"</system_reminder>");
|
|
183
|
+
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, type BuildRequestContextInput, } from "./build.js";
|
|
2
|
+
export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, type AgentSkillLike, } from "./dynamic-catalog.js";
|
|
2
3
|
export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
|
|
3
4
|
export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
|
|
4
5
|
export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
|
package/dist/context/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, } from "./build.js";
|
|
2
|
+
export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, } from "./dynamic-catalog.js";
|
|
2
3
|
export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
|
|
3
4
|
export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
|
|
4
5
|
export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
|
|
@@ -39,6 +39,11 @@ export type OverlayWire = {
|
|
|
39
39
|
};
|
|
40
40
|
/** Merge live discovery into the conversation overlay epoch. */
|
|
41
41
|
export declare function holdCapabilityOverlay(conversationId: string, live: OverlayHold): OverlayWire;
|
|
42
|
+
/**
|
|
43
|
+
* Epoch-held skills with their OpenCode ids. The wire shape drops `id`
|
|
44
|
+
* (`AgentSkill` has no name field), but the host `skill` tool needs it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getHeldOverlaySkills(conversationId: string): OverlaySkill[];
|
|
42
47
|
export declare function clearOverlayHold(conversationId: string): void;
|
|
43
48
|
export declare function transferOverlayHold(previousConversationId: string, nextConversationId: string): void;
|
|
44
49
|
export declare function resetOverlayHoldsForTests(): void;
|
package/dist/context/overlay.js
CHANGED
|
@@ -78,6 +78,13 @@ export function holdCapabilityOverlay(conversationId, live) {
|
|
|
78
78
|
}
|
|
79
79
|
return toWire(remember(conversationId, next));
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Epoch-held skills with their OpenCode ids. The wire shape drops `id`
|
|
83
|
+
* (`AgentSkill` has no name field), but the host `skill` tool needs it.
|
|
84
|
+
*/
|
|
85
|
+
export function getHeldOverlaySkills(conversationId) {
|
|
86
|
+
return structuredClone(byConversationId.get(conversationId)?.skills ?? []);
|
|
87
|
+
}
|
|
81
88
|
export function clearOverlayHold(conversationId) {
|
|
82
89
|
byConversationId.delete(conversationId);
|
|
83
90
|
}
|
package/dist/context/rules.d.ts
CHANGED
|
@@ -17,8 +17,9 @@ export declare function fetchRemoteInstruction(url: string, timeoutMs?: number):
|
|
|
17
17
|
export declare function loadMergedConfig(workspaceRoot: string): Promise<OpencodeJson>;
|
|
18
18
|
/**
|
|
19
19
|
* Collect OpenCode instruction files.
|
|
20
|
+
* `preloadedConfig` reuses a merged config already loaded on this Run.
|
|
20
21
|
*/
|
|
21
|
-
export declare function collectRules(workspaceRoot: string): Promise<{
|
|
22
|
+
export declare function collectRules(workspaceRoot: string, preloadedConfig?: OpencodeJson): Promise<{
|
|
22
23
|
rules: CollectedRule[];
|
|
23
24
|
config: OpencodeJson;
|
|
24
25
|
worktree: string;
|
package/dist/context/rules.js
CHANGED
|
@@ -178,12 +178,13 @@ export async function loadMergedConfig(workspaceRoot) {
|
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
180
|
* Collect OpenCode instruction files.
|
|
181
|
+
* `preloadedConfig` reuses a merged config already loaded on this Run.
|
|
181
182
|
*/
|
|
182
|
-
export async function collectRules(workspaceRoot) {
|
|
183
|
+
export async function collectRules(workspaceRoot, preloadedConfig) {
|
|
183
184
|
const worktree = await findGitWorktree(workspaceRoot);
|
|
184
185
|
const rules = [];
|
|
185
186
|
const seen = new Set();
|
|
186
|
-
const config = await loadMergedConfig(workspaceRoot);
|
|
187
|
+
const config = preloadedConfig ?? await loadMergedConfig(workspaceRoot);
|
|
187
188
|
const add = async (file) => {
|
|
188
189
|
if (!file)
|
|
189
190
|
return;
|
package/dist/image-save.d.ts
CHANGED
|
@@ -55,4 +55,4 @@ export declare function resolveContainedImagePath(target: string, allowedRoots:
|
|
|
55
55
|
*/
|
|
56
56
|
export declare function executeCursorImageSave(args: {
|
|
57
57
|
image_id?: unknown;
|
|
58
|
-
}, ctx: ImageSaveToolContext): Promise<ImageSaveResult
|
|
58
|
+
}, ctx: ImageSaveToolContext): Promise<ImageSaveResult>;
|
package/dist/image-save.js
CHANGED
|
@@ -106,18 +106,18 @@ function isInsideProject(target, worktree, directory) {
|
|
|
106
106
|
export async function executeCursorImageSave(args, ctx) {
|
|
107
107
|
const imageId = typeof args.image_id === "string" ? args.image_id : "";
|
|
108
108
|
if (!imageId)
|
|
109
|
-
|
|
109
|
+
throw new Error("No image id was provided, so there is nothing to save.");
|
|
110
110
|
const image = takePendingCursorImage(imageId);
|
|
111
111
|
if (!image) {
|
|
112
112
|
// Expired, already committed, or never existed. Identical response for all
|
|
113
113
|
// three: nothing here should confirm whether an id was ever valid.
|
|
114
|
-
|
|
114
|
+
throw new Error("No pending Cursor image matches that id. It may have already been saved or expired.");
|
|
115
115
|
}
|
|
116
116
|
const workspace = ctx.worktree || ctx.directory;
|
|
117
117
|
const contained = resolveContainedImagePath(image.path, [image.projectDir, workspace].filter((root) => !!root));
|
|
118
118
|
if ("error" in contained) {
|
|
119
119
|
trace(`image save: refused path=${JSON.stringify(image.path)} reason=${contained.error}`);
|
|
120
|
-
|
|
120
|
+
throw new Error(`Refusing to save the generated image: ${contained.error}.`);
|
|
121
121
|
}
|
|
122
122
|
// Mirror what OpenCode's own `write` tool does, in the same order: an
|
|
123
123
|
// external-directory gate for targets outside the project, then the `edit`
|
package/dist/image-staging.js
CHANGED
|
@@ -25,7 +25,12 @@ export const MAX_STAGED_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
|
25
25
|
export const STAGED_IMAGE_TTL_MS = 10 * 60_000;
|
|
26
26
|
/** Bounds memory if a turn generates several images and none are committed. */
|
|
27
27
|
const MAX_PENDING = 8;
|
|
28
|
-
|
|
28
|
+
// Some hosts load the model and the image-save subpath in separate module
|
|
29
|
+
// graphs. Keep the one-use handles in the process registry so both graphs see
|
|
30
|
+
// the same staged bytes. The UUID handle remains the only tool input.
|
|
31
|
+
const PENDING_IMAGES = Symbol.for("cursor-opencode-provider.pending-images");
|
|
32
|
+
const globals = globalThis;
|
|
33
|
+
const pending = globals[PENDING_IMAGES] ??= new Map();
|
|
29
34
|
export class StagedImageTooLargeError extends Error {
|
|
30
35
|
constructor(bytes) {
|
|
31
36
|
super(`Generated image is ${bytes} bytes, above the ${MAX_STAGED_IMAGE_BYTES} byte limit`);
|
package/dist/language-model.d.ts
CHANGED
|
@@ -133,6 +133,8 @@ type ExtractedToolResult = {
|
|
|
133
133
|
* are ignored — they are conversation history, not replies for a held-open Run.
|
|
134
134
|
*/
|
|
135
135
|
export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
|
|
136
|
+
/** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
|
|
137
|
+
export declare function hasApprovedUncorrelatedPlanStageResult(prompt: LanguageModelV3CallOptions["prompt"]): boolean;
|
|
136
138
|
/**
|
|
137
139
|
* Checkpointed Runs do not resend the system prompt. Keep the workspace root
|
|
138
140
|
* on the live user message, and require absolute `path` arguments when that is
|
|
@@ -147,7 +149,9 @@ export declare function groundCheckpointTurnText(userText: string, checkpoint: b
|
|
|
147
149
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
|
148
150
|
* compaction keeps its dedicated summary prompt unchanged.
|
|
149
151
|
*/
|
|
150
|
-
export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string
|
|
152
|
+
export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string, options?: {
|
|
153
|
+
knownMcpServers?: Iterable<string>;
|
|
154
|
+
}): string | undefined;
|
|
151
155
|
/** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
|
|
152
156
|
export declare function estimateTokens(chars: number): number;
|
|
153
157
|
/** Preserve exact request-local Cursor counters as diagnostics. */
|
package/dist/language-model.js
CHANGED
|
@@ -32,6 +32,9 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
|
|
|
32
32
|
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
33
33
|
import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
34
34
|
import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
35
|
+
import { getHeldOverlaySkills } from "./context/overlay.js";
|
|
36
|
+
import { loadMergedConfig } from "./context/rules.js";
|
|
37
|
+
import { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, } from "./context/dynamic-catalog.js";
|
|
35
38
|
import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
|
|
36
39
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
37
40
|
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
@@ -507,6 +510,12 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
507
510
|
// false "orphaned tool results" errors after Cursor turn_ended and OpenCode
|
|
508
511
|
// started the next step with old tools still in the prompt body.
|
|
509
512
|
const trailingToolResults = extractTrailingToolResults(prompt);
|
|
513
|
+
// A host may submit an already rendered plan through its canonical stage
|
|
514
|
+
// tool after the Cursor Run has ended. The next call carries that tool result
|
|
515
|
+
// without a Cursor exec id; reconcile the approved mode before a fresh Run.
|
|
516
|
+
if (hasApprovedUncorrelatedPlanStageResult(prompt)) {
|
|
517
|
+
setActiveCursorMode(opencodeSessionKey(callOptions), "agent");
|
|
518
|
+
}
|
|
510
519
|
let session = findContinuationSession(trailingToolResults);
|
|
511
520
|
if (session) {
|
|
512
521
|
// Write pending results onto the held-open Run. A dead stream closes the
|
|
@@ -801,15 +810,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
801
810
|
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
802
811
|
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
803
812
|
// projects, so its static option is only a last-resort fallback. Prefer the
|
|
804
|
-
// per-request `x-opencode-directory` header
|
|
805
|
-
//
|
|
813
|
+
// per-request `x-opencode-directory` header (set by the 2.0 plugin's
|
|
814
|
+
// `session.hook("model.request")`), then the session mark recorded from the
|
|
815
|
+
// session hooks via `getSessionDirectory`.
|
|
806
816
|
const workspaceRoot = resolveSessionWorkspaceRoot({
|
|
807
817
|
sessionKey,
|
|
808
818
|
headers: callOptions.headers,
|
|
809
819
|
workspaceRoot: options.workspaceRoot,
|
|
810
820
|
});
|
|
811
821
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
812
|
-
|
|
822
|
+
// One merged-config load per Run: guidance MCP ids and RequestContext
|
|
823
|
+
// descriptors must agree, and warm turns must not pay for a second disk read.
|
|
824
|
+
const mergedConfig = isCompaction ? undefined : await loadMergedConfig(workspaceRoot);
|
|
825
|
+
const knownMcpServers = Object.keys(mergedConfig?.mcp ?? {});
|
|
826
|
+
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot, {
|
|
827
|
+
knownMcpServers,
|
|
828
|
+
});
|
|
813
829
|
// Prompt-identity diagnostics are filled after Context Epoch admission below
|
|
814
830
|
// (baseline hash is the epoch baseline, not a per-turn host hash).
|
|
815
831
|
let frozenSystemPromptHash;
|
|
@@ -864,6 +880,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
864
880
|
let userText = recovery?.kind === "rebase"
|
|
865
881
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
866
882
|
: (extractUserText(lastUser) || ".");
|
|
883
|
+
// Skill matching must see the live user utterance only — not Mid-Conversation
|
|
884
|
+
// MCP/system injections appended below (those false-trigger firecrawl dumps).
|
|
885
|
+
const skillMatchText = userText;
|
|
867
886
|
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
868
887
|
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
869
888
|
const startedWithCheckpoint = !!conversationState;
|
|
@@ -987,7 +1006,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
987
1006
|
});
|
|
988
1007
|
// Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
|
|
989
1008
|
// Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
|
|
990
|
-
const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
|
|
1009
|
+
const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
|
|
1010
|
+
// Issue #29: after skills are in RequestContext, nudge matched skill ids onto
|
|
1011
|
+
// the Mid-Conversation channel so models CallDynamicTool(`skill`) instead of
|
|
1012
|
+
// Grep/Read SKILL.md. Shared by OpenCode 1.x and 2.0 catalogs. Gate on the
|
|
1013
|
+
// host-permitted set, not the epoch-held advertisement: a turn that withheld
|
|
1014
|
+
// `skill` would refuse the call this nudge asks for.
|
|
1015
|
+
if (!isCompaction && !lifecycle) {
|
|
1016
|
+
const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
|
|
1017
|
+
const skillNudge = buildSkillCatalogNudge({
|
|
1018
|
+
hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
|
|
1019
|
+
skills: getHeldOverlaySkills(conversationId),
|
|
1020
|
+
userText: skillMatchText,
|
|
1021
|
+
skillArgKey: skillDialect.skillArgKey,
|
|
1022
|
+
});
|
|
1023
|
+
userText = appendMidConversationMessage(userText, skillNudge);
|
|
1024
|
+
}
|
|
991
1025
|
const contextSubagents = Array.isArray(requestContext.custom_subagents)
|
|
992
1026
|
? requestContext.custom_subagents
|
|
993
1027
|
.map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
|
|
@@ -1097,7 +1131,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1097
1131
|
}
|
|
1098
1132
|
const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
|
|
1099
1133
|
trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
|
|
1100
|
-
`tools=[${tools.map((t) => t.name).join(",")}]`);
|
|
1134
|
+
`skillArgKey=${hostToolDialect.skillArgKey} tools=[${tools.map((t) => t.name).join(",")}]`);
|
|
1101
1135
|
const session = {
|
|
1102
1136
|
sessionId: crypto.randomUUID(),
|
|
1103
1137
|
conversationId,
|
|
@@ -1836,6 +1870,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
1836
1870
|
});
|
|
1837
1871
|
}
|
|
1838
1872
|
}
|
|
1873
|
+
// A directly called advertised plan-stage tool can own the same review
|
|
1874
|
+
// gate as a bridged CreatePlan. Its successful result means execution was
|
|
1875
|
+
// approved, so the next Run must carry agent-mode guidance.
|
|
1876
|
+
if (!pending.bridged
|
|
1877
|
+
&& pending.toolName === CURSOR_PLAN_STAGE_TOOL
|
|
1878
|
+
&& r.error === undefined) {
|
|
1879
|
+
setActiveCursorMode(session.openCodeSessionId, "agent");
|
|
1880
|
+
}
|
|
1839
1881
|
// A host `todoread` result is the authoritative list. Refresh the mirrored
|
|
1840
1882
|
// snapshot (direct reads and bridged native reads alike) so later merges
|
|
1841
1883
|
// apply onto host truth, not a stale write.
|
|
@@ -2864,7 +2906,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2864
2906
|
imageId = stageCursorImage({
|
|
2865
2907
|
path: target,
|
|
2866
2908
|
projectDir,
|
|
2867
|
-
mime: imageMimeForPath(target),
|
|
2909
|
+
mime: imageMimeForPath(target, binaryWrite.data),
|
|
2868
2910
|
data: binaryWrite.data,
|
|
2869
2911
|
sessionId: session.openCodeSessionId,
|
|
2870
2912
|
});
|
|
@@ -3299,6 +3341,25 @@ export function extractTrailingToolResults(prompt) {
|
|
|
3299
3341
|
return [];
|
|
3300
3342
|
return extractToolResults(prompt.slice(i + 1));
|
|
3301
3343
|
}
|
|
3344
|
+
/** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
|
|
3345
|
+
export function hasApprovedUncorrelatedPlanStageResult(prompt) {
|
|
3346
|
+
if (prompt.length === 0 || prompt[prompt.length - 1].role !== "tool")
|
|
3347
|
+
return false;
|
|
3348
|
+
for (let i = prompt.length - 1; i >= 0 && prompt[i].role === "tool"; i--) {
|
|
3349
|
+
const message = prompt[i];
|
|
3350
|
+
if (!Array.isArray(message.content))
|
|
3351
|
+
continue;
|
|
3352
|
+
for (const part of message.content) {
|
|
3353
|
+
if (part.type !== "tool-result" || part.toolName !== CURSOR_PLAN_STAGE_TOOL)
|
|
3354
|
+
continue;
|
|
3355
|
+
if (parseExecIdFromToolCallId(part.toolCallId))
|
|
3356
|
+
continue;
|
|
3357
|
+
const result = toolResultOutputToText(part.output);
|
|
3358
|
+
return !result.isError;
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
return false;
|
|
3362
|
+
}
|
|
3302
3363
|
function toolResultOutputToText(output) {
|
|
3303
3364
|
if (output == null)
|
|
3304
3365
|
return { text: "", isError: false };
|
|
@@ -3351,7 +3412,7 @@ export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, to
|
|
|
3351
3412
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
|
3352
3413
|
* compaction keeps its dedicated summary prompt unchanged.
|
|
3353
3414
|
*/
|
|
3354
|
-
export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
|
|
3415
|
+
export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot, options = {}) {
|
|
3355
3416
|
if (isCompaction)
|
|
3356
3417
|
return undefined;
|
|
3357
3418
|
const names = new Set(tools.map((tool) => tool.name));
|
|
@@ -3419,6 +3480,16 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
3419
3480
|
instructions.push("- Host `scout` is available for external documentation and dependency-source research. Use Cursor `cursor-guide` for that use case; local repository discovery still uses `bugbot`/`explore`.");
|
|
3420
3481
|
}
|
|
3421
3482
|
}
|
|
3483
|
+
// Issue #29: skill/MCP stay off Cursor's native top-level list on both
|
|
3484
|
+
// OpenCode 1.x and 2.0 — route them through the dynamic catalog instead.
|
|
3485
|
+
{
|
|
3486
|
+
const dynamicCatalog = buildDynamicCatalogRoutingInstruction({
|
|
3487
|
+
toolNames: tools.map((tool) => tool.sourceName ?? tool.name),
|
|
3488
|
+
knownMcpServers: options.knownMcpServers,
|
|
3489
|
+
});
|
|
3490
|
+
if (dynamicCatalog)
|
|
3491
|
+
instructions.push(dynamicCatalog);
|
|
3492
|
+
}
|
|
3422
3493
|
if (names.has("write")) {
|
|
3423
3494
|
instructions.push(names.has("edit")
|
|
3424
3495
|
? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
|
|
@@ -3,8 +3,13 @@ import type { ToolDraft } from "./types.js";
|
|
|
3
3
|
* OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
|
|
4
4
|
* config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
|
|
5
5
|
* `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
|
|
6
|
-
* the AI SDK catalog (`packages/core/src/tool.ts`).
|
|
7
|
-
* only when it is in that catalog.
|
|
6
|
+
* the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
|
|
7
|
+
* a tool to Cursor only when it is in that catalog.
|
|
8
|
+
*
|
|
9
|
+
* That AI SDK placement is necessary but not sufficient for Cursor models:
|
|
10
|
+
* non-native tools are still invoked through GetDynamicTools / CallDynamicTool
|
|
11
|
+
* (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
|
|
12
|
+
* puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
|
|
8
13
|
*
|
|
9
14
|
* `config.codemode` is also the remote-connection switch: while it is not
|
|
10
15
|
* false, OpenCode appends `?codemode=false` so servers that bundle their own
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
|
|
3
3
|
* config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
|
|
4
4
|
* `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
|
|
5
|
-
* the AI SDK catalog (`packages/core/src/tool.ts`).
|
|
6
|
-
* only when it is in that catalog.
|
|
5
|
+
* the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
|
|
6
|
+
* a tool to Cursor only when it is in that catalog.
|
|
7
|
+
*
|
|
8
|
+
* That AI SDK placement is necessary but not sufficient for Cursor models:
|
|
9
|
+
* non-native tools are still invoked through GetDynamicTools / CallDynamicTool
|
|
10
|
+
* (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
|
|
11
|
+
* puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
|
|
7
12
|
*
|
|
8
13
|
* `config.codemode` is also the remote-connection switch: while it is not
|
|
9
14
|
* false, OpenCode appends `?codemode=false` so servers that bundle their own
|
|
@@ -17,6 +17,13 @@ export type Registration = {
|
|
|
17
17
|
readonly dispose: () => Promise<void>;
|
|
18
18
|
};
|
|
19
19
|
export type Hooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void) => Promise<Registration>;
|
|
20
|
+
export type ModelHookOptions = {
|
|
21
|
+
/** Limits the hook to one provider; the host skips events for any other. */
|
|
22
|
+
readonly providerID?: string;
|
|
23
|
+
};
|
|
24
|
+
export type ModelHooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void, options?: Spec[Name] extends {
|
|
25
|
+
readonly model: unknown;
|
|
26
|
+
} ? ModelHookOptions : never) => Promise<Registration>;
|
|
20
27
|
export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>;
|
|
21
28
|
export type ProviderInfo = {
|
|
22
29
|
id: string;
|
|
@@ -304,11 +311,28 @@ export type SessionTitle = {
|
|
|
304
311
|
options?: Record<string, unknown>;
|
|
305
312
|
result?: string;
|
|
306
313
|
};
|
|
314
|
+
/**
|
|
315
|
+
* Outbound request headers / route overrides for one model call.
|
|
316
|
+
* Headers reach the AI SDK as `callOptions.headers`.
|
|
317
|
+
*/
|
|
318
|
+
export type SessionModelRequest = {
|
|
319
|
+
readonly sessionID: string;
|
|
320
|
+
readonly agent: string;
|
|
321
|
+
readonly model: {
|
|
322
|
+
providerID: string;
|
|
323
|
+
id: string;
|
|
324
|
+
variant?: string;
|
|
325
|
+
};
|
|
326
|
+
readonly kind: "primary" | "compaction" | "title" | "generate";
|
|
327
|
+
baseURL?: string;
|
|
328
|
+
headers: Record<string, string>;
|
|
329
|
+
};
|
|
307
330
|
export type SessionHooks = {
|
|
308
331
|
readonly context: SessionContext;
|
|
309
332
|
readonly compaction: SessionCompaction;
|
|
310
333
|
readonly generate: SessionGenerate;
|
|
311
334
|
readonly title: SessionTitle;
|
|
335
|
+
readonly "model.request": SessionModelRequest;
|
|
312
336
|
};
|
|
313
337
|
/** Only the `location.directory` field we actually read. */
|
|
314
338
|
export type SessionInfo = {
|
|
@@ -318,7 +342,7 @@ export type SessionInfo = {
|
|
|
318
342
|
};
|
|
319
343
|
};
|
|
320
344
|
export type SessionDomain = {
|
|
321
|
-
readonly hook:
|
|
345
|
+
readonly hook: ModelHooks<SessionHooks>;
|
|
322
346
|
readonly get: (input: {
|
|
323
347
|
sessionID: string;
|
|
324
348
|
}) => Promise<SessionInfo>;
|
package/dist/plugin-opencode2.js
CHANGED
|
@@ -13,7 +13,8 @@ import { registerTodoTools } from "./opencode2/todo-tools.js";
|
|
|
13
13
|
import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
|
|
14
14
|
import { clearSessionTodos } from "./todo-store.js";
|
|
15
15
|
import { markCompactionSession } from "./compaction-marker.js";
|
|
16
|
-
import { markSessionDirectory } from "./session-directory.js";
|
|
16
|
+
import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
|
|
17
|
+
import { trace } from "./debug.js";
|
|
17
18
|
import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
|
|
18
19
|
import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
|
|
19
20
|
import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
|
|
@@ -215,8 +216,8 @@ const plugin = {
|
|
|
215
216
|
...(token ? { accessToken: token } : {}),
|
|
216
217
|
// Static fallback only. This hook fires once per model/package, not
|
|
217
218
|
// per session, and 2.0 runs one daemon across many projects — the
|
|
218
|
-
// real per-request directory comes from `x-opencode-directory`
|
|
219
|
-
//
|
|
219
|
+
// real per-request directory comes from `x-opencode-directory`
|
|
220
|
+
// (set on `session.model.request`) and the session-directory mark.
|
|
220
221
|
workspaceRoot,
|
|
221
222
|
cacheDir,
|
|
222
223
|
...event.options,
|
|
@@ -267,8 +268,9 @@ const plugin = {
|
|
|
267
268
|
// the host does not already own those names. When off, register none.
|
|
268
269
|
registerTodoTools(draft);
|
|
269
270
|
// MCP tools default into Code Mode. Move every server that did not
|
|
270
|
-
// explicitly opt in onto the direct catalog so
|
|
271
|
-
//
|
|
271
|
+
// explicitly opt in onto the direct catalog so this provider can
|
|
272
|
+
// advertise them to Cursor (issue #29 still routes via CallDynamicTool).
|
|
273
|
+
// See `opencode2/mcp-direct.ts`.
|
|
272
274
|
exposeDirectMcpTools(draft, directMcpNamespaces);
|
|
273
275
|
}));
|
|
274
276
|
// ── Shell timeout wrapper ────────────────────────────────
|
|
@@ -342,13 +344,12 @@ const plugin = {
|
|
|
342
344
|
}
|
|
343
345
|
const rememberSessionDirectory = async (sessionID) => {
|
|
344
346
|
try {
|
|
345
|
-
const info =
|
|
346
|
-
|
|
347
|
-
// under `location.directory`. Prefer the flat field when both exist.
|
|
348
|
-
markSessionDirectory(sessionID, info.directory ?? info.location?.directory);
|
|
347
|
+
const info = await ctx.session.get({ sessionID });
|
|
348
|
+
markSessionDirectory(sessionID, info.location.directory);
|
|
349
349
|
}
|
|
350
|
-
catch {
|
|
350
|
+
catch (error) {
|
|
351
351
|
// Best effort — falls back to the static workspaceRoot above.
|
|
352
|
+
trace(`session directory: session.get failed sessionID=${sessionID}: ${String(error)}`);
|
|
352
353
|
}
|
|
353
354
|
};
|
|
354
355
|
await track(ctx.session.hook("context", async (event) => {
|
|
@@ -371,6 +372,31 @@ const plugin = {
|
|
|
371
372
|
}
|
|
372
373
|
await rememberSessionDirectory(event.sessionID);
|
|
373
374
|
}));
|
|
375
|
+
// The session mark lives in module state, and OpenCode re-evaluates a local
|
|
376
|
+
// plugin's module graph per Location, so the copy running the model may not
|
|
377
|
+
// hold it. The header travels with the request (AI SDK
|
|
378
|
+
// `callOptions.headers` → `resolveSessionWorkspaceRoot`) and never reaches
|
|
379
|
+
// Cursor. Only a successful lookup updates the mark; on failure keep the
|
|
380
|
+
// last known session directory ahead of this Location's static root.
|
|
381
|
+
// Scoped by the host to this provider: other providers' requests never
|
|
382
|
+
// reach the callback, so the header cannot leak to their endpoints.
|
|
383
|
+
await track(ctx.session.hook("model.request", async (event) => {
|
|
384
|
+
const current = await ctx.session
|
|
385
|
+
.get({ sessionID: event.sessionID })
|
|
386
|
+
.then((info) => info.location.directory)
|
|
387
|
+
.catch((error) => {
|
|
388
|
+
trace(`model.request: session.get failed sessionID=${event.sessionID}: ${String(error)}`);
|
|
389
|
+
return undefined;
|
|
390
|
+
});
|
|
391
|
+
markSessionDirectory(event.sessionID, current);
|
|
392
|
+
const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
|
|
393
|
+
if (!directory)
|
|
394
|
+
return;
|
|
395
|
+
event.headers = {
|
|
396
|
+
...event.headers,
|
|
397
|
+
"x-opencode-directory": encodeURIComponent(directory),
|
|
398
|
+
};
|
|
399
|
+
}, { providerID: CURSOR_PROVIDER_ID }));
|
|
374
400
|
await track(ctx.session.hook("compaction", async (event) => {
|
|
375
401
|
markCompactionAndOptions(event, true);
|
|
376
402
|
await rememberSessionDirectory(event.sessionID);
|
package/dist/pricing-data.d.ts
CHANGED
|
@@ -91,6 +91,12 @@ export declare const CURSOR_MODEL_COSTS: {
|
|
|
91
91
|
readonly cache_read: 0.2;
|
|
92
92
|
readonly cache_write: 2.5;
|
|
93
93
|
};
|
|
94
|
+
readonly "claude-sonnet-5-5": {
|
|
95
|
+
readonly input: 2;
|
|
96
|
+
readonly output: 10;
|
|
97
|
+
readonly cache_read: 0.2;
|
|
98
|
+
readonly cache_write: 2.5;
|
|
99
|
+
};
|
|
94
100
|
readonly "composer-2.5": {
|
|
95
101
|
readonly input: 0.5;
|
|
96
102
|
readonly output: 2.5;
|
|
@@ -141,6 +147,16 @@ export declare const CURSOR_MODEL_COSTS: {
|
|
|
141
147
|
readonly output: 4.4;
|
|
142
148
|
readonly cache_read: 0.26;
|
|
143
149
|
};
|
|
150
|
+
readonly "glm-5.3": {
|
|
151
|
+
readonly input: 1.4;
|
|
152
|
+
readonly output: 4.4;
|
|
153
|
+
readonly cache_read: 0.26;
|
|
154
|
+
};
|
|
155
|
+
readonly "glm-5.3-flash": {
|
|
156
|
+
readonly input: 0.15;
|
|
157
|
+
readonly output: 0.5;
|
|
158
|
+
readonly cache_read: 0.029;
|
|
159
|
+
};
|
|
144
160
|
readonly "gpt-5-mini": {
|
|
145
161
|
readonly input: 0.25;
|
|
146
162
|
readonly output: 2;
|
|
@@ -335,6 +351,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
|
|
|
335
351
|
readonly maxContext: 200000;
|
|
336
352
|
readonly maxContextForMaxMode: 1000000;
|
|
337
353
|
};
|
|
354
|
+
readonly "claude-sonnet-5-5": {
|
|
355
|
+
readonly maxContext: 200000;
|
|
356
|
+
readonly maxContextForMaxMode: 1000000;
|
|
357
|
+
};
|
|
338
358
|
readonly "composer-2.5": {
|
|
339
359
|
readonly maxContext: 200000;
|
|
340
360
|
};
|
|
@@ -369,6 +389,12 @@ export declare const CURSOR_MODEL_CONTEXTS: {
|
|
|
369
389
|
readonly "glm-5.2": {
|
|
370
390
|
readonly maxContext: 200000;
|
|
371
391
|
};
|
|
392
|
+
readonly "glm-5.3": {
|
|
393
|
+
readonly maxContext: 1000000;
|
|
394
|
+
};
|
|
395
|
+
readonly "glm-5.3-flash": {
|
|
396
|
+
readonly maxContext: 1000000;
|
|
397
|
+
};
|
|
372
398
|
readonly "gpt-5-mini": {
|
|
373
399
|
readonly maxContext: 272000;
|
|
374
400
|
};
|
|
@@ -469,6 +495,9 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
|
|
|
469
495
|
readonly "claude-sonnet-5": {
|
|
470
496
|
readonly supportsImages: true;
|
|
471
497
|
};
|
|
498
|
+
readonly "claude-sonnet-5-5": {
|
|
499
|
+
readonly supportsImages: true;
|
|
500
|
+
};
|
|
472
501
|
readonly "composer-2.5": {
|
|
473
502
|
readonly supportsImages: true;
|
|
474
503
|
};
|
|
@@ -496,6 +525,12 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
|
|
|
496
525
|
readonly "glm-5.2": {
|
|
497
526
|
readonly supportsImages: false;
|
|
498
527
|
};
|
|
528
|
+
readonly "glm-5.3": {
|
|
529
|
+
readonly supportsImages: false;
|
|
530
|
+
};
|
|
531
|
+
readonly "glm-5.3-flash": {
|
|
532
|
+
readonly supportsImages: true;
|
|
533
|
+
};
|
|
499
534
|
readonly "gpt-5-mini": {
|
|
500
535
|
readonly supportsImages: true;
|
|
501
536
|
};
|
package/dist/pricing-data.js
CHANGED
|
@@ -91,6 +91,12 @@ export const CURSOR_MODEL_COSTS = {
|
|
|
91
91
|
"cache_read": 0.2,
|
|
92
92
|
"cache_write": 2.5
|
|
93
93
|
},
|
|
94
|
+
"claude-sonnet-5-5": {
|
|
95
|
+
"input": 2,
|
|
96
|
+
"output": 10,
|
|
97
|
+
"cache_read": 0.2,
|
|
98
|
+
"cache_write": 2.5
|
|
99
|
+
},
|
|
94
100
|
"composer-2.5": {
|
|
95
101
|
"input": 0.5,
|
|
96
102
|
"output": 2.5,
|
|
@@ -141,6 +147,16 @@ export const CURSOR_MODEL_COSTS = {
|
|
|
141
147
|
"output": 4.4,
|
|
142
148
|
"cache_read": 0.26
|
|
143
149
|
},
|
|
150
|
+
"glm-5.3": {
|
|
151
|
+
"input": 1.4,
|
|
152
|
+
"output": 4.4,
|
|
153
|
+
"cache_read": 0.26
|
|
154
|
+
},
|
|
155
|
+
"glm-5.3-flash": {
|
|
156
|
+
"input": 0.15,
|
|
157
|
+
"output": 0.5,
|
|
158
|
+
"cache_read": 0.029
|
|
159
|
+
},
|
|
144
160
|
"gpt-5-mini": {
|
|
145
161
|
"input": 0.25,
|
|
146
162
|
"output": 2,
|
|
@@ -335,6 +351,10 @@ export const CURSOR_MODEL_CONTEXTS = {
|
|
|
335
351
|
"maxContext": 200000,
|
|
336
352
|
"maxContextForMaxMode": 1000000
|
|
337
353
|
},
|
|
354
|
+
"claude-sonnet-5-5": {
|
|
355
|
+
"maxContext": 200000,
|
|
356
|
+
"maxContextForMaxMode": 1000000
|
|
357
|
+
},
|
|
338
358
|
"composer-2.5": {
|
|
339
359
|
"maxContext": 200000
|
|
340
360
|
},
|
|
@@ -369,6 +389,12 @@ export const CURSOR_MODEL_CONTEXTS = {
|
|
|
369
389
|
"glm-5.2": {
|
|
370
390
|
"maxContext": 200000
|
|
371
391
|
},
|
|
392
|
+
"glm-5.3": {
|
|
393
|
+
"maxContext": 1000000
|
|
394
|
+
},
|
|
395
|
+
"glm-5.3-flash": {
|
|
396
|
+
"maxContext": 1000000
|
|
397
|
+
},
|
|
372
398
|
"gpt-5-mini": {
|
|
373
399
|
"maxContext": 272000
|
|
374
400
|
},
|
|
@@ -469,6 +495,9 @@ export const CURSOR_MODEL_CAPABILITIES = {
|
|
|
469
495
|
"claude-sonnet-5": {
|
|
470
496
|
"supportsImages": true
|
|
471
497
|
},
|
|
498
|
+
"claude-sonnet-5-5": {
|
|
499
|
+
"supportsImages": true
|
|
500
|
+
},
|
|
472
501
|
"composer-2.5": {
|
|
473
502
|
"supportsImages": true
|
|
474
503
|
},
|
|
@@ -496,6 +525,12 @@ export const CURSOR_MODEL_CAPABILITIES = {
|
|
|
496
525
|
"glm-5.2": {
|
|
497
526
|
"supportsImages": false
|
|
498
527
|
},
|
|
528
|
+
"glm-5.3": {
|
|
529
|
+
"supportsImages": false
|
|
530
|
+
},
|
|
531
|
+
"glm-5.3-flash": {
|
|
532
|
+
"supportsImages": true
|
|
533
|
+
},
|
|
499
534
|
"gpt-5-mini": {
|
|
500
535
|
"supportsImages": true
|
|
501
536
|
},
|
|
@@ -30,8 +30,8 @@ export type DecodedGenerateImageQuery = {
|
|
|
30
30
|
referenceImagePaths: string[];
|
|
31
31
|
toolCallId: string;
|
|
32
32
|
};
|
|
33
|
-
/**
|
|
34
|
-
export declare function imageMimeForPath(filePath: string): string;
|
|
33
|
+
/** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
|
|
34
|
+
export declare function imageMimeForPath(filePath: string, data?: Uint8Array): string;
|
|
35
35
|
/** Decode a `generate_image_request_query` body, or undefined when unusable. */
|
|
36
36
|
export declare function decodeGenerateImageQuery(queryBytes: Uint8Array): DecodedGenerateImageQuery | undefined;
|
|
37
37
|
export type ImageWriteRoots = {
|
|
@@ -32,8 +32,20 @@ const IMAGE_MIME_BY_EXTENSION = {
|
|
|
32
32
|
".gif": "image/gif",
|
|
33
33
|
".webp": "image/webp",
|
|
34
34
|
};
|
|
35
|
-
/**
|
|
36
|
-
export function imageMimeForPath(filePath) {
|
|
35
|
+
/** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
|
|
36
|
+
export function imageMimeForPath(filePath, data) {
|
|
37
|
+
if (data) {
|
|
38
|
+
const matches = (signature, offset = 0) => signature.every((byte, index) => data[offset + index] === byte);
|
|
39
|
+
if (matches([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
40
|
+
return "image/png";
|
|
41
|
+
if (matches([0xff, 0xd8, 0xff]))
|
|
42
|
+
return "image/jpeg";
|
|
43
|
+
if (matches([0x47, 0x49, 0x46, 0x38]) && (data[4] === 0x37 || data[4] === 0x39) && data[5] === 0x61) {
|
|
44
|
+
return "image/gif";
|
|
45
|
+
}
|
|
46
|
+
if (matches([0x52, 0x49, 0x46, 0x46]) && matches([0x57, 0x45, 0x42, 0x50], 8))
|
|
47
|
+
return "image/webp";
|
|
48
|
+
}
|
|
37
49
|
const dot = path.basename(filePath).lastIndexOf(".");
|
|
38
50
|
if (dot <= 0)
|
|
39
51
|
return "image/png";
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -12,9 +12,12 @@ export type OpencodeToolDef = {
|
|
|
12
12
|
export type HostFilePathKey = "path" | "filePath";
|
|
13
13
|
/** Host shell tool id. OpenCode 1.x uses `bash`; 2.0 uses `shell`. */
|
|
14
14
|
export type HostShellTool = "bash" | "shell";
|
|
15
|
+
/** OpenCode 1.x skill params use `name`; OpenCode 2.0 uses `id`. */
|
|
16
|
+
export type HostSkillArgKey = "name" | "id";
|
|
15
17
|
export type HostToolDialect = {
|
|
16
18
|
filePathKey: HostFilePathKey;
|
|
17
19
|
shellTool: HostShellTool;
|
|
20
|
+
skillArgKey: HostSkillArgKey;
|
|
18
21
|
};
|
|
19
22
|
export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
|
|
20
23
|
export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
|
|
@@ -23,6 +26,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
|
|
|
23
26
|
/**
|
|
24
27
|
* Infer the host tool dialect from advertised AI SDK schemas.
|
|
25
28
|
* OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
|
|
29
|
+
* Skill: OpenCode 1.x requires `name`; OpenCode 2.0 requires `id`.
|
|
26
30
|
*/
|
|
27
31
|
export declare function hostToolDialectFromTools(tools: readonly {
|
|
28
32
|
name?: string;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -16,10 +16,12 @@ export const REQUEST_CONTEXT_RESULT_FIELD = 10;
|
|
|
16
16
|
export const OPENCODE_1_TOOL_DIALECT = {
|
|
17
17
|
filePathKey: "filePath",
|
|
18
18
|
shellTool: "bash",
|
|
19
|
+
skillArgKey: "name",
|
|
19
20
|
};
|
|
20
21
|
export const OPENCODE_2_TOOL_DIALECT = {
|
|
21
22
|
filePathKey: "path",
|
|
22
23
|
shellTool: "shell",
|
|
24
|
+
skillArgKey: "id",
|
|
23
25
|
};
|
|
24
26
|
function jsonSchemaProperties(schema) {
|
|
25
27
|
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
@@ -46,6 +48,7 @@ export function opencodePathArg(args) {
|
|
|
46
48
|
/**
|
|
47
49
|
* Infer the host tool dialect from advertised AI SDK schemas.
|
|
48
50
|
* OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
|
|
51
|
+
* Skill: OpenCode 1.x requires `name`; OpenCode 2.0 requires `id`.
|
|
49
52
|
*/
|
|
50
53
|
export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL_DIALECT) {
|
|
51
54
|
let filePathKey;
|
|
@@ -71,7 +74,21 @@ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL
|
|
|
71
74
|
// OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
|
|
72
75
|
filePathKey = shellTool === "shell" ? "path" : defaultDialect.filePathKey;
|
|
73
76
|
}
|
|
74
|
-
|
|
77
|
+
const skillProps = jsonSchemaProperties(tools.find((tool) => tool.name === "skill")?.inputSchema);
|
|
78
|
+
let skillArgKey;
|
|
79
|
+
if (skillProps) {
|
|
80
|
+
if ("id" in skillProps && !("name" in skillProps))
|
|
81
|
+
skillArgKey = "id";
|
|
82
|
+
else if ("name" in skillProps)
|
|
83
|
+
skillArgKey = "name";
|
|
84
|
+
}
|
|
85
|
+
if (!skillArgKey) {
|
|
86
|
+
// Opaque skill schema: OpenCode 2 pairs `shell`+`path` with skill `id`.
|
|
87
|
+
skillArgKey = shellTool === "shell" && filePathKey === "path"
|
|
88
|
+
? "id"
|
|
89
|
+
: defaultDialect.skillArgKey;
|
|
90
|
+
}
|
|
91
|
+
return { filePathKey, shellTool, skillArgKey };
|
|
75
92
|
}
|
|
76
93
|
function assignHostFilePath(args, filePath, dialect) {
|
|
77
94
|
delete args.filePath;
|
|
@@ -1257,6 +1274,18 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1257
1274
|
args.path = path;
|
|
1258
1275
|
return { toolName: "glob", args };
|
|
1259
1276
|
}
|
|
1277
|
+
case "skill": {
|
|
1278
|
+
// OpenCode 1.x skill requires `name`; OpenCode 2.0 requires `id`. Both
|
|
1279
|
+
// reject the other key (`additionalProperties: false`). Accept either
|
|
1280
|
+
// model/Cursor shape and emit only the dialect key.
|
|
1281
|
+
const value = str(cleaned[dialect.skillArgKey]) ?? str(cleaned.id) ?? str(cleaned.name);
|
|
1282
|
+
const args = { ...cleaned };
|
|
1283
|
+
delete args.id;
|
|
1284
|
+
delete args.name;
|
|
1285
|
+
if (value)
|
|
1286
|
+
args[dialect.skillArgKey] = value;
|
|
1287
|
+
return { toolName: "skill", args };
|
|
1288
|
+
}
|
|
1260
1289
|
default:
|
|
1261
1290
|
return { toolName, args: cleaned };
|
|
1262
1291
|
}
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* time is wrong for any session but the one open when the daemon started.
|
|
8
8
|
*
|
|
9
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
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - request header `x-opencode-directory` (per-request; preferred in the LM).
|
|
11
|
+
* The host does not set it on model requests; the opencode2 plugin adds it
|
|
12
|
+
* from `session.hook("model.request")`, so it travels with the request
|
|
13
|
+
* instead of depending on this module's in-memory state.
|
|
14
|
+
* - `ctx.session.get()` → `info.location.directory`, reachable from session
|
|
15
|
+
* hooks
|
|
13
16
|
*
|
|
14
17
|
* This module records the session-get value by id so the language model can
|
|
15
18
|
* fall back when the header is absent, same mechanism as `compaction-marker.ts`.
|
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* time is wrong for any session but the one open when the daemon started.
|
|
8
8
|
*
|
|
9
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
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - request header `x-opencode-directory` (per-request; preferred in the LM).
|
|
11
|
+
* The host does not set it on model requests; the opencode2 plugin adds it
|
|
12
|
+
* from `session.hook("model.request")`, so it travels with the request
|
|
13
|
+
* instead of depending on this module's in-memory state.
|
|
14
|
+
* - `ctx.session.get()` → `info.location.directory`, reachable from session
|
|
15
|
+
* hooks
|
|
13
16
|
*
|
|
14
17
|
* This module records the session-get value by id so the language model can
|
|
15
18
|
* fall back when the header is absent, same mechanism as `compaction-marker.ts`.
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
18
21
|
*/
|
|
19
22
|
import path from "node:path";
|
|
23
|
+
import { trace } from "./debug.js";
|
|
20
24
|
const MAX_TRACKED_SESSIONS = 256;
|
|
21
25
|
const sessionDirectories = new Map();
|
|
22
26
|
export function markSessionDirectory(sessionID, directory) {
|
|
@@ -61,7 +65,10 @@ export function opencodeDirectoryHeader(headers) {
|
|
|
61
65
|
* Prefer the per-request header, then the session mark, then static options/cwd.
|
|
62
66
|
*/
|
|
63
67
|
export function resolveSessionWorkspaceRoot(input) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
const resolved = opencodeDirectoryHeader(input.headers) ?? getSessionDirectory(input.sessionKey);
|
|
69
|
+
if (resolved)
|
|
70
|
+
return path.resolve(resolved);
|
|
71
|
+
const fallback = input.workspaceRoot || input.cwd || process.cwd();
|
|
72
|
+
trace(`session directory: no header or session mark sessionKey=${input.sessionKey ?? "-"}; fallback=${fallback}`);
|
|
73
|
+
return path.resolve(fallback);
|
|
67
74
|
}
|