@kolisachint/hoocode-agent 0.5.46 → 0.5.48
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/CHANGELOG.md +24 -0
- package/dist/core/agent-session-retry.d.ts.map +1 -1
- package/dist/core/agent-session-retry.js +10 -1
- package/dist/core/agent-session-retry.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +1 -0
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/edit-diff.d.ts +7 -3
- package/dist/core/tools/edit-diff.d.ts.map +1 -1
- package/dist/core/tools/edit-diff.js +156 -65
- package/dist/core/tools/edit-diff.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +5 -4
- package/dist/core/tools/read.js.map +1 -1
- package/dist/core/tools/webfetch.d.ts +3 -0
- package/dist/core/tools/webfetch.d.ts.map +1 -1
- package/dist/core/tools/webfetch.js +31 -7
- package/dist/core/tools/webfetch.js.map +1 -1
- package/dist/core/tools/webtools-shared.d.ts +12 -0
- package/dist/core/tools/webtools-shared.d.ts.map +1 -1
- package/dist/core/tools/webtools-shared.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/sleep.d.ts +9 -0
- package/dist/utils/sleep.d.ts.map +1 -1
- package/dist/utils/sleep.js +11 -1
- package/dist/utils/sleep.js.map +1 -1
- package/docs/settings.md +20 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.48] - 2026-09-03
|
|
4
|
+
|
|
5
|
+
## [0.5.47] - 2026-08-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `webfetch` takes `grep`, finding where a long page mentions something.
|
|
10
|
+
|
|
11
|
+
An outline maps a page by heading, which helps when the page has headings and
|
|
12
|
+
one of them names what you are after. `grep` covers the rest: it returns each
|
|
13
|
+
match with its offset, the text around it, and the section it falls in, so a
|
|
14
|
+
page with no headings — or whose headings do not name the thing — stops
|
|
15
|
+
needing a front-to-back read. Match offsets are the offsets `offset` reads, so
|
|
16
|
+
a hit is followed by fetching at it.
|
|
17
|
+
|
|
18
|
+
`outline` and `grep` are two views of one page, so asking for both is rejected
|
|
19
|
+
here rather than spending a subprocess to be told by the binary. A blank
|
|
20
|
+
pattern is not a search: it resolves to a plain fetch and never reaches the
|
|
21
|
+
binary. The TUI marks a search result with its hit count, as it marks an
|
|
22
|
+
outline with its section count.
|
|
23
|
+
|
|
24
|
+
Needs `webtools` v0.6.0 or newer; an older binary rejects the flag and the
|
|
25
|
+
error names the binary and how to update it.
|
|
26
|
+
|
|
3
27
|
## [0.5.46] - 2026-08-31
|
|
4
28
|
|
|
5
29
|
## [0.5.45] - 2026-08-31
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-session-retry.d.ts","sourceRoot":"","sources":["../../src/core/agent-session-retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAChF,OAAO,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAU5D,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC7B,gBAAgB,IAAI;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAClF,QAAQ,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACnC,gBAAgB,IAAI,YAAY,EAAE,CAAC;IACnC,gBAAgB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC;IACjD,sFAAsF;IACtF,aAAa,IAAI,IAAI,CAAC;IACtB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;CACrC;AAED,qBAAa,mBAAmB;IAMnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IALjC,OAAO,CAAC,gBAAgB,CAA0C;IAClE,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,QAAQ,CAAwC;IACxD,OAAO,CAAC,QAAQ,CAAuC;IAEvD,YAA6B,IAAI,EAAE,aAAa,EAAI;IAEpD,gDAAgD;IAChD,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,+CAA+C;IAC/C,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;OAGG;IACH,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"agent-session-retry.d.ts","sourceRoot":"","sources":["../../src/core/agent-session-retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAChF,OAAO,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AAGvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAU5D,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC7B,gBAAgB,IAAI;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAClF,QAAQ,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACnC,gBAAgB,IAAI,YAAY,EAAE,CAAC;IACnC,gBAAgB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,IAAI,CAAC;IACjD,sFAAsF;IACtF,aAAa,IAAI,IAAI,CAAC;IACtB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;CACrC;AAED,qBAAa,mBAAmB;IAMnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IALjC,OAAO,CAAC,gBAAgB,CAA0C;IAClE,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,QAAQ,CAAwC;IACxD,OAAO,CAAC,QAAQ,CAAuC;IAEvD,YAA6B,IAAI,EAAE,aAAa,EAAI;IAEpD,gDAAgD;IAChD,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,+CAA+C;IAC/C,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;OAGG;IACH,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAiBnD;IAED;;;;;;OAMG;IACH,wBAAwB,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAkBhD;IAED,OAAO,CAAC,4BAA4B;IAUpC;;;;OAIG;IACH,6BAA6B,IAAI,IAAI,CASpC;IAED,wCAAwC;IACxC,OAAO,IAAI,IAAI,CAMd;IAED;;;OAGG;IACG,oBAAoB,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CAwEtE;IAED;;OAEG;IACH,KAAK,IAAI,IAAI,CAIZ;IAED;;;OAGG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAOlC;CACD","sourcesContent":["/**\n * Auto-retry controller for AgentSession.\n *\n * Owns the retry lifecycle for transient assistant errors (overloaded, rate\n * limit, server/network/transport failures): deciding whether an error is\n * retryable, arming the retry promise synchronously on agent_end, backing off\n * exponentially, and re-driving the agent via continue(). Context-overflow\n * errors are intentionally excluded here — those are handled by compaction.\n */\n\nimport type { AgentEvent, AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, Model } from \"@kolisachint/hoocode-ai\";\nimport { isContextOverflow, isLongRetryDelayError } from \"@kolisachint/hoocode-ai\";\nimport { sleep } from \"../utils/sleep.js\";\nimport type { AgentSessionEvent } from \"./agent-session.js\";\n\n/**\n * Retryable error signatures (overloaded, rate limit, server/network errors,\n * transport closes). Compiled once at module load instead of on every assistant\n * response. Context-overflow errors are handled separately by compaction.\n */\nconst RETRYABLE_ERROR_PATTERN =\n\t/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;\n\n/** Narrow dependencies the retry controller needs from AgentSession. */\nexport interface AutoRetryDeps {\n\tgetRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number };\n\tgetModel(): Model<any> | undefined;\n\tgetAgentMessages(): AgentMessage[];\n\tsetAgentMessages(messages: AgentMessage[]): void;\n\t/** Fire-and-forget continue() on the agent (errors surface on the next agent_end). */\n\tcontinueAgent(): void;\n\twaitForAgentIdle(): Promise<void>;\n\temit(event: AgentSessionEvent): void;\n}\n\nexport class AutoRetryController {\n\tprivate _abortController: AbortController | undefined = undefined;\n\tprivate _attempt = 0;\n\tprivate _promise: Promise<void> | undefined = undefined;\n\tprivate _resolve: (() => void) | undefined = undefined;\n\n\tconstructor(private readonly deps: AutoRetryDeps) {}\n\n\t/** Current retry attempt (0 if not retrying) */\n\tget attempt(): number {\n\t\treturn this._attempt;\n\t}\n\n\t/** Whether a retry is currently in progress */\n\tget isRetrying(): boolean {\n\t\treturn this._promise !== undefined;\n\t}\n\n\t/**\n\t * Check if an error is retryable (overloaded, rate limit, server errors).\n\t * Context overflow errors are NOT retryable (handled by compaction instead).\n\t */\n\tisRetryableError(message: AssistantMessage): boolean {\n\t\tif (message.stopReason !== \"error\" || !message.errorMessage) return false;\n\n\t\t// Context overflow is handled by compaction, not retry\n\t\tconst contextWindow = this.deps.getModel()?.contextWindow ?? 0;\n\t\tif (isContextOverflow(message, contextWindow)) return false;\n\n\t\t// An exhausted quota answers 429 the same way a burst rate limit does,\n\t\t// and the pattern below cannot tell them apart — both say \"429\". The\n\t\t// difference is in how long the provider asked us to wait: seconds mean\n\t\t// try again, weeks mean this session is not getting through no matter\n\t\t// how patiently it backs off. Retrying the second kind spends the whole\n\t\t// budget in a few seconds and reports failure as if the network were at\n\t\t// fault.\n\t\tif (isLongRetryDelayError(message.errorMessage)) return false;\n\n\t\treturn RETRYABLE_ERROR_PATTERN.test(message.errorMessage);\n\t}\n\n\t/**\n\t * Create the retry promise synchronously when an agent_end carries a\n\t * retryable error. Agent.emit() runs handlers synchronously and prompt()\n\t * calls waitForRetry() as soon as agent.prompt() resolves; arming the promise\n\t * here (rather than inside async event processing) ensures waitForRetry()\n\t * never misses an in-flight retry.\n\t */\n\tcreatePromiseForAgentEnd(event: AgentEvent): void {\n\t\tif (event.type !== \"agent_end\" || this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst lastAssistant = this._findLastAssistantInMessages(event.messages);\n\t\tif (!lastAssistant || !this.isRetryableError(lastAssistant)) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis._promise = new Promise((resolve) => {\n\t\t\tthis._resolve = resolve;\n\t\t});\n\t}\n\n\tprivate _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined {\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst message = messages[i];\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\treturn message as AssistantMessage;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Reset the attempt counter after a successful assistant response.\n\t * Callers invoke this only for non-error responses; it emits a success event\n\t * when a retry was in progress.\n\t */\n\tonSuccessfulAssistantResponse(): void {\n\t\tif (this._attempt > 0) {\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: true,\n\t\t\t\tattempt: this._attempt,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t}\n\t}\n\n\t/** Resolve the pending retry promise */\n\tresolve(): void {\n\t\tif (this._resolve) {\n\t\t\tthis._resolve();\n\t\t\tthis._resolve = undefined;\n\t\t\tthis._promise = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Handle retryable errors with exponential backoff.\n\t * @returns true if retry was initiated, false if max retries exceeded or disabled\n\t */\n\tasync handleRetryableError(message: AssistantMessage): Promise<boolean> {\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\n\t\t// Retry promise is created synchronously in createPromiseForAgentEnd for agent_end.\n\t\t// Keep a defensive fallback here in case a future refactor bypasses that path.\n\t\tif (!this._promise) {\n\t\t\tthis._promise = new Promise((resolve) => {\n\t\t\t\tthis._resolve = resolve;\n\t\t\t});\n\t\t}\n\n\t\tthis._attempt++;\n\n\t\tif (this._attempt > settings.maxRetries) {\n\t\t\t// Max retries exceeded, emit final failure and reset\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt: this._attempt - 1,\n\t\t\t\tfinalError: message.errorMessage,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t\tthis.resolve(); // Resolve so waitForRetry() completes\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = settings.baseDelayMs * 2 ** (this._attempt - 1);\n\n\t\tthis.deps.emit({\n\t\t\ttype: \"auto_retry_start\",\n\t\t\tattempt: this._attempt,\n\t\t\tmaxAttempts: settings.maxRetries,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove error message from agent state (keep in session for history)\n\t\tconst messages = this.deps.getAgentMessages();\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.deps.setAgentMessages(messages.slice(0, -1));\n\t\t}\n\n\t\t// Wait with exponential backoff (abortable)\n\t\tthis._abortController = new AbortController();\n\t\ttry {\n\t\t\tawait sleep(delayMs, this._abortController.signal);\n\t\t} catch {\n\t\t\t// Aborted during sleep - emit end event so UI can clean up\n\t\t\tconst attempt = this._attempt;\n\t\t\tthis._attempt = 0;\n\t\t\tthis._abortController = undefined;\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt,\n\t\t\t\tfinalError: \"Retry cancelled\",\n\t\t\t});\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\t\tthis._abortController = undefined;\n\n\t\t// Retry via continue() - use setTimeout to break out of event handler chain\n\t\tsetTimeout(() => {\n\t\t\tthis.deps.continueAgent();\n\t\t}, 0);\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Cancel in-progress retry.\n\t */\n\tabort(): void {\n\t\tthis._abortController?.abort();\n\t\t// Note: _attempt is reset in the catch block of handleRetryableError\n\t\tthis.resolve();\n\t}\n\n\t/**\n\t * Wait for any in-progress retry to complete.\n\t * Returns immediately if no retry is in progress.\n\t */\n\tasync waitForRetry(): Promise<void> {\n\t\tif (!this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tawait this._promise;\n\t\tawait this.deps.waitForAgentIdle();\n\t}\n}\n"]}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* exponentially, and re-driving the agent via continue(). Context-overflow
|
|
8
8
|
* errors are intentionally excluded here — those are handled by compaction.
|
|
9
9
|
*/
|
|
10
|
-
import { isContextOverflow } from "@kolisachint/hoocode-ai";
|
|
10
|
+
import { isContextOverflow, isLongRetryDelayError } from "@kolisachint/hoocode-ai";
|
|
11
11
|
import { sleep } from "../utils/sleep.js";
|
|
12
12
|
/**
|
|
13
13
|
* Retryable error signatures (overloaded, rate limit, server/network errors,
|
|
@@ -43,6 +43,15 @@ export class AutoRetryController {
|
|
|
43
43
|
const contextWindow = this.deps.getModel()?.contextWindow ?? 0;
|
|
44
44
|
if (isContextOverflow(message, contextWindow))
|
|
45
45
|
return false;
|
|
46
|
+
// An exhausted quota answers 429 the same way a burst rate limit does,
|
|
47
|
+
// and the pattern below cannot tell them apart — both say "429". The
|
|
48
|
+
// difference is in how long the provider asked us to wait: seconds mean
|
|
49
|
+
// try again, weeks mean this session is not getting through no matter
|
|
50
|
+
// how patiently it backs off. Retrying the second kind spends the whole
|
|
51
|
+
// budget in a few seconds and reports failure as if the network were at
|
|
52
|
+
// fault.
|
|
53
|
+
if (isLongRetryDelayError(message.errorMessage))
|
|
54
|
+
return false;
|
|
46
55
|
return RETRYABLE_ERROR_PATTERN.test(message.errorMessage);
|
|
47
56
|
}
|
|
48
57
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-session-retry.js","sourceRoot":"","sources":["../../src/core/agent-session-retry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Auto-retry controller for AgentSession.\n *\n * Owns the retry lifecycle for transient assistant errors (overloaded, rate\n * limit, server/network/transport failures): deciding whether an error is\n * retryable, arming the retry promise synchronously on agent_end, backing off\n * exponentially, and re-driving the agent via continue(). Context-overflow\n * errors are intentionally excluded here — those are handled by compaction.\n */\n\nimport type { AgentEvent, AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, Model } from \"@kolisachint/hoocode-ai\";\nimport { isContextOverflow } from \"@kolisachint/hoocode-ai\";\nimport { sleep } from \"../utils/sleep.js\";\nimport type { AgentSessionEvent } from \"./agent-session.js\";\n\n/**\n * Retryable error signatures (overloaded, rate limit, server/network errors,\n * transport closes). Compiled once at module load instead of on every assistant\n * response. Context-overflow errors are handled separately by compaction.\n */\nconst RETRYABLE_ERROR_PATTERN =\n\t/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;\n\n/** Narrow dependencies the retry controller needs from AgentSession. */\nexport interface AutoRetryDeps {\n\tgetRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number };\n\tgetModel(): Model<any> | undefined;\n\tgetAgentMessages(): AgentMessage[];\n\tsetAgentMessages(messages: AgentMessage[]): void;\n\t/** Fire-and-forget continue() on the agent (errors surface on the next agent_end). */\n\tcontinueAgent(): void;\n\twaitForAgentIdle(): Promise<void>;\n\temit(event: AgentSessionEvent): void;\n}\n\nexport class AutoRetryController {\n\tprivate _abortController: AbortController | undefined = undefined;\n\tprivate _attempt = 0;\n\tprivate _promise: Promise<void> | undefined = undefined;\n\tprivate _resolve: (() => void) | undefined = undefined;\n\n\tconstructor(private readonly deps: AutoRetryDeps) {}\n\n\t/** Current retry attempt (0 if not retrying) */\n\tget attempt(): number {\n\t\treturn this._attempt;\n\t}\n\n\t/** Whether a retry is currently in progress */\n\tget isRetrying(): boolean {\n\t\treturn this._promise !== undefined;\n\t}\n\n\t/**\n\t * Check if an error is retryable (overloaded, rate limit, server errors).\n\t * Context overflow errors are NOT retryable (handled by compaction instead).\n\t */\n\tisRetryableError(message: AssistantMessage): boolean {\n\t\tif (message.stopReason !== \"error\" || !message.errorMessage) return false;\n\n\t\t// Context overflow is handled by compaction, not retry\n\t\tconst contextWindow = this.deps.getModel()?.contextWindow ?? 0;\n\t\tif (isContextOverflow(message, contextWindow)) return false;\n\n\t\treturn RETRYABLE_ERROR_PATTERN.test(message.errorMessage);\n\t}\n\n\t/**\n\t * Create the retry promise synchronously when an agent_end carries a\n\t * retryable error. Agent.emit() runs handlers synchronously and prompt()\n\t * calls waitForRetry() as soon as agent.prompt() resolves; arming the promise\n\t * here (rather than inside async event processing) ensures waitForRetry()\n\t * never misses an in-flight retry.\n\t */\n\tcreatePromiseForAgentEnd(event: AgentEvent): void {\n\t\tif (event.type !== \"agent_end\" || this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst lastAssistant = this._findLastAssistantInMessages(event.messages);\n\t\tif (!lastAssistant || !this.isRetryableError(lastAssistant)) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis._promise = new Promise((resolve) => {\n\t\t\tthis._resolve = resolve;\n\t\t});\n\t}\n\n\tprivate _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined {\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst message = messages[i];\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\treturn message as AssistantMessage;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Reset the attempt counter after a successful assistant response.\n\t * Callers invoke this only for non-error responses; it emits a success event\n\t * when a retry was in progress.\n\t */\n\tonSuccessfulAssistantResponse(): void {\n\t\tif (this._attempt > 0) {\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: true,\n\t\t\t\tattempt: this._attempt,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t}\n\t}\n\n\t/** Resolve the pending retry promise */\n\tresolve(): void {\n\t\tif (this._resolve) {\n\t\t\tthis._resolve();\n\t\t\tthis._resolve = undefined;\n\t\t\tthis._promise = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Handle retryable errors with exponential backoff.\n\t * @returns true if retry was initiated, false if max retries exceeded or disabled\n\t */\n\tasync handleRetryableError(message: AssistantMessage): Promise<boolean> {\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\n\t\t// Retry promise is created synchronously in createPromiseForAgentEnd for agent_end.\n\t\t// Keep a defensive fallback here in case a future refactor bypasses that path.\n\t\tif (!this._promise) {\n\t\t\tthis._promise = new Promise((resolve) => {\n\t\t\t\tthis._resolve = resolve;\n\t\t\t});\n\t\t}\n\n\t\tthis._attempt++;\n\n\t\tif (this._attempt > settings.maxRetries) {\n\t\t\t// Max retries exceeded, emit final failure and reset\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt: this._attempt - 1,\n\t\t\t\tfinalError: message.errorMessage,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t\tthis.resolve(); // Resolve so waitForRetry() completes\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = settings.baseDelayMs * 2 ** (this._attempt - 1);\n\n\t\tthis.deps.emit({\n\t\t\ttype: \"auto_retry_start\",\n\t\t\tattempt: this._attempt,\n\t\t\tmaxAttempts: settings.maxRetries,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove error message from agent state (keep in session for history)\n\t\tconst messages = this.deps.getAgentMessages();\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.deps.setAgentMessages(messages.slice(0, -1));\n\t\t}\n\n\t\t// Wait with exponential backoff (abortable)\n\t\tthis._abortController = new AbortController();\n\t\ttry {\n\t\t\tawait sleep(delayMs, this._abortController.signal);\n\t\t} catch {\n\t\t\t// Aborted during sleep - emit end event so UI can clean up\n\t\t\tconst attempt = this._attempt;\n\t\t\tthis._attempt = 0;\n\t\t\tthis._abortController = undefined;\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt,\n\t\t\t\tfinalError: \"Retry cancelled\",\n\t\t\t});\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\t\tthis._abortController = undefined;\n\n\t\t// Retry via continue() - use setTimeout to break out of event handler chain\n\t\tsetTimeout(() => {\n\t\t\tthis.deps.continueAgent();\n\t\t}, 0);\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Cancel in-progress retry.\n\t */\n\tabort(): void {\n\t\tthis._abortController?.abort();\n\t\t// Note: _attempt is reset in the catch block of handleRetryableError\n\t\tthis.resolve();\n\t}\n\n\t/**\n\t * Wait for any in-progress retry to complete.\n\t * Returns immediately if no retry is in progress.\n\t */\n\tasync waitForRetry(): Promise<void> {\n\t\tif (!this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tawait this._promise;\n\t\tawait this.deps.waitForAgentIdle();\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"agent-session-retry.js","sourceRoot":"","sources":["../../src/core/agent-session-retry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AACnF,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAG1C;;;;GAIG;AACH,MAAM,uBAAuB,GAC5B,uaAAua,CAAC;AAcza,MAAM,OAAO,mBAAmB;IAMF,IAAI;IALzB,gBAAgB,GAAgC,SAAS,CAAC;IAC1D,QAAQ,GAAG,CAAC,CAAC;IACb,QAAQ,GAA8B,SAAS,CAAC;IAChD,QAAQ,GAA6B,SAAS,CAAC;IAEvD,YAA6B,IAAmB,EAAE;oBAArB,IAAI;IAAkB,CAAC;IAEpD,gDAAgD;IAChD,IAAI,OAAO,GAAW;QACrB,OAAO,IAAI,CAAC,QAAQ,CAAC;IAAA,CACrB;IAED,+CAA+C;IAC/C,IAAI,UAAU,GAAY;QACzB,OAAO,IAAI,CAAC,QAAQ,KAAK,SAAS,CAAC;IAAA,CACnC;IAED;;;OAGG;IACH,gBAAgB,CAAC,OAAyB,EAAW;QACpD,IAAI,OAAO,CAAC,UAAU,KAAK,OAAO,IAAI,CAAC,OAAO,CAAC,YAAY;YAAE,OAAO,KAAK,CAAC;QAE1E,uDAAuD;QACvD,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,aAAa,IAAI,CAAC,CAAC;QAC/D,IAAI,iBAAiB,CAAC,OAAO,EAAE,aAAa,CAAC;YAAE,OAAO,KAAK,CAAC;QAE5D,uEAAuE;QACvE,uEAAqE;QACrE,wEAAwE;QACxE,sEAAsE;QACtE,wEAAwE;QACxE,wEAAwE;QACxE,SAAS;QACT,IAAI,qBAAqB,CAAC,OAAO,CAAC,YAAY,CAAC;YAAE,OAAO,KAAK,CAAC;QAE9D,OAAO,uBAAuB,CAAC,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IAAA,CAC1D;IAED;;;;;;OAMG;IACH,wBAAwB,CAAC,KAAiB,EAAQ;QACjD,IAAI,KAAK,CAAC,IAAI,KAAK,WAAW,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YACjD,OAAO;QACR,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC9C,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;YACvB,OAAO;QACR,CAAC;QAED,MAAM,aAAa,GAAG,IAAI,CAAC,4BAA4B,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QACxE,IAAI,CAAC,aAAa,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,aAAa,CAAC,EAAE,CAAC;YAC7D,OAAO;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,GAAG,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;YACxC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;QAAA,CACxB,CAAC,CAAC;IAAA,CACH;IAEO,4BAA4B,CAAC,QAAwB,EAAgC;QAC5F,KAAK,IAAI,CAAC,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC/C,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;YAC5B,IAAI,OAAO,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;gBAClC,OAAO,OAA2B,CAAC;YACpC,CAAC;QACF,CAAC;QACD,OAAO,SAAS,CAAC;IAAA,CACjB;IAED;;;;OAIG;IACH,6BAA6B,GAAS;QACrC,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC,EAAE,CAAC;YACvB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;gBACd,IAAI,EAAE,gBAAgB;gBACtB,OAAO,EAAE,IAAI;gBACb,OAAO,EAAE,IAAI,CAAC,QAAQ;aACtB,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;QACnB,CAAC;IAAA,CACD;IAED,wCAAwC;IACxC,OAAO,GAAS;QACf,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,QAAQ,EAAE,CAAC;YAChB,IAAI,CAAC,QAAQ,GAAG,SAAS,CAAC;YAC1B,IAAI,CAAC,QAAQ,GAAG,SAAS,CAAC;QAC3B,CAAC;IAAA,CACD;IAED;;;OAGG;IACH,KAAK,CAAC,oBAAoB,CAAC,OAAyB,EAAoB;QACvE,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC9C,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;YACvB,IAAI,CAAC,OAAO,EAAE,CAAC;YACf,OAAO,KAAK,CAAC;QACd,CAAC;QAED,oFAAoF;QACpF,+EAA+E;QAC/E,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACpB,IAAI,CAAC,QAAQ,GAAG,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC;gBACxC,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC;YAAA,CACxB,CAAC,CAAC;QACJ,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;QAEhB,IAAI,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC,UAAU,EAAE,CAAC;YACzC,qDAAqD;YACrD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;gBACd,IAAI,EAAE,gBAAgB;gBACtB,OAAO,EAAE,KAAK;gBACd,OAAO,EAAE,IAAI,CAAC,QAAQ,GAAG,CAAC;gBAC1B,UAAU,EAAE,OAAO,CAAC,YAAY;aAChC,CAAC,CAAC;YACH,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;YAClB,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,sCAAsC;YACtD,OAAO,KAAK,CAAC;QACd,CAAC;QAED,MAAM,OAAO,GAAG,QAAQ,CAAC,WAAW,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;QAEhE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;YACd,IAAI,EAAE,kBAAkB;YACxB,OAAO,EAAE,IAAI,CAAC,QAAQ;YACtB,WAAW,EAAE,QAAQ,CAAC,UAAU;YAChC,OAAO;YACP,YAAY,EAAE,OAAO,CAAC,YAAY,IAAI,eAAe;SACrD,CAAC,CAAC;QAEH,sEAAsE;QACtE,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC9C,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,WAAW,EAAE,CAAC;YAC/E,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,CAAC;QAED,4CAA4C;QAC5C,IAAI,CAAC,gBAAgB,GAAG,IAAI,eAAe,EAAE,CAAC;QAC9C,IAAI,CAAC;YACJ,MAAM,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACR,2DAA2D;YAC3D,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC;YAC9B,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;YAClB,IAAI,CAAC,gBAAgB,GAAG,SAAS,CAAC;YAClC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;gBACd,IAAI,EAAE,gBAAgB;gBACtB,OAAO,EAAE,KAAK;gBACd,OAAO;gBACP,UAAU,EAAE,iBAAiB;aAC7B,CAAC,CAAC;YACH,IAAI,CAAC,OAAO,EAAE,CAAC;YACf,OAAO,KAAK,CAAC;QACd,CAAC;QACD,IAAI,CAAC,gBAAgB,GAAG,SAAS,CAAC;QAElC,4EAA4E;QAC5E,UAAU,CAAC,GAAG,EAAE,CAAC;YAChB,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC;QAAA,CAC1B,EAAE,CAAC,CAAC,CAAC;QAEN,OAAO,IAAI,CAAC;IAAA,CACZ;IAED;;OAEG;IACH,KAAK,GAAS;QACb,IAAI,CAAC,gBAAgB,EAAE,KAAK,EAAE,CAAC;QAC/B,qEAAqE;QACrE,IAAI,CAAC,OAAO,EAAE,CAAC;IAAA,CACf;IAED;;;OAGG;IACH,KAAK,CAAC,YAAY,GAAkB;QACnC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACpB,OAAO;QACR,CAAC;QAED,MAAM,IAAI,CAAC,QAAQ,CAAC;QACpB,MAAM,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,CAAC;IAAA,CACnC;CACD","sourcesContent":["/**\n * Auto-retry controller for AgentSession.\n *\n * Owns the retry lifecycle for transient assistant errors (overloaded, rate\n * limit, server/network/transport failures): deciding whether an error is\n * retryable, arming the retry promise synchronously on agent_end, backing off\n * exponentially, and re-driving the agent via continue(). Context-overflow\n * errors are intentionally excluded here — those are handled by compaction.\n */\n\nimport type { AgentEvent, AgentMessage } from \"@kolisachint/hoocode-agent-core\";\nimport type { AssistantMessage, Model } from \"@kolisachint/hoocode-ai\";\nimport { isContextOverflow, isLongRetryDelayError } from \"@kolisachint/hoocode-ai\";\nimport { sleep } from \"../utils/sleep.js\";\nimport type { AgentSessionEvent } from \"./agent-session.js\";\n\n/**\n * Retryable error signatures (overloaded, rate limit, server/network errors,\n * transport closes). Compiled once at module load instead of on every assistant\n * response. Context-overflow errors are handled separately by compaction.\n */\nconst RETRYABLE_ERROR_PATTERN =\n\t/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;\n\n/** Narrow dependencies the retry controller needs from AgentSession. */\nexport interface AutoRetryDeps {\n\tgetRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number };\n\tgetModel(): Model<any> | undefined;\n\tgetAgentMessages(): AgentMessage[];\n\tsetAgentMessages(messages: AgentMessage[]): void;\n\t/** Fire-and-forget continue() on the agent (errors surface on the next agent_end). */\n\tcontinueAgent(): void;\n\twaitForAgentIdle(): Promise<void>;\n\temit(event: AgentSessionEvent): void;\n}\n\nexport class AutoRetryController {\n\tprivate _abortController: AbortController | undefined = undefined;\n\tprivate _attempt = 0;\n\tprivate _promise: Promise<void> | undefined = undefined;\n\tprivate _resolve: (() => void) | undefined = undefined;\n\n\tconstructor(private readonly deps: AutoRetryDeps) {}\n\n\t/** Current retry attempt (0 if not retrying) */\n\tget attempt(): number {\n\t\treturn this._attempt;\n\t}\n\n\t/** Whether a retry is currently in progress */\n\tget isRetrying(): boolean {\n\t\treturn this._promise !== undefined;\n\t}\n\n\t/**\n\t * Check if an error is retryable (overloaded, rate limit, server errors).\n\t * Context overflow errors are NOT retryable (handled by compaction instead).\n\t */\n\tisRetryableError(message: AssistantMessage): boolean {\n\t\tif (message.stopReason !== \"error\" || !message.errorMessage) return false;\n\n\t\t// Context overflow is handled by compaction, not retry\n\t\tconst contextWindow = this.deps.getModel()?.contextWindow ?? 0;\n\t\tif (isContextOverflow(message, contextWindow)) return false;\n\n\t\t// An exhausted quota answers 429 the same way a burst rate limit does,\n\t\t// and the pattern below cannot tell them apart — both say \"429\". The\n\t\t// difference is in how long the provider asked us to wait: seconds mean\n\t\t// try again, weeks mean this session is not getting through no matter\n\t\t// how patiently it backs off. Retrying the second kind spends the whole\n\t\t// budget in a few seconds and reports failure as if the network were at\n\t\t// fault.\n\t\tif (isLongRetryDelayError(message.errorMessage)) return false;\n\n\t\treturn RETRYABLE_ERROR_PATTERN.test(message.errorMessage);\n\t}\n\n\t/**\n\t * Create the retry promise synchronously when an agent_end carries a\n\t * retryable error. Agent.emit() runs handlers synchronously and prompt()\n\t * calls waitForRetry() as soon as agent.prompt() resolves; arming the promise\n\t * here (rather than inside async event processing) ensures waitForRetry()\n\t * never misses an in-flight retry.\n\t */\n\tcreatePromiseForAgentEnd(event: AgentEvent): void {\n\t\tif (event.type !== \"agent_end\" || this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst lastAssistant = this._findLastAssistantInMessages(event.messages);\n\t\tif (!lastAssistant || !this.isRetryableError(lastAssistant)) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis._promise = new Promise((resolve) => {\n\t\t\tthis._resolve = resolve;\n\t\t});\n\t}\n\n\tprivate _findLastAssistantInMessages(messages: AgentMessage[]): AssistantMessage | undefined {\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst message = messages[i];\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\treturn message as AssistantMessage;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Reset the attempt counter after a successful assistant response.\n\t * Callers invoke this only for non-error responses; it emits a success event\n\t * when a retry was in progress.\n\t */\n\tonSuccessfulAssistantResponse(): void {\n\t\tif (this._attempt > 0) {\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: true,\n\t\t\t\tattempt: this._attempt,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t}\n\t}\n\n\t/** Resolve the pending retry promise */\n\tresolve(): void {\n\t\tif (this._resolve) {\n\t\t\tthis._resolve();\n\t\t\tthis._resolve = undefined;\n\t\t\tthis._promise = undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Handle retryable errors with exponential backoff.\n\t * @returns true if retry was initiated, false if max retries exceeded or disabled\n\t */\n\tasync handleRetryableError(message: AssistantMessage): Promise<boolean> {\n\t\tconst settings = this.deps.getRetrySettings();\n\t\tif (!settings.enabled) {\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\n\t\t// Retry promise is created synchronously in createPromiseForAgentEnd for agent_end.\n\t\t// Keep a defensive fallback here in case a future refactor bypasses that path.\n\t\tif (!this._promise) {\n\t\t\tthis._promise = new Promise((resolve) => {\n\t\t\t\tthis._resolve = resolve;\n\t\t\t});\n\t\t}\n\n\t\tthis._attempt++;\n\n\t\tif (this._attempt > settings.maxRetries) {\n\t\t\t// Max retries exceeded, emit final failure and reset\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt: this._attempt - 1,\n\t\t\t\tfinalError: message.errorMessage,\n\t\t\t});\n\t\t\tthis._attempt = 0;\n\t\t\tthis.resolve(); // Resolve so waitForRetry() completes\n\t\t\treturn false;\n\t\t}\n\n\t\tconst delayMs = settings.baseDelayMs * 2 ** (this._attempt - 1);\n\n\t\tthis.deps.emit({\n\t\t\ttype: \"auto_retry_start\",\n\t\t\tattempt: this._attempt,\n\t\t\tmaxAttempts: settings.maxRetries,\n\t\t\tdelayMs,\n\t\t\terrorMessage: message.errorMessage || \"Unknown error\",\n\t\t});\n\n\t\t// Remove error message from agent state (keep in session for history)\n\t\tconst messages = this.deps.getAgentMessages();\n\t\tif (messages.length > 0 && messages[messages.length - 1].role === \"assistant\") {\n\t\t\tthis.deps.setAgentMessages(messages.slice(0, -1));\n\t\t}\n\n\t\t// Wait with exponential backoff (abortable)\n\t\tthis._abortController = new AbortController();\n\t\ttry {\n\t\t\tawait sleep(delayMs, this._abortController.signal);\n\t\t} catch {\n\t\t\t// Aborted during sleep - emit end event so UI can clean up\n\t\t\tconst attempt = this._attempt;\n\t\t\tthis._attempt = 0;\n\t\t\tthis._abortController = undefined;\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"auto_retry_end\",\n\t\t\t\tsuccess: false,\n\t\t\t\tattempt,\n\t\t\t\tfinalError: \"Retry cancelled\",\n\t\t\t});\n\t\t\tthis.resolve();\n\t\t\treturn false;\n\t\t}\n\t\tthis._abortController = undefined;\n\n\t\t// Retry via continue() - use setTimeout to break out of event handler chain\n\t\tsetTimeout(() => {\n\t\t\tthis.deps.continueAgent();\n\t\t}, 0);\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Cancel in-progress retry.\n\t */\n\tabort(): void {\n\t\tthis._abortController?.abort();\n\t\t// Note: _attempt is reset in the catch block of handleRetryableError\n\t\tthis.resolve();\n\t}\n\n\t/**\n\t * Wait for any in-progress retry to complete.\n\t * Returns immediately if no retry is in progress.\n\t */\n\tasync waitForRetry(): Promise<void> {\n\t\tif (!this._promise) {\n\t\t\treturn;\n\t\t}\n\n\t\tawait this._promise;\n\t\tawait this.deps.waitForAgentIdle();\n\t}\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../src/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,wBAAwB,CAAC;AAG9E,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB;;;;OAIG;IACH,MAAM,CAAC,EAAE,eAAe,EAAE,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CA0L3E","sourcesContent":["/**\n * System prompt construction and project context loading\n */\n\nimport { type AgentDefinition, TASK_TOOL_NAME } from \"./agent-frontmatter.js\";\nimport { formatAgentsForPrompt } from \"./agent-registry.js\";\nimport { formatSelfDocsForPrompt } from \"./self-docs.js\";\nimport { formatSkillsForPrompt, type Skill } from \"./skills.js\";\n\nexport interface BuildSystemPromptOptions {\n\t/** Custom system prompt (replaces default). */\n\tcustomPrompt?: string;\n\t/** Tools to include in prompt. Default: [read, bash, edit, write, search, grep, find, ls] */\n\tselectedTools?: string[];\n\t/** Optional one-line tool snippets keyed by tool name. */\n\ttoolSnippets?: Record<string, string>;\n\t/** Additional guideline bullets appended to the default system prompt guidelines. */\n\tpromptGuidelines?: string[];\n\t/** Text to append to system prompt. */\n\tappendSystemPrompt?: string;\n\t/** Working directory. */\n\tcwd: string;\n\t/** Pre-loaded context files. */\n\tcontextFiles?: Array<{ path: string; content: string }>;\n\t/** Pre-loaded skills. */\n\tskills?: Skill[];\n\t/**\n\t * Available agents for delegation, emitted as `<available_agents>` XML.\n\t * Only populated when the Task tool is active so the model knows which\n\t * agents exist without re-reading the agent registry each turn.\n\t */\n\tagents?: AgentDefinition[];\n\t/**\n\t * Point the model at hoocode's own shipped docs so it can answer questions\n\t * about hoocode itself.\n\t *\n\t * Defaults to true for the built-in prompt and false when `customPrompt`\n\t * replaces it. Every other appended section (context files, skills, agents)\n\t * only appears because the caller passed the content in; this one\n\t * materializes on its own, so a caller who has taken over the system prompt\n\t * gets it only by asking. That also keeps it out of light mode, whose whole\n\t * point is a minimal fixed per-turn surface. Needs the read tool either way.\n\t */\n\tincludeSelfDocs?: boolean;\n}\n\n/** Build the system prompt with tools, guidelines, and context */\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n\tconst {\n\t\tcustomPrompt,\n\t\tselectedTools,\n\t\ttoolSnippets,\n\t\tpromptGuidelines,\n\t\tappendSystemPrompt,\n\t\tcwd,\n\t\tcontextFiles: providedContextFiles,\n\t\tskills: providedSkills,\n\t\tagents: providedAgents,\n\t\tincludeSelfDocs,\n\t} = options;\n\tconst resolvedCwd = cwd;\n\tconst promptCwd = resolvedCwd.replace(/\\\\/g, \"/\");\n\n\tconst now = new Date();\n\tconst year = now.getFullYear();\n\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(now.getDate()).padStart(2, \"0\");\n\tconst date = `${year}-${month}-${day}`;\n\n\tconst appendSection = appendSystemPrompt ? `\\n\\n${appendSystemPrompt}` : \"\";\n\tconst wantSelfDocs = includeSelfDocs ?? !customPrompt;\n\n\tconst contextFiles = providedContextFiles ?? [];\n\tconst skills = providedSkills ?? [];\n\tconst agents = providedAgents ?? [];\n\n\tif (customPrompt) {\n\t\tlet prompt = customPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Append skills section (only if read tool is available)\n\t\tconst hasRead = !selectedTools || selectedTools.includes(\"read\");\n\t\tif (hasRead && skills.length > 0) {\n\t\t\tprompt += formatSkillsForPrompt(skills);\n\t\t}\n\n\t\t// Append agents section (only when Task tool is active)\n\t\tconst hasTask = !selectedTools || selectedTools.includes(TASK_TOOL_NAME);\n\t\tif (hasTask && agents.length > 0) {\n\t\t\tprompt += formatAgentsForPrompt(agents);\n\t\t}\n\n\t\t// Append hoocode's own docs (only if read tool is available)\n\t\tif (wantSelfDocs && hasRead) {\n\t\t\tprompt += formatSelfDocsForPrompt();\n\t\t}\n\n\t\t// Add date and working directory last\n\t\tprompt += `\\nCurrent date: ${date}`;\n\t\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Build tools list based on selected tools.\n\t// A tool appears in Available tools only when the caller provides a one-line snippet.\n\tconst tools = selectedTools || [\"read\", \"bash\", \"edit\", \"write\", \"search\", \"grep\", \"find\", \"ls\"];\n\tconst visibleTools = tools.filter((name) => !!toolSnippets?.[name]);\n\tconst toolsList =\n\t\tvisibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join(\"\\n\") : \"(none)\";\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\tconst guidelinesSet = new Set<string>();\n\tconst addGuideline = (guideline: string): void => {\n\t\tif (guidelinesSet.has(guideline)) {\n\t\t\treturn;\n\t\t}\n\t\tguidelinesSet.add(guideline);\n\t\tguidelinesList.push(guideline);\n\t};\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasSearch = tools.includes(\"search\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// File exploration guidelines. Name only the tools that are actually\n\t// registered (the condition used to OR the three but hardcode all three\n\t// names, advertising tools that might not exist) and map each to its job so\n\t// the model picks the right one instead of defaulting to its bash habit.\n\tconst explore: string[] = [];\n\tif (hasSearch) explore.push(\"search (find where code lives by concept or identifier)\");\n\tif (hasGrep) explore.push(\"grep (exact line/regex search)\");\n\tif (hasFind) explore.push(\"find (locate files by name/glob)\");\n\tif (hasLs) explore.push(\"ls (list directory contents)\");\n\tif (explore.length > 0) {\n\t\taddGuideline(\n\t\t\t`For file exploration use the dedicated tools — ${explore.join(\", \")} — instead of bash; they are faster, and respect .gitignore where applicable`,\n\t\t);\n\t} else if (hasBash) {\n\t\taddGuideline(\"Use bash for file exploration (ls, rg/grep, find)\");\n\t}\n\n\t// Single source of truth for the search↔grep decision. Gated on both tools\n\t// being active so we never reference a tool that isn't registered — grep.ts\n\t// and search.ts intentionally no longer cross-reference each other, since a\n\t// tool factory can't know what else is in the bundle.\n\tif (hasSearch && hasGrep) {\n\t\taddGuideline(\n\t\t\t\"Between search and grep: search finds where code lives by concept, behavior, or half-known name (ranked results); grep enumerates exact matching lines, regexes, and counts (output proportional to matches)\",\n\t\t);\n\t}\n\n\tfor (const guideline of promptGuidelines ?? []) {\n\t\tconst normalized = guideline.trim();\n\t\tif (normalized.length > 0) {\n\t\t\taddGuideline(normalized);\n\t\t}\n\t}\n\n\t// Always include these\n\taddGuideline(\"Be concise in your responses\");\n\taddGuideline(\"No preamble or postamble; do not restate the task or summarize what you just did\");\n\taddGuideline('Do not add closers like \"Let me know\" or \"Hope this helps\"');\n\taddGuideline(\n\t\t\"Do not narrate routine tool calls or results — the permission gate already shows them; speak when you have the answer or need a decision\",\n\t);\n\taddGuideline(\n\t\t\"Match the surrounding code's conventions for comments, docstrings, and types — do not add or strip them by default\",\n\t);\n\taddGuideline(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant operating inside hoocode, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines}`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Append skills section (only if read tool is available)\n\tif (hasRead && skills.length > 0) {\n\t\tprompt += formatSkillsForPrompt(skills);\n\t}\n\n\t// Append agents section (only when Task tool is active)\n\tconst hasTask = tools.includes(TASK_TOOL_NAME);\n\tif (hasTask && agents.length > 0) {\n\t\tprompt += formatAgentsForPrompt(agents);\n\t}\n\n\t// Append hoocode's own docs (only if read tool is available)\n\tif (wantSelfDocs && hasRead) {\n\t\tprompt += formatSelfDocsForPrompt();\n\t}\n\n\t// Add date and working directory last\n\tprompt += `\\nCurrent date: ${date}`;\n\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\treturn prompt;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"system-prompt.d.ts","sourceRoot":"","sources":["../../src/core/system-prompt.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,wBAAwB,CAAC;AAG9E,OAAO,EAAyB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,wBAAwB;IACxC,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,0DAA0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,uCAAuC;IACvC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,YAAY,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,yBAAyB;IACzB,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC;IACjB;;;;OAIG;IACH,MAAM,CAAC,EAAE,eAAe,EAAE,CAAC;IAC3B;;;;;;;;;;OAUG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,kEAAkE;AAClE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,MAAM,CA6L3E","sourcesContent":["/**\n * System prompt construction and project context loading\n */\n\nimport { type AgentDefinition, TASK_TOOL_NAME } from \"./agent-frontmatter.js\";\nimport { formatAgentsForPrompt } from \"./agent-registry.js\";\nimport { formatSelfDocsForPrompt } from \"./self-docs.js\";\nimport { formatSkillsForPrompt, type Skill } from \"./skills.js\";\n\nexport interface BuildSystemPromptOptions {\n\t/** Custom system prompt (replaces default). */\n\tcustomPrompt?: string;\n\t/** Tools to include in prompt. Default: [read, bash, edit, write, search, grep, find, ls] */\n\tselectedTools?: string[];\n\t/** Optional one-line tool snippets keyed by tool name. */\n\ttoolSnippets?: Record<string, string>;\n\t/** Additional guideline bullets appended to the default system prompt guidelines. */\n\tpromptGuidelines?: string[];\n\t/** Text to append to system prompt. */\n\tappendSystemPrompt?: string;\n\t/** Working directory. */\n\tcwd: string;\n\t/** Pre-loaded context files. */\n\tcontextFiles?: Array<{ path: string; content: string }>;\n\t/** Pre-loaded skills. */\n\tskills?: Skill[];\n\t/**\n\t * Available agents for delegation, emitted as `<available_agents>` XML.\n\t * Only populated when the Task tool is active so the model knows which\n\t * agents exist without re-reading the agent registry each turn.\n\t */\n\tagents?: AgentDefinition[];\n\t/**\n\t * Point the model at hoocode's own shipped docs so it can answer questions\n\t * about hoocode itself.\n\t *\n\t * Defaults to true for the built-in prompt and false when `customPrompt`\n\t * replaces it. Every other appended section (context files, skills, agents)\n\t * only appears because the caller passed the content in; this one\n\t * materializes on its own, so a caller who has taken over the system prompt\n\t * gets it only by asking. That also keeps it out of light mode, whose whole\n\t * point is a minimal fixed per-turn surface. Needs the read tool either way.\n\t */\n\tincludeSelfDocs?: boolean;\n}\n\n/** Build the system prompt with tools, guidelines, and context */\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n\tconst {\n\t\tcustomPrompt,\n\t\tselectedTools,\n\t\ttoolSnippets,\n\t\tpromptGuidelines,\n\t\tappendSystemPrompt,\n\t\tcwd,\n\t\tcontextFiles: providedContextFiles,\n\t\tskills: providedSkills,\n\t\tagents: providedAgents,\n\t\tincludeSelfDocs,\n\t} = options;\n\tconst resolvedCwd = cwd;\n\tconst promptCwd = resolvedCwd.replace(/\\\\/g, \"/\");\n\n\tconst now = new Date();\n\tconst year = now.getFullYear();\n\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(now.getDate()).padStart(2, \"0\");\n\tconst date = `${year}-${month}-${day}`;\n\n\tconst appendSection = appendSystemPrompt ? `\\n\\n${appendSystemPrompt}` : \"\";\n\tconst wantSelfDocs = includeSelfDocs ?? !customPrompt;\n\n\tconst contextFiles = providedContextFiles ?? [];\n\tconst skills = providedSkills ?? [];\n\tconst agents = providedAgents ?? [];\n\n\tif (customPrompt) {\n\t\tlet prompt = customPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Append skills section (only if read tool is available)\n\t\tconst hasRead = !selectedTools || selectedTools.includes(\"read\");\n\t\tif (hasRead && skills.length > 0) {\n\t\t\tprompt += formatSkillsForPrompt(skills);\n\t\t}\n\n\t\t// Append agents section (only when Task tool is active)\n\t\tconst hasTask = !selectedTools || selectedTools.includes(TASK_TOOL_NAME);\n\t\tif (hasTask && agents.length > 0) {\n\t\t\tprompt += formatAgentsForPrompt(agents);\n\t\t}\n\n\t\t// Append hoocode's own docs (only if read tool is available)\n\t\tif (wantSelfDocs && hasRead) {\n\t\t\tprompt += formatSelfDocsForPrompt();\n\t\t}\n\n\t\t// Add date and working directory last\n\t\tprompt += `\\nCurrent date: ${date}`;\n\t\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Build tools list based on selected tools.\n\t// A tool appears in Available tools only when the caller provides a one-line snippet.\n\tconst tools = selectedTools || [\"read\", \"bash\", \"edit\", \"write\", \"search\", \"grep\", \"find\", \"ls\"];\n\tconst visibleTools = tools.filter((name) => !!toolSnippets?.[name]);\n\tconst toolsList =\n\t\tvisibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join(\"\\n\") : \"(none)\";\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\tconst guidelinesSet = new Set<string>();\n\tconst addGuideline = (guideline: string): void => {\n\t\tif (guidelinesSet.has(guideline)) {\n\t\t\treturn;\n\t\t}\n\t\tguidelinesSet.add(guideline);\n\t\tguidelinesList.push(guideline);\n\t};\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasSearch = tools.includes(\"search\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// File exploration guidelines. Name only the tools that are actually\n\t// registered (the condition used to OR the three but hardcode all three\n\t// names, advertising tools that might not exist) and map each to its job so\n\t// the model picks the right one instead of defaulting to its bash habit.\n\tconst explore: string[] = [];\n\tif (hasSearch) explore.push(\"search (find where code lives by concept or identifier)\");\n\tif (hasGrep) explore.push(\"grep (exact line/regex search)\");\n\tif (hasFind) explore.push(\"find (locate files by name/glob)\");\n\tif (hasLs) explore.push(\"ls (list directory contents)\");\n\tif (explore.length > 0) {\n\t\taddGuideline(\n\t\t\t`For file exploration use the dedicated tools — ${explore.join(\", \")} — instead of bash; they are faster, and respect .gitignore where applicable`,\n\t\t);\n\t} else if (hasBash) {\n\t\taddGuideline(\"Use bash for file exploration (ls, rg/grep, find)\");\n\t}\n\n\t// Single source of truth for the search↔grep decision. Gated on both tools\n\t// being active so we never reference a tool that isn't registered — grep.ts\n\t// and search.ts intentionally no longer cross-reference each other, since a\n\t// tool factory can't know what else is in the bundle.\n\tif (hasSearch && hasGrep) {\n\t\taddGuideline(\n\t\t\t\"Between search and grep: search finds where code lives by concept, behavior, or half-known name (ranked results); grep enumerates exact matching lines, regexes, and counts (output proportional to matches)\",\n\t\t);\n\t}\n\n\tfor (const guideline of promptGuidelines ?? []) {\n\t\tconst normalized = guideline.trim();\n\t\tif (normalized.length > 0) {\n\t\t\taddGuideline(normalized);\n\t\t}\n\t}\n\n\t// Always include these\n\taddGuideline(\n\t\t\"Put independent tool calls in one message — they execute in parallel; only split them across turns when a call needs an earlier call's result\",\n\t);\n\taddGuideline(\"Be concise in your responses\");\n\taddGuideline(\"No preamble or postamble; do not restate the task or summarize what you just did\");\n\taddGuideline('Do not add closers like \"Let me know\" or \"Hope this helps\"');\n\taddGuideline(\n\t\t\"Do not narrate routine tool calls or results — the permission gate already shows them; speak when you have the answer or need a decision\",\n\t);\n\taddGuideline(\n\t\t\"Match the surrounding code's conventions for comments, docstrings, and types — do not add or strip them by default\",\n\t);\n\taddGuideline(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant operating inside hoocode, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines}`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Append skills section (only if read tool is available)\n\tif (hasRead && skills.length > 0) {\n\t\tprompt += formatSkillsForPrompt(skills);\n\t}\n\n\t// Append agents section (only when Task tool is active)\n\tconst hasTask = tools.includes(TASK_TOOL_NAME);\n\tif (hasTask && agents.length > 0) {\n\t\tprompt += formatAgentsForPrompt(agents);\n\t}\n\n\t// Append hoocode's own docs (only if read tool is available)\n\tif (wantSelfDocs && hasRead) {\n\t\tprompt += formatSelfDocsForPrompt();\n\t}\n\n\t// Add date and working directory last\n\tprompt += `\\nCurrent date: ${date}`;\n\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\treturn prompt;\n}\n"]}
|
|
@@ -106,6 +106,7 @@ export function buildSystemPrompt(options) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
// Always include these
|
|
109
|
+
addGuideline("Put independent tool calls in one message — they execute in parallel; only split them across turns when a call needs an earlier call's result");
|
|
109
110
|
addGuideline("Be concise in your responses");
|
|
110
111
|
addGuideline("No preamble or postamble; do not restate the task or summarize what you just did");
|
|
111
112
|
addGuideline('Do not add closers like "Let me know" or "Hope this helps"');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"system-prompt.js","sourceRoot":"","sources":["../../src/core/system-prompt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * System prompt construction and project context loading\n */\n\nimport { type AgentDefinition, TASK_TOOL_NAME } from \"./agent-frontmatter.js\";\nimport { formatAgentsForPrompt } from \"./agent-registry.js\";\nimport { formatSelfDocsForPrompt } from \"./self-docs.js\";\nimport { formatSkillsForPrompt, type Skill } from \"./skills.js\";\n\nexport interface BuildSystemPromptOptions {\n\t/** Custom system prompt (replaces default). */\n\tcustomPrompt?: string;\n\t/** Tools to include in prompt. Default: [read, bash, edit, write, search, grep, find, ls] */\n\tselectedTools?: string[];\n\t/** Optional one-line tool snippets keyed by tool name. */\n\ttoolSnippets?: Record<string, string>;\n\t/** Additional guideline bullets appended to the default system prompt guidelines. */\n\tpromptGuidelines?: string[];\n\t/** Text to append to system prompt. */\n\tappendSystemPrompt?: string;\n\t/** Working directory. */\n\tcwd: string;\n\t/** Pre-loaded context files. */\n\tcontextFiles?: Array<{ path: string; content: string }>;\n\t/** Pre-loaded skills. */\n\tskills?: Skill[];\n\t/**\n\t * Available agents for delegation, emitted as `<available_agents>` XML.\n\t * Only populated when the Task tool is active so the model knows which\n\t * agents exist without re-reading the agent registry each turn.\n\t */\n\tagents?: AgentDefinition[];\n\t/**\n\t * Point the model at hoocode's own shipped docs so it can answer questions\n\t * about hoocode itself.\n\t *\n\t * Defaults to true for the built-in prompt and false when `customPrompt`\n\t * replaces it. Every other appended section (context files, skills, agents)\n\t * only appears because the caller passed the content in; this one\n\t * materializes on its own, so a caller who has taken over the system prompt\n\t * gets it only by asking. That also keeps it out of light mode, whose whole\n\t * point is a minimal fixed per-turn surface. Needs the read tool either way.\n\t */\n\tincludeSelfDocs?: boolean;\n}\n\n/** Build the system prompt with tools, guidelines, and context */\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n\tconst {\n\t\tcustomPrompt,\n\t\tselectedTools,\n\t\ttoolSnippets,\n\t\tpromptGuidelines,\n\t\tappendSystemPrompt,\n\t\tcwd,\n\t\tcontextFiles: providedContextFiles,\n\t\tskills: providedSkills,\n\t\tagents: providedAgents,\n\t\tincludeSelfDocs,\n\t} = options;\n\tconst resolvedCwd = cwd;\n\tconst promptCwd = resolvedCwd.replace(/\\\\/g, \"/\");\n\n\tconst now = new Date();\n\tconst year = now.getFullYear();\n\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(now.getDate()).padStart(2, \"0\");\n\tconst date = `${year}-${month}-${day}`;\n\n\tconst appendSection = appendSystemPrompt ? `\\n\\n${appendSystemPrompt}` : \"\";\n\tconst wantSelfDocs = includeSelfDocs ?? !customPrompt;\n\n\tconst contextFiles = providedContextFiles ?? [];\n\tconst skills = providedSkills ?? [];\n\tconst agents = providedAgents ?? [];\n\n\tif (customPrompt) {\n\t\tlet prompt = customPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Append skills section (only if read tool is available)\n\t\tconst hasRead = !selectedTools || selectedTools.includes(\"read\");\n\t\tif (hasRead && skills.length > 0) {\n\t\t\tprompt += formatSkillsForPrompt(skills);\n\t\t}\n\n\t\t// Append agents section (only when Task tool is active)\n\t\tconst hasTask = !selectedTools || selectedTools.includes(TASK_TOOL_NAME);\n\t\tif (hasTask && agents.length > 0) {\n\t\t\tprompt += formatAgentsForPrompt(agents);\n\t\t}\n\n\t\t// Append hoocode's own docs (only if read tool is available)\n\t\tif (wantSelfDocs && hasRead) {\n\t\t\tprompt += formatSelfDocsForPrompt();\n\t\t}\n\n\t\t// Add date and working directory last\n\t\tprompt += `\\nCurrent date: ${date}`;\n\t\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Build tools list based on selected tools.\n\t// A tool appears in Available tools only when the caller provides a one-line snippet.\n\tconst tools = selectedTools || [\"read\", \"bash\", \"edit\", \"write\", \"search\", \"grep\", \"find\", \"ls\"];\n\tconst visibleTools = tools.filter((name) => !!toolSnippets?.[name]);\n\tconst toolsList =\n\t\tvisibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join(\"\\n\") : \"(none)\";\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\tconst guidelinesSet = new Set<string>();\n\tconst addGuideline = (guideline: string): void => {\n\t\tif (guidelinesSet.has(guideline)) {\n\t\t\treturn;\n\t\t}\n\t\tguidelinesSet.add(guideline);\n\t\tguidelinesList.push(guideline);\n\t};\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasSearch = tools.includes(\"search\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// File exploration guidelines. Name only the tools that are actually\n\t// registered (the condition used to OR the three but hardcode all three\n\t// names, advertising tools that might not exist) and map each to its job so\n\t// the model picks the right one instead of defaulting to its bash habit.\n\tconst explore: string[] = [];\n\tif (hasSearch) explore.push(\"search (find where code lives by concept or identifier)\");\n\tif (hasGrep) explore.push(\"grep (exact line/regex search)\");\n\tif (hasFind) explore.push(\"find (locate files by name/glob)\");\n\tif (hasLs) explore.push(\"ls (list directory contents)\");\n\tif (explore.length > 0) {\n\t\taddGuideline(\n\t\t\t`For file exploration use the dedicated tools — ${explore.join(\", \")} — instead of bash; they are faster, and respect .gitignore where applicable`,\n\t\t);\n\t} else if (hasBash) {\n\t\taddGuideline(\"Use bash for file exploration (ls, rg/grep, find)\");\n\t}\n\n\t// Single source of truth for the search↔grep decision. Gated on both tools\n\t// being active so we never reference a tool that isn't registered — grep.ts\n\t// and search.ts intentionally no longer cross-reference each other, since a\n\t// tool factory can't know what else is in the bundle.\n\tif (hasSearch && hasGrep) {\n\t\taddGuideline(\n\t\t\t\"Between search and grep: search finds where code lives by concept, behavior, or half-known name (ranked results); grep enumerates exact matching lines, regexes, and counts (output proportional to matches)\",\n\t\t);\n\t}\n\n\tfor (const guideline of promptGuidelines ?? []) {\n\t\tconst normalized = guideline.trim();\n\t\tif (normalized.length > 0) {\n\t\t\taddGuideline(normalized);\n\t\t}\n\t}\n\n\t// Always include these\n\taddGuideline(\"Be concise in your responses\");\n\taddGuideline(\"No preamble or postamble; do not restate the task or summarize what you just did\");\n\taddGuideline('Do not add closers like \"Let me know\" or \"Hope this helps\"');\n\taddGuideline(\n\t\t\"Do not narrate routine tool calls or results — the permission gate already shows them; speak when you have the answer or need a decision\",\n\t);\n\taddGuideline(\n\t\t\"Match the surrounding code's conventions for comments, docstrings, and types — do not add or strip them by default\",\n\t);\n\taddGuideline(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant operating inside hoocode, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines}`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Append skills section (only if read tool is available)\n\tif (hasRead && skills.length > 0) {\n\t\tprompt += formatSkillsForPrompt(skills);\n\t}\n\n\t// Append agents section (only when Task tool is active)\n\tconst hasTask = tools.includes(TASK_TOOL_NAME);\n\tif (hasTask && agents.length > 0) {\n\t\tprompt += formatAgentsForPrompt(agents);\n\t}\n\n\t// Append hoocode's own docs (only if read tool is available)\n\tif (wantSelfDocs && hasRead) {\n\t\tprompt += formatSelfDocsForPrompt();\n\t}\n\n\t// Add date and working directory last\n\tprompt += `\\nCurrent date: ${date}`;\n\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\treturn prompt;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"system-prompt.js","sourceRoot":"","sources":["../../src/core/system-prompt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * System prompt construction and project context loading\n */\n\nimport { type AgentDefinition, TASK_TOOL_NAME } from \"./agent-frontmatter.js\";\nimport { formatAgentsForPrompt } from \"./agent-registry.js\";\nimport { formatSelfDocsForPrompt } from \"./self-docs.js\";\nimport { formatSkillsForPrompt, type Skill } from \"./skills.js\";\n\nexport interface BuildSystemPromptOptions {\n\t/** Custom system prompt (replaces default). */\n\tcustomPrompt?: string;\n\t/** Tools to include in prompt. Default: [read, bash, edit, write, search, grep, find, ls] */\n\tselectedTools?: string[];\n\t/** Optional one-line tool snippets keyed by tool name. */\n\ttoolSnippets?: Record<string, string>;\n\t/** Additional guideline bullets appended to the default system prompt guidelines. */\n\tpromptGuidelines?: string[];\n\t/** Text to append to system prompt. */\n\tappendSystemPrompt?: string;\n\t/** Working directory. */\n\tcwd: string;\n\t/** Pre-loaded context files. */\n\tcontextFiles?: Array<{ path: string; content: string }>;\n\t/** Pre-loaded skills. */\n\tskills?: Skill[];\n\t/**\n\t * Available agents for delegation, emitted as `<available_agents>` XML.\n\t * Only populated when the Task tool is active so the model knows which\n\t * agents exist without re-reading the agent registry each turn.\n\t */\n\tagents?: AgentDefinition[];\n\t/**\n\t * Point the model at hoocode's own shipped docs so it can answer questions\n\t * about hoocode itself.\n\t *\n\t * Defaults to true for the built-in prompt and false when `customPrompt`\n\t * replaces it. Every other appended section (context files, skills, agents)\n\t * only appears because the caller passed the content in; this one\n\t * materializes on its own, so a caller who has taken over the system prompt\n\t * gets it only by asking. That also keeps it out of light mode, whose whole\n\t * point is a minimal fixed per-turn surface. Needs the read tool either way.\n\t */\n\tincludeSelfDocs?: boolean;\n}\n\n/** Build the system prompt with tools, guidelines, and context */\nexport function buildSystemPrompt(options: BuildSystemPromptOptions): string {\n\tconst {\n\t\tcustomPrompt,\n\t\tselectedTools,\n\t\ttoolSnippets,\n\t\tpromptGuidelines,\n\t\tappendSystemPrompt,\n\t\tcwd,\n\t\tcontextFiles: providedContextFiles,\n\t\tskills: providedSkills,\n\t\tagents: providedAgents,\n\t\tincludeSelfDocs,\n\t} = options;\n\tconst resolvedCwd = cwd;\n\tconst promptCwd = resolvedCwd.replace(/\\\\/g, \"/\");\n\n\tconst now = new Date();\n\tconst year = now.getFullYear();\n\tconst month = String(now.getMonth() + 1).padStart(2, \"0\");\n\tconst day = String(now.getDate()).padStart(2, \"0\");\n\tconst date = `${year}-${month}-${day}`;\n\n\tconst appendSection = appendSystemPrompt ? `\\n\\n${appendSystemPrompt}` : \"\";\n\tconst wantSelfDocs = includeSelfDocs ?? !customPrompt;\n\n\tconst contextFiles = providedContextFiles ?? [];\n\tconst skills = providedSkills ?? [];\n\tconst agents = providedAgents ?? [];\n\n\tif (customPrompt) {\n\t\tlet prompt = customPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Append skills section (only if read tool is available)\n\t\tconst hasRead = !selectedTools || selectedTools.includes(\"read\");\n\t\tif (hasRead && skills.length > 0) {\n\t\t\tprompt += formatSkillsForPrompt(skills);\n\t\t}\n\n\t\t// Append agents section (only when Task tool is active)\n\t\tconst hasTask = !selectedTools || selectedTools.includes(TASK_TOOL_NAME);\n\t\tif (hasTask && agents.length > 0) {\n\t\t\tprompt += formatAgentsForPrompt(agents);\n\t\t}\n\n\t\t// Append hoocode's own docs (only if read tool is available)\n\t\tif (wantSelfDocs && hasRead) {\n\t\t\tprompt += formatSelfDocsForPrompt();\n\t\t}\n\n\t\t// Add date and working directory last\n\t\tprompt += `\\nCurrent date: ${date}`;\n\t\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Build tools list based on selected tools.\n\t// A tool appears in Available tools only when the caller provides a one-line snippet.\n\tconst tools = selectedTools || [\"read\", \"bash\", \"edit\", \"write\", \"search\", \"grep\", \"find\", \"ls\"];\n\tconst visibleTools = tools.filter((name) => !!toolSnippets?.[name]);\n\tconst toolsList =\n\t\tvisibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join(\"\\n\") : \"(none)\";\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\tconst guidelinesSet = new Set<string>();\n\tconst addGuideline = (guideline: string): void => {\n\t\tif (guidelinesSet.has(guideline)) {\n\t\t\treturn;\n\t\t}\n\t\tguidelinesSet.add(guideline);\n\t\tguidelinesList.push(guideline);\n\t};\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasSearch = tools.includes(\"search\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// File exploration guidelines. Name only the tools that are actually\n\t// registered (the condition used to OR the three but hardcode all three\n\t// names, advertising tools that might not exist) and map each to its job so\n\t// the model picks the right one instead of defaulting to its bash habit.\n\tconst explore: string[] = [];\n\tif (hasSearch) explore.push(\"search (find where code lives by concept or identifier)\");\n\tif (hasGrep) explore.push(\"grep (exact line/regex search)\");\n\tif (hasFind) explore.push(\"find (locate files by name/glob)\");\n\tif (hasLs) explore.push(\"ls (list directory contents)\");\n\tif (explore.length > 0) {\n\t\taddGuideline(\n\t\t\t`For file exploration use the dedicated tools — ${explore.join(\", \")} — instead of bash; they are faster, and respect .gitignore where applicable`,\n\t\t);\n\t} else if (hasBash) {\n\t\taddGuideline(\"Use bash for file exploration (ls, rg/grep, find)\");\n\t}\n\n\t// Single source of truth for the search↔grep decision. Gated on both tools\n\t// being active so we never reference a tool that isn't registered — grep.ts\n\t// and search.ts intentionally no longer cross-reference each other, since a\n\t// tool factory can't know what else is in the bundle.\n\tif (hasSearch && hasGrep) {\n\t\taddGuideline(\n\t\t\t\"Between search and grep: search finds where code lives by concept, behavior, or half-known name (ranked results); grep enumerates exact matching lines, regexes, and counts (output proportional to matches)\",\n\t\t);\n\t}\n\n\tfor (const guideline of promptGuidelines ?? []) {\n\t\tconst normalized = guideline.trim();\n\t\tif (normalized.length > 0) {\n\t\t\taddGuideline(normalized);\n\t\t}\n\t}\n\n\t// Always include these\n\taddGuideline(\n\t\t\"Put independent tool calls in one message — they execute in parallel; only split them across turns when a call needs an earlier call's result\",\n\t);\n\taddGuideline(\"Be concise in your responses\");\n\taddGuideline(\"No preamble or postamble; do not restate the task or summarize what you just did\");\n\taddGuideline('Do not add closers like \"Let me know\" or \"Hope this helps\"');\n\taddGuideline(\n\t\t\"Do not narrate routine tool calls or results — the permission gate already shows them; speak when you have the answer or need a decision\",\n\t);\n\taddGuideline(\n\t\t\"Match the surrounding code's conventions for comments, docstrings, and types — do not add or strip them by default\",\n\t);\n\taddGuideline(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant operating inside hoocode, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nIn addition to the tools above, you may have access to other custom tools depending on the project.\n\nGuidelines:\n${guidelines}`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"Project-specific instructions and guidelines:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Append skills section (only if read tool is available)\n\tif (hasRead && skills.length > 0) {\n\t\tprompt += formatSkillsForPrompt(skills);\n\t}\n\n\t// Append agents section (only when Task tool is active)\n\tconst hasTask = tools.includes(TASK_TOOL_NAME);\n\tif (hasTask && agents.length > 0) {\n\t\tprompt += formatAgentsForPrompt(agents);\n\t}\n\n\t// Append hoocode's own docs (only if read tool is available)\n\tif (wantSelfDocs && hasRead) {\n\t\tprompt += formatSelfDocsForPrompt();\n\t}\n\n\t// Add date and working directory last\n\tprompt += `\\nCurrent date: ${date}`;\n\tprompt += `\\nCurrent working directory: ${promptCwd}`;\n\n\treturn prompt;\n}\n"]}
|
|
@@ -27,9 +27,13 @@ export declare function stripBom(content: string): {
|
|
|
27
27
|
* Apply one or more exact-text replacements to LF-normalized content.
|
|
28
28
|
*
|
|
29
29
|
* All edits are matched against the same original content. Replacements are
|
|
30
|
-
* then applied in reverse order so offsets remain stable.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* then applied in reverse order so offsets remain stable.
|
|
31
|
+
*
|
|
32
|
+
* Every match, however loosely it was found, is resolved back to a span of the
|
|
33
|
+
* *original* content before anything is written. A fuzzy or indentation-tolerant
|
|
34
|
+
* match therefore rewrites only the lines it landed on: the rest of the file
|
|
35
|
+
* keeps its exact bytes, and the diff callers render from `baseContent` is the
|
|
36
|
+
* real change on disk rather than a normalized-vs-normalized view of it.
|
|
33
37
|
*/
|
|
34
38
|
export declare function applyEditsToNormalizedContent(normalizedContent: string, edits: Edit[], path: string): AppliedEditsResult;
|
|
35
39
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"edit-diff.d.ts","sourceRoot":"","sources":["../../../src/core/tools/edit-diff.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM/D;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAE9E;AA6ED,MAAM,WAAW,IAAI;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AASD,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACnB;AAgDD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAEvE;AAsHD;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAC5C,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,IAAI,EAAE,MAAM,GACV,kBAAkB,CA2FpB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,YAAY,SAAI,GACd;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAuHxD;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,IAAI,EAAE,EACb,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,cAAc,GAAG,aAAa,CAAC,CAyBzC","sourcesContent":["/**\n * Shared diff computation utilities for the edit tool.\n * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).\n */\n\nimport * as Diff from \"diff\";\nimport { constants } from \"fs\";\nimport { access, readFile } from \"fs/promises\";\nimport { resolveToCwd } from \"./path-utils.js\";\n\n/** Cache for normalized text to avoid redundant processing. Max 100 entries. */\nconst normalizeCache = new Map<string, string>();\nconst MAX_CACHE_SIZE = 100;\n\nexport function detectLineEnding(content: string): \"\\r\\n\" | \"\\n\" {\n\tconst crlfIdx = content.indexOf(\"\\r\\n\");\n\tconst lfIdx = content.indexOf(\"\\n\");\n\tif (lfIdx === -1) return \"\\n\";\n\tif (crlfIdx === -1) return \"\\n\";\n\treturn crlfIdx < lfIdx ? \"\\r\\n\" : \"\\n\";\n}\n\nexport function normalizeToLF(text: string): string {\n\treturn text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n}\n\nexport function restoreLineEndings(text: string, ending: \"\\r\\n\" | \"\\n\"): string {\n\treturn ending === \"\\r\\n\" ? text.replace(/\\n/g, \"\\r\\n\") : text;\n}\n\n/**\n * Normalize text for fuzzy matching. Applies progressive transformations:\n * - Normalize line endings to LF\n * - Strip trailing whitespace from each line\n * - Normalize tabs to spaces (2 spaces per tab)\n * - Collapse multiple spaces to single space\n * - Normalize smart quotes to ASCII equivalents\n * - Normalize Unicode dashes/hyphens to ASCII hyphen\n * - Normalize special Unicode spaces to regular space\n */\nfunction normalizeForFuzzyMatch(text: string): string {\n\t// Check cache first\n\tconst cached = normalizeCache.get(text);\n\tif (cached !== undefined) return cached;\n\n\tconst normalized = text\n\t\t.normalize(\"NFKC\")\n\t\t// Normalize line endings to LF\n\t\t.replace(/\\r\\n/g, \"\\n\")\n\t\t.replace(/\\r/g, \"\\n\")\n\t\t// Strip trailing whitespace per line\n\t\t.split(\"\\n\")\n\t\t.map((line) => {\n\t\t\t// Normalize tabs to 2 spaces\n\t\t\tlet normalized = line.replace(/\\t/g, \" \");\n\t\t\t// Collapse multiple spaces to single space (but preserve leading indentation pattern)\n\t\t\t// Only collapse spaces that are NOT at the start of the line (indentation)\n\t\t\tconst leadingSpaces = normalized.match(/^(\\s*)/)?.[1] ?? \"\";\n\t\t\tconst rest = normalized.slice(leadingSpaces.length);\n\t\t\tnormalized = leadingSpaces + rest.replace(/ {2,}/g, \" \");\n\t\t\treturn normalized.trimEnd();\n\t\t})\n\t\t.join(\"\\n\")\n\t\t// Smart single quotes → '\n\t\t.replace(/[\\u2018\\u2019\\u201A\\u201B]/g, \"'\")\n\t\t// Smart double quotes → \"\n\t\t.replace(/[\\u201C\\u201D\\u201E\\u201F]/g, '\"')\n\t\t// Various dashes/hyphens → -\n\t\t// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,\n\t\t// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus\n\t\t.replace(/[\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212]/g, \"-\")\n\t\t// Special spaces → regular space\n\t\t// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,\n\t\t// U+205F medium math space, U+3000 ideographic space\n\t\t.replace(/[\\u00A0\\u2002-\\u200A\\u202F\\u205F\\u3000]/g, \" \");\n\n\t// Cache the result (with size limit)\n\tif (normalizeCache.size >= MAX_CACHE_SIZE) {\n\t\t// Remove oldest entry (first key)\n\t\tconst firstKey = normalizeCache.keys().next().value;\n\t\tif (firstKey !== undefined) {\n\t\t\tnormalizeCache.delete(firstKey);\n\t\t}\n\t}\n\tnormalizeCache.set(text, normalized);\n\n\treturn normalized;\n}\n\ninterface FuzzyMatchResult {\n\t/** Whether a match was found */\n\tfound: boolean;\n\t/** The index where the match starts (in the content that should be used for replacement) */\n\tindex: number;\n\t/** Length of the matched text */\n\tmatchLength: number;\n\t/** Whether fuzzy matching was used (false = exact match) */\n\tusedFuzzyMatch: boolean;\n\t/**\n\t * The content to use for replacement operations.\n\t * When exact match: original content. When fuzzy match: normalized content.\n\t */\n\tcontentForReplacement: string;\n}\n\nexport interface Edit {\n\toldText: string;\n\tnewText: string;\n\t/**\n\t * When true, replace every occurrence of oldText instead of requiring it to\n\t * be unique. Default (false/undefined) keeps the uniqueness guardrail.\n\t */\n\treplaceAll?: boolean;\n}\n\ninterface MatchedEdit {\n\teditIndex: number;\n\tmatchIndex: number;\n\tmatchLength: number;\n\tnewText: string;\n}\n\nexport interface AppliedEditsResult {\n\tbaseContent: string;\n\tnewContent: string;\n}\n\n/**\n * Find oldText in content, trying exact match first, then fuzzy match.\n * When fuzzy matching is used, the returned contentForReplacement is the\n * fuzzy-normalized version of the content (trailing whitespace stripped,\n * Unicode quotes/dashes normalized to ASCII).\n */\nfunction fuzzyFindText(content: string, oldText: string): FuzzyMatchResult {\n\t// Try exact match first\n\tconst exactIndex = content.indexOf(oldText);\n\tif (exactIndex !== -1) {\n\t\treturn {\n\t\t\tfound: true,\n\t\t\tindex: exactIndex,\n\t\t\tmatchLength: oldText.length,\n\t\t\tusedFuzzyMatch: false,\n\t\t\tcontentForReplacement: content,\n\t\t};\n\t}\n\n\t// Try fuzzy match - work entirely in normalized space\n\tconst fuzzyContent = normalizeForFuzzyMatch(content);\n\tconst fuzzyOldText = normalizeForFuzzyMatch(oldText);\n\tconst fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);\n\n\tif (fuzzyIndex === -1) {\n\t\treturn {\n\t\t\tfound: false,\n\t\t\tindex: -1,\n\t\t\tmatchLength: 0,\n\t\t\tusedFuzzyMatch: false,\n\t\t\tcontentForReplacement: content,\n\t\t};\n\t}\n\n\t// When fuzzy matching, we work in the normalized space for replacement.\n\t// This means the output will have normalized whitespace/quotes/dashes,\n\t// which is acceptable since we're fixing minor formatting differences anyway.\n\treturn {\n\t\tfound: true,\n\t\tindex: fuzzyIndex,\n\t\tmatchLength: fuzzyOldText.length,\n\t\tusedFuzzyMatch: true,\n\t\tcontentForReplacement: fuzzyContent,\n\t};\n}\n\n/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */\nexport function stripBom(content: string): { bom: string; text: string } {\n\treturn content.startsWith(\"\\uFEFF\") ? { bom: \"\\uFEFF\", text: content.slice(1) } : { bom: \"\", text: content };\n}\n\n/**\n * Collect the start index of every non-overlapping occurrence of needle in\n * haystack. The needle must already be in the same space as haystack (raw for\n * exact matches, fuzzy-normalized when haystack is fuzzy-normalized).\n */\nfunction collectMatchIndices(haystack: string, needle: string): number[] {\n\tconst indices: number[] = [];\n\tif (needle.length === 0) return indices;\n\tlet from = 0;\n\twhile (true) {\n\t\tconst idx = haystack.indexOf(needle, from);\n\t\tif (idx === -1) break;\n\t\tindices.push(idx);\n\t\tfrom = idx + needle.length; // non-overlapping\n\t}\n\treturn indices;\n}\n\n/**\n * Per-line normalization for indentation-tolerant block matching. Applies the\n * same Unicode/space normalization as fuzzy matching, then strips all leading\n * and trailing whitespace so indentation differences are ignored entirely.\n */\nfunction blockNormalizeLine(line: string): string {\n\treturn normalizeForFuzzyMatch(line).trim();\n}\n\ninterface LineBlockMatch {\n\tmatchIndex: number;\n\tmatchLength: number;\n}\n\n/**\n * Indentation-tolerant fallback matcher. Compares oldText against content line\n * by line, ignoring each line's leading/trailing whitespace (and Unicode\n * formatting). Returns character spans in `content` for every block whose\n * trimmed lines equal the trimmed oldText lines. Replacement still happens in\n * the original content space, so surrounding formatting is preserved.\n */\nfunction findLineBlockMatches(content: string, oldText: string): LineBlockMatch[] {\n\tconst hadTrailingNewline = oldText.endsWith(\"\\n\");\n\tconst oldLines = oldText.split(\"\\n\");\n\tif (hadTrailingNewline) oldLines.pop();\n\tif (oldLines.length === 0) return [];\n\tconst trimmedOld = oldLines.map(blockNormalizeLine);\n\n\tconst contentLines = content.split(\"\\n\");\n\tconst k = trimmedOld.length;\n\tif (k > contentLines.length) return [];\n\n\t// Char offset of each line start within content.\n\tconst offsets = new Array<number>(contentLines.length);\n\tlet acc = 0;\n\tfor (let i = 0; i < contentLines.length; i++) {\n\t\toffsets[i] = acc;\n\t\tacc += contentLines[i].length + 1; // + newline\n\t}\n\n\tconst matches: LineBlockMatch[] = [];\n\tfor (let i = 0; i + k <= contentLines.length; i++) {\n\t\tlet ok = true;\n\t\tfor (let j = 0; j < k; j++) {\n\t\t\tif (blockNormalizeLine(contentLines[i + j]) !== trimmedOld[j]) {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!ok) continue;\n\t\tconst matchIndex = offsets[i];\n\t\tlet matchLength = 0;\n\t\tfor (let j = 0; j < k; j++) matchLength += contentLines[i + j].length + (j < k - 1 ? 1 : 0);\n\t\t// Include the trailing newline when oldText carried one and a line follows the block.\n\t\tif (hadTrailingNewline && i + k < contentLines.length) matchLength += 1;\n\t\tmatches.push({ matchIndex, matchLength });\n\t}\n\treturn matches;\n}\n\nfunction getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,\n\t);\n}\n\nfunction getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,\n\t);\n}\n\nfunction getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(`oldText must not be empty in ${path}.`);\n\t}\n\treturn new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);\n}\n\nfunction getNoChangeError(path: string, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,\n\t\t);\n\t}\n\treturn new Error(`No changes made to ${path}. The replacements produced identical content.`);\n}\n\n/**\n * Apply one or more exact-text replacements to LF-normalized content.\n *\n * All edits are matched against the same original content. Replacements are\n * then applied in reverse order so offsets remain stable. If any edit needs\n * fuzzy matching, the operation runs in fuzzy-normalized content space to\n * preserve current single-edit behavior.\n */\nexport function applyEditsToNormalizedContent(\n\tnormalizedContent: string,\n\tedits: Edit[],\n\tpath: string,\n): AppliedEditsResult {\n\tconst normalizedEdits = edits.map((edit) => ({\n\t\toldText: normalizeToLF(edit.oldText),\n\t\tnewText: normalizeToLF(edit.newText),\n\t\treplaceAll: edit.replaceAll === true,\n\t}));\n\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tif (normalizedEdits[i].oldText.length === 0) {\n\t\t\tthrow getEmptyOldTextError(path, i, normalizedEdits.length);\n\t\t}\n\t}\n\n\tconst initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));\n\tconst baseContent = initialMatches.some((match) => match.usedFuzzyMatch)\n\t\t? normalizeForFuzzyMatch(normalizedContent)\n\t\t: normalizedContent;\n\n\tconst matchedEdits: MatchedEdit[] = [];\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tconst edit = normalizedEdits[i];\n\t\tconst matchResult = fuzzyFindText(baseContent, edit.oldText);\n\n\t\t// Resolve the match spans for this edit, all in baseContent space.\n\t\t// Tier 1/2: exact then fuzzy (fuzzyFindText). Tier 3: indentation-tolerant\n\t\t// line-block fallback, only when both fail — the common cause of edit\n\t\t// failures is leading-whitespace drift that survives fuzzy normalization.\n\t\tlet spans: LineBlockMatch[];\n\t\tif (matchResult.found) {\n\t\t\tconst needle = matchResult.usedFuzzyMatch ? normalizeForFuzzyMatch(edit.oldText) : edit.oldText;\n\t\t\tspans = collectMatchIndices(baseContent, needle).map((matchIndex) => ({\n\t\t\t\tmatchIndex,\n\t\t\t\tmatchLength: matchResult.matchLength,\n\t\t\t}));\n\t\t} else {\n\t\t\tspans = findLineBlockMatches(baseContent, edit.oldText);\n\t\t\tif (spans.length === 0) {\n\t\t\t\tthrow getNotFoundError(path, i, normalizedEdits.length);\n\t\t\t}\n\t\t}\n\n\t\tif (edit.replaceAll) {\n\t\t\t// Replace every occurrence so the shared reverse-order applier rewrites them all.\n\t\t\tfor (const span of spans) {\n\t\t\t\tmatchedEdits.push({\n\t\t\t\t\teditIndex: i,\n\t\t\t\t\tmatchIndex: span.matchIndex,\n\t\t\t\t\tmatchLength: span.matchLength,\n\t\t\t\t\tnewText: edit.newText,\n\t\t\t\t});\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (spans.length > 1) {\n\t\t\tthrow getDuplicateError(path, i, normalizedEdits.length, spans.length);\n\t\t}\n\n\t\tmatchedEdits.push({\n\t\t\teditIndex: i,\n\t\t\tmatchIndex: spans[0].matchIndex,\n\t\t\tmatchLength: spans[0].matchLength,\n\t\t\tnewText: edit.newText,\n\t\t});\n\t}\n\n\tmatchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);\n\tfor (let i = 1; i < matchedEdits.length; i++) {\n\t\tconst previous = matchedEdits[i - 1];\n\t\tconst current = matchedEdits[i];\n\t\tif (previous.matchIndex + previous.matchLength > current.matchIndex) {\n\t\t\tthrow new Error(\n\t\t\t\t`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tlet newContent = baseContent;\n\tfor (let i = matchedEdits.length - 1; i >= 0; i--) {\n\t\tconst edit = matchedEdits[i];\n\t\tnewContent =\n\t\t\tnewContent.substring(0, edit.matchIndex) +\n\t\t\tedit.newText +\n\t\t\tnewContent.substring(edit.matchIndex + edit.matchLength);\n\t}\n\n\tif (baseContent === newContent) {\n\t\tthrow getNoChangeError(path, normalizedEdits.length);\n\t}\n\n\treturn { baseContent, newContent };\n}\n\n/**\n * Generate a unified diff string with line numbers and context.\n * Returns both the diff string and the first changed line number (in the new file).\n */\nexport function generateDiffString(\n\toldContent: string,\n\tnewContent: string,\n\tcontextLines = 4,\n): { diff: string; firstChangedLine: number | undefined } {\n\tconst parts = Diff.diffLines(oldContent, newContent);\n\tconst output: string[] = [];\n\n\tconst oldLines = oldContent.split(\"\\n\");\n\tconst newLines = newContent.split(\"\\n\");\n\tconst maxLineNum = Math.max(oldLines.length, newLines.length);\n\tconst lineNumWidth = String(maxLineNum).length;\n\n\tlet oldLineNum = 1;\n\tlet newLineNum = 1;\n\tlet lastWasChange = false;\n\tlet firstChangedLine: number | undefined;\n\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i];\n\t\tconst raw = part.value.split(\"\\n\");\n\t\tif (raw[raw.length - 1] === \"\") {\n\t\t\traw.pop();\n\t\t}\n\n\t\tif (part.added || part.removed) {\n\t\t\t// Capture the first changed line (in the new file)\n\t\t\tif (firstChangedLine === undefined) {\n\t\t\t\tfirstChangedLine = newLineNum;\n\t\t\t}\n\n\t\t\t// Show the change\n\t\t\tfor (const line of raw) {\n\t\t\t\tif (part.added) {\n\t\t\t\t\tconst lineNum = String(newLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`+${lineNum} ${line}`);\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t} else {\n\t\t\t\t\t// removed\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`-${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t}\n\t\t\t}\n\t\t\tlastWasChange = true;\n\t\t} else {\n\t\t\t// Context lines - only show a few before/after changes\n\t\t\tconst nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);\n\t\t\tconst hasLeadingChange = lastWasChange;\n\t\t\tconst hasTrailingChange = nextPartIsChange;\n\n\t\t\tif (hasLeadingChange && hasTrailingChange) {\n\t\t\t\tif (raw.length <= contextLines * 2) {\n\t\t\t\t\tfor (const line of raw) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst leadingLines = raw.slice(0, contextLines);\n\t\t\t\t\tconst trailingLines = raw.slice(raw.length - contextLines);\n\t\t\t\t\tconst skippedLines = raw.length - leadingLines.length - trailingLines.length;\n\n\t\t\t\t\tfor (const line of leadingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\n\t\t\t\t\tfor (const line of trailingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (hasLeadingChange) {\n\t\t\t\tconst shownLines = raw.slice(0, contextLines);\n\t\t\t\tconst skippedLines = raw.length - shownLines.length;\n\n\t\t\t\tfor (const line of shownLines) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\t\t\t} else if (hasTrailingChange) {\n\t\t\t\tconst skippedLines = Math.max(0, raw.length - contextLines);\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\n\t\t\t\tfor (const line of raw.slice(skippedLines)) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip these context lines entirely\n\t\t\t\toldLineNum += raw.length;\n\t\t\t\tnewLineNum += raw.length;\n\t\t\t}\n\n\t\t\tlastWasChange = false;\n\t\t}\n\t}\n\n\treturn { diff: output.join(\"\\n\"), firstChangedLine };\n}\n\nexport interface EditDiffResult {\n\tdiff: string;\n\tfirstChangedLine: number | undefined;\n}\n\nexport interface EditDiffError {\n\terror: string;\n}\n\n/**\n * Compute the diff for one or more edit operations without applying them.\n * Used for preview rendering in the TUI before the tool executes.\n */\nexport async function computeEditsDiff(\n\tpath: string,\n\tedits: Edit[],\n\tcwd: string,\n): Promise<EditDiffResult | EditDiffError> {\n\tconst absolutePath = resolveToCwd(path, cwd);\n\n\ttry {\n\t\t// Check if file exists and is readable\n\t\ttry {\n\t\t\tawait access(absolutePath, constants.R_OK);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error && \"code\" in error ? `Error code: ${error.code}` : String(error);\n\t\t\treturn { error: `Could not edit file: ${path}. ${errorMessage}.` };\n\t\t}\n\n\t\t// Read the file\n\t\tconst rawContent = await readFile(absolutePath, \"utf-8\");\n\n\t\t// Strip BOM before matching (LLM won't include invisible BOM in oldText)\n\t\tconst { text: content } = stripBom(rawContent);\n\t\tconst normalizedContent = normalizeToLF(content);\n\t\tconst { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);\n\n\t\t// Generate the diff\n\t\treturn generateDiffString(baseContent, newContent);\n\t} catch (err) {\n\t\treturn { error: err instanceof Error ? err.message : String(err) };\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"edit-diff.d.ts","sourceRoot":"","sources":["../../../src/core/tools/edit-diff.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM/D;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAE9E;AAqGD,MAAM,WAAW,IAAI;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AAqBD,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACnB;AAoID,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAEvE;AAsHD;;;;;;;;;;;GAWG;AACH,wBAAgB,6BAA6B,CAC5C,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,IAAI,EAAE,MAAM,GACV,kBAAkB,CAuFpB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,YAAY,SAAI,GACd;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAuHxD;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,IAAI,EAAE,EACb,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,cAAc,GAAG,aAAa,CAAC,CAyBzC","sourcesContent":["/**\n * Shared diff computation utilities for the edit tool.\n * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).\n */\n\nimport * as Diff from \"diff\";\nimport { constants } from \"fs\";\nimport { access, readFile } from \"fs/promises\";\nimport { resolveToCwd } from \"./path-utils.js\";\n\n/** Cache for normalized text to avoid redundant processing. Max 100 entries. */\nconst normalizeCache = new Map<string, string>();\nconst MAX_CACHE_SIZE = 100;\n\nexport function detectLineEnding(content: string): \"\\r\\n\" | \"\\n\" {\n\tconst crlfIdx = content.indexOf(\"\\r\\n\");\n\tconst lfIdx = content.indexOf(\"\\n\");\n\tif (lfIdx === -1) return \"\\n\";\n\tif (crlfIdx === -1) return \"\\n\";\n\treturn crlfIdx < lfIdx ? \"\\r\\n\" : \"\\n\";\n}\n\nexport function normalizeToLF(text: string): string {\n\treturn text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n}\n\nexport function restoreLineEndings(text: string, ending: \"\\r\\n\" | \"\\n\"): string {\n\treturn ending === \"\\r\\n\" ? text.replace(/\\n/g, \"\\r\\n\") : text;\n}\n\n/**\n * Normalize text for fuzzy matching. Applies progressive transformations:\n * - Normalize line endings to LF\n * - Strip trailing whitespace from each line\n * - Normalize tabs to spaces (2 spaces per tab)\n * - Collapse multiple spaces to single space\n * - Normalize smart quotes to ASCII equivalents\n * - Normalize Unicode dashes/hyphens to ASCII hyphen\n * - Normalize special Unicode spaces to regular space\n */\nfunction normalizeForFuzzyMatch(text: string): string {\n\t// Check cache first\n\tconst cached = normalizeCache.get(text);\n\tif (cached !== undefined) return cached;\n\n\tconst normalized = text\n\t\t.normalize(\"NFKC\")\n\t\t// Normalize line endings to LF\n\t\t.replace(/\\r\\n/g, \"\\n\")\n\t\t.replace(/\\r/g, \"\\n\")\n\t\t// Strip trailing whitespace per line\n\t\t.split(\"\\n\")\n\t\t.map((line) => {\n\t\t\t// Normalize tabs to 2 spaces\n\t\t\tlet normalized = line.replace(/\\t/g, \" \");\n\t\t\t// Collapse multiple spaces to single space (but preserve leading indentation pattern)\n\t\t\t// Only collapse spaces that are NOT at the start of the line (indentation)\n\t\t\tconst leadingSpaces = normalized.match(/^(\\s*)/)?.[1] ?? \"\";\n\t\t\tconst rest = normalized.slice(leadingSpaces.length);\n\t\t\tnormalized = leadingSpaces + rest.replace(/ {2,}/g, \" \");\n\t\t\treturn normalized.trimEnd();\n\t\t})\n\t\t.join(\"\\n\")\n\t\t// Smart single quotes → '\n\t\t.replace(/[\\u2018\\u2019\\u201A\\u201B]/g, \"'\")\n\t\t// Smart double quotes → \"\n\t\t.replace(/[\\u201C\\u201D\\u201E\\u201F]/g, '\"')\n\t\t// Various dashes/hyphens → -\n\t\t// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,\n\t\t// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus\n\t\t.replace(/[\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212]/g, \"-\")\n\t\t// Special spaces → regular space\n\t\t// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,\n\t\t// U+205F medium math space, U+3000 ideographic space\n\t\t.replace(/[\\u00A0\\u2002-\\u200A\\u202F\\u205F\\u3000]/g, \" \");\n\n\t// Cache the result (with size limit)\n\tif (normalizeCache.size >= MAX_CACHE_SIZE) {\n\t\t// Remove oldest entry (first key)\n\t\tconst firstKey = normalizeCache.keys().next().value;\n\t\tif (firstKey !== undefined) {\n\t\t\tnormalizeCache.delete(firstKey);\n\t\t}\n\t}\n\tnormalizeCache.set(text, normalized);\n\n\treturn normalized;\n}\n\n/**\n * Line starts and line texts for one string. Each entry in `lines` includes its\n * own trailing newline, so `starts[i] + lines[i].length` is the start of line\n * `i + 1`. Used to translate a fuzzy match back into original coordinates.\n */\ninterface LineIndex {\n\ttext: string;\n\tstarts: number[];\n\tlines: string[];\n}\n\nfunction buildLineIndex(text: string): LineIndex {\n\tconst starts: number[] = [];\n\tconst lines: string[] = [];\n\tlet start = 0;\n\tfor (;;) {\n\t\tconst newline = text.indexOf(\"\\n\", start);\n\t\tif (newline === -1) {\n\t\t\tstarts.push(start);\n\t\t\tlines.push(text.slice(start));\n\t\t\treturn { text, starts, lines };\n\t\t}\n\t\tstarts.push(start);\n\t\tlines.push(text.slice(start, newline + 1));\n\t\tstart = newline + 1;\n\t}\n}\n\n/** Index of the line containing `offset` (greatest `i` with `starts[i] <= offset`). */\nfunction lineIndexAt(starts: number[], offset: number): number {\n\tlet low = 0;\n\tlet high = starts.length - 1;\n\twhile (low < high) {\n\t\tconst mid = (low + high + 1) >> 1;\n\t\tif (starts[mid] <= offset) low = mid;\n\t\telse high = mid - 1;\n\t}\n\treturn low;\n}\n\nexport interface Edit {\n\toldText: string;\n\tnewText: string;\n\t/**\n\t * When true, replace every occurrence of oldText instead of requiring it to\n\t * be unique. Default (false/undefined) keeps the uniqueness guardrail.\n\t */\n\treplaceAll?: boolean;\n}\n\ninterface MatchedEdit {\n\teditIndex: number;\n\tmatchIndex: number;\n\tmatchLength: number;\n\t/**\n\t * Text written over the span. Usually the edit's `newText` verbatim; for a\n\t * fuzzy match that covered only part of a line, the untouched remainder of\n\t * the first/last line rides along in normalized form (see `resolveFuzzySpan`).\n\t */\n\treplacement: string;\n}\n\n/** A match located in, and expressed in coordinates of, the original content. */\ninterface ResolvedSpan {\n\tmatchIndex: number;\n\tmatchLength: number;\n\treplacement: string;\n}\n\nexport interface AppliedEditsResult {\n\tbaseContent: string;\n\tnewContent: string;\n}\n\n/**\n * Translate a match found in fuzzy-normalized space back into a span of the\n * original content.\n *\n * Normalization never adds or removes a newline, so normalized line N always\n * corresponds to original line N; only columns within a line can shift (a tab\n * widens to two spaces, a run of spaces collapses, NFKC changes a character's\n * length). Rather than track per-character offsets through those transforms,\n * the span is widened to whole original lines, and whatever of the first and\n * last line the match did not cover is re-attached around `newText` in\n * normalized form.\n *\n * The practical effect: a match that covers whole lines - by far the common\n * case - rewrites exactly those lines and leaves every other byte of the file\n * untouched. Only a partial-line fuzzy match normalizes anything the edit did\n * not ask for, and never beyond the lines it landed on.\n */\nfunction resolveFuzzySpans(\n\toriginal: LineIndex,\n\tnormalized: LineIndex,\n\tnormStarts: number[],\n\tnormLength: number,\n\tnewText: string,\n): ResolvedSpan[] {\n\tconst spans: ResolvedSpan[] = [];\n\tconst lineOf = (offset: number) => lineIndexAt(normalized.starts, offset);\n\n\tlet i = 0;\n\twhile (i < normStarts.length) {\n\t\tconst firstLine = lineOf(normStarts[i]);\n\t\tlet lastLine = lineOf(normStarts[i] + normLength - 1);\n\n\t\t// Matches are ascending and non-overlapping. Absorb any that land inside\n\t\t// the same line block so the block is emitted once with every\n\t\t// substitution applied, rather than as colliding same-line spans.\n\t\tlet end = i + 1;\n\t\twhile (end < normStarts.length && lineOf(normStarts[end]) <= lastLine) {\n\t\t\tlastLine = Math.max(lastLine, lineOf(normStarts[end] + normLength - 1));\n\t\t\tend++;\n\t\t}\n\n\t\tconst blockStart = normalized.starts[firstLine];\n\t\tconst blockEnd = normalized.starts[lastLine] + normalized.lines[lastLine].length;\n\t\tlet replacement = \"\";\n\t\tlet cursor = blockStart;\n\t\tfor (let k = i; k < end; k++) {\n\t\t\treplacement += normalized.text.slice(cursor, normStarts[k]) + newText;\n\t\t\tcursor = normStarts[k] + normLength;\n\t\t}\n\t\treplacement += normalized.text.slice(cursor, blockEnd);\n\n\t\tconst matchIndex = original.starts[firstLine];\n\t\tconst matchEnd = original.starts[lastLine] + original.lines[lastLine].length;\n\t\tspans.push({ matchIndex, matchLength: matchEnd - matchIndex, replacement });\n\t\ti = end;\n\t}\n\n\treturn spans;\n}\n\n/**\n * Locate one edit's `oldText` in `content`, always returning spans in\n * `content`'s own coordinates.\n *\n * Three tiers, tried in order: exact, fuzzy-normalized, then the\n * indentation-tolerant line-block fallback. `occurrences` counts the matches the\n * winning tier found, which is what the uniqueness guardrail tests; `spans` can\n * be shorter, because two fuzzy matches sharing a line are emitted as one\n * rewrite of that line.\n */\nfunction findEditSpans(\n\tcontent: string,\n\tedit: { oldText: string; newText: string },\n\tlineIndexes: () => { original: LineIndex; normalized: LineIndex } | null,\n): { spans: ResolvedSpan[]; occurrences: number } {\n\t// Tier 1: exact. Already in original coordinates.\n\tconst exact = collectMatchIndices(content, edit.oldText);\n\tif (exact.length > 0) {\n\t\treturn {\n\t\t\tspans: exact.map((matchIndex) => ({\n\t\t\t\tmatchIndex,\n\t\t\t\tmatchLength: edit.oldText.length,\n\t\t\t\treplacement: edit.newText,\n\t\t\t})),\n\t\t\toccurrences: exact.length,\n\t\t};\n\t}\n\n\t// Tier 2: fuzzy. Located in normalized space, then translated back.\n\tconst indexes = lineIndexes();\n\tif (indexes) {\n\t\tconst fuzzyOldText = normalizeForFuzzyMatch(edit.oldText);\n\t\tconst fuzzy = collectMatchIndices(indexes.normalized.text, fuzzyOldText);\n\t\tif (fuzzy.length > 0) {\n\t\t\t// oldText did not match the file byte-for-byte, so the whitespace the model\n\t\t\t// used is its own rendering rather than a statement about the file. If\n\t\t\t// newText normalizes to the same thing, it asked for no change: leave the\n\t\t\t// span exactly as it is so the no-change error fires, instead of rewriting\n\t\t\t// the line's indentation to match the model's rendering of it.\n\t\t\tif (normalizeForFuzzyMatch(edit.newText) === fuzzyOldText) {\n\t\t\t\treturn {\n\t\t\t\t\tspans: resolveFuzzySpans(\n\t\t\t\t\t\tindexes.original,\n\t\t\t\t\t\tindexes.normalized,\n\t\t\t\t\t\tfuzzy,\n\t\t\t\t\t\tfuzzyOldText.length,\n\t\t\t\t\t\tedit.newText,\n\t\t\t\t\t).map((span) => ({\n\t\t\t\t\t\t...span,\n\t\t\t\t\t\treplacement: content.slice(span.matchIndex, span.matchIndex + span.matchLength),\n\t\t\t\t\t})),\n\t\t\t\t\toccurrences: fuzzy.length,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tspans: resolveFuzzySpans(indexes.original, indexes.normalized, fuzzy, fuzzyOldText.length, edit.newText),\n\t\t\t\toccurrences: fuzzy.length,\n\t\t\t};\n\t\t}\n\t}\n\n\t// Tier 3: indentation-tolerant line blocks. Already in original coordinates.\n\tconst blocks = findLineBlockMatches(content, edit.oldText).map((span) => ({\n\t\tmatchIndex: span.matchIndex,\n\t\tmatchLength: span.matchLength,\n\t\treplacement: edit.newText,\n\t}));\n\treturn { spans: blocks, occurrences: blocks.length };\n}\n\n/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */\nexport function stripBom(content: string): { bom: string; text: string } {\n\treturn content.startsWith(\"\\uFEFF\") ? { bom: \"\\uFEFF\", text: content.slice(1) } : { bom: \"\", text: content };\n}\n\n/**\n * Collect the start index of every non-overlapping occurrence of needle in\n * haystack. The needle must already be in the same space as haystack (raw for\n * exact matches, fuzzy-normalized when haystack is fuzzy-normalized).\n */\nfunction collectMatchIndices(haystack: string, needle: string): number[] {\n\tconst indices: number[] = [];\n\tif (needle.length === 0) return indices;\n\tlet from = 0;\n\twhile (true) {\n\t\tconst idx = haystack.indexOf(needle, from);\n\t\tif (idx === -1) break;\n\t\tindices.push(idx);\n\t\tfrom = idx + needle.length; // non-overlapping\n\t}\n\treturn indices;\n}\n\n/**\n * Per-line normalization for indentation-tolerant block matching. Applies the\n * same Unicode/space normalization as fuzzy matching, then strips all leading\n * and trailing whitespace so indentation differences are ignored entirely.\n */\nfunction blockNormalizeLine(line: string): string {\n\treturn normalizeForFuzzyMatch(line).trim();\n}\n\ninterface LineBlockMatch {\n\tmatchIndex: number;\n\tmatchLength: number;\n}\n\n/**\n * Indentation-tolerant fallback matcher. Compares oldText against content line\n * by line, ignoring each line's leading/trailing whitespace (and Unicode\n * formatting). Returns character spans in `content` for every block whose\n * trimmed lines equal the trimmed oldText lines. Replacement still happens in\n * the original content space, so surrounding formatting is preserved.\n */\nfunction findLineBlockMatches(content: string, oldText: string): LineBlockMatch[] {\n\tconst hadTrailingNewline = oldText.endsWith(\"\\n\");\n\tconst oldLines = oldText.split(\"\\n\");\n\tif (hadTrailingNewline) oldLines.pop();\n\tif (oldLines.length === 0) return [];\n\tconst trimmedOld = oldLines.map(blockNormalizeLine);\n\n\tconst contentLines = content.split(\"\\n\");\n\tconst k = trimmedOld.length;\n\tif (k > contentLines.length) return [];\n\n\t// Char offset of each line start within content.\n\tconst offsets = new Array<number>(contentLines.length);\n\tlet acc = 0;\n\tfor (let i = 0; i < contentLines.length; i++) {\n\t\toffsets[i] = acc;\n\t\tacc += contentLines[i].length + 1; // + newline\n\t}\n\n\tconst matches: LineBlockMatch[] = [];\n\tfor (let i = 0; i + k <= contentLines.length; i++) {\n\t\tlet ok = true;\n\t\tfor (let j = 0; j < k; j++) {\n\t\t\tif (blockNormalizeLine(contentLines[i + j]) !== trimmedOld[j]) {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!ok) continue;\n\t\tconst matchIndex = offsets[i];\n\t\tlet matchLength = 0;\n\t\tfor (let j = 0; j < k; j++) matchLength += contentLines[i + j].length + (j < k - 1 ? 1 : 0);\n\t\t// Include the trailing newline when oldText carried one and a line follows the block.\n\t\tif (hadTrailingNewline && i + k < contentLines.length) matchLength += 1;\n\t\tmatches.push({ matchIndex, matchLength });\n\t}\n\treturn matches;\n}\n\nfunction getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,\n\t);\n}\n\nfunction getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,\n\t);\n}\n\nfunction getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(`oldText must not be empty in ${path}.`);\n\t}\n\treturn new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);\n}\n\nfunction getNoChangeError(path: string, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,\n\t\t);\n\t}\n\treturn new Error(`No changes made to ${path}. The replacements produced identical content.`);\n}\n\n/**\n * Apply one or more exact-text replacements to LF-normalized content.\n *\n * All edits are matched against the same original content. Replacements are\n * then applied in reverse order so offsets remain stable.\n *\n * Every match, however loosely it was found, is resolved back to a span of the\n * *original* content before anything is written. A fuzzy or indentation-tolerant\n * match therefore rewrites only the lines it landed on: the rest of the file\n * keeps its exact bytes, and the diff callers render from `baseContent` is the\n * real change on disk rather than a normalized-vs-normalized view of it.\n */\nexport function applyEditsToNormalizedContent(\n\tnormalizedContent: string,\n\tedits: Edit[],\n\tpath: string,\n): AppliedEditsResult {\n\tconst normalizedEdits = edits.map((edit) => ({\n\t\toldText: normalizeToLF(edit.oldText),\n\t\tnewText: normalizeToLF(edit.newText),\n\t\treplaceAll: edit.replaceAll === true,\n\t}));\n\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tif (normalizedEdits[i].oldText.length === 0) {\n\t\t\tthrow getEmptyOldTextError(path, i, normalizedEdits.length);\n\t\t}\n\t}\n\n\tconst baseContent = normalizedContent;\n\n\t// Both line indexes are needed only if some edit reaches the fuzzy tier, and\n\t// they are identical for every edit, so build them at most once. Normalization\n\t// is line-preserving; if that ever fails to hold, the fuzzy tier is skipped\n\t// rather than risk translating a span against a mismatched line table.\n\tlet lineIndexesCache: { original: LineIndex; normalized: LineIndex } | null | undefined;\n\tconst lineIndexes = () => {\n\t\tif (lineIndexesCache === undefined) {\n\t\t\tconst original = buildLineIndex(baseContent);\n\t\t\tconst normalized = buildLineIndex(normalizeForFuzzyMatch(baseContent));\n\t\t\tlineIndexesCache = original.lines.length === normalized.lines.length ? { original, normalized } : null;\n\t\t}\n\t\treturn lineIndexesCache;\n\t};\n\n\tconst matchedEdits: MatchedEdit[] = [];\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tconst edit = normalizedEdits[i];\n\t\tconst { spans, occurrences } = findEditSpans(baseContent, edit, lineIndexes);\n\t\tif (spans.length === 0) {\n\t\t\tthrow getNotFoundError(path, i, normalizedEdits.length);\n\t\t}\n\n\t\tif (edit.replaceAll) {\n\t\t\t// Replace every occurrence so the shared reverse-order applier rewrites them all.\n\t\t\tfor (const span of spans) {\n\t\t\t\tmatchedEdits.push({\n\t\t\t\t\teditIndex: i,\n\t\t\t\t\tmatchIndex: span.matchIndex,\n\t\t\t\t\tmatchLength: span.matchLength,\n\t\t\t\t\treplacement: span.replacement,\n\t\t\t\t});\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (occurrences > 1) {\n\t\t\tthrow getDuplicateError(path, i, normalizedEdits.length, occurrences);\n\t\t}\n\n\t\tmatchedEdits.push({\n\t\t\teditIndex: i,\n\t\t\tmatchIndex: spans[0].matchIndex,\n\t\t\tmatchLength: spans[0].matchLength,\n\t\t\treplacement: spans[0].replacement,\n\t\t});\n\t}\n\n\tmatchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);\n\tfor (let i = 1; i < matchedEdits.length; i++) {\n\t\tconst previous = matchedEdits[i - 1];\n\t\tconst current = matchedEdits[i];\n\t\tif (previous.matchIndex + previous.matchLength > current.matchIndex) {\n\t\t\tthrow new Error(\n\t\t\t\t`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tlet newContent = baseContent;\n\tfor (let i = matchedEdits.length - 1; i >= 0; i--) {\n\t\tconst edit = matchedEdits[i];\n\t\tnewContent =\n\t\t\tnewContent.substring(0, edit.matchIndex) +\n\t\t\tedit.replacement +\n\t\t\tnewContent.substring(edit.matchIndex + edit.matchLength);\n\t}\n\n\tif (baseContent === newContent) {\n\t\tthrow getNoChangeError(path, normalizedEdits.length);\n\t}\n\n\treturn { baseContent, newContent };\n}\n\n/**\n * Generate a unified diff string with line numbers and context.\n * Returns both the diff string and the first changed line number (in the new file).\n */\nexport function generateDiffString(\n\toldContent: string,\n\tnewContent: string,\n\tcontextLines = 4,\n): { diff: string; firstChangedLine: number | undefined } {\n\tconst parts = Diff.diffLines(oldContent, newContent);\n\tconst output: string[] = [];\n\n\tconst oldLines = oldContent.split(\"\\n\");\n\tconst newLines = newContent.split(\"\\n\");\n\tconst maxLineNum = Math.max(oldLines.length, newLines.length);\n\tconst lineNumWidth = String(maxLineNum).length;\n\n\tlet oldLineNum = 1;\n\tlet newLineNum = 1;\n\tlet lastWasChange = false;\n\tlet firstChangedLine: number | undefined;\n\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i];\n\t\tconst raw = part.value.split(\"\\n\");\n\t\tif (raw[raw.length - 1] === \"\") {\n\t\t\traw.pop();\n\t\t}\n\n\t\tif (part.added || part.removed) {\n\t\t\t// Capture the first changed line (in the new file)\n\t\t\tif (firstChangedLine === undefined) {\n\t\t\t\tfirstChangedLine = newLineNum;\n\t\t\t}\n\n\t\t\t// Show the change\n\t\t\tfor (const line of raw) {\n\t\t\t\tif (part.added) {\n\t\t\t\t\tconst lineNum = String(newLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`+${lineNum} ${line}`);\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t} else {\n\t\t\t\t\t// removed\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`-${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t}\n\t\t\t}\n\t\t\tlastWasChange = true;\n\t\t} else {\n\t\t\t// Context lines - only show a few before/after changes\n\t\t\tconst nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);\n\t\t\tconst hasLeadingChange = lastWasChange;\n\t\t\tconst hasTrailingChange = nextPartIsChange;\n\n\t\t\tif (hasLeadingChange && hasTrailingChange) {\n\t\t\t\tif (raw.length <= contextLines * 2) {\n\t\t\t\t\tfor (const line of raw) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst leadingLines = raw.slice(0, contextLines);\n\t\t\t\t\tconst trailingLines = raw.slice(raw.length - contextLines);\n\t\t\t\t\tconst skippedLines = raw.length - leadingLines.length - trailingLines.length;\n\n\t\t\t\t\tfor (const line of leadingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\n\t\t\t\t\tfor (const line of trailingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (hasLeadingChange) {\n\t\t\t\tconst shownLines = raw.slice(0, contextLines);\n\t\t\t\tconst skippedLines = raw.length - shownLines.length;\n\n\t\t\t\tfor (const line of shownLines) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\t\t\t} else if (hasTrailingChange) {\n\t\t\t\tconst skippedLines = Math.max(0, raw.length - contextLines);\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\n\t\t\t\tfor (const line of raw.slice(skippedLines)) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip these context lines entirely\n\t\t\t\toldLineNum += raw.length;\n\t\t\t\tnewLineNum += raw.length;\n\t\t\t}\n\n\t\t\tlastWasChange = false;\n\t\t}\n\t}\n\n\treturn { diff: output.join(\"\\n\"), firstChangedLine };\n}\n\nexport interface EditDiffResult {\n\tdiff: string;\n\tfirstChangedLine: number | undefined;\n}\n\nexport interface EditDiffError {\n\terror: string;\n}\n\n/**\n * Compute the diff for one or more edit operations without applying them.\n * Used for preview rendering in the TUI before the tool executes.\n */\nexport async function computeEditsDiff(\n\tpath: string,\n\tedits: Edit[],\n\tcwd: string,\n): Promise<EditDiffResult | EditDiffError> {\n\tconst absolutePath = resolveToCwd(path, cwd);\n\n\ttry {\n\t\t// Check if file exists and is readable\n\t\ttry {\n\t\t\tawait access(absolutePath, constants.R_OK);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error && \"code\" in error ? `Error code: ${error.code}` : String(error);\n\t\t\treturn { error: `Could not edit file: ${path}. ${errorMessage}.` };\n\t\t}\n\n\t\t// Read the file\n\t\tconst rawContent = await readFile(absolutePath, \"utf-8\");\n\n\t\t// Strip BOM before matching (LLM won't include invisible BOM in oldText)\n\t\tconst { text: content } = stripBom(rawContent);\n\t\tconst normalizedContent = normalizeToLF(content);\n\t\tconst { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);\n\n\t\t// Generate the diff\n\t\treturn generateDiffString(baseContent, newContent);\n\t} catch (err) {\n\t\treturn { error: err instanceof Error ? err.message : String(err) };\n\t}\n}\n"]}
|