@evanovation/open-cursor 2.4.15

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 (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
@@ -0,0 +1,359 @@
1
+ import type { StreamJsonToolCallEvent } from "../streaming/types.js";
2
+ import { createLogger } from "../utils/logger.js";
3
+
4
+ const log = createLogger("proxy:tool-loop");
5
+
6
+ export interface OpenAiToolCall {
7
+ id: string;
8
+ type: "function";
9
+ function: {
10
+ name: string;
11
+ arguments: string;
12
+ };
13
+ }
14
+
15
+ export interface ToolCallExtractionResult {
16
+ action: "intercept" | "passthrough" | "skip";
17
+ toolCall?: OpenAiToolCall;
18
+ passthroughName?: string;
19
+ skipReason?: string;
20
+ }
21
+
22
+ export interface ToolLoopMeta {
23
+ id: string;
24
+ created: number;
25
+ model: string;
26
+ }
27
+
28
+ const TOOL_NAME_ALIASES = new Map<string, string>([
29
+ // bash aliases
30
+ ["runcommand", "bash"],
31
+ ["executecommand", "bash"],
32
+ ["runterminalcommand", "bash"],
33
+ ["terminalcommand", "bash"],
34
+ ["shellcommand", "bash"],
35
+ ["shell", "bash"],
36
+ ["terminal", "bash"],
37
+ ["bashcommand", "bash"],
38
+ ["runbash", "bash"],
39
+ ["executebash", "bash"],
40
+ // edit/write aliases
41
+ ["ocedit", "edit"],
42
+ ["strreplace", "edit"],
43
+ ["ocwrite", "write"],
44
+ ["writefile", "write"],
45
+ // read aliases
46
+ ["ocread", "read"],
47
+ // grep aliases
48
+ ["ocgrep", "grep"],
49
+ // glob aliases
50
+ ["findfiles", "glob"],
51
+ ["searchfiles", "glob"],
52
+ ["globfiles", "glob"],
53
+ ["fileglob", "glob"],
54
+ ["matchfiles", "glob"],
55
+ // mkdir aliases
56
+ ["createdirectory", "mkdir"],
57
+ ["makedirectory", "mkdir"],
58
+ ["mkdirp", "mkdir"],
59
+ ["createdir", "mkdir"],
60
+ ["makefolder", "mkdir"],
61
+ // rm aliases
62
+ ["delete", "rm"],
63
+ ["deletefile", "rm"],
64
+ ["deletepath", "rm"],
65
+ ["deletedirectory", "rm"],
66
+ ["remove", "rm"],
67
+ ["removefile", "rm"],
68
+ ["removepath", "rm"],
69
+ ["unlink", "rm"],
70
+ ["rmdir", "rm"],
71
+ // stat aliases
72
+ ["getfileinfo", "stat"],
73
+ ["fileinfo", "stat"],
74
+ ["filestat", "stat"],
75
+ ["pathinfo", "stat"],
76
+ // ls aliases
77
+ ["listdirectory", "ls"],
78
+ ["listfiles", "ls"],
79
+ ["listdir", "ls"],
80
+ ["readdir", "ls"],
81
+ // todo write aliases
82
+ ["updatetodos", "todowrite"],
83
+ ["updatetodostoolcall", "todowrite"],
84
+ ["todowrite", "todowrite"],
85
+ ["todowritetoolcall", "todowrite"],
86
+ ["writetodos", "todowrite"],
87
+ ["todowritefn", "todowrite"],
88
+ // todo read aliases
89
+ ["readtodos", "todoread"],
90
+ ["readtodostoolcall", "todoread"],
91
+ ["todoread", "todoread"],
92
+ ["todoreadtoolcall", "todoread"],
93
+ // sub-agent and delegation aliases
94
+ ["callomoagent", "call_omo_agent"],
95
+ ["callagent", "call_omo_agent"],
96
+ ["invokeagent", "call_omo_agent"],
97
+ ["delegatetask", "task"],
98
+ ["delegate", "task"],
99
+ ["runtask", "task"],
100
+ ["subagent", "task"],
101
+ // skill aliases
102
+ ["useskill", "skill"],
103
+ ["invokeskill", "skill"],
104
+ ["runskill", "skill"],
105
+ ["skillmcp", "skill_mcp"],
106
+ ["mcp_skill", "skill_mcp"],
107
+ ["runmcpskill", "skill_mcp"],
108
+ ["invokeskillmcp", "skill_mcp"],
109
+ // question aliases (Cursor-trained models often emit AskQuestion / ask_user)
110
+ ["askquestion", "question"],
111
+ ["askuser", "question"],
112
+ ["askuserquestion", "question"],
113
+ ["askquestions", "question"],
114
+ ["promptuser", "question"],
115
+ ]);
116
+
117
+ export function extractAllowedToolNames(tools: Array<any>): Set<string> {
118
+ const names = new Set<string>();
119
+ for (const tool of tools) {
120
+ const fn = tool?.function ?? tool;
121
+ if (fn && typeof fn.name === "string" && fn.name.length > 0) {
122
+ names.add(fn.name);
123
+ }
124
+ }
125
+ return names;
126
+ }
127
+
128
+ export function extractOpenAiToolCall(
129
+ event: StreamJsonToolCallEvent,
130
+ allowedToolNames: Set<string>,
131
+ ): ToolCallExtractionResult {
132
+ if (allowedToolNames.size === 0) {
133
+ return { action: "skip", skipReason: "no_allowed_tools" };
134
+ }
135
+
136
+ const { name, args, skipped } = extractToolNameAndArgs(event);
137
+ if (skipped) {
138
+ return { action: "skip", skipReason: "event_skipped" };
139
+ }
140
+ if (!name) {
141
+ return { action: "skip", skipReason: "no_name" };
142
+ }
143
+
144
+ // Defensive check: if model tries to call "mcp" directly, it's a mistake.
145
+ // MCP tools must be called with their full names like mcp__server__tool.
146
+ if (name.toLowerCase() === "mcp") {
147
+ log.warn("Model attempted to call 'mcp' directly (not a valid tool name)", {
148
+ args,
149
+ hint: "MCP tools must be called by their full name (e.g. mcp__engram__mem_save), not 'mcp'",
150
+ });
151
+ return {
152
+ action: "passthrough",
153
+ passthroughName: name,
154
+ };
155
+ }
156
+
157
+ const resolvedName = resolveAllowedToolName(name, allowedToolNames);
158
+ if (resolvedName) {
159
+ // Known tool → intercept and forward to OpenCode
160
+ if (args === undefined && event.subtype === "started") {
161
+ log.debug("Tool call args extraction returned undefined", {
162
+ toolName: name,
163
+ subtype: event.subtype ?? "none",
164
+ payloadKeys: Object.entries(event.tool_call || {}).map(([k, v]) =>
165
+ `${k}:[${isRecord(v) ? Object.keys(v).join(",") : typeof v}]`),
166
+ hasCallId: Boolean(event.call_id),
167
+ });
168
+ }
169
+
170
+ const callId = event.call_id || (event as any).tool_call_id || "call_unknown";
171
+ return {
172
+ action: "intercept",
173
+ toolCall: {
174
+ id: callId,
175
+ type: "function",
176
+ function: {
177
+ name: resolvedName,
178
+ arguments: toOpenAiArguments(args),
179
+ },
180
+ },
181
+ };
182
+ }
183
+
184
+ // Unknown tool → pass through to cursor-agent
185
+ log.debug("Tool call not in allowlist; passing through to cursor-agent", {
186
+ name,
187
+ normalized: normalizeAliasKey(name),
188
+ allowedToolCount: allowedToolNames.size,
189
+ });
190
+ return {
191
+ action: "passthrough",
192
+ passthroughName: name,
193
+ };
194
+ }
195
+
196
+ export function createToolCallCompletionResponse(meta: ToolLoopMeta, toolCall: OpenAiToolCall) {
197
+ return {
198
+ id: meta.id,
199
+ object: "chat.completion",
200
+ created: meta.created,
201
+ model: meta.model,
202
+ choices: [
203
+ {
204
+ index: 0,
205
+ message: {
206
+ role: "assistant",
207
+ content: null,
208
+ tool_calls: [toolCall],
209
+ },
210
+ finish_reason: "tool_calls",
211
+ },
212
+ ],
213
+ };
214
+ }
215
+
216
+ export function createToolCallStreamChunks(meta: ToolLoopMeta, toolCall: OpenAiToolCall): Array<any> {
217
+ const toolDelta = {
218
+ id: meta.id,
219
+ object: "chat.completion.chunk",
220
+ created: meta.created,
221
+ model: meta.model,
222
+ choices: [
223
+ {
224
+ index: 0,
225
+ delta: {
226
+ role: "assistant",
227
+ tool_calls: [
228
+ {
229
+ index: 0,
230
+ ...toolCall,
231
+ },
232
+ ],
233
+ },
234
+ finish_reason: null,
235
+ },
236
+ ],
237
+ };
238
+
239
+ const finishChunk = {
240
+ id: meta.id,
241
+ object: "chat.completion.chunk",
242
+ created: meta.created,
243
+ model: meta.model,
244
+ choices: [
245
+ {
246
+ index: 0,
247
+ delta: {},
248
+ finish_reason: "tool_calls",
249
+ },
250
+ ],
251
+ };
252
+
253
+ return [toolDelta, finishChunk];
254
+ }
255
+
256
+ function extractToolNameAndArgs(event: StreamJsonToolCallEvent): {
257
+ name: string | null;
258
+ args: unknown;
259
+ skipped: boolean;
260
+ } {
261
+ let name = typeof (event as any).name === "string" ? (event as any).name : null;
262
+ let args: unknown = undefined;
263
+
264
+ const entries = Object.entries(event.tool_call || {});
265
+ if (entries.length > 0) {
266
+ const [rawName, payload] = entries[0];
267
+ if (!name) {
268
+ name = normalizeToolName(rawName);
269
+ }
270
+ const payloadRecord = isRecord(payload) ? payload : null;
271
+ args = payloadRecord?.args;
272
+
273
+ // Some tool-call events include a flat payload without an `args` wrapper.
274
+ if (args === undefined && payloadRecord) {
275
+ const { result: _result, ...rest } = payloadRecord;
276
+ const restKeys = Object.keys(rest);
277
+ if (restKeys.length === 0) {
278
+ if (name) {
279
+ name = normalizeToolName(name);
280
+ }
281
+ return { name, args: undefined, skipped: true };
282
+ }
283
+ args = rest;
284
+ }
285
+ }
286
+
287
+ if (name) {
288
+ name = normalizeToolName(name);
289
+ }
290
+
291
+ return { name, args, skipped: false };
292
+ }
293
+
294
+ function normalizeToolName(raw: string): string {
295
+ if (raw.endsWith("ToolCall")) {
296
+ const base = raw.slice(0, -"ToolCall".length);
297
+ return base.charAt(0).toLowerCase() + base.slice(1);
298
+ }
299
+ return raw;
300
+ }
301
+
302
+ function resolveAllowedToolName(name: string, allowedToolNames: Set<string>): string | null {
303
+ if (allowedToolNames.has(name)) {
304
+ return name;
305
+ }
306
+
307
+ const normalizedName = normalizeAliasKey(name);
308
+ for (const allowedName of allowedToolNames) {
309
+ if (normalizeAliasKey(allowedName) === normalizedName) {
310
+ return allowedName;
311
+ }
312
+ }
313
+
314
+ const aliasedCanonical = TOOL_NAME_ALIASES.get(normalizedName);
315
+ if (!aliasedCanonical) {
316
+ return null;
317
+ }
318
+
319
+ const canonicalNormalized = normalizeAliasKey(aliasedCanonical);
320
+ for (const allowedName of allowedToolNames) {
321
+ if (normalizeAliasKey(allowedName) === canonicalNormalized) {
322
+ return allowedName;
323
+ }
324
+ }
325
+
326
+ return null;
327
+ }
328
+
329
+ function normalizeAliasKey(value: string): string {
330
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
331
+ }
332
+
333
+ function toOpenAiArguments(args: unknown): string {
334
+ if (args === undefined) {
335
+ return "{}";
336
+ }
337
+
338
+ if (typeof args === "string") {
339
+ try {
340
+ const parsed = JSON.parse(args);
341
+ if (parsed && typeof parsed === "object") {
342
+ return JSON.stringify(parsed);
343
+ }
344
+ return JSON.stringify({ value: parsed });
345
+ } catch {
346
+ return JSON.stringify({ value: args });
347
+ }
348
+ }
349
+
350
+ if (typeof args === "object" && args !== null) {
351
+ return JSON.stringify(args);
352
+ }
353
+
354
+ return JSON.stringify({ value: args });
355
+ }
356
+
357
+ function isRecord(value: unknown): value is Record<string, unknown> {
358
+ return typeof value === "object" && value !== null && !Array.isArray(value);
359
+ }
@@ -0,0 +1,13 @@
1
+ export interface ProxyConfig {
2
+ port?: number;
3
+ host?: string;
4
+ healthCheckPath?: string;
5
+ requestTimeout?: number;
6
+ }
7
+
8
+ export interface ProxyServer {
9
+ start(): Promise<string>;
10
+ stop(): Promise<void>;
11
+ getBaseURL(): string;
12
+ getPort(): number | null;
13
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ToastService - OpenCode toast notification integration
3
+ *
4
+ * Provides toast notifications for MCP tool pass-through visibility.
5
+ * Gracefully degrades when client.tui.showToast is unavailable.
6
+ */
7
+
8
+ import { createLogger } from "../utils/logger.js";
9
+
10
+ const log = createLogger("services:toast");
11
+
12
+ export interface ToastOptions {
13
+ title?: string;
14
+ message: string;
15
+ variant: "info" | "success" | "warning" | "error";
16
+ }
17
+
18
+ export interface OpenCodeTuiClient {
19
+ showToast: (options: { body: { title?: string; message: string; variant: string } }) => Promise<void>;
20
+ }
21
+
22
+ export interface OpenCodeClientWithTui {
23
+ tui?: OpenCodeTuiClient;
24
+ }
25
+
26
+ export class ToastService {
27
+ private client: OpenCodeClientWithTui | null = null;
28
+
29
+ setClient(client: OpenCodeClientWithTui): void {
30
+ this.client = client;
31
+ }
32
+
33
+ async show(options: ToastOptions): Promise<void> {
34
+ if (!this.client?.tui?.showToast) {
35
+ log.debug("Toast not available; client.tui.showToast missing", { message: options.message });
36
+ return;
37
+ }
38
+
39
+ try {
40
+ await this.client.tui.showToast({
41
+ body: {
42
+ title: options.title,
43
+ message: options.message,
44
+ variant: options.variant,
45
+ },
46
+ });
47
+ } catch (error) {
48
+ log.debug("Toast failed", { error, message: options.message });
49
+ }
50
+ }
51
+
52
+ async showPassThroughSummary(tools: string[]): Promise<void> {
53
+ if (tools.length === 0) return;
54
+
55
+ const toolList = tools.length <= 3
56
+ ? tools.join(", ")
57
+ : `${tools.slice(0, 3).join(", ")} +${tools.length - 3} more`;
58
+
59
+ await this.show({
60
+ title: "MCP Tools",
61
+ message: `🎭 ${tools.length} tool${tools.length > 1 ? "s" : ""} handled by cursor-agent: ${toolList}`,
62
+ variant: "info",
63
+ });
64
+ }
65
+
66
+ async showErrorSummary(errors: string[]): Promise<void> {
67
+ if (errors.length === 0) return;
68
+
69
+ const errorList = errors.length <= 2
70
+ ? errors.join("; ")
71
+ : `${errors.slice(0, 2).join("; ")} +${errors.length - 2} more`;
72
+
73
+ await this.show({
74
+ title: "MCP Errors",
75
+ message: `⚠️ ${errors.length} MCP tool${errors.length > 1 ? "s" : ""} failed: ${errorList}`,
76
+ variant: "warning",
77
+ });
78
+ }
79
+ }
80
+
81
+ export const toastService = new ToastService();
@@ -0,0 +1,109 @@
1
+ import {
2
+ extractText,
3
+ extractThinking,
4
+ inferToolName,
5
+ isAssistantText,
6
+ isThinking,
7
+ isToolCall,
8
+ type StreamJsonEvent,
9
+ type StreamJsonToolCallEvent,
10
+ } from "./types.js";
11
+ import { MixedDeltaTracker } from "./delta-tracker.js";
12
+
13
+ export type AiSdkStreamPart =
14
+ | {
15
+ type: "text-delta";
16
+ textDelta: string;
17
+ }
18
+ | {
19
+ type: "tool-call-streaming-start";
20
+ toolCallId: string;
21
+ toolName: string;
22
+ }
23
+ | {
24
+ type: "tool-call-delta";
25
+ toolCallId: string;
26
+ toolName: string;
27
+ argsTextDelta: string;
28
+ }
29
+ | {
30
+ type: "tool-input-available";
31
+ toolCallId: string;
32
+ toolName: string;
33
+ inputText: string;
34
+ };
35
+
36
+ export class StreamToAiSdkParts {
37
+ private readonly toolArgsById = new Map<string, string>();
38
+ private readonly startedToolIds = new Set<string>();
39
+ private readonly tracker = new MixedDeltaTracker();
40
+
41
+ handleEvent(event: StreamJsonEvent): AiSdkStreamPart[] {
42
+ if (isAssistantText(event)) {
43
+ const text = extractText(event);
44
+ if (!text) return [];
45
+ const delta = this.tracker.nextText(text);
46
+ return delta ? [{ type: "text-delta", textDelta: delta }] : [];
47
+ }
48
+
49
+ if (isThinking(event)) {
50
+ const text = extractThinking(event);
51
+ if (!text) return [];
52
+ const delta = this.tracker.nextThinking(text);
53
+ return delta ? [{ type: "text-delta", textDelta: delta }] : [];
54
+ }
55
+
56
+ if (isToolCall(event)) {
57
+ return this.handleToolCall(event);
58
+ }
59
+
60
+ return [];
61
+ }
62
+
63
+ private handleToolCall(event: StreamJsonToolCallEvent): AiSdkStreamPart[] {
64
+ const toolCallId = event.call_id || (event as { tool_call_id?: string }).tool_call_id || "unknown";
65
+ const toolName = inferToolName(event) || "tool";
66
+ const toolKey = Object.keys(event.tool_call ?? {})[0];
67
+ const entry = toolKey ? event.tool_call[toolKey] : undefined;
68
+ const parts: AiSdkStreamPart[] = [];
69
+
70
+ if (entry?.args) {
71
+ const argsText = JSON.stringify(entry.args);
72
+ const previous = this.toolArgsById.get(toolCallId) ?? "";
73
+ const delta = argsText.startsWith(previous)
74
+ ? argsText.slice(previous.length)
75
+ : argsText;
76
+
77
+ this.toolArgsById.set(toolCallId, argsText);
78
+
79
+ if (!this.startedToolIds.has(toolCallId)) {
80
+ this.startedToolIds.add(toolCallId);
81
+ parts.push({
82
+ type: "tool-call-streaming-start",
83
+ toolCallId,
84
+ toolName,
85
+ });
86
+ }
87
+
88
+ if (delta) {
89
+ parts.push({
90
+ type: "tool-call-delta",
91
+ toolCallId,
92
+ toolName,
93
+ argsTextDelta: delta,
94
+ });
95
+ }
96
+ }
97
+
98
+ if (entry?.result) {
99
+ parts.push({
100
+ type: "tool-input-available",
101
+ toolCallId,
102
+ toolName,
103
+ inputText: JSON.stringify(entry.result),
104
+ });
105
+ }
106
+
107
+ return parts;
108
+ }
109
+ }
@@ -0,0 +1,89 @@
1
+ export class DeltaTracker {
2
+ private lastText = "";
3
+ private lastThinking = "";
4
+
5
+ nextText(value: string): string {
6
+ const delta = this.diff(this.lastText, value);
7
+ this.lastText = value;
8
+ return delta;
9
+ }
10
+
11
+ nextThinking(value: string): string {
12
+ const delta = this.diff(this.lastThinking, value);
13
+ this.lastThinking = value;
14
+ return delta;
15
+ }
16
+
17
+ reset(): void {
18
+ this.lastText = "";
19
+ this.lastThinking = "";
20
+ }
21
+
22
+ private diff(previous: string, current: string): string {
23
+ if (!previous) {
24
+ return current;
25
+ }
26
+
27
+ // Happy path: accumulated text grows with exact prefix match
28
+ if (current.startsWith(previous)) {
29
+ return current.slice(previous.length);
30
+ }
31
+
32
+ // Accumulated text was already fully emitted (e.g. duplicate or trimmed event)
33
+ if (previous.startsWith(current)) {
34
+ return "";
35
+ }
36
+
37
+ // Prefix mismatch (formatting drift, unicode normalization, whitespace changes):
38
+ // find longest common prefix and emit only the new suffix.
39
+ // This prevents re-emitting the entire accumulated text as a "delta".
40
+ let i = 0;
41
+ const minLen = Math.min(previous.length, current.length);
42
+ while (i < minLen && previous[i] === current[i]) {
43
+ i++;
44
+ }
45
+ return current.slice(i);
46
+ }
47
+ }
48
+
49
+ export class MixedDeltaTracker {
50
+ private emittedText = "";
51
+ private emittedThinking = "";
52
+
53
+ nextText(value: string): string {
54
+ const delta = this.diff(this.emittedText, value);
55
+ if (delta) {
56
+ this.emittedText += delta;
57
+ }
58
+ return delta;
59
+ }
60
+
61
+ nextThinking(value: string): string {
62
+ const delta = this.diff(this.emittedThinking, value);
63
+ if (delta) {
64
+ this.emittedThinking += delta;
65
+ }
66
+ return delta;
67
+ }
68
+
69
+ reset(): void {
70
+ this.emittedText = "";
71
+ this.emittedThinking = "";
72
+ }
73
+
74
+ private diff(emitted: string, current: string): string {
75
+ if (!emitted) {
76
+ return current;
77
+ }
78
+
79
+ if (current.startsWith(emitted)) {
80
+ return current.slice(emitted.length);
81
+ }
82
+
83
+ if (emitted.startsWith(current)) {
84
+ return "";
85
+ }
86
+
87
+ return current;
88
+ }
89
+ }
@@ -0,0 +1,44 @@
1
+ export class LineBuffer {
2
+ private buffer = "";
3
+ private decoder = new TextDecoder();
4
+
5
+ push(chunk: string | Uint8Array): string[] {
6
+ const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk);
7
+ if (!text) {
8
+ return [];
9
+ }
10
+
11
+ this.buffer += text;
12
+ const lines = this.buffer.split("\n");
13
+ this.buffer = lines.pop() ?? "";
14
+
15
+ const completed: string[] = [];
16
+ for (const line of lines) {
17
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
18
+ if (!normalized.trim()) {
19
+ continue;
20
+ }
21
+ completed.push(normalized);
22
+ }
23
+
24
+ return completed;
25
+ }
26
+
27
+ flush(): string[] {
28
+ if (!this.buffer.trim()) {
29
+ this.buffer = "";
30
+ return [];
31
+ }
32
+
33
+ const normalized = this.buffer.endsWith("\r")
34
+ ? this.buffer.slice(0, -1)
35
+ : this.buffer;
36
+ this.buffer = "";
37
+
38
+ if (!normalized.trim()) {
39
+ return [];
40
+ }
41
+
42
+ return [normalized];
43
+ }
44
+ }