@playwo/opencode-cursor-oauth 0.2.0 → 0.3.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.
Files changed (75) hide show
  1. package/README.md +26 -12
  2. package/dist/auth.js +1 -2
  3. package/dist/constants.d.ts +2 -0
  4. package/dist/constants.js +2 -0
  5. package/dist/cursor/bidi-session.d.ts +12 -0
  6. package/dist/cursor/bidi-session.js +164 -0
  7. package/dist/cursor/config.d.ts +4 -0
  8. package/dist/cursor/config.js +4 -0
  9. package/dist/cursor/connect-framing.d.ts +10 -0
  10. package/dist/cursor/connect-framing.js +80 -0
  11. package/dist/cursor/headers.d.ts +6 -0
  12. package/dist/cursor/headers.js +16 -0
  13. package/dist/cursor/index.d.ts +5 -0
  14. package/dist/cursor/index.js +5 -0
  15. package/dist/cursor/unary-rpc.d.ts +12 -0
  16. package/dist/cursor/unary-rpc.js +124 -0
  17. package/dist/index.d.ts +2 -14
  18. package/dist/index.js +2 -306
  19. package/dist/logger.d.ts +1 -0
  20. package/dist/logger.js +10 -2
  21. package/dist/models.js +1 -23
  22. package/dist/openai/index.d.ts +3 -0
  23. package/dist/openai/index.js +3 -0
  24. package/dist/openai/messages.d.ts +39 -0
  25. package/dist/openai/messages.js +228 -0
  26. package/dist/openai/tools.d.ts +7 -0
  27. package/dist/openai/tools.js +58 -0
  28. package/dist/openai/types.d.ts +41 -0
  29. package/dist/openai/types.js +1 -0
  30. package/dist/plugin/cursor-auth-plugin.d.ts +3 -0
  31. package/dist/plugin/cursor-auth-plugin.js +139 -0
  32. package/dist/proto/agent_pb.js +637 -319
  33. package/dist/provider/index.d.ts +2 -0
  34. package/dist/provider/index.js +2 -0
  35. package/dist/provider/model-cost.d.ts +9 -0
  36. package/dist/provider/model-cost.js +206 -0
  37. package/dist/provider/models.d.ts +8 -0
  38. package/dist/provider/models.js +86 -0
  39. package/dist/proxy/bridge-close-controller.d.ts +6 -0
  40. package/dist/proxy/bridge-close-controller.js +37 -0
  41. package/dist/proxy/bridge-non-streaming.d.ts +3 -0
  42. package/dist/proxy/bridge-non-streaming.js +123 -0
  43. package/dist/proxy/bridge-session.d.ts +5 -0
  44. package/dist/proxy/bridge-session.js +11 -0
  45. package/dist/proxy/bridge-streaming.d.ts +5 -0
  46. package/dist/proxy/bridge-streaming.js +409 -0
  47. package/dist/proxy/bridge.d.ts +3 -0
  48. package/dist/proxy/bridge.js +3 -0
  49. package/dist/proxy/chat-completion.d.ts +2 -0
  50. package/dist/proxy/chat-completion.js +153 -0
  51. package/dist/proxy/conversation-meta.d.ts +12 -0
  52. package/dist/proxy/conversation-meta.js +1 -0
  53. package/dist/proxy/conversation-state.d.ts +35 -0
  54. package/dist/proxy/conversation-state.js +95 -0
  55. package/dist/proxy/cursor-request.d.ts +6 -0
  56. package/dist/proxy/cursor-request.js +101 -0
  57. package/dist/proxy/index.d.ts +12 -0
  58. package/dist/proxy/index.js +12 -0
  59. package/dist/proxy/server.d.ts +6 -0
  60. package/dist/proxy/server.js +107 -0
  61. package/dist/proxy/sse.d.ts +5 -0
  62. package/dist/proxy/sse.js +5 -0
  63. package/dist/proxy/state-sync.d.ts +2 -0
  64. package/dist/proxy/state-sync.js +17 -0
  65. package/dist/proxy/stream-dispatch.d.ts +42 -0
  66. package/dist/proxy/stream-dispatch.js +634 -0
  67. package/dist/proxy/stream-state.d.ts +7 -0
  68. package/dist/proxy/stream-state.js +1 -0
  69. package/dist/proxy/title.d.ts +1 -0
  70. package/dist/proxy/title.js +103 -0
  71. package/dist/proxy/types.d.ts +32 -0
  72. package/dist/proxy/types.js +1 -0
  73. package/dist/proxy.d.ts +2 -20
  74. package/dist/proxy.js +2 -1852
  75. package/package.json +1 -2
package/dist/index.js CHANGED
@@ -1,306 +1,2 @@
1
- import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
- import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
3
- import { getCursorModels } from "./models";
4
- import { startProxy, stopProxy, } from "./proxy";
5
- const CURSOR_PROVIDER_ID = "cursor";
6
- let lastModelDiscoveryError = null;
7
- /**
8
- * OpenCode plugin that provides Cursor authentication and model access.
9
- * Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
10
- */
11
- export const CursorAuthPlugin = async (input) => {
12
- configurePluginLogger(input);
13
- return {
14
- auth: {
15
- provider: CURSOR_PROVIDER_ID,
16
- async loader(getAuth, provider) {
17
- try {
18
- const auth = await getAuth();
19
- if (!auth || auth.type !== "oauth")
20
- return {};
21
- // Ensure we have a valid access token, refreshing if expired
22
- let accessToken = auth.access;
23
- if (!accessToken || auth.expires < Date.now()) {
24
- const refreshed = await refreshCursorToken(auth.refresh);
25
- await input.client.auth.set({
26
- path: { id: CURSOR_PROVIDER_ID },
27
- body: {
28
- type: "oauth",
29
- refresh: refreshed.refresh,
30
- access: refreshed.access,
31
- expires: refreshed.expires,
32
- },
33
- });
34
- accessToken = refreshed.access;
35
- }
36
- let models;
37
- models = await getCursorModels(accessToken);
38
- lastModelDiscoveryError = null;
39
- const port = await startProxy(async () => {
40
- const currentAuth = await getAuth();
41
- if (currentAuth.type !== "oauth") {
42
- const authError = new Error("Cursor auth not configured");
43
- logPluginError("Cursor proxy access token lookup failed", {
44
- stage: "proxy_access_token",
45
- ...errorDetails(authError),
46
- });
47
- throw authError;
48
- }
49
- if (!currentAuth.access || currentAuth.expires < Date.now()) {
50
- const refreshed = await refreshCursorToken(currentAuth.refresh);
51
- await input.client.auth.set({
52
- path: { id: CURSOR_PROVIDER_ID },
53
- body: {
54
- type: "oauth",
55
- refresh: refreshed.refresh,
56
- access: refreshed.access,
57
- expires: refreshed.expires,
58
- },
59
- });
60
- return refreshed.access;
61
- }
62
- return currentAuth.access;
63
- }, models);
64
- if (provider) {
65
- provider.models = buildCursorProviderModels(models, port);
66
- }
67
- return {
68
- baseURL: `http://localhost:${port}/v1`,
69
- apiKey: "cursor-proxy",
70
- async fetch(requestInput, init) {
71
- if (init?.headers) {
72
- if (init.headers instanceof Headers) {
73
- init.headers.delete("authorization");
74
- }
75
- else if (Array.isArray(init.headers)) {
76
- init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
77
- }
78
- else {
79
- delete init.headers["authorization"];
80
- delete init.headers["Authorization"];
81
- }
82
- }
83
- return fetch(requestInput, init);
84
- },
85
- };
86
- }
87
- catch (error) {
88
- const message = error instanceof Error
89
- ? error.message
90
- : "Cursor model discovery failed.";
91
- logPluginError("Cursor auth loader failed", {
92
- stage: "loader",
93
- providerID: CURSOR_PROVIDER_ID,
94
- ...errorDetails(error),
95
- });
96
- stopProxy();
97
- if (provider) {
98
- provider.models = {};
99
- }
100
- if (message !== lastModelDiscoveryError) {
101
- lastModelDiscoveryError = message;
102
- await showDiscoveryFailureToast(input, message);
103
- }
104
- return buildDisabledProviderConfig(message);
105
- }
106
- },
107
- methods: [
108
- {
109
- type: "oauth",
110
- label: "Login with Cursor",
111
- async authorize() {
112
- const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
113
- return {
114
- url: loginUrl,
115
- instructions: "Complete login in your browser. This window will close automatically.",
116
- method: "auto",
117
- async callback() {
118
- const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier);
119
- return {
120
- type: "success",
121
- refresh: refreshToken,
122
- access: accessToken,
123
- expires: getTokenExpiry(accessToken),
124
- };
125
- },
126
- };
127
- },
128
- },
129
- ],
130
- },
131
- async "chat.headers"(incoming, output) {
132
- if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
133
- return;
134
- output.headers["x-opencode-session-id"] = incoming.sessionID;
135
- output.headers["x-session-id"] = incoming.sessionID;
136
- if (incoming.agent) {
137
- output.headers["x-opencode-agent"] = incoming.agent;
138
- }
139
- },
140
- };
141
- };
142
- function buildCursorProviderModels(models, port) {
143
- return Object.fromEntries(models.map((model) => [
144
- model.id,
145
- {
146
- id: model.id,
147
- providerID: CURSOR_PROVIDER_ID,
148
- api: {
149
- id: model.id,
150
- url: `http://localhost:${port}/v1`,
151
- npm: "@ai-sdk/openai-compatible",
152
- },
153
- name: model.name,
154
- capabilities: {
155
- temperature: true,
156
- reasoning: model.reasoning,
157
- attachment: false,
158
- toolcall: true,
159
- input: {
160
- text: true,
161
- audio: false,
162
- image: false,
163
- video: false,
164
- pdf: false,
165
- },
166
- output: {
167
- text: true,
168
- audio: false,
169
- image: false,
170
- video: false,
171
- pdf: false,
172
- },
173
- interleaved: false,
174
- },
175
- cost: estimateModelCost(model.id),
176
- limit: {
177
- context: model.contextWindow,
178
- output: model.maxTokens,
179
- },
180
- status: "active",
181
- options: {},
182
- headers: {},
183
- release_date: "",
184
- variants: {},
185
- },
186
- ]));
187
- }
188
- async function showDiscoveryFailureToast(input, message) {
189
- try {
190
- await input.client.tui.showToast({
191
- body: {
192
- title: "Cursor plugin disabled",
193
- message,
194
- variant: "error",
195
- duration: 8_000,
196
- },
197
- });
198
- }
199
- catch (error) {
200
- logPluginWarn("Failed to display Cursor plugin toast", {
201
- title: "Cursor plugin disabled",
202
- message,
203
- ...errorDetails(error),
204
- });
205
- }
206
- }
207
- function buildDisabledProviderConfig(message) {
208
- return {
209
- baseURL: "http://127.0.0.1/cursor-disabled/v1",
210
- apiKey: "cursor-disabled",
211
- async fetch() {
212
- return new Response(JSON.stringify({
213
- error: {
214
- message,
215
- type: "server_error",
216
- code: "cursor_model_discovery_failed",
217
- },
218
- }), {
219
- status: 503,
220
- headers: { "Content-Type": "application/json" },
221
- });
222
- },
223
- };
224
- }
225
- // $/M token rates from cursor.com/docs/models-and-pricing
226
- const MODEL_COST_TABLE = {
227
- // Anthropic
228
- "claude-4-sonnet": { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
229
- "claude-4-sonnet-1m": { input: 6, output: 22.5, cache: { read: 0.6, write: 7.5 } },
230
- "claude-4.5-haiku": { input: 1, output: 5, cache: { read: 0.1, write: 1.25 } },
231
- "claude-4.5-opus": { input: 5, output: 25, cache: { read: 0.5, write: 6.25 } },
232
- "claude-4.5-sonnet": { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
233
- "claude-4.6-opus": { input: 5, output: 25, cache: { read: 0.5, write: 6.25 } },
234
- "claude-4.6-opus-fast": { input: 30, output: 150, cache: { read: 3, write: 37.5 } },
235
- "claude-4.6-sonnet": { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
236
- // Cursor
237
- "composer-1": { input: 1.25, output: 10, cache: { read: 0.125, write: 0 } },
238
- "composer-1.5": { input: 3.5, output: 17.5, cache: { read: 0.35, write: 0 } },
239
- "composer-2": { input: 0.5, output: 2.5, cache: { read: 0.2, write: 0 } },
240
- "composer-2-fast": { input: 1.5, output: 7.5, cache: { read: 0.2, write: 0 } },
241
- // Google
242
- "gemini-2.5-flash": { input: 0.3, output: 2.5, cache: { read: 0.03, write: 0 } },
243
- "gemini-3-flash": { input: 0.5, output: 3, cache: { read: 0.05, write: 0 } },
244
- "gemini-3-pro": { input: 2, output: 12, cache: { read: 0.2, write: 0 } },
245
- "gemini-3-pro-image": { input: 2, output: 12, cache: { read: 0.2, write: 0 } },
246
- "gemini-3.1-pro": { input: 2, output: 12, cache: { read: 0.2, write: 0 } },
247
- // OpenAI
248
- "gpt-5": { input: 1.25, output: 10, cache: { read: 0.125, write: 0 } },
249
- "gpt-5-fast": { input: 2.5, output: 20, cache: { read: 0.25, write: 0 } },
250
- "gpt-5-mini": { input: 0.25, output: 2, cache: { read: 0.025, write: 0 } },
251
- "gpt-5-codex": { input: 1.25, output: 10, cache: { read: 0.125, write: 0 } },
252
- "gpt-5.1-codex": { input: 1.25, output: 10, cache: { read: 0.125, write: 0 } },
253
- "gpt-5.1-codex-max": { input: 1.25, output: 10, cache: { read: 0.125, write: 0 } },
254
- "gpt-5.1-codex-mini": { input: 0.25, output: 2, cache: { read: 0.025, write: 0 } },
255
- "gpt-5.2": { input: 1.75, output: 14, cache: { read: 0.175, write: 0 } },
256
- "gpt-5.2-codex": { input: 1.75, output: 14, cache: { read: 0.175, write: 0 } },
257
- "gpt-5.3-codex": { input: 1.75, output: 14, cache: { read: 0.175, write: 0 } },
258
- "gpt-5.4": { input: 2.5, output: 15, cache: { read: 0.25, write: 0 } },
259
- "gpt-5.4-mini": { input: 0.75, output: 4.5, cache: { read: 0.075, write: 0 } },
260
- "gpt-5.4-nano": { input: 0.2, output: 1.25, cache: { read: 0.02, write: 0 } },
261
- // xAI
262
- "grok-4.20": { input: 2, output: 6, cache: { read: 0.2, write: 0 } },
263
- // Moonshot
264
- "kimi-k2.5": { input: 0.6, output: 3, cache: { read: 0.1, write: 0 } },
265
- };
266
- // Most-specific first
267
- const MODEL_COST_PATTERNS = [
268
- { match: (id) => /claude.*opus.*fast/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-opus-fast"] },
269
- { match: (id) => /claude.*opus/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-opus"] },
270
- { match: (id) => /claude.*haiku/i.test(id), cost: MODEL_COST_TABLE["claude-4.5-haiku"] },
271
- { match: (id) => /claude.*sonnet/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-sonnet"] },
272
- { match: (id) => /claude/i.test(id), cost: MODEL_COST_TABLE["claude-4.6-sonnet"] },
273
- { match: (id) => /composer-?2/i.test(id), cost: MODEL_COST_TABLE["composer-2"] },
274
- { match: (id) => /composer-?1\.5/i.test(id), cost: MODEL_COST_TABLE["composer-1.5"] },
275
- { match: (id) => /composer/i.test(id), cost: MODEL_COST_TABLE["composer-1"] },
276
- { match: (id) => /gpt-5\.4.*nano/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4-nano"] },
277
- { match: (id) => /gpt-5\.4.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4-mini"] },
278
- { match: (id) => /gpt-5\.4/i.test(id), cost: MODEL_COST_TABLE["gpt-5.4"] },
279
- { match: (id) => /gpt-5\.3/i.test(id), cost: MODEL_COST_TABLE["gpt-5.3-codex"] },
280
- { match: (id) => /gpt-5\.2/i.test(id), cost: MODEL_COST_TABLE["gpt-5.2"] },
281
- { match: (id) => /gpt-5\.1.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5.1-codex-mini"] },
282
- { match: (id) => /gpt-5\.1/i.test(id), cost: MODEL_COST_TABLE["gpt-5.1-codex"] },
283
- { match: (id) => /gpt-5.*mini/i.test(id), cost: MODEL_COST_TABLE["gpt-5-mini"] },
284
- { match: (id) => /gpt-5.*fast/i.test(id), cost: MODEL_COST_TABLE["gpt-5-fast"] },
285
- { match: (id) => /gpt-5/i.test(id), cost: MODEL_COST_TABLE["gpt-5"] },
286
- { match: (id) => /gemini.*3\.1/i.test(id), cost: MODEL_COST_TABLE["gemini-3.1-pro"] },
287
- { match: (id) => /gemini.*3.*flash/i.test(id), cost: MODEL_COST_TABLE["gemini-3-flash"] },
288
- { match: (id) => /gemini.*3/i.test(id), cost: MODEL_COST_TABLE["gemini-3-pro"] },
289
- { match: (id) => /gemini.*flash/i.test(id), cost: MODEL_COST_TABLE["gemini-2.5-flash"] },
290
- { match: (id) => /gemini/i.test(id), cost: MODEL_COST_TABLE["gemini-3.1-pro"] },
291
- { match: (id) => /grok/i.test(id), cost: MODEL_COST_TABLE["grok-4.20"] },
292
- { match: (id) => /kimi/i.test(id), cost: MODEL_COST_TABLE["kimi-k2.5"] },
293
- ];
294
- const DEFAULT_COST = { input: 3, output: 15, cache: { read: 0.3, write: 0 } };
295
- function estimateModelCost(modelId) {
296
- const normalized = modelId.toLowerCase();
297
- const exact = MODEL_COST_TABLE[normalized];
298
- if (exact)
299
- return exact;
300
- const stripped = normalized.replace(/-(high|medium|low|preview|thinking|spark-preview)$/g, "");
301
- const strippedMatch = MODEL_COST_TABLE[stripped];
302
- if (strippedMatch)
303
- return strippedMatch;
304
- return MODEL_COST_PATTERNS.find((p) => p.match(normalized))?.cost ?? DEFAULT_COST;
305
- }
306
- export default CursorAuthPlugin;
1
+ export { CursorAuthPlugin } from "./plugin/cursor-auth-plugin";
2
+ export { CursorAuthPlugin as default } from "./plugin/cursor-auth-plugin";
package/dist/logger.d.ts CHANGED
@@ -2,5 +2,6 @@ import type { PluginInput } from "@opencode-ai/plugin";
2
2
  export declare function configurePluginLogger(input: PluginInput): void;
3
3
  export declare function errorDetails(error: unknown): Record<string, unknown>;
4
4
  export declare function logPluginWarn(message: string, extra?: Record<string, unknown>): void;
5
+ export declare function logPluginInfo(message: string, extra?: Record<string, unknown>): void;
5
6
  export declare function logPluginError(message: string, extra?: Record<string, unknown>): void;
6
7
  export declare function flushPluginLogs(): Promise<void>;
package/dist/logger.js CHANGED
@@ -27,6 +27,9 @@ export function errorDetails(error) {
27
27
  export function logPluginWarn(message, extra = {}) {
28
28
  logPlugin("warn", message, extra);
29
29
  }
30
+ export function logPluginInfo(message, extra = {}) {
31
+ logPlugin("info", message, extra);
32
+ }
30
33
  export function logPluginError(message, extra = {}) {
31
34
  logPlugin("error", message, extra);
32
35
  }
@@ -103,7 +106,9 @@ function serializeValue(value, depth, seen = new WeakSet()) {
103
106
  if (Array.isArray(value)) {
104
107
  if (depth >= 3)
105
108
  return `[array(${value.length})]`;
106
- return value.slice(0, MAX_ARRAY_LENGTH).map((entry) => serializeValue(entry, depth + 1, seen));
109
+ return value
110
+ .slice(0, MAX_ARRAY_LENGTH)
111
+ .map((entry) => serializeValue(entry, depth + 1, seen));
107
112
  }
108
113
  if (typeof value === "object") {
109
114
  if (seen.has(value))
@@ -113,7 +118,10 @@ function serializeValue(value, depth, seen = new WeakSet()) {
113
118
  return `[object ${value.constructor?.name || "Object"}]`;
114
119
  }
115
120
  const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
116
- return Object.fromEntries(entries.map(([key, entry]) => [key, serializeValue(entry, depth + 1, seen)]));
121
+ return Object.fromEntries(entries.map(([key, entry]) => [
122
+ key,
123
+ serializeValue(entry, depth + 1, seen),
124
+ ]));
117
125
  }
118
126
  return String(value);
119
127
  }
package/dist/models.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
2
  import { z } from "zod";
3
+ import { callCursorUnaryRpc, decodeConnectUnaryBody } from "./cursor";
3
4
  import { errorDetails, logPluginError, logPluginWarn } from "./logger";
4
- import { callCursorUnaryRpc } from "./proxy";
5
5
  import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
6
6
  const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
7
7
  const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
@@ -138,28 +138,6 @@ function decodeGetUsableModelsResponse(payload) {
138
138
  }
139
139
  }
140
140
  }
141
- function decodeConnectUnaryBody(payload) {
142
- if (payload.length < 5)
143
- return null;
144
- let offset = 0;
145
- while (offset + 5 <= payload.length) {
146
- const flags = payload[offset];
147
- const view = new DataView(payload.buffer, payload.byteOffset + offset, payload.byteLength - offset);
148
- const messageLength = view.getUint32(1, false);
149
- const frameEnd = offset + 5 + messageLength;
150
- if (frameEnd > payload.length)
151
- return null;
152
- // Compression flag
153
- if ((flags & 0b0000_0001) !== 0)
154
- return null;
155
- // End-of-stream flag — skip trailer frames
156
- if ((flags & 0b0000_0010) === 0) {
157
- return payload.subarray(offset + 5, frameEnd);
158
- }
159
- offset = frameEnd;
160
- }
161
- return null;
162
- }
163
141
  function normalizeCursorModels(models) {
164
142
  if (models.length === 0)
165
143
  return [];
@@ -0,0 +1,3 @@
1
+ export * from "./messages";
2
+ export * from "./tools";
3
+ export * from "./types";
@@ -0,0 +1,3 @@
1
+ export * from "./messages";
2
+ export * from "./tools";
3
+ export * from "./types";
@@ -0,0 +1,39 @@
1
+ import type { ChatCompletionRequest, OpenAIMessage, OpenAIToolCall } from "./types";
2
+ export interface ToolResultInfo {
3
+ toolCallId: string;
4
+ content: string;
5
+ }
6
+ interface ParsedMessages {
7
+ systemPrompt: string;
8
+ userText: string;
9
+ turns: Array<{
10
+ userText: string;
11
+ assistantText: string;
12
+ }>;
13
+ toolResults: ToolResultInfo[];
14
+ pendingAssistantSummary: string;
15
+ completedTurnsFingerprint: string;
16
+ }
17
+ /** Normalize OpenAI message content to a plain string. */
18
+ export declare function textContent(content: OpenAIMessage["content"]): string;
19
+ export declare function parseMessages(messages: OpenAIMessage[]): ParsedMessages;
20
+ export declare function formatToolCallSummary(call: OpenAIToolCall): string;
21
+ export declare function formatToolResultSummary(result: ToolResultInfo): string;
22
+ export declare function buildCompletedTurnsFingerprint(systemPrompt: string, turns: Array<{
23
+ userText: string;
24
+ assistantText: string;
25
+ }>): string;
26
+ export declare function buildToolResumePrompt(userText: string, pendingAssistantSummary: string, toolResults: ToolResultInfo[]): string;
27
+ export declare function buildInitialHandoffPrompt(userText: string, turns: Array<{
28
+ userText: string;
29
+ assistantText: string;
30
+ }>, pendingAssistantSummary: string, toolResults: ToolResultInfo[]): string;
31
+ export declare function buildTitleSourceText(userText: string, turns: Array<{
32
+ userText: string;
33
+ assistantText: string;
34
+ }>, pendingAssistantSummary: string, toolResults: ToolResultInfo[]): string;
35
+ export declare function detectTitleRequest(body: ChatCompletionRequest): {
36
+ matched: boolean;
37
+ reason: string;
38
+ };
39
+ export {};
@@ -0,0 +1,228 @@
1
+ import { createHash } from "node:crypto";
2
+ import { OPENCODE_TITLE_REQUEST_MARKER } from "../constants";
3
+ /** Normalize OpenAI message content to a plain string. */
4
+ export function textContent(content) {
5
+ if (content == null)
6
+ return "";
7
+ if (typeof content === "string")
8
+ return content;
9
+ return content
10
+ .filter((p) => p.type === "text" && p.text)
11
+ .map((p) => p.text)
12
+ .join("\n");
13
+ }
14
+ export function parseMessages(messages) {
15
+ let systemPrompt = "You are a helpful assistant.";
16
+ // Collect system messages
17
+ const systemParts = messages
18
+ .filter((m) => m.role === "system")
19
+ .map((m) => textContent(m.content));
20
+ if (systemParts.length > 0) {
21
+ systemPrompt = systemParts.join("\n");
22
+ }
23
+ const nonSystem = messages.filter((m) => m.role !== "system");
24
+ const parsedTurns = [];
25
+ let currentTurn;
26
+ for (const msg of nonSystem) {
27
+ if (msg.role === "user") {
28
+ if (currentTurn)
29
+ parsedTurns.push(currentTurn);
30
+ currentTurn = {
31
+ userText: textContent(msg.content),
32
+ segments: [],
33
+ };
34
+ continue;
35
+ }
36
+ if (!currentTurn) {
37
+ currentTurn = { userText: "", segments: [] };
38
+ }
39
+ if (msg.role === "assistant") {
40
+ const text = textContent(msg.content);
41
+ if (text) {
42
+ currentTurn.segments.push({ kind: "assistantText", text });
43
+ }
44
+ if (msg.tool_calls?.length) {
45
+ currentTurn.segments.push({
46
+ kind: "assistantToolCalls",
47
+ toolCalls: msg.tool_calls,
48
+ });
49
+ }
50
+ continue;
51
+ }
52
+ if (msg.role === "tool") {
53
+ currentTurn.segments.push({
54
+ kind: "toolResult",
55
+ result: {
56
+ toolCallId: msg.tool_call_id ?? "",
57
+ content: textContent(msg.content),
58
+ },
59
+ });
60
+ }
61
+ }
62
+ if (currentTurn)
63
+ parsedTurns.push(currentTurn);
64
+ let userText = "";
65
+ let toolResults = [];
66
+ let pendingAssistantSummary = "";
67
+ let completedTurnStates = parsedTurns;
68
+ const lastTurn = parsedTurns.at(-1);
69
+ if (lastTurn) {
70
+ const trailingSegments = splitTrailingToolResults(lastTurn.segments);
71
+ const hasAssistantSummary = trailingSegments.base.length > 0;
72
+ if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
73
+ completedTurnStates = parsedTurns.slice(0, -1);
74
+ userText = lastTurn.userText;
75
+ toolResults = trailingSegments.trailing.map((segment) => segment.result);
76
+ pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
77
+ }
78
+ else if (lastTurn.userText && lastTurn.segments.length === 0) {
79
+ completedTurnStates = parsedTurns.slice(0, -1);
80
+ userText = lastTurn.userText;
81
+ }
82
+ else if (lastTurn.userText && hasAssistantSummary) {
83
+ completedTurnStates = parsedTurns.slice(0, -1);
84
+ userText = lastTurn.userText;
85
+ pendingAssistantSummary = summarizeTurnSegments(lastTurn.segments);
86
+ }
87
+ }
88
+ const turns = completedTurnStates
89
+ .map((turn) => ({
90
+ userText: turn.userText,
91
+ assistantText: summarizeTurnSegments(turn.segments),
92
+ }))
93
+ .filter((turn) => turn.userText || turn.assistantText);
94
+ return {
95
+ systemPrompt,
96
+ userText,
97
+ turns,
98
+ toolResults,
99
+ pendingAssistantSummary,
100
+ completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
101
+ };
102
+ }
103
+ function splitTrailingToolResults(segments) {
104
+ let index = segments.length;
105
+ while (index > 0 && segments[index - 1]?.kind === "toolResult") {
106
+ index -= 1;
107
+ }
108
+ return {
109
+ base: segments.slice(0, index),
110
+ trailing: segments
111
+ .slice(index)
112
+ .filter((segment) => segment.kind === "toolResult"),
113
+ };
114
+ }
115
+ function summarizeTurnSegments(segments) {
116
+ const parts = [];
117
+ for (const segment of segments) {
118
+ if (segment.kind === "assistantText") {
119
+ const trimmed = segment.text.trim();
120
+ if (trimmed)
121
+ parts.push(trimmed);
122
+ continue;
123
+ }
124
+ if (segment.kind === "assistantToolCalls") {
125
+ const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
126
+ if (summary)
127
+ parts.push(summary);
128
+ continue;
129
+ }
130
+ parts.push(formatToolResultSummary(segment.result));
131
+ }
132
+ return parts.join("\n\n").trim();
133
+ }
134
+ export function formatToolCallSummary(call) {
135
+ const args = call.function.arguments?.trim();
136
+ return args
137
+ ? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
138
+ : `[assistant requested tool ${call.function.name} id=${call.id}]`;
139
+ }
140
+ export function formatToolResultSummary(result) {
141
+ const label = result.toolCallId
142
+ ? `[tool result id=${result.toolCallId}]`
143
+ : "[tool result]";
144
+ const content = result.content.trim();
145
+ return content ? `${label}\n${content}` : label;
146
+ }
147
+ export function buildCompletedTurnsFingerprint(systemPrompt, turns) {
148
+ return createHash("sha256")
149
+ .update(JSON.stringify({ systemPrompt, turns }))
150
+ .digest("hex");
151
+ }
152
+ export function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
153
+ const parts = [userText.trim()];
154
+ if (pendingAssistantSummary.trim()) {
155
+ parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
156
+ }
157
+ if (toolResults.length > 0) {
158
+ parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
159
+ }
160
+ return parts.filter(Boolean).join("\n\n");
161
+ }
162
+ export function buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults) {
163
+ const transcript = turns.map((turn, index) => {
164
+ const sections = [`Turn ${index + 1}`];
165
+ if (turn.userText.trim())
166
+ sections.push(`User: ${turn.userText.trim()}`);
167
+ if (turn.assistantText.trim())
168
+ sections.push(`Assistant: ${turn.assistantText.trim()}`);
169
+ return sections.join("\n");
170
+ });
171
+ const inProgress = buildToolResumePrompt("", pendingAssistantSummary, toolResults).trim();
172
+ const history = [
173
+ ...transcript,
174
+ ...(inProgress ? [`In-progress turn\n${inProgress}`] : []),
175
+ ]
176
+ .join("\n\n")
177
+ .trim();
178
+ if (!history)
179
+ return userText;
180
+ return [
181
+ "[OpenCode session handoff]",
182
+ "You are continuing an existing session that previously ran on another provider/model.",
183
+ "Treat the transcript below as prior conversation history before answering the latest user message.",
184
+ "",
185
+ "<previous-session-transcript>",
186
+ history,
187
+ "</previous-session-transcript>",
188
+ "",
189
+ "Latest user message:",
190
+ userText.trim(),
191
+ ]
192
+ .filter(Boolean)
193
+ .join("\n");
194
+ }
195
+ export function buildTitleSourceText(userText, turns, pendingAssistantSummary, toolResults) {
196
+ const history = turns
197
+ .map((turn) => [
198
+ isTitleRequestMarker(turn.userText) ? "" : turn.userText.trim(),
199
+ turn.assistantText.trim(),
200
+ ]
201
+ .filter(Boolean)
202
+ .join("\n"))
203
+ .filter(Boolean);
204
+ if (pendingAssistantSummary.trim()) {
205
+ history.push(pendingAssistantSummary.trim());
206
+ }
207
+ if (toolResults.length > 0) {
208
+ history.push(toolResults.map(formatToolResultSummary).join("\n\n"));
209
+ }
210
+ if (userText.trim() && !isTitleRequestMarker(userText)) {
211
+ history.push(userText.trim());
212
+ }
213
+ return history.join("\n\n").trim();
214
+ }
215
+ export function detectTitleRequest(body) {
216
+ if ((body.tools?.length ?? 0) > 0) {
217
+ return { matched: false, reason: "tools-present" };
218
+ }
219
+ const firstNonSystem = body.messages.find((message) => message.role !== "system");
220
+ if (firstNonSystem?.role === "user" &&
221
+ isTitleRequestMarker(textContent(firstNonSystem.content))) {
222
+ return { matched: true, reason: "opencode-title-marker" };
223
+ }
224
+ return { matched: false, reason: "no-title-marker" };
225
+ }
226
+ function isTitleRequestMarker(text) {
227
+ return text.trim() === OPENCODE_TITLE_REQUEST_MARKER;
228
+ }
@@ -0,0 +1,7 @@
1
+ import type { McpToolDefinition } from "../proto/agent_pb";
2
+ import type { OpenAIToolDef } from "./types";
3
+ export declare function selectToolsForChoice(tools: OpenAIToolDef[], toolChoice: unknown): OpenAIToolDef[];
4
+ /** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
5
+ export declare function buildMcpToolDefinitions(tools: OpenAIToolDef[]): McpToolDefinition[];
6
+ /** Decode a map of MCP arg values. */
7
+ export declare function decodeMcpArgsMap(args: Record<string, Uint8Array>): Record<string, unknown>;