@rama_nigg/open-cursor 2.4.4 → 2.4.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.
@@ -49,6 +49,7 @@ export interface ToolSchemaValidationResult {
49
49
  export interface ToolSchemaCompatResult {
50
50
  toolCall: OpenAiToolCall;
51
51
  normalizedArgs: JsonRecord;
52
+ originalArgs: JsonRecord;
52
53
  originalArgKeys: string[];
53
54
  normalizedArgKeys: string[];
54
55
  collisionKeys: string[];
@@ -89,6 +90,10 @@ export function applyToolSchemaCompat(
89
90
  sanitization.args,
90
91
  schema,
91
92
  sanitization.unexpected,
93
+ {
94
+ originalArgs: parsedArgs,
95
+ writeSchema: toolSchemaMap.get("write"),
96
+ },
92
97
  );
93
98
 
94
99
  const normalizedToolCall: OpenAiToolCall = {
@@ -102,6 +107,7 @@ export function applyToolSchemaCompat(
102
107
  return {
103
108
  toolCall: normalizedToolCall,
104
109
  normalizedArgs: sanitization.args,
110
+ originalArgs: parsedArgs,
105
111
  originalArgKeys,
106
112
  normalizedArgKeys: Object.keys(sanitization.args),
107
113
  collisionKeys,
@@ -109,6 +115,96 @@ export function applyToolSchemaCompat(
109
115
  };
110
116
  }
111
117
 
118
+ export function isFullFileShapedEditValidationFailure(
119
+ toolName: string,
120
+ args: JsonRecord,
121
+ validation: ToolSchemaValidationResult,
122
+ originalArgs: JsonRecord,
123
+ writeSchema?: unknown,
124
+ ): boolean {
125
+ if (toolName.toLowerCase() !== "edit" || validation.ok) {
126
+ return false;
127
+ }
128
+ return buildEditFullFileHint(args, validation.missing, validation.typeErrors, {
129
+ originalArgs,
130
+ writeSchema,
131
+ }) !== null;
132
+ }
133
+
134
+ function buildWriteArguments(
135
+ filePath: string,
136
+ content: string,
137
+ writeSchema: unknown,
138
+ ): JsonRecord {
139
+ if (!isRecord(writeSchema)) {
140
+ return { path: filePath, content };
141
+ }
142
+ const required = Array.isArray(writeSchema.required)
143
+ ? writeSchema.required.filter((value): value is string => typeof value === "string")
144
+ : [];
145
+ if (required.includes("filePath")) {
146
+ return { filePath, content };
147
+ }
148
+ return { path: filePath, content };
149
+ }
150
+
151
+ /** Malformed full-file edit (path + body, no old_string) → write tool call when write is available. */
152
+ export function tryRerouteEditToWrite(
153
+ toolCall: OpenAiToolCall,
154
+ compat: ToolSchemaCompatResult,
155
+ allowedToolNames: Set<string>,
156
+ toolSchemaMap: Map<string, unknown>,
157
+ ): OpenAiToolCall | null {
158
+ if (toolCall.function.name.toLowerCase() !== "edit") {
159
+ return null;
160
+ }
161
+ if (!allowedToolNames.has("write") || !toolSchemaMap.has("write")) {
162
+ return null;
163
+ }
164
+
165
+ const writeSchema = toolSchemaMap.get("write");
166
+ if (
167
+ !isFullFileShapedEditValidationFailure(
168
+ toolCall.function.name,
169
+ compat.normalizedArgs,
170
+ compat.validation,
171
+ compat.originalArgs,
172
+ writeSchema,
173
+ )
174
+ ) {
175
+ return null;
176
+ }
177
+
178
+ const filePath = typeof compat.normalizedArgs.path === "string" && compat.normalizedArgs.path.length > 0
179
+ ? compat.normalizedArgs.path
180
+ : typeof compat.normalizedArgs.filePath === "string" && compat.normalizedArgs.filePath.length > 0
181
+ ? compat.normalizedArgs.filePath
182
+ : null;
183
+ if (!filePath) {
184
+ return null;
185
+ }
186
+
187
+ const content =
188
+ typeof compat.normalizedArgs.new_string === "string"
189
+ ? compat.normalizedArgs.new_string
190
+ : typeof compat.normalizedArgs.newString === "string"
191
+ ? compat.normalizedArgs.newString
192
+ : typeof compat.normalizedArgs.content === "string"
193
+ ? compat.normalizedArgs.content
194
+ : null;
195
+ if (content === null) {
196
+ return null;
197
+ }
198
+
199
+ return {
200
+ ...toolCall,
201
+ function: {
202
+ name: "write",
203
+ arguments: JSON.stringify(buildWriteArguments(filePath, content, writeSchema)),
204
+ },
205
+ };
206
+ }
207
+
112
208
  function parseArguments(rawArguments: string): JsonRecord {
113
209
  try {
114
210
  const parsed = JSON.parse(rawArguments);
@@ -350,11 +446,17 @@ function sanitizeArgumentsForSchema(
350
446
  return { args: sanitized, unexpected };
351
447
  }
352
448
 
449
+ type ValidateToolArgumentsContext = {
450
+ originalArgs?: JsonRecord;
451
+ writeSchema?: unknown;
452
+ };
453
+
353
454
  function validateToolArguments(
354
455
  toolName: string,
355
456
  args: JsonRecord,
356
457
  schema: unknown,
357
458
  unexpected: string[],
459
+ context: ValidateToolArgumentsContext = {},
358
460
  ): ToolSchemaValidationResult {
359
461
  if (!isRecord(schema)) {
360
462
  return {
@@ -399,16 +501,90 @@ function validateToolArguments(
399
501
  missing,
400
502
  unexpected,
401
503
  typeErrors,
402
- repairHint: ok ? undefined : buildRepairHint(toolName, missing, unexpected, typeErrors),
504
+ repairHint: ok
505
+ ? undefined
506
+ : buildRepairHint(toolName, args, missing, unexpected, typeErrors, context),
403
507
  };
404
508
  }
405
509
 
510
+ function hadOldStringPropertyInPayload(args: JsonRecord): boolean {
511
+ for (const key of Object.keys(args)) {
512
+ const token = key.toLowerCase().replace(/[^a-z0-9]/g, "");
513
+ if (token === "oldstring") {
514
+ return true;
515
+ }
516
+ }
517
+ return false;
518
+ }
519
+
520
+ function hasEditFilePath(args: JsonRecord): boolean {
521
+ const pathValue = args.path ?? args.filePath;
522
+ return typeof pathValue === "string" && pathValue.trim().length > 0;
523
+ }
524
+
525
+ function hasEditBody(args: JsonRecord): boolean {
526
+ const body = args.new_string ?? args.newString ?? args.content;
527
+ return typeof body === "string" && body.length > 0;
528
+ }
529
+
530
+ function writeToolExample(writeSchema: unknown): string {
531
+ if (!isRecord(writeSchema)) {
532
+ return "write with path and content";
533
+ }
534
+ const required = Array.isArray(writeSchema.required)
535
+ ? writeSchema.required.filter((value): value is string => typeof value === "string")
536
+ : [];
537
+ if (required.includes("filePath")) {
538
+ return "write with filePath and content";
539
+ }
540
+ return "write with path and content";
541
+ }
542
+
543
+ function buildEditFullFileHint(
544
+ args: JsonRecord,
545
+ missing: string[],
546
+ typeErrors: string[],
547
+ context: ValidateToolArgumentsContext,
548
+ ): string | null {
549
+ if (typeErrors.length > 0) {
550
+ return null;
551
+ }
552
+
553
+ const missingOldStringOnly =
554
+ (missing.includes("old_string") || missing.includes("oldString"))
555
+ && missing.every((key) => key === "old_string" || key === "oldString");
556
+ if (!missingOldStringOnly) {
557
+ return null;
558
+ }
559
+
560
+ const originalArgs = context.originalArgs ?? {};
561
+ if (hadOldStringPropertyInPayload(originalArgs)) {
562
+ return null;
563
+ }
564
+
565
+ if (!hasEditFilePath(args) || !hasEditBody(args)) {
566
+ return null;
567
+ }
568
+
569
+ const example = writeToolExample(context.writeSchema);
570
+ return `For a full file body, use ${example} instead of edit without old_string`;
571
+ }
572
+
406
573
  function buildRepairHint(
407
574
  toolName: string,
575
+ args: JsonRecord,
408
576
  missing: string[],
409
577
  unexpected: string[],
410
578
  typeErrors: string[],
579
+ context: ValidateToolArgumentsContext = {},
411
580
  ): string {
581
+ const fullFileHint = toolName.toLowerCase() === "edit"
582
+ ? buildEditFullFileHint(args, missing, typeErrors, context)
583
+ : null;
584
+ if (fullFileHint) {
585
+ return fullFileHint;
586
+ }
587
+
412
588
  const hints: string[] = [];
413
589
  if (missing.length > 0) {
414
590
  hints.push(`missing required: ${missing.join(", ")}`);
@@ -419,12 +595,14 @@ function buildRepairHint(
419
595
  if (typeErrors.length > 0) {
420
596
  hints.push(`fix type errors: ${typeErrors.join("; ")}`);
421
597
  }
598
+
422
599
  if (
423
600
  toolName.toLowerCase() === "edit"
424
- && (missing.includes("old_string") || missing.includes("new_string"))
601
+ && (missing.includes("old_string") || missing.includes("oldString") || missing.includes("new_string") || missing.includes("newString"))
425
602
  ) {
426
603
  hints.push("edit requires path, old_string, and new_string");
427
604
  }
605
+
428
606
  return hints.join(" | ");
429
607
  }
430
608
 
@@ -1,43 +1,13 @@
1
- import { appendFileSync, existsSync, mkdirSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { createLogger } from "../utils/logger.js";
5
2
 
6
3
  const log = createLogger("proxy:prompt-builder");
7
4
 
8
- // Debug log file for tool-loop investigation
9
- const DEBUG_LOG_DIR = join(homedir(), ".config", "opencode", "logs");
10
- const DEBUG_LOG_FILE = join(DEBUG_LOG_DIR, "tool-loop-debug.log");
11
-
12
- function ensureLogDir(): void {
13
- try {
14
- if (!existsSync(DEBUG_LOG_DIR)) {
15
- mkdirSync(DEBUG_LOG_DIR, { recursive: true });
16
- }
17
- } catch {
18
- // Ignore errors creating log directory
19
- }
20
- }
21
-
22
- function debugLogToFile(message: string, data: any): void {
23
- try {
24
- ensureLogDir();
25
- const timestamp = new Date().toISOString();
26
- const logLine = `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`;
27
- appendFileSync(DEBUG_LOG_FILE, logLine);
28
- } catch (err) {
29
- // Fall back to regular debug log if file writing fails
30
- log.debug(message, data);
31
- }
32
- }
33
-
34
5
  /**
35
6
  * Build a text prompt from OpenAI chat messages + tool definitions.
36
7
  * Handles role:"tool" result messages and assistant tool_calls that
37
8
  * plain text flattening would silently drop.
38
9
  */
39
10
  export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>, subagentNames: string[] = []): string {
40
- // DEBUG: Log incoming message structure to file for root cause analysis
41
11
  const messageSummary = messages.map((m: any, i: number) => {
42
12
  const role = m?.role ?? "?";
43
13
  const hasToolCalls = Array.isArray(m?.tool_calls) ? m.tool_calls.length : 0;
@@ -52,7 +22,7 @@ export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>,
52
22
  const assistantEmpty = messages.filter((m: any) => m?.role === "assistant" && (!m?.tool_calls || m.tool_calls.length === 0) && (!m?.content || m.content === "" || m.content === null));
53
23
  const toolResults = messages.filter((m: any) => m?.role === "tool");
54
24
 
55
- debugLogToFile("buildPromptFromMessages", {
25
+ log.debug("buildPromptFromMessages", {
56
26
  totalMessages: messages.length,
57
27
  totalTools: tools.length,
58
28
  messageSummary,
@@ -165,16 +135,15 @@ export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>,
165
135
  );
166
136
  }
167
137
 
168
- // DEBUG: Log the final prompt structure
169
138
  const finalPrompt = lines.join("\n\n");
170
- debugLogToFile("buildPromptFromMessages: final prompt", {
139
+ log.debug("buildPromptFromMessages: final prompt", {
171
140
  lineCount: lines.length,
172
141
  promptLength: finalPrompt.length,
173
142
  promptPreview: finalPrompt.slice(0, 500),
174
143
  hasToolResultFormat: finalPrompt.includes("TOOL_RESULT"),
175
144
  hasAssistantToolCallFormat: finalPrompt.includes("tool_call(id:"),
176
- hasCompletionSignal: finalPrompt.includes("Based on the tool results"),
145
+ hasCompletionSignal: finalPrompt.includes("The above tool calls have been executed"),
177
146
  });
178
147
 
179
148
  return finalPrompt;
180
- }
149
+ }
@@ -8,7 +8,7 @@ import {
8
8
  type StreamJsonEvent,
9
9
  type StreamJsonToolCallEvent,
10
10
  } from "./types.js";
11
- import { DeltaTracker } from "./delta-tracker.js";
11
+ import { MixedDeltaTracker } from "./delta-tracker.js";
12
12
 
13
13
  export type AiSdkStreamPart =
14
14
  | {
@@ -34,44 +34,22 @@ export type AiSdkStreamPart =
34
34
  };
35
35
 
36
36
  export class StreamToAiSdkParts {
37
- private readonly tracker = new DeltaTracker();
38
37
  private readonly toolArgsById = new Map<string, string>();
39
38
  private readonly startedToolIds = new Set<string>();
40
- private sawAssistantPartials = false;
41
- private sawThinkingPartials = false;
39
+ private readonly tracker = new MixedDeltaTracker();
42
40
 
43
41
  handleEvent(event: StreamJsonEvent): AiSdkStreamPart[] {
44
42
  if (isAssistantText(event)) {
45
- const isPartial = typeof event.timestamp_ms === "number";
46
- if (isPartial) {
47
- const text = extractText(event);
48
- if (text) {
49
- this.sawAssistantPartials = true;
50
- return [{ type: "text-delta", textDelta: text }];
51
- }
52
- return [];
53
- }
54
- if (this.sawAssistantPartials) {
55
- return [];
56
- }
57
- const delta = this.tracker.nextText(extractText(event));
43
+ const text = extractText(event);
44
+ if (!text) return [];
45
+ const delta = this.tracker.nextText(text);
58
46
  return delta ? [{ type: "text-delta", textDelta: delta }] : [];
59
47
  }
60
48
 
61
49
  if (isThinking(event)) {
62
- const isPartial = typeof event.timestamp_ms === "number";
63
- if (isPartial) {
64
- const text = extractThinking(event);
65
- if (text) {
66
- this.sawThinkingPartials = true;
67
- return [{ type: "text-delta", textDelta: text }];
68
- }
69
- return [];
70
- }
71
- if (this.sawThinkingPartials) {
72
- return [];
73
- }
74
- const delta = this.tracker.nextThinking(extractThinking(event));
50
+ const text = extractThinking(event);
51
+ if (!text) return [];
52
+ const delta = this.tracker.nextThinking(text);
75
53
  return delta ? [{ type: "text-delta", textDelta: delta }] : [];
76
54
  }
77
55
 
@@ -45,3 +45,45 @@ export class DeltaTracker {
45
45
  return current.slice(i);
46
46
  }
47
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
+ }
@@ -8,7 +8,7 @@ import {
8
8
  type StreamJsonEvent,
9
9
  type StreamJsonToolCallEvent,
10
10
  } from "./types.js";
11
- import { DeltaTracker } from "./delta-tracker.js";
11
+ import { MixedDeltaTracker } from "./delta-tracker.js";
12
12
 
13
13
  type OpenAiToolCall = {
14
14
  index: number;
@@ -60,12 +60,7 @@ export class StreamToSseConverter {
60
60
  private readonly id: string;
61
61
  private readonly created: number;
62
62
  private readonly model: string;
63
- private readonly tracker = new DeltaTracker();
64
- // Events with timestamp_ms carry delta text; events without carry accumulated text.
65
- // DeltaTracker handles accumulated text only. When partials (delta) were seen,
66
- // the final accumulated event must be skipped to prevent 2x duplication.
67
- private sawAssistantPartials = false;
68
- private sawThinkingPartials = false;
63
+ private readonly tracker = new MixedDeltaTracker();
69
64
 
70
65
  constructor(model: string, options?: { id?: string; created?: number }) {
71
66
  this.model = model;
@@ -75,36 +70,16 @@ export class StreamToSseConverter {
75
70
 
76
71
  handleEvent(event: StreamJsonEvent): string[] {
77
72
  if (isAssistantText(event)) {
78
- const isPartial = typeof event.timestamp_ms === "number";
79
- if (isPartial) {
80
- const text = extractText(event);
81
- if (text) {
82
- this.sawAssistantPartials = true;
83
- return [this.chunkWith({ content: text })];
84
- }
85
- return [];
86
- }
87
- if (this.sawAssistantPartials) {
88
- return [];
89
- }
90
- const delta = this.tracker.nextText(extractText(event));
73
+ const text = extractText(event);
74
+ if (!text) return [];
75
+ const delta = this.tracker.nextText(text);
91
76
  return delta ? [this.chunkWith({ content: delta })] : [];
92
77
  }
93
78
 
94
79
  if (isThinking(event)) {
95
- const isPartial = typeof event.timestamp_ms === "number";
96
- if (isPartial) {
97
- const text = extractThinking(event);
98
- if (text) {
99
- this.sawThinkingPartials = true;
100
- return [this.chunkWith({ reasoning_content: text })];
101
- }
102
- return [];
103
- }
104
- if (this.sawThinkingPartials) {
105
- return [];
106
- }
107
- const delta = this.tracker.nextThinking(extractThinking(event));
80
+ const text = extractThinking(event);
81
+ if (!text) return [];
82
+ const delta = this.tracker.nextThinking(text);
108
83
  return delta ? [this.chunkWith({ reasoning_content: delta })] : [];
109
84
  }
110
85
 
@@ -533,10 +533,6 @@ function resolveEditArguments(args: Record<string, unknown>): {
533
533
  }
534
534
  }
535
535
 
536
- if (oldString === undefined && newString !== undefined) {
537
- oldString = "";
538
- }
539
-
540
536
  return {
541
537
  path,
542
538
  old_string: oldString,
@@ -3,9 +3,10 @@
3
3
  // Resolves the cursor-agent executable path. On Windows the binary is a `.cmd`
4
4
  // shim, which Node's spawn cannot execute directly without `shell: true` —
5
5
  // callers therefore pair this resolver with `shell: process.platform === "win32"`
6
- // at every spawn site. That re-enables shell metacharacter interpretation, so
7
- // any user-controlled string passed as an argument on Windows must be treated
8
- // as untrusted; never concatenate user input into argv on win32.
6
+ // and `formatShellCommandForPlatform()` at every Node spawn site. That re-enables
7
+ // shell metacharacter interpretation, so any user-controlled string passed as an
8
+ // argument on Windows must be treated as untrusted; never concatenate user input
9
+ // into argv on win32.
9
10
  import { existsSync as fsExistsSync } from "fs";
10
11
  import * as pathModule from "path";
11
12
  import { homedir as osHomedir } from "os";
@@ -55,3 +56,16 @@ export function resolveCursorAgentBinary(deps: BinaryDeps = {}): string {
55
56
  log.warn("cursor-agent not found at known paths, falling back to PATH", { checkedPaths: knownPaths });
56
57
  return "cursor-agent";
57
58
  }
59
+
60
+ export function formatShellCommandForPlatform(
61
+ command: string,
62
+ platform: NodeJS.Platform = process.platform,
63
+ ): string {
64
+ if (platform !== "win32") {
65
+ return command;
66
+ }
67
+ if (command.startsWith("\"") && command.endsWith("\"")) {
68
+ return command;
69
+ }
70
+ return `"${command}"`;
71
+ }