cursor-opencode-provider 0.7.5 → 0.8.0
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 +9 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +42 -5
- package/dist/context/build.js +96 -92
- package/dist/context/dynamic-catalog.d.ts +3 -28
- package/dist/context/dynamic-catalog.js +25 -130
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +37 -9
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +7 -4
- package/dist/context/paths.js +13 -9
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.js +2 -2
- package/dist/image-save.js +3 -3
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +59 -8
- package/dist/language-model.js +458 -118
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/plan-execution-kickoff.js +2 -2
- package/dist/plugin-opencode2.js +10 -16
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +138 -109
- package/dist/protocol/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.js +2 -1
- package/dist/protocol/messages.js +22 -4
- package/dist/protocol/request.d.ts +11 -9
- package/dist/protocol/request.js +15 -25
- package/dist/protocol/tools.d.ts +17 -10
- package/dist/protocol/tools.js +53 -93
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/session.d.ts +9 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/package.json +1 -1
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/context/skills.d.ts +0 -11
- package/dist/context/skills.js +0 -110
package/dist/auth.js
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import { CURSOR_API_HOST, CURSOR_WEBSITE_HOST } from "./shared.js";
|
|
1
|
+
import { CURSOR_API_HOST, CURSOR_OAUTH_CLIENT_ID, CURSOR_WEBSITE_HOST } from "./shared.js";
|
|
2
2
|
import { withAbortDeadline } from "./deadline.js";
|
|
3
|
+
import { errorMessage } from "./debug.js";
|
|
3
4
|
const API_BASE = `https://${CURSOR_API_HOST}`;
|
|
4
5
|
const AUTH_REQUEST_TIMEOUT_MS = 5_000;
|
|
6
|
+
/** Cursor IDE aborts its session refresh after 20 s (`_performAccessTokenRefresh`). */
|
|
7
|
+
const SESSION_REFRESH_TIMEOUT_MS = 20_000;
|
|
8
|
+
/** Server error code Cursor's IDE and CLI both surface as a sign-in policy block. */
|
|
9
|
+
const SIGN_IN_POLICY_VIOLATION = "sign_in_policy_violation";
|
|
5
10
|
export class AuthExchangeError extends Error {
|
|
6
11
|
cause;
|
|
7
|
-
|
|
12
|
+
kind;
|
|
13
|
+
status;
|
|
14
|
+
constructor(message, cause, kind = "transient", status) {
|
|
8
15
|
super(message);
|
|
9
16
|
this.cause = cause;
|
|
17
|
+
this.kind = kind;
|
|
18
|
+
this.status = status;
|
|
10
19
|
this.name = "AuthExchangeError";
|
|
11
20
|
}
|
|
12
21
|
}
|
|
13
|
-
export class AuthRefreshError extends Error {
|
|
14
|
-
cause;
|
|
15
|
-
constructor(message, cause) {
|
|
16
|
-
super(message);
|
|
17
|
-
this.cause = cause;
|
|
18
|
-
this.name = "AuthRefreshError";
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
22
|
export class AuthPollError extends Error {
|
|
22
23
|
cause;
|
|
23
24
|
constructor(message, cause) {
|
|
@@ -69,10 +70,19 @@ function base64url(bytes) {
|
|
|
69
70
|
.replace(/\//g, "_")
|
|
70
71
|
.replace(/=+$/, "");
|
|
71
72
|
}
|
|
73
|
+
/** A raw Cursor API key (as opposed to a JWT already exchanged from one). */
|
|
74
|
+
export function isExchangeableApiKey(value) {
|
|
75
|
+
return value.startsWith("crsr_");
|
|
76
|
+
}
|
|
72
77
|
// ── Mode A: pass-through auth token ──
|
|
73
78
|
export function useAuthToken(token) {
|
|
74
79
|
return { accessToken: token };
|
|
75
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Exchange a raw `crsr_` API key for a short-lived JWT. This is also how an
|
|
83
|
+
* API-key login is renewed: Cursor CLI re-runs the exchange and never uses the
|
|
84
|
+
* returned refresh token (`auth-refresh.ts` → `loginWithApiKey`).
|
|
85
|
+
*/
|
|
76
86
|
export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
|
|
77
87
|
return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthExchangeError("API key exchange timed out"), async (signal) => {
|
|
78
88
|
let res;
|
|
@@ -91,7 +101,18 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
|
|
|
91
101
|
throw new AuthExchangeError("API key exchange request failed", cause);
|
|
92
102
|
}
|
|
93
103
|
if (!res.ok) {
|
|
94
|
-
|
|
104
|
+
let errorCode;
|
|
105
|
+
if (res.status === 403) {
|
|
106
|
+
errorCode = (await res.json().catch(() => undefined))?.error;
|
|
107
|
+
}
|
|
108
|
+
// Only an authentication refusal says the key itself is bad; rate
|
|
109
|
+
// limits, timeouts, and server errors are worth another attempt.
|
|
110
|
+
const kind = errorCode === SIGN_IN_POLICY_VIOLATION
|
|
111
|
+
? "policy"
|
|
112
|
+
: res.status === 401 || res.status === 403 ? "rejected" : "transient";
|
|
113
|
+
throw new AuthExchangeError(kind === "policy"
|
|
114
|
+
? `API key exchange blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})`
|
|
115
|
+
: `API key exchange failed: ${res.status} ${res.statusText}`, undefined, kind, res.status);
|
|
95
116
|
}
|
|
96
117
|
let body;
|
|
97
118
|
try {
|
|
@@ -106,97 +127,59 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
|
|
|
106
127
|
return { accessToken: body.accessToken, refreshToken: body.refreshToken };
|
|
107
128
|
});
|
|
108
129
|
}
|
|
109
|
-
export async function refreshAccessToken(refreshToken, baseUrl = API_BASE) {
|
|
110
|
-
return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthRefreshError("Token refresh timed out"), async (signal) => {
|
|
111
|
-
let res;
|
|
112
|
-
try {
|
|
113
|
-
res = await fetch(`${baseUrl}/auth/token`, {
|
|
114
|
-
method: "POST",
|
|
115
|
-
headers: { "Content-Type": "application/json" },
|
|
116
|
-
body: JSON.stringify({ refreshToken }),
|
|
117
|
-
signal,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
catch (cause) {
|
|
121
|
-
throw new AuthRefreshError("Token refresh request failed", cause);
|
|
122
|
-
}
|
|
123
|
-
if (!res.ok) {
|
|
124
|
-
throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
|
|
125
|
-
}
|
|
126
|
-
let body;
|
|
127
|
-
try {
|
|
128
|
-
body = await res.json();
|
|
129
|
-
}
|
|
130
|
-
catch (cause) {
|
|
131
|
-
throw new AuthRefreshError("Token refresh returned malformed JSON", cause);
|
|
132
|
-
}
|
|
133
|
-
if (typeof body.accessToken !== "string" || typeof body.refreshToken !== "string") {
|
|
134
|
-
throw new AuthRefreshError("Refresh response missing tokens");
|
|
135
|
-
}
|
|
136
|
-
return { accessToken: body.accessToken, refreshToken: body.refreshToken };
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
// Cache JWTs obtained via API-key exchange so doStream doesn't re-exchange
|
|
140
|
-
// on every turn when the caller only supplied `apiKey`.
|
|
141
|
-
const _apiKeyTokenCache = new Map();
|
|
142
|
-
const inflightBearer = new Map();
|
|
143
|
-
/** Clear the apiKey→JWT cache (tests). */
|
|
144
|
-
export function clearBearerTokenCache() {
|
|
145
|
-
_apiKeyTokenCache.clear();
|
|
146
|
-
inflightBearer.clear();
|
|
147
|
-
}
|
|
148
130
|
/**
|
|
149
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
131
|
+
* Renew a browser-login session token, exactly as Cursor's IDE does
|
|
132
|
+
* (`_performAccessTokenRefresh`): `POST /oauth/token` with a refresh_token
|
|
133
|
+
* grant and the IDE's client id. The response carries no new refresh token;
|
|
134
|
+
* the IDE stores the new access token as both, and so must callers.
|
|
152
135
|
*
|
|
153
|
-
*
|
|
154
|
-
* `
|
|
155
|
-
*
|
|
156
|
-
* doesn't distinguish our OAuth vs. API-key connection methods and may pass
|
|
157
|
-
* an already-issued JWT through the `apiKey` field — hand us a token that's
|
|
158
|
-
* already good to use as-is; POSTing it to the exchange endpoint 401s.
|
|
136
|
+
* A rejected token is not an HTTP error: Cursor answers 200 with
|
|
137
|
+
* `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
|
|
138
|
+
* Never throws; every failure is a classified result.
|
|
159
139
|
*/
|
|
160
|
-
export async function
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
140
|
+
export async function refreshCursorSession(refreshToken, baseUrl = API_BASE) {
|
|
141
|
+
try {
|
|
142
|
+
return await withAbortDeadline(SESSION_REFRESH_TIMEOUT_MS, () => new Error("session refresh timed out"), async (signal) => {
|
|
143
|
+
const res = await fetch(`${baseUrl}/oauth/token`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: {
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"x-cursor-client-type": "cli",
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
grant_type: "refresh_token",
|
|
151
|
+
client_id: CURSOR_OAUTH_CLIENT_ID,
|
|
152
|
+
refresh_token: refreshToken,
|
|
153
|
+
}),
|
|
154
|
+
signal,
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
kind: "transient",
|
|
160
|
+
status: res.status,
|
|
161
|
+
message: `session refresh failed: ${res.status} ${res.statusText}`,
|
|
162
|
+
};
|
|
183
163
|
}
|
|
184
|
-
catch
|
|
185
|
-
|
|
164
|
+
const body = await res.json().catch(() => undefined);
|
|
165
|
+
if (body?.shouldLogout === true) {
|
|
166
|
+
return body.error === SIGN_IN_POLICY_VIOLATION
|
|
167
|
+
? { ok: false, kind: "policy", status: res.status, message: `session blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})` }
|
|
168
|
+
: { ok: false, kind: "logout", status: res.status, message: "Cursor ended the session (shouldLogout)" };
|
|
186
169
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
inflightBearer.set(inflightKey, pending);
|
|
194
|
-
try {
|
|
195
|
-
return await pending;
|
|
170
|
+
// The IDE would store an empty token here; treat it as a failed attempt.
|
|
171
|
+
if (typeof body?.access_token !== "string" || body.access_token === "") {
|
|
172
|
+
return { ok: false, kind: "transient", status: res.status, message: "session refresh returned no access token" };
|
|
173
|
+
}
|
|
174
|
+
return { ok: true, accessToken: body.access_token };
|
|
175
|
+
});
|
|
196
176
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
177
|
+
catch (cause) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
kind: "transient",
|
|
181
|
+
message: `session refresh request failed: ${errorMessage(cause)}`,
|
|
182
|
+
};
|
|
200
183
|
}
|
|
201
184
|
}
|
|
202
185
|
// ── Mode C: PKCE browser login ──
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classic plugin tools built with the host's own `tool()` helper when it can
|
|
3
|
+
* be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
|
|
4
|
+
* of a plugin module as a plugin (`getLegacyPlugins`), so that module may
|
|
5
|
+
* export `CursorPlugin` only.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadClassicTools(options?: {
|
|
8
|
+
importModule?: (specifier: string) => Promise<{
|
|
9
|
+
tool?: any;
|
|
10
|
+
}>;
|
|
11
|
+
configDirs?: string[];
|
|
12
|
+
}): Promise<{
|
|
13
|
+
webSearch: any;
|
|
14
|
+
imageSave: any;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { opencodeGlobalConfigDirs } from "./context/paths.js";
|
|
4
|
+
import { createOpenCodeWebSearchTool, openCodeWebSearchTool } from "./web-search-tool.js";
|
|
5
|
+
import { createCursorImageSaveTool, cursorImageSaveTool } from "./image-save-tool.js";
|
|
6
|
+
/**
|
|
7
|
+
* Classic plugin tools built with the host's own `tool()` helper when it can
|
|
8
|
+
* be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
|
|
9
|
+
* of a plugin module as a plugin (`getLegacyPlugins`), so that module may
|
|
10
|
+
* export `CursorPlugin` only.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadClassicTools(options = {}) {
|
|
13
|
+
const configDir = options.configDirs?.[0] ?? opencodeGlobalConfigDirs()[0];
|
|
14
|
+
const candidates = [
|
|
15
|
+
...(configDir
|
|
16
|
+
? [path.join(configDir, "node_modules", "@opencode-ai", "plugin", "dist", "index.js")]
|
|
17
|
+
: []),
|
|
18
|
+
"@opencode-ai/plugin",
|
|
19
|
+
];
|
|
20
|
+
const importModule = options.importModule ?? ((specifier) => import(specifier));
|
|
21
|
+
// The global config copy is the host-owned installation; bare import is a fallback.
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
try {
|
|
24
|
+
const specifier = path.win32.isAbsolute(candidate) && !path.isAbsolute(candidate)
|
|
25
|
+
? new URL(`file:///${candidate.replaceAll("\\", "/")}`).href
|
|
26
|
+
: path.isAbsolute(candidate)
|
|
27
|
+
? pathToFileURL(candidate).href
|
|
28
|
+
: candidate;
|
|
29
|
+
const module = await importModule(specifier);
|
|
30
|
+
if (typeof module.tool === "function" && module.tool.schema) {
|
|
31
|
+
const factory = { tool: module.tool, schema: module.tool.schema };
|
|
32
|
+
return {
|
|
33
|
+
webSearch: createOpenCodeWebSearchTool(factory),
|
|
34
|
+
imageSave: createCursorImageSaveTool(factory),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Try the next host-owned/normal resolution location.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// `tool()` is an identity helper; the fallback definitions use plain JSON
|
|
43
|
+
// Schema accepted by OpenCode's legacy registry when no Zod helper is present.
|
|
44
|
+
return { webSearch: openCodeWebSearchTool, imageSave: cursorImageSaveTool };
|
|
45
|
+
}
|
package/dist/context/build.d.ts
CHANGED
|
@@ -11,15 +11,52 @@ export type BuildRequestContextInput = {
|
|
|
11
11
|
* guidance MCP server ids). Skips a second `loadMergedConfig` disk read.
|
|
12
12
|
*/
|
|
13
13
|
mergedConfig?: OpencodeJson;
|
|
14
|
+
/** Host system context to deliver as the frozen system-instructions rule. */
|
|
15
|
+
systemInstructions?: SystemInstructions;
|
|
14
16
|
};
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Host system context (OpenCode's system prompt plus this provider's
|
|
19
|
+
* interaction guidance) for one Cursor conversation.
|
|
20
|
+
*/
|
|
21
|
+
export type SystemInstructions = {
|
|
22
|
+
text: string;
|
|
23
|
+
/**
|
|
24
|
+
* True when `text` is this conversation's own frozen baseline (a Context
|
|
25
|
+
* Epoch started in this process, or an ephemeral Run's prompt): it replaces
|
|
26
|
+
* any rule already frozen. False when a restart recovered the epoch without
|
|
27
|
+
* its bytes: the persisted rule wins and `text` only fills a missing one.
|
|
28
|
+
*/
|
|
29
|
+
authoritative: boolean;
|
|
30
|
+
};
|
|
31
|
+
/** Rule name Cursor shows for the host system context (not a file). */
|
|
32
|
+
export declare const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
|
|
33
|
+
/**
|
|
34
|
+
* Cursor does not follow a client-seeded `system` message, and applies a rule
|
|
35
|
+
* only by its type. The Cursor CLI sends AGENTS.md as `alwaysApply`
|
|
36
|
+
* (`CursorRuleType.global`) and the IDE ships its own guidance as a non-file
|
|
37
|
+
* global rule; the host system context goes out the same way, once, frozen
|
|
38
|
+
* with the RequestContext base.
|
|
39
|
+
*/
|
|
40
|
+
export declare function systemInstructionsRule(text: string): Record<string, unknown>;
|
|
41
|
+
/** Text of the frozen system-instructions rule, if the context carries one. */
|
|
42
|
+
export declare function systemInstructionsRuleText(context: Record<string, unknown>): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Return `context` with the system-instructions rule applied, or the same
|
|
45
|
+
* object when nothing changes (see `SystemInstructions.authoritative`).
|
|
46
|
+
*/
|
|
47
|
+
export declare function withSystemInstructions(context: Record<string, unknown>, instructions: SystemInstructions | undefined): Record<string, unknown>;
|
|
48
|
+
export declare const DYNAMIC_REQUEST_CONTEXT_KEYS: readonly ["tools", "custom_subagents", "mcp_file_system_options", "mcp_meta_tool_options", "web_search_enabled", "web_fetch_enabled", "custom_subagents_info_complete", "mcp_file_system_info_complete", "mcp_info_complete", "hooks_additional_context"];
|
|
16
49
|
export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[number];
|
|
50
|
+
/**
|
|
51
|
+
* The host system context (delivered as the system-instructions rule) already
|
|
52
|
+
* carries these; never keep them on a frozen base.
|
|
53
|
+
*/
|
|
54
|
+
export declare const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS: readonly ["agent_skills", "agent_skills_info_complete"];
|
|
17
55
|
/**
|
|
18
56
|
* Full RequestContext payload for live UMA + exec #10 reply.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* user's own OpenCode config lists it, exactly as the host would read it.
|
|
57
|
+
* Workspace env/git/layout, the host system-instructions rule, and
|
|
58
|
+
* host-advertised tools and subagents. The provider never looks in Cursor's
|
|
59
|
+
* own directories.
|
|
23
60
|
*/
|
|
24
61
|
export declare function buildRequestContext(input: BuildRequestContextInput): Promise<Record<string, unknown>>;
|
|
25
62
|
/** Rediscover only capability/plugin sections that may change during a chat. */
|
package/dist/context/build.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { extractHostSubagentCatalog,
|
|
3
|
-
import {
|
|
4
|
-
import { collectSkills } from "./skills.js";
|
|
5
|
-
import { collectAgents } from "./agents.js";
|
|
2
|
+
import { extractHostSubagentCatalog, toolsToMcpDescriptors, } from "../protocol/tools.js";
|
|
3
|
+
import { loadMergedConfig, } from "./rules.js";
|
|
6
4
|
import { collectPlugins } from "./plugins.js";
|
|
7
5
|
import { collectGit } from "./git.js";
|
|
8
6
|
import { collectProjectLayout } from "./layout.js";
|
|
@@ -10,75 +8,108 @@ import { buildEnv } from "./env.js";
|
|
|
10
8
|
import { ensureOpencodeProjectDir } from "./paths.js";
|
|
11
9
|
import { holdCapabilityOverlay } from "./overlay.js";
|
|
12
10
|
import { traceRequestContextPaths } from "../debug.js";
|
|
11
|
+
/** Rule name Cursor shows for the host system context (not a file). */
|
|
12
|
+
export const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
|
|
13
|
+
/**
|
|
14
|
+
* Cursor does not follow a client-seeded `system` message, and applies a rule
|
|
15
|
+
* only by its type. The Cursor CLI sends AGENTS.md as `alwaysApply`
|
|
16
|
+
* (`CursorRuleType.global`) and the IDE ships its own guidance as a non-file
|
|
17
|
+
* global rule; the host system context goes out the same way, once, frozen
|
|
18
|
+
* with the RequestContext base.
|
|
19
|
+
*/
|
|
20
|
+
export function systemInstructionsRule(text) {
|
|
21
|
+
return {
|
|
22
|
+
full_path: SYSTEM_INSTRUCTIONS_RULE_PATH,
|
|
23
|
+
content: text,
|
|
24
|
+
type: { global: {} },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function isSystemInstructionsRule(rule) {
|
|
28
|
+
if (!rule || typeof rule !== "object")
|
|
29
|
+
return false;
|
|
30
|
+
const record = rule;
|
|
31
|
+
const type = record.type;
|
|
32
|
+
return record.full_path === SYSTEM_INSTRUCTIONS_RULE_PATH && !!type?.global
|
|
33
|
+
&& typeof record.content === "string";
|
|
34
|
+
}
|
|
35
|
+
/** Text of the frozen system-instructions rule, if the context carries one. */
|
|
36
|
+
export function systemInstructionsRuleText(context) {
|
|
37
|
+
const rules = Array.isArray(context.rules) ? context.rules : [];
|
|
38
|
+
const rule = rules.find(isSystemInstructionsRule);
|
|
39
|
+
return rule ? rule.content : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Return `context` with the system-instructions rule applied, or the same
|
|
43
|
+
* object when nothing changes (see `SystemInstructions.authoritative`).
|
|
44
|
+
*/
|
|
45
|
+
export function withSystemInstructions(context, instructions) {
|
|
46
|
+
const text = instructions?.text.trim() ? instructions.text : undefined;
|
|
47
|
+
if (!text)
|
|
48
|
+
return context;
|
|
49
|
+
const current = systemInstructionsRuleText(context);
|
|
50
|
+
if (current !== undefined && (current === text || !instructions.authoritative))
|
|
51
|
+
return context;
|
|
52
|
+
return { ...context, rules: [systemInstructionsRule(text)] };
|
|
53
|
+
}
|
|
54
|
+
/** Keep only the system-instructions rule; older bases carried untyped rules Cursor never applied. */
|
|
55
|
+
function keepSystemInstructionsRule(context) {
|
|
56
|
+
if (!Object.hasOwn(context, "rules"))
|
|
57
|
+
return;
|
|
58
|
+
const rules = Array.isArray(context.rules) ? context.rules.filter(isSystemInstructionsRule) : [];
|
|
59
|
+
if (rules.length > 0)
|
|
60
|
+
context.rules = rules.slice(0, 1);
|
|
61
|
+
else
|
|
62
|
+
delete context.rules;
|
|
63
|
+
}
|
|
13
64
|
export const DYNAMIC_REQUEST_CONTEXT_KEYS = [
|
|
14
65
|
"tools",
|
|
15
|
-
"agent_skills",
|
|
16
66
|
"custom_subagents",
|
|
17
67
|
"mcp_file_system_options",
|
|
18
68
|
"mcp_meta_tool_options",
|
|
19
69
|
"web_search_enabled",
|
|
20
70
|
"web_fetch_enabled",
|
|
21
|
-
"agent_skills_info_complete",
|
|
22
71
|
"custom_subagents_info_complete",
|
|
23
72
|
"mcp_file_system_info_complete",
|
|
24
73
|
"mcp_info_complete",
|
|
25
74
|
"hooks_additional_context",
|
|
26
75
|
];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
description: "Read-only agent for searching and understanding the local codebase.",
|
|
35
|
-
},
|
|
76
|
+
/**
|
|
77
|
+
* The host system context (delivered as the system-instructions rule) already
|
|
78
|
+
* carries these; never keep them on a frozen base.
|
|
79
|
+
*/
|
|
80
|
+
export const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS = [
|
|
81
|
+
"agent_skills",
|
|
82
|
+
"agent_skills_info_complete",
|
|
36
83
|
];
|
|
37
84
|
/**
|
|
38
|
-
*
|
|
39
|
-
* to Cursor. `hostSubagents.complete` answers a narrow question: did the host's
|
|
40
|
-
* task tool itself exposes an exhaustive recipient list (schema enum or
|
|
41
|
-
* catalog marker)? OpenCode's task tool intentionally uses a plain string, so
|
|
42
|
-
* that raw flag is false even though we can still advertise a complete Cursor
|
|
43
|
-
* catalog by adding built-in defaults and discovered agent files.
|
|
44
|
-
*
|
|
45
|
-
* `custom_subagents_info_complete` must describe the final advertised list, not
|
|
46
|
-
* the raw extraction source. If there is no executor, the empty list is
|
|
47
|
-
* complete. If the host catalog is complete, use it verbatim. Otherwise we make
|
|
48
|
-
* the advertised set complete by augmenting with default host agents and all
|
|
49
|
-
* locally discovered agents.
|
|
85
|
+
* Advertise only what the host `task` / `subagent` catalog already named.
|
|
50
86
|
*/
|
|
51
|
-
function buildAdvertisedSubagentCatalog(hostSubagents
|
|
87
|
+
function buildAdvertisedSubagentCatalog(hostSubagents) {
|
|
52
88
|
if (!hostSubagents.executor)
|
|
53
89
|
return { agents: [], complete: true };
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
90
|
+
return { agents: hostSubagents.agents, complete: hostSubagents.complete };
|
|
91
|
+
}
|
|
92
|
+
function stripHostDuplicatedRequestContextFields(context) {
|
|
93
|
+
for (const key of HOST_DUPLICATED_REQUEST_CONTEXT_KEYS)
|
|
94
|
+
delete context[key];
|
|
95
|
+
keepSystemInstructionsRule(context);
|
|
60
96
|
}
|
|
61
97
|
/**
|
|
62
98
|
* Full RequestContext payload for live UMA + exec #10 reply.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* user's own OpenCode config lists it, exactly as the host would read it.
|
|
99
|
+
* Workspace env/git/layout, the host system-instructions rule, and
|
|
100
|
+
* host-advertised tools and subagents. The provider never looks in Cursor's
|
|
101
|
+
* own directories.
|
|
67
102
|
*/
|
|
68
103
|
export async function buildRequestContext(input) {
|
|
69
104
|
const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
|
|
70
|
-
const
|
|
105
|
+
const config = input.mergedConfig ?? await loadMergedConfig(workspaceRoot);
|
|
71
106
|
const [dynamic, git, layout] = await Promise.all([
|
|
72
|
-
buildDynamicRequestContextFromDiscovery(input, workspaceRoot,
|
|
107
|
+
buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config),
|
|
73
108
|
collectGit(workspaceRoot),
|
|
74
109
|
collectProjectLayout(workspaceRoot),
|
|
75
110
|
]);
|
|
76
|
-
const
|
|
111
|
+
const workspace = {
|
|
77
112
|
env: buildEnv(workspaceRoot),
|
|
78
|
-
rules: rules.map((r) => ({
|
|
79
|
-
full_path: r.fullPath,
|
|
80
|
-
content: r.content,
|
|
81
|
-
})),
|
|
82
113
|
repository_info: git.repositoryInfo,
|
|
83
114
|
git_repos: git.gitRepos,
|
|
84
115
|
project_layouts: [layout],
|
|
@@ -88,47 +119,25 @@ export async function buildRequestContext(input) {
|
|
|
88
119
|
git_repo_info_complete: true,
|
|
89
120
|
git_status_info_complete: true,
|
|
90
121
|
};
|
|
122
|
+
const base = withSystemInstructions(workspace, input.systemInstructions);
|
|
91
123
|
const ctx = materializeRequestContext(base, dynamic);
|
|
92
124
|
traceRequestContextPaths("buildRequestContext", ctx);
|
|
93
125
|
return ctx;
|
|
94
126
|
}
|
|
95
|
-
async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot,
|
|
127
|
+
async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config) {
|
|
96
128
|
const providerIdentifier = input.providerIdentifier ?? "opencode";
|
|
97
129
|
const tools = input.tools ?? [];
|
|
98
|
-
const
|
|
99
|
-
collectSkills(workspaceRoot, worktree),
|
|
100
|
-
collectAgents(workspaceRoot),
|
|
101
|
-
collectPlugins(workspaceRoot, config),
|
|
102
|
-
]);
|
|
130
|
+
const plugins = await collectPlugins(workspaceRoot, config);
|
|
103
131
|
const mcpServerNames = Object.keys(config.mcp ?? {});
|
|
104
|
-
const
|
|
105
|
-
const nested = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames);
|
|
132
|
+
const slim = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames, { namesOnly: true });
|
|
106
133
|
const projectDir = ensureOpencodeProjectDir(workspaceRoot);
|
|
107
134
|
const hostSubagents = extractHostSubagentCatalog(tools);
|
|
108
|
-
const advertisedSubagents = buildAdvertisedSubagentCatalog(hostSubagents
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
const customSubagents = [...advertisedByName.values()].map((agent) => {
|
|
116
|
-
const discovered = discoveredByName.get(agent.name);
|
|
117
|
-
return {
|
|
118
|
-
full_path: discovered?.fullPath ?? "",
|
|
119
|
-
name: agent.name,
|
|
120
|
-
description: discovered?.description || agent.description || "Host-configured subagent.",
|
|
121
|
-
// The host applies the real configured prompt when Task/Actor executes.
|
|
122
|
-
// Cursor only needs enough context to select the recipient intentionally.
|
|
123
|
-
prompt: discovered?.prompt ||
|
|
124
|
-
`Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
|
|
125
|
-
};
|
|
126
|
-
});
|
|
127
|
-
const liveSkills = skills.map((s) => ({
|
|
128
|
-
id: s.name,
|
|
129
|
-
full_path: s.fullPath,
|
|
130
|
-
content: s.content,
|
|
131
|
-
description: s.description,
|
|
135
|
+
const advertisedSubagents = buildAdvertisedSubagentCatalog(hostSubagents);
|
|
136
|
+
const customSubagents = advertisedSubagents.agents.map((agent) => ({
|
|
137
|
+
full_path: "",
|
|
138
|
+
name: agent.name,
|
|
139
|
+
description: agent.description || "Host-configured subagent.",
|
|
140
|
+
prompt: `Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
|
|
132
141
|
}));
|
|
133
142
|
const livePlugins = plugins.map((p) => ({
|
|
134
143
|
id: p.id,
|
|
@@ -136,24 +145,19 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
|
|
|
136
145
|
}));
|
|
137
146
|
const overlay = input.conversationId
|
|
138
147
|
? holdCapabilityOverlay(input.conversationId, {
|
|
139
|
-
skills: liveSkills,
|
|
140
148
|
subagents: customSubagents,
|
|
141
149
|
plugins: livePlugins,
|
|
142
150
|
})
|
|
143
|
-
: {
|
|
151
|
+
: { subagents: customSubagents, plugins: livePlugins };
|
|
144
152
|
const dynamic = {
|
|
145
|
-
tools: flat,
|
|
146
|
-
agent_skills: overlay.skills,
|
|
147
|
-
custom_subagents: overlay.subagents,
|
|
148
153
|
mcp_file_system_options: {
|
|
149
154
|
enabled: true,
|
|
150
155
|
// Cursor metadata root (mcps / agent-tools), not the git workspace.
|
|
151
156
|
workspace_project_dir: projectDir,
|
|
152
|
-
mcp_descriptors: nested,
|
|
153
157
|
},
|
|
154
158
|
mcp_meta_tool_options: {
|
|
155
159
|
enabled: true,
|
|
156
|
-
mcp_descriptors:
|
|
160
|
+
...(slim.length > 0 ? { mcp_descriptors: slim } : {}),
|
|
157
161
|
},
|
|
158
162
|
// This provider always rejects native web_search/web_fetch interaction
|
|
159
163
|
// queries with a headless-UI reason (see interactions.ts). Advertise that
|
|
@@ -161,11 +165,12 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
|
|
|
161
165
|
// custom_web* aliases instead of routing through a query doomed to fail.
|
|
162
166
|
web_search_enabled: false,
|
|
163
167
|
web_fetch_enabled: false,
|
|
164
|
-
agent_skills_info_complete: true,
|
|
165
168
|
custom_subagents_info_complete: advertisedSubagents.complete,
|
|
166
169
|
mcp_file_system_info_complete: true,
|
|
167
170
|
mcp_info_complete: true,
|
|
168
171
|
};
|
|
172
|
+
if (overlay.subagents.length > 0)
|
|
173
|
+
dynamic.custom_subagents = overlay.subagents;
|
|
169
174
|
if (overlay.plugins.length > 0) {
|
|
170
175
|
dynamic.hooks_additional_context = overlay.plugins.map((p) => p.line).join("\n");
|
|
171
176
|
}
|
|
@@ -174,17 +179,15 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
|
|
|
174
179
|
/** Rediscover only capability/plugin sections that may change during a chat. */
|
|
175
180
|
export async function buildDynamicRequestContext(input) {
|
|
176
181
|
const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
: loadMergedConfig(workspaceRoot),
|
|
182
|
-
]);
|
|
183
|
-
return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config);
|
|
182
|
+
const config = input.mergedConfig
|
|
183
|
+
? input.mergedConfig
|
|
184
|
+
: await loadMergedConfig(workspaceRoot);
|
|
185
|
+
return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config);
|
|
184
186
|
}
|
|
185
187
|
/** Keep expensive workspace state frozen while replacing every live capability field. */
|
|
186
188
|
export function materializeRequestContext(base, dynamic) {
|
|
187
189
|
const context = structuredClone(base);
|
|
190
|
+
stripHostDuplicatedRequestContextFields(context);
|
|
188
191
|
for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS)
|
|
189
192
|
delete context[key];
|
|
190
193
|
for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS) {
|
|
@@ -196,6 +199,7 @@ export function materializeRequestContext(base, dynamic) {
|
|
|
196
199
|
/** Strip live capability fields before retaining/persisting a conversation base. */
|
|
197
200
|
export function requestContextBase(context) {
|
|
198
201
|
const base = structuredClone(context);
|
|
202
|
+
stripHostDuplicatedRequestContextFields(base);
|
|
199
203
|
for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS)
|
|
200
204
|
delete base[key];
|
|
201
205
|
return base;
|