cursor-opencode-provider 0.7.5 → 0.7.6
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 +1 -1
- package/dist/context/dynamic-catalog.d.ts +40 -9
- package/dist/context/dynamic-catalog.js +152 -107
- package/dist/context/frozen.js +5 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/language-model.d.ts +18 -0
- package/dist/language-model.js +73 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,7 +293,7 @@ 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
|
|
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 and mirrors OpenCode's load-when-matches / do-not-reinvoke rules. Mid-Conversation skill reminders fire only when the available-skills catalog changes after baseline admission (same contract as OpenCode `SkillGuidance` / `SkillInstructions`) — not on every user turn that happens to mention a skill id — 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
297
|
|
|
298
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`).
|
|
299
299
|
|
|
@@ -17,27 +17,58 @@ export declare function skillNameFromAgentSkill(skill: AgentSkillLike): string |
|
|
|
17
17
|
/**
|
|
18
18
|
* Shared system-guidance line: name advertised dynamic-catalog tools and prefer
|
|
19
19
|
* them over Grep/Shell when skills or project rules apply. Concrete skill ids
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* stay out of the frozen baseline (they live in RequestContext `agent_skills`
|
|
21
|
+
* and in Mid-Conversation updates when the catalog changes — same split as
|
|
22
|
+
* OpenCode's SkillGuidance / SkillInstructions).
|
|
22
23
|
*/
|
|
23
24
|
export declare function buildDynamicCatalogRoutingInstruction(options: {
|
|
24
25
|
toolNames: Iterable<string>;
|
|
25
26
|
knownMcpServers?: Iterable<string>;
|
|
26
27
|
}): string | undefined;
|
|
27
28
|
/**
|
|
28
|
-
* Mid-Conversation
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* OpenCode-shaped Mid-Conversation skill update.
|
|
30
|
+
*
|
|
31
|
+
* Pure: compare `previousSkillIds` (null = not yet admitted this conversation)
|
|
32
|
+
* to the current catalog. First admission returns undefined — baseline
|
|
33
|
+
* RequestContext + routing instruction already carry the list. Later turns
|
|
34
|
+
* only speak when the id set changes (agent switch / discovery growth), matching
|
|
35
|
+
* `SkillGuidance.update` / `SkillInstructions.update`.
|
|
33
36
|
*/
|
|
34
|
-
export declare function
|
|
37
|
+
export declare function buildSkillCatalogChangeReminder(options: {
|
|
35
38
|
hasSkillTool: boolean;
|
|
36
39
|
skills: readonly AgentSkillLike[];
|
|
37
|
-
|
|
40
|
+
/** Sorted skill ids already admitted; `null` means first admission this conversation. */
|
|
41
|
+
previousSkillIds: readonly string[] | null;
|
|
38
42
|
/**
|
|
39
43
|
* Host skill parameter key from the advertised schema.
|
|
40
44
|
* OpenCode 1.x: `name`. OpenCode 2.0: `id`.
|
|
41
45
|
*/
|
|
42
46
|
skillArgKey?: "name" | "id";
|
|
47
|
+
}): {
|
|
48
|
+
text: string | undefined;
|
|
49
|
+
nextSkillIds: string[];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Admit the current skill catalog for a conversation and return a Mid-Conversation
|
|
53
|
+
* reminder only when OpenCode would (catalog changed after baseline admission).
|
|
54
|
+
*/
|
|
55
|
+
export declare function takeSkillCatalogChangeReminder(conversationId: string, options: {
|
|
56
|
+
hasSkillTool: boolean;
|
|
57
|
+
skills: readonly AgentSkillLike[];
|
|
58
|
+
skillArgKey?: "name" | "id";
|
|
59
|
+
}): string | undefined;
|
|
60
|
+
export declare function clearSkillCatalogAdmission(conversationId: string): void;
|
|
61
|
+
export declare function transferSkillCatalogAdmission(previousConversationId: string, nextConversationId: string): void;
|
|
62
|
+
export declare function resetSkillCatalogAdmissionsForTests(): void;
|
|
63
|
+
/**
|
|
64
|
+
* @deprecated Prefer `buildSkillCatalogChangeReminder` / `takeSkillCatalogChangeReminder`.
|
|
65
|
+
* Kept as a thin alias for callers that still expect the old name; always returns
|
|
66
|
+
* undefined for userText-based matching (OpenCode never did per-turn id nudges).
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildSkillCatalogNudge(options: {
|
|
69
|
+
hasSkillTool: boolean;
|
|
70
|
+
skills: readonly AgentSkillLike[];
|
|
71
|
+
userText?: string;
|
|
72
|
+
skillArgKey?: "name" | "id";
|
|
73
|
+
previousSkillIds?: readonly string[] | null;
|
|
43
74
|
}): string | undefined;
|
|
@@ -4,41 +4,27 @@ import { resolveToolServerIdentity } from "../protocol/tools.js";
|
|
|
4
4
|
* top-level function list. Both OpenCode 1.x and OpenCode 2.0 reach the model
|
|
5
5
|
* as MCP-shaped RequestContext ads, then GetDynamicTools / CallDynamicTool
|
|
6
6
|
* (exec `mcp_args`). This module builds the shared routing guidance and the
|
|
7
|
-
*
|
|
7
|
+
* OpenCode-shaped Mid-Conversation skill-catalog update used on both hosts.
|
|
8
|
+
*
|
|
9
|
+
* OpenCode itself does **not** per-turn nudge matched skill ids. It:
|
|
10
|
+
* - Puts name/description guidance in the frozen baseline (`SystemPrompt.skills`
|
|
11
|
+
* / `SkillGuidance` / `SkillInstructions`)
|
|
12
|
+
* - Emits a Mid-Conversation update only when the available-skills list changes
|
|
13
|
+
* - OpenCode 2: skips re-invoke when `<skill_content>` is already in the turn
|
|
8
14
|
*
|
|
9
15
|
* OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
|
|
10
16
|
* and enter the AI SDK catalog; without that step they never reach this
|
|
11
17
|
* advertisement path at all.
|
|
12
18
|
*/
|
|
13
|
-
const MAX_NAMED_SKILLS = 8;
|
|
14
19
|
const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
|
|
15
20
|
/** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
|
|
16
21
|
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
22
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
23
|
+
* Skill ids already admitted to the model for a conversation (baseline RequestContext
|
|
24
|
+
* freeze, or the last Mid-Conversation catalog update). First admission is silent —
|
|
25
|
+
* OpenCode puts that list in the system baseline, not a per-turn reminder.
|
|
35
26
|
*/
|
|
36
|
-
|
|
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
|
-
}
|
|
27
|
+
const admittedSkillIdsByConversation = new Map();
|
|
42
28
|
function listWithOverflow(items, limit) {
|
|
43
29
|
const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
|
|
44
30
|
const hidden = items.length - limit;
|
|
@@ -73,11 +59,32 @@ export function skillNameFromAgentSkill(skill) {
|
|
|
73
59
|
return undefined;
|
|
74
60
|
return parts.at(-2);
|
|
75
61
|
}
|
|
62
|
+
function skillIds(skills) {
|
|
63
|
+
const ids = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const skill of skills) {
|
|
66
|
+
const name = skillNameFromAgentSkill(skill);
|
|
67
|
+
if (!name || seen.has(name))
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(name);
|
|
70
|
+
ids.push(name);
|
|
71
|
+
}
|
|
72
|
+
return [...ids].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
73
|
+
}
|
|
74
|
+
function sameIdList(left, right) {
|
|
75
|
+
if (left.length !== right.length)
|
|
76
|
+
return false;
|
|
77
|
+
for (let i = 0; i < left.length; i++)
|
|
78
|
+
if (left[i] !== right[i])
|
|
79
|
+
return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
76
82
|
/**
|
|
77
83
|
* Shared system-guidance line: name advertised dynamic-catalog tools and prefer
|
|
78
84
|
* them over Grep/Shell when skills or project rules apply. Concrete skill ids
|
|
79
|
-
*
|
|
80
|
-
*
|
|
85
|
+
* stay out of the frozen baseline (they live in RequestContext `agent_skills`
|
|
86
|
+
* and in Mid-Conversation updates when the catalog changes — same split as
|
|
87
|
+
* OpenCode's SkillGuidance / SkillInstructions).
|
|
81
88
|
*/
|
|
82
89
|
export function buildDynamicCatalogRoutingInstruction(options) {
|
|
83
90
|
const names = [...options.toolNames];
|
|
@@ -91,93 +98,131 @@ export function buildDynamicCatalogRoutingInstruction(options) {
|
|
|
91
98
|
if (mcpServers.length > 0) {
|
|
92
99
|
extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
|
|
93
100
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
.
|
|
103
|
-
|
|
101
|
+
const lines = [
|
|
102
|
+
`- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
|
|
103
|
+
"are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
|
|
104
|
+
"When a skill matches or project rules name an MCP server, discover and call those tools that way " +
|
|
105
|
+
"before Grep/Shell fallbacks. Do not narrate that they are unavailable.",
|
|
106
|
+
];
|
|
107
|
+
if (hasSkill) {
|
|
108
|
+
// Mirror OpenCode 1 SystemPrompt.skills / OC2 SkillInstructions.render.
|
|
109
|
+
lines.push("- Skills provide specialized instructions and workflows for specific tasks. " +
|
|
110
|
+
"Use the `skill` tool to load a skill when a task matches its description " +
|
|
111
|
+
"(RequestContext `agent_skills` lists names and descriptions). " +
|
|
112
|
+
"A skill that is already present in the conversation as a `<skill_content>` block " +
|
|
113
|
+
"does not need to be invoked again.");
|
|
114
|
+
}
|
|
115
|
+
return lines.join("\n");
|
|
104
116
|
}
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!
|
|
117
|
+
function skillEntriesXml(skills, argKey) {
|
|
118
|
+
const lines = ["<available_skills>"];
|
|
119
|
+
for (const skill of skills) {
|
|
120
|
+
const name = skillNameFromAgentSkill(skill);
|
|
121
|
+
if (!name)
|
|
110
122
|
continue;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
const description = typeof skill.description === "string" ? skill.description : "";
|
|
124
|
+
lines.push(" <skill>");
|
|
125
|
+
if (argKey === "id")
|
|
126
|
+
lines.push(` <id>${name}</id>`);
|
|
127
|
+
lines.push(` <name>${name}</name>`);
|
|
128
|
+
if (description)
|
|
129
|
+
lines.push(` <description>${description}</description>`);
|
|
130
|
+
lines.push(" </skill>");
|
|
116
131
|
}
|
|
117
|
-
|
|
118
|
-
return
|
|
132
|
+
lines.push("</available_skills>");
|
|
133
|
+
return lines;
|
|
119
134
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
/**
|
|
136
|
+
* OpenCode-shaped Mid-Conversation skill update.
|
|
137
|
+
*
|
|
138
|
+
* Pure: compare `previousSkillIds` (null = not yet admitted this conversation)
|
|
139
|
+
* to the current catalog. First admission returns undefined — baseline
|
|
140
|
+
* RequestContext + routing instruction already carry the list. Later turns
|
|
141
|
+
* only speak when the id set changes (agent switch / discovery growth), matching
|
|
142
|
+
* `SkillGuidance.update` / `SkillInstructions.update`.
|
|
143
|
+
*/
|
|
144
|
+
export function buildSkillCatalogChangeReminder(options) {
|
|
145
|
+
const nextSkillIds = skillIds(options.skills);
|
|
146
|
+
if (!options.hasSkillTool) {
|
|
147
|
+
return { text: undefined, nextSkillIds: options.previousSkillIds ? [...options.previousSkillIds] : [] };
|
|
148
|
+
}
|
|
149
|
+
if (options.previousSkillIds === null) {
|
|
150
|
+
return { text: undefined, nextSkillIds };
|
|
127
151
|
}
|
|
128
|
-
|
|
152
|
+
if (sameIdList(options.previousSkillIds, nextSkillIds)) {
|
|
153
|
+
return { text: undefined, nextSkillIds };
|
|
154
|
+
}
|
|
155
|
+
const argKey = options.skillArgKey === "id" ? "id" : "name";
|
|
156
|
+
const callHint = argKey === "id"
|
|
157
|
+
? "Call `skill` with `{ \"id\": \"<skill-id>\" }` (host schema key is `id`)."
|
|
158
|
+
: "Call `skill` with `{ \"name\": \"<skill-id>\" }` (host schema key is `name`).";
|
|
159
|
+
const body = nextSkillIds.length === 0
|
|
160
|
+
? ["Skill guidance is no longer available. Do not use any previously listed skill."]
|
|
161
|
+
: [
|
|
162
|
+
"The available skills have changed. This list supersedes the previous available skills list.",
|
|
163
|
+
"Skills provide specialized instructions and workflows for specific tasks.",
|
|
164
|
+
"Use the skill tool to load a skill when a task matches its description.",
|
|
165
|
+
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
|
166
|
+
`OpenCode skills are invoked with the host \`skill\` tool through GetDynamicTools / CallDynamicTool (MCP server \`${DEFAULT_TOOL_SERVER}\`, tool \`skill\`), not by Grep/Read of SKILL.md. ${callHint}`,
|
|
167
|
+
...skillEntriesXml(options.skills, argKey),
|
|
168
|
+
];
|
|
169
|
+
return {
|
|
170
|
+
text: `<system_reminder>\n${body.join("\n")}\n</system_reminder>`,
|
|
171
|
+
nextSkillIds,
|
|
172
|
+
};
|
|
129
173
|
}
|
|
130
174
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
* Admit the current skill catalog for a conversation and return a Mid-Conversation
|
|
176
|
+
* reminder only when OpenCode would (catalog changed after baseline admission).
|
|
177
|
+
*/
|
|
178
|
+
export function takeSkillCatalogChangeReminder(conversationId, options) {
|
|
179
|
+
const previous = conversationId
|
|
180
|
+
? (admittedSkillIdsByConversation.get(conversationId) ?? null)
|
|
181
|
+
: null;
|
|
182
|
+
const { text, nextSkillIds } = buildSkillCatalogChangeReminder({
|
|
183
|
+
...options,
|
|
184
|
+
previousSkillIds: previous,
|
|
185
|
+
});
|
|
186
|
+
if (!conversationId)
|
|
187
|
+
return text;
|
|
188
|
+
if (!options.hasSkillTool && previous === null && nextSkillIds.length === 0) {
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
191
|
+
admittedSkillIdsByConversation.delete(conversationId);
|
|
192
|
+
admittedSkillIdsByConversation.set(conversationId, nextSkillIds);
|
|
193
|
+
while (admittedSkillIdsByConversation.size > 256) {
|
|
194
|
+
const oldest = admittedSkillIdsByConversation.keys().next().value;
|
|
195
|
+
if (!oldest)
|
|
196
|
+
break;
|
|
197
|
+
admittedSkillIdsByConversation.delete(oldest);
|
|
198
|
+
}
|
|
199
|
+
return text;
|
|
200
|
+
}
|
|
201
|
+
export function clearSkillCatalogAdmission(conversationId) {
|
|
202
|
+
admittedSkillIdsByConversation.delete(conversationId);
|
|
203
|
+
}
|
|
204
|
+
export function transferSkillCatalogAdmission(previousConversationId, nextConversationId) {
|
|
205
|
+
if (!previousConversationId || !nextConversationId)
|
|
206
|
+
return;
|
|
207
|
+
const prior = admittedSkillIdsByConversation.get(previousConversationId);
|
|
208
|
+
admittedSkillIdsByConversation.delete(previousConversationId);
|
|
209
|
+
admittedSkillIdsByConversation.delete(nextConversationId);
|
|
210
|
+
if (prior)
|
|
211
|
+
admittedSkillIdsByConversation.set(nextConversationId, prior);
|
|
212
|
+
}
|
|
213
|
+
export function resetSkillCatalogAdmissionsForTests() {
|
|
214
|
+
admittedSkillIdsByConversation.clear();
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* @deprecated Prefer `buildSkillCatalogChangeReminder` / `takeSkillCatalogChangeReminder`.
|
|
218
|
+
* Kept as a thin alias for callers that still expect the old name; always returns
|
|
219
|
+
* undefined for userText-based matching (OpenCode never did per-turn id nudges).
|
|
136
220
|
*/
|
|
137
221
|
export function buildSkillCatalogNudge(options) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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>");
|
|
222
|
+
return buildSkillCatalogChangeReminder({
|
|
223
|
+
hasSkillTool: options.hasSkillTool,
|
|
224
|
+
skills: options.skills,
|
|
225
|
+
previousSkillIds: options.previousSkillIds ?? null,
|
|
226
|
+
skillArgKey: options.skillArgKey,
|
|
227
|
+
}).text;
|
|
183
228
|
}
|
package/dist/context/frozen.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, } from "./build.js";
|
|
2
2
|
import { clearContextEpoch, endContextEpoch, resetContextEpochsForTests } from "./epoch.js";
|
|
3
3
|
import { clearOverlayHold, resetOverlayHoldsForTests, transferOverlayHold, } from "./overlay.js";
|
|
4
|
+
import { clearSkillCatalogAdmission, resetSkillCatalogAdmissionsForTests, transferSkillCatalogAdmission, } from "./dynamic-catalog.js";
|
|
4
5
|
import { trace } from "../debug.js";
|
|
5
6
|
import { encodeMessage } from "../protocol/messages.js";
|
|
6
7
|
/**
|
|
@@ -44,6 +45,7 @@ function remember(conversationId, context) {
|
|
|
44
45
|
byConversationId.delete(oldest);
|
|
45
46
|
materializedByConversationId.delete(oldest);
|
|
46
47
|
clearOverlayHold(oldest);
|
|
48
|
+
clearSkillCatalogAdmission(oldest);
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
/** Frozen stable RequestContext base for this conversation, if any. */
|
|
@@ -67,6 +69,7 @@ export function clearFrozenRequestContext(conversationId) {
|
|
|
67
69
|
byConversationId.delete(conversationId);
|
|
68
70
|
materializedByConversationId.delete(conversationId);
|
|
69
71
|
clearOverlayHold(conversationId);
|
|
72
|
+
clearSkillCatalogAdmission(conversationId);
|
|
70
73
|
clearContextEpoch(conversationId);
|
|
71
74
|
}
|
|
72
75
|
/**
|
|
@@ -89,6 +92,7 @@ export function transferFrozenRequestContext(previousConversationId, nextConvers
|
|
|
89
92
|
// also drops epoch state for each id.
|
|
90
93
|
endContextEpoch(previousConversationId, nextConversationId);
|
|
91
94
|
transferOverlayHold(previousConversationId, nextConversationId);
|
|
95
|
+
transferSkillCatalogAdmission(previousConversationId, nextConversationId);
|
|
92
96
|
byConversationId.delete(previousConversationId);
|
|
93
97
|
materializedByConversationId.delete(previousConversationId);
|
|
94
98
|
byConversationId.delete(nextConversationId);
|
|
@@ -110,6 +114,7 @@ export function resetFrozenRequestContextsForTests() {
|
|
|
110
114
|
materializedByConversationId.clear();
|
|
111
115
|
buildsByConversationId.clear();
|
|
112
116
|
resetOverlayHoldsForTests();
|
|
117
|
+
resetSkillCatalogAdmissionsForTests();
|
|
113
118
|
resetContextEpochsForTests();
|
|
114
119
|
}
|
|
115
120
|
function sameBytes(a, b) {
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,5 +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
|
+
export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, listAdvertisedMcpServers, skillNameFromAgentSkill, type AgentSkillLike, } from "./dynamic-catalog.js";
|
|
3
3
|
export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
|
|
4
4
|
export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
|
|
5
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,5 +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
|
+
export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, listAdvertisedMcpServers, skillNameFromAgentSkill, } from "./dynamic-catalog.js";
|
|
3
3
|
export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
|
|
4
4
|
export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
|
|
5
5
|
export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
|
package/dist/language-model.d.ts
CHANGED
|
@@ -68,6 +68,24 @@ export declare function findContinuationSession(toolResults: Array<{
|
|
|
68
68
|
sessionId: string;
|
|
69
69
|
execId: number;
|
|
70
70
|
}>): CursorSession | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* True when `incoming` is a non-empty proper subset of `parent` by tool name.
|
|
73
|
+
* Used to spot in-session helpers that reuse the parent OpenCode session id
|
|
74
|
+
* with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
|
|
75
|
+
*/
|
|
76
|
+
export declare function isProperCatalogSubset(incoming: ReadonlyArray<{
|
|
77
|
+
name?: string;
|
|
78
|
+
}>, parent: ReadonlyArray<{
|
|
79
|
+
name?: string;
|
|
80
|
+
}>): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* An open parent Run for this OpenCode session whose catalog strictly contains
|
|
83
|
+
* the incoming tools is treated as an in-session helper. Those calls must not
|
|
84
|
+
* cancel/supersede the parent (that remints on the trailing tool result).
|
|
85
|
+
*/
|
|
86
|
+
export declare function shouldIsolateInSessionHelper(openCodeSessionId: string | undefined, incomingTools: ReadonlyArray<{
|
|
87
|
+
name?: string;
|
|
88
|
+
}>): boolean;
|
|
71
89
|
/** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
|
|
72
90
|
export declare const FRESH_TURN_DRAIN_TIMEOUT_MS = 8000;
|
|
73
91
|
/**
|
package/dist/language-model.js
CHANGED
|
@@ -34,7 +34,7 @@ import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMo
|
|
|
34
34
|
import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
35
35
|
import { getHeldOverlaySkills } from "./context/overlay.js";
|
|
36
36
|
import { loadMergedConfig } from "./context/rules.js";
|
|
37
|
-
import { buildDynamicCatalogRoutingInstruction,
|
|
37
|
+
import { buildDynamicCatalogRoutingInstruction, takeSkillCatalogChangeReminder, } from "./context/dynamic-catalog.js";
|
|
38
38
|
import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
|
|
39
39
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
40
40
|
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
@@ -542,19 +542,34 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
542
542
|
if (historical > 0) {
|
|
543
543
|
trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
|
|
544
544
|
}
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
545
|
+
// An in-session helper (title/memory/task child) that reuses the parent
|
|
546
|
+
// OpenCode session id with a strictly smaller catalog must not cancel or
|
|
547
|
+
// supersede the held parent Run — that remints (`interrupted-run`) when
|
|
548
|
+
// the trailing tool result returns. Isolate onto an ephemeral conversation.
|
|
549
|
+
const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
|
|
550
|
+
if (busyPrior
|
|
551
|
+
&& !busyPrior.closed
|
|
552
|
+
&& isProperCatalogSubset(extractTools(callOptions), busyPrior.toolCatalog ?? [])) {
|
|
553
|
+
trace(`fresh turn: isolating in-session helper — incomingTools subset of ` +
|
|
554
|
+
`parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
|
|
555
|
+
`priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
|
|
556
|
+
session = await openSession({ isolate: true });
|
|
552
557
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
558
|
+
else {
|
|
559
|
+
// Host may start a new user turn while the prior Run still has pending
|
|
560
|
+
// tools. Finish that Run on the same conversation (cancel stranded execs,
|
|
561
|
+
// drain turn_ended) before opening the new Run so the checkpoint prefix
|
|
562
|
+
// is preserved. registerSession will not close a prior Run that still
|
|
563
|
+
// has real pending execs; a failed prepare leaves that Run held.
|
|
564
|
+
try {
|
|
565
|
+
await preparePriorSessionForFreshTurn(sessionKey);
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
|
|
569
|
+
`any still-pending prior held — ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
session = await openSession();
|
|
556
572
|
}
|
|
557
|
-
session = await openSession();
|
|
558
573
|
}
|
|
559
574
|
}
|
|
560
575
|
let activeSession = session;
|
|
@@ -756,6 +771,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
756
771
|
const prompt = callOptions.prompt;
|
|
757
772
|
const incomingTools = extractTools(callOptions);
|
|
758
773
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
774
|
+
const isolateHelper = startOptions?.isolate === true;
|
|
759
775
|
const cacheDir = opencodeGlobalCacheDir();
|
|
760
776
|
if (sessionKey) {
|
|
761
777
|
const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
|
|
@@ -807,6 +823,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
807
823
|
let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
|
|
808
824
|
let resuming = !!resumeRecovery;
|
|
809
825
|
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
826
|
+
// Isolated helpers must not share the parent's openCodeSessionId or sticky
|
|
827
|
+
// conversation: registerSession would otherwise supersede the held parent Run.
|
|
828
|
+
const ephemeralRun = lifecycle || isolateHelper;
|
|
810
829
|
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
811
830
|
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
812
831
|
// projects, so its static option is only a last-resort fallback. Prefer the
|
|
@@ -841,9 +860,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
841
860
|
? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
|
|
842
861
|
: bindConversationId(sessionKey, {
|
|
843
862
|
reset: resetState.reset || recovery?.kind === "rebase",
|
|
844
|
-
ephemeral:
|
|
863
|
+
ephemeral: ephemeralRun,
|
|
845
864
|
});
|
|
846
|
-
let conversationState =
|
|
865
|
+
let conversationState = ephemeralRun
|
|
847
866
|
? undefined
|
|
848
867
|
: resuming
|
|
849
868
|
? resumeRecovery.checkpoint
|
|
@@ -880,9 +899,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
880
899
|
let userText = recovery?.kind === "rebase"
|
|
881
900
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
882
901
|
: (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;
|
|
886
902
|
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
887
903
|
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
888
904
|
const startedWithCheckpoint = !!conversationState;
|
|
@@ -1007,17 +1023,15 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1007
1023
|
// Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
|
|
1008
1024
|
// Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
|
|
1009
1025
|
const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
|
|
1010
|
-
// Issue #29: after skills are in RequestContext,
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
// host-permitted set, not the epoch-held advertisement
|
|
1014
|
-
// `skill` would refuse the call this nudge asks for.
|
|
1026
|
+
// Issue #29: after skills are in RequestContext, admit the catalog. OpenCode
|
|
1027
|
+
// only Mid-Conversation-updates when the available-skills list changes
|
|
1028
|
+
// (SkillGuidance / SkillInstructions) — never per-turn matched-id nudges.
|
|
1029
|
+
// Gate on the host-permitted set, not the epoch-held advertisement.
|
|
1015
1030
|
if (!isCompaction && !lifecycle) {
|
|
1016
1031
|
const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
|
|
1017
|
-
const skillNudge =
|
|
1032
|
+
const skillNudge = takeSkillCatalogChangeReminder(conversationId, {
|
|
1018
1033
|
hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
|
|
1019
1034
|
skills: getHeldOverlaySkills(conversationId),
|
|
1020
|
-
userText: skillMatchText,
|
|
1021
1035
|
skillArgKey: skillDialect.skillArgKey,
|
|
1022
1036
|
});
|
|
1023
1037
|
userText = appendMidConversationMessage(userText, skillNudge);
|
|
@@ -1159,7 +1173,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1159
1173
|
createPlanInTurn: false,
|
|
1160
1174
|
switchModeInTurn: false,
|
|
1161
1175
|
},
|
|
1162
|
-
openCodeSessionId:
|
|
1176
|
+
openCodeSessionId: ephemeralRun ? undefined : sessionKey,
|
|
1163
1177
|
hostAgent,
|
|
1164
1178
|
stableSystemPromptHash: frozenSystemPromptHash,
|
|
1165
1179
|
postCompactionRebase: isCompaction,
|
|
@@ -1172,7 +1186,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1172
1186
|
// Seed from the per-OpenCode-session copy: merges in this turn (and after
|
|
1173
1187
|
// checkpoint resumes/rebases, which rebuild the session here) expand
|
|
1174
1188
|
// against the last observed host list, not an empty one.
|
|
1175
|
-
mirroredTodos: snapshotMirroredTodosBySession(
|
|
1189
|
+
mirroredTodos: snapshotMirroredTodosBySession(ephemeralRun ? undefined : sessionKey),
|
|
1176
1190
|
nextBridgedExecId: 900_000,
|
|
1177
1191
|
blobs: new Map(),
|
|
1178
1192
|
toolDescriptors,
|
|
@@ -1276,6 +1290,39 @@ export function findContinuationSession(toolResults) {
|
|
|
1276
1290
|
}
|
|
1277
1291
|
return undefined;
|
|
1278
1292
|
}
|
|
1293
|
+
/**
|
|
1294
|
+
* True when `incoming` is a non-empty proper subset of `parent` by tool name.
|
|
1295
|
+
* Used to spot in-session helpers that reuse the parent OpenCode session id
|
|
1296
|
+
* with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
|
|
1297
|
+
*/
|
|
1298
|
+
export function isProperCatalogSubset(incoming, parent) {
|
|
1299
|
+
if (incoming.length === 0 || parent.length === 0)
|
|
1300
|
+
return false;
|
|
1301
|
+
if (incoming.length >= parent.length)
|
|
1302
|
+
return false;
|
|
1303
|
+
const parentNames = new Set(parent.map((tool) => tool.name).filter((name) => !!name));
|
|
1304
|
+
if (parentNames.size === 0)
|
|
1305
|
+
return false;
|
|
1306
|
+
let matched = 0;
|
|
1307
|
+
for (const tool of incoming) {
|
|
1308
|
+
const name = tool.name;
|
|
1309
|
+
if (!name || !parentNames.has(name))
|
|
1310
|
+
return false;
|
|
1311
|
+
matched++;
|
|
1312
|
+
}
|
|
1313
|
+
return matched > 0 && matched < parentNames.size;
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* An open parent Run for this OpenCode session whose catalog strictly contains
|
|
1317
|
+
* the incoming tools is treated as an in-session helper. Those calls must not
|
|
1318
|
+
* cancel/supersede the parent (that remints on the trailing tool result).
|
|
1319
|
+
*/
|
|
1320
|
+
export function shouldIsolateInSessionHelper(openCodeSessionId, incomingTools) {
|
|
1321
|
+
const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
|
|
1322
|
+
if (!prior || prior.closed)
|
|
1323
|
+
return false;
|
|
1324
|
+
return isProperCatalogSubset(incomingTools, prior.toolCatalog ?? []);
|
|
1325
|
+
}
|
|
1279
1326
|
/** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
|
|
1280
1327
|
export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
|
|
1281
1328
|
/**
|