pi-supernova 0.0.7 → 0.0.8

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/index.js CHANGED
@@ -3,7 +3,7 @@ import { isString, isFunction, isObject } from "./decode.js";
3
3
  import { buildCatalog, searchCatalog, describeTool, mergeNativeToolDefinitions } from "./catalog.js";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { createHostBridge } from "./host-bridge.js";
6
- import { runGuestProgram } from "./runtime.js";
6
+ import { runGuestProgram, warmGuestWorker } from "./runtime.js";
7
7
  import {
8
8
  extractOperationsFromCode,
9
9
  renderSupernovaCall,
@@ -30,6 +30,30 @@ try {
30
30
  function result(text, details) {
31
31
  return { content: [{ type: "text", text }], details };
32
32
  }
33
+
34
+ /** Live trace updates for the card; a throwing host callback must never break the run. */
35
+ function progressEmitter(onUpdate) {
36
+ if (!isFunction(onUpdate)) return () => {};
37
+ return (trace) => {
38
+ try {
39
+ onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
40
+ } catch {}
41
+ };
42
+ }
43
+
44
+ function logsBlock(outcome, tail = "") {
45
+ return outcome.logs?.length ? `\n--- logs\n${outcome.logs.join("\n")}${tail}` : "";
46
+ }
47
+
48
+ function errorText(outcome) {
49
+ return `error ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
50
+ }
51
+
52
+ function successText(outcome) {
53
+ const truncated = outcome.returnTruncated ? " [return truncated]" : "";
54
+ const hint = outcome.undefinedReturn ? " (no return statement — add \`return\` to get a value)" : "";
55
+ return `ok ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
56
+ }
33
57
  function unwrapStructuredResult(response, operation) {
34
58
  if (response?.ok === false) {
35
59
  throw new Error(response.value || response.error || `${operation} failed`);
@@ -43,21 +67,16 @@ function unwrapStructuredResult(response, operation) {
43
67
  }
44
68
  }
45
69
 
46
- const TOOL_DESCRIPTION = `Execute JavaScript that orchestrates host tools in one shot (Code Mode).
70
+ const TOOL_DESCRIPTION = `Run one JavaScript program that composes host tools. Async body or arrow; \`return\` a small shaped value (compact literal, capped; strings raw; console.log is captured).
47
71
 
48
- Inside the program you get:
49
- nova.search(query) — thin catalog hits (name + one-liner)
50
- nova.describe(name) full parameter summary on demand
51
- nova.call(name, args) invoke a host tool (or native adapter)
52
- nova.callMany([{name,args}]) Auto parallel wave (serial if any mutating)
53
- nova.snap(query, root?) resolve a concept to a source location
54
- nova.surface(path) structural source outline
55
- nova.has(name) test host-tool availability
56
- parallel(thunks) / pipeline(items, ...stages)
57
-
58
- Shorthand globals: read, write, edit, patch, exec, snap, surface.
59
- Prefer search→describe→call. Keep intermediates in the program; return a shaped value.
60
- Schemas are NOT dumped into the system prompt — discover them inside the runtime.`;
72
+ Globals (async):
73
+ read(path|paths, offset?, limit?) text | text[]
74
+ write(path, text) · edit(path, oldText, newText) · patch(path, unifiedDiff)
75
+ bash(cmd, {cwd?, timeoutMs?}) output, throws on non-zero exit · exec(cmd, argv?) quotes argv
76
+ snap(query, root?) {path, line, signature, context} · surface(path) {items: [{name, kind, line}]}
77
+ nova.call(name, args) {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
78
+ nova.search(query) [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
79
+ parallel(thunks) · pipeline(items, ...stages)`;
61
80
 
62
81
  export default function piSupernova(pi) {
63
82
  const config = loadConfig();
@@ -101,16 +120,18 @@ export default function piSupernova(pi) {
101
120
  async callMany(calls) {
102
121
  return bridge.callMany(calls);
103
122
  },
104
- async speculate(fn) {
123
+ speculateBegin() {
105
124
  bridge.beginSpeculation();
106
- try {
107
- const val = await fn();
108
- await bridge.commitSpeculation();
109
- return { ok: true, committed: true, value: val };
110
- } catch (err) {
111
- bridge.rollbackSpeculation();
112
- return { ok: false, committed: false, error: err instanceof Error ? err.message : String(err) };
113
- }
125
+ },
126
+ async speculateCommit() {
127
+ await bridge.commitSpeculation();
128
+ },
129
+ speculateRollback() {
130
+ bridge.rollbackSpeculation();
131
+ },
132
+ names() {
133
+ const cat = catalog.length ? catalog : refreshCatalog();
134
+ return [...new Set([...cat.map((t) => t.name), ...bridge.executors.keys(), ...Object.keys(bridge.natives)])];
114
135
  },
115
136
  async surface(filePath) {
116
137
  return unwrapStructuredResult(await bridge.call("surface", { path: filePath }), "surface");
@@ -118,9 +139,6 @@ export default function piSupernova(pi) {
118
139
  async snap(query, targetPath) {
119
140
  return unwrapStructuredResult(await bridge.call("snap", { query, path: targetPath }), "snap");
120
141
  },
121
- has(name) {
122
- return bridge.hasExecutor(name) || catalog.some((t) => t.name === name);
123
- },
124
142
  };
125
143
  }
126
144
 
@@ -128,23 +146,13 @@ export default function piSupernova(pi) {
128
146
  name: "supernova",
129
147
  label: "Supernova",
130
148
  description: TOOL_DESCRIPTION,
131
- promptSnippet: "Compose multiple host tools in one JavaScript program via supernova",
149
+ promptSnippet: "Compose host tools in one JavaScript program",
132
150
  promptGuidelines: [
133
- "Use supernova when a task needs multi-step tool composition, loops, filtering, or parallel reads.",
134
- "Discover tools with nova.search / nova.describe inside the program — do not guess full schemas.",
135
- "Return a compact shaped value; intermediates stay in the runtime.",
151
+ "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. Return a compact shaped value; keep raw tool output inside the program.",
136
152
  ],
137
153
  parameters: Type.Object({
138
- code: Type.String({
139
- description:
140
- "JavaScript async body or arrow. Globals: nova/tools, parallel, pipeline, console.",
141
- }),
142
- timeoutMs: Type.Optional(
143
- Type.Integer({
144
- minimum: 1000,
145
- description: "Hard timeout in ms (default from supernova.json / package default)",
146
- }),
147
- ),
154
+ code: Type.String({ description: "JavaScript program: async body or arrow function." }),
155
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
148
156
  }),
149
157
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
150
158
  // so separate call/result slots cannot duplicate the lifecycle card.
@@ -163,49 +171,13 @@ export default function piSupernova(pi) {
163
171
  bridge.resetCallBudget();
164
172
  refreshCatalog();
165
173
  bridge.beginSpeculation();
174
+ const emitProgress = progressEmitter(onUpdate);
175
+ bridge.setCallListener((_record, allTrace) => emitProgress(allTrace));
176
+ emitProgress([]);
166
177
 
167
- bridge.setCallListener((_record, allTrace) => {
168
- if (isFunction(onUpdate)) {
169
- try {
170
- onUpdate({
171
- content: [{ type: "text", text: "" }],
172
- details: { trace: allTrace, running: true },
173
- });
174
- } catch {}
175
- }
176
- });
177
-
178
- if (isFunction(onUpdate)) {
179
- try {
180
- onUpdate({
181
- content: [{ type: "text", text: "" }],
182
- details: { trace: [], running: true },
183
- });
184
- } catch {}
185
- }
186
-
187
- const runConfig = {
188
- ...config,
189
- timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
190
- };
191
-
192
- const runStartedAt = performance.now();
193
178
  let outcome;
194
179
  try {
195
- outcome = await runGuestProgram({
196
- code: String(params?.code || ""),
197
- nova: makeNovaApi(),
198
- config: runConfig,
199
- signal: runController.signal,
200
- onTimeout: abortRun,
201
- });
202
- } catch (error) {
203
- outcome = {
204
- ok: false,
205
- error: error instanceof Error ? error.message : String(error),
206
- logs: [],
207
- wallMs: Math.round(performance.now() - runStartedAt),
208
- };
180
+ outcome = await runProgram(params, runController.signal, abortRun);
209
181
  } finally {
210
182
  bridge.setCallListener(null);
211
183
  signal?.removeEventListener("abort", abortRun);
@@ -214,23 +186,10 @@ export default function piSupernova(pi) {
214
186
  const trace = bridge.getTrace();
215
187
  if (!outcome.ok) {
216
188
  bridge.rollbackSpeculation();
217
- let text = `Supernova error (${outcome.wallMs}ms):\n${outcome.error}`;
218
- if (outcome.logs?.length) text += `\n\nLogs:\n${outcome.logs.join("\n")}`;
219
- return result(text, {
220
- ok: false,
221
- error: outcome.error,
222
- wallMs: outcome.wallMs,
223
- logs: outcome.logs,
224
- trace,
225
- });
189
+ return result(errorText(outcome), { ok: false, error: outcome.error, wallMs: outcome.wallMs, logs: outcome.logs, trace });
226
190
  }
227
-
228
191
  await bridge.commitSpeculation();
229
- let text = `Supernova ok (${outcome.wallMs}ms)`;
230
- if (outcome.returnTruncated) text += " [return truncated]";
231
- if (outcome.logs?.length) text += `\n\nLogs:\n${outcome.logs.join("\n")}`;
232
- text += `\n\nResult:\n${outcome.resultText}`;
233
- return result(text, {
192
+ return result(successText(outcome), {
234
193
  ok: true,
235
194
  wallMs: outcome.wallMs,
236
195
  returnTruncated: outcome.returnTruncated,
@@ -242,9 +201,28 @@ export default function piSupernova(pi) {
242
201
  },
243
202
  });
244
203
 
204
+ async function runProgram(params, signal, onTimeout) {
205
+ const runStartedAt = performance.now();
206
+ const runConfig = {
207
+ ...config,
208
+ timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
209
+ };
210
+ try {
211
+ return await runGuestProgram({ code: String(params?.code || ""), nova: makeNovaApi(), config: runConfig, signal, onTimeout });
212
+ } catch (error) {
213
+ return {
214
+ ok: false,
215
+ error: error instanceof Error ? error.message : String(error),
216
+ logs: [],
217
+ wallMs: Math.round(performance.now() - runStartedAt),
218
+ };
219
+ }
220
+ }
221
+
245
222
  pi.on("session_start", (_event, ctx) => {
246
223
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
247
224
  refreshCatalog();
225
+ warmGuestWorker(config).catch(() => {});
248
226
  });
249
227
 
250
228
  pi.registerCommand("supernova", {
@@ -257,7 +235,7 @@ export default function piSupernova(pi) {
257
235
  `pi-supernova catalog: ${catalog.length} tools`,
258
236
  `captured executors: ${captured.length ? captured.join(", ") : "(none yet — load this package early)"}`,
259
237
  `native adapters: ${natives.join(", ")}`,
260
- `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxBridgeCalls=${config.maxBridgeCalls}`,
238
+ `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
261
239
  ];
262
240
  ctx.ui.notify(lines.join("\n"), "info");
263
241
  },
package/omp-frame.js CHANGED
@@ -25,10 +25,14 @@ function boxOf(theme) {
25
25
  return DEFAULT_BOX;
26
26
  }
27
27
 
28
+ const BORDER_BY_STATE = { error: "error", warning: "warning", running: "accent", pending: "accent" };
29
+
30
+ function borderKeyFor(state) {
31
+ return BORDER_BY_STATE[state] || "dim";
32
+ }
33
+
28
34
  function borderPaint(theme, state, borderColor) {
29
- const key =
30
- borderColor ||
31
- (state === "error" ? "error" : state === "warning" ? "warning" : state === "running" || state === "pending" ? "accent" : "dim");
35
+ const key = borderColor || borderKeyFor(state);
32
36
  if (theme && isFunction(theme.fg)) {
33
37
  try {
34
38
  return (text) => theme.fg(key, text);
@@ -39,22 +43,30 @@ function borderPaint(theme, state, borderColor) {
39
43
  return (text) => text;
40
44
  }
41
45
 
46
+ function resolveStatusIcon({ icon, iconOverride, state }) {
47
+ if (iconOverride !== undefined) return undefined;
48
+ if (icon !== undefined) return icon;
49
+ return state === "error" ? "error" : undefined;
50
+ }
51
+
52
+ const STATUS_GLYPH = { error: "✗ ", running: "… " };
53
+ const STATUS_COLOR = { error: "error", running: "dim" };
54
+
55
+ function statusPrefix(theme, resolvedIcon, iconOverride, spinnerFrame) {
56
+ if (iconOverride) return `${iconOverride} `;
57
+ const key = resolvedIcon === "error" ? "error" : resolvedIcon === "running" || spinnerFrame ? "running" : undefined;
58
+ const glyph = STATUS_GLYPH[key];
59
+ if (!glyph) return "";
60
+ return theme?.fg ? theme.fg(STATUS_COLOR[key], glyph) : glyph;
61
+ }
62
+
42
63
  function statusHeader(theme, { title, description, state, spinnerFrame, icon, iconOverride }) {
43
- const resolvedIcon =
44
- iconOverride !== undefined
45
- ? undefined
46
- : icon !== undefined
47
- ? icon
48
- : state === "error"
49
- ? "error"
50
- : undefined;
64
+ const resolvedIcon = resolveStatusIcon({ icon, iconOverride, state });
51
65
  const titleText = theme?.fg ? theme.fg("accent", title) : title;
52
66
  const descText = description ? (theme?.fg ? theme.fg("muted", description) : description) : "";
53
- let prefix = "";
54
- if (iconOverride) prefix = `${iconOverride} `;
55
- else if (resolvedIcon === "error") prefix = theme?.fg ? theme.fg("error", "✗ ") : "✗ ";
56
- else if (resolvedIcon === "running" || spinnerFrame) prefix = theme?.fg ? theme.fg("dim", "… ") : "… ";
57
- return descText ? `${prefix}${titleText}: ${descText}` : `${prefix}${titleText}`;
67
+ const prefix = statusPrefix(theme, resolvedIcon, iconOverride, spinnerFrame);
68
+ if (!descText) return `${prefix}${titleText}`;
69
+ return `${prefix}${titleText}: ${descText}`;
58
70
  }
59
71
 
60
72
  function padLine(line, width, bgFn) {
@@ -65,34 +77,55 @@ function padLine(line, width, bgFn) {
65
77
  return bgFn ? bgFn(padded) : padded;
66
78
  }
67
79
 
80
+ const BG_BY_STATE = { error: "toolErrorBg", pending: "toolPendingBg", running: "toolPendingBg" };
81
+
82
+ function bgKeyFor(state) {
83
+ return BG_BY_STATE[state] || "toolSuccessBg";
84
+ }
85
+
86
+ function wrapBg(paint) {
87
+ return (text) => {
88
+ const out = paint(text);
89
+ return isString(out) ? out : text;
90
+ };
91
+ }
92
+
68
93
  function bgFnForState(theme, state) {
69
94
  if (!state || !theme) return undefined;
95
+ const key = bgKeyFor(state);
70
96
  if (isFunction(theme.bg)) {
71
- const key =
72
- state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
73
97
  try {
74
- const probe = theme.bg(key, "x");
75
- if (!isString(probe)) return undefined;
76
- return (text) => {
77
- const painted = theme.bg(key, text);
78
- return isString(painted) ? painted : text;
79
- };
98
+ if (!isString(theme.bg(key, "x"))) return undefined;
80
99
  } catch {
81
100
  return undefined;
82
101
  }
102
+ return wrapBg((text) => theme.bg(key, text));
83
103
  }
84
- if (isFunction(theme.getBgAnsi)) {
85
- try {
86
- const key =
87
- state === "error" ? "toolErrorBg" : state === "pending" || state === "running" ? "toolPendingBg" : "toolSuccessBg";
88
- const ansi = theme.getBgAnsi(key);
89
- if (!ansi) return undefined;
90
- return (text) => `${ansi}${text}\x1b[49m`;
91
- } catch {
92
- return undefined;
104
+ if (!isFunction(theme.getBgAnsi)) return undefined;
105
+ try {
106
+ const ansi = theme.getBgAnsi(key);
107
+ if (!ansi) return undefined;
108
+ return (text) => `${ansi}${text}\x1b[49m`;
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ function frameBodyLines(sections, contentWidth, box, border, bgFn, w, paintBar) {
115
+ const lines = [];
116
+ const normalized = sections.length > 0 ? sections : [{ lines: [] }];
117
+ const v = box.vertical;
118
+ for (const section of normalized) {
119
+ if (section.label) lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
120
+ for (const raw of section.lines || []) {
121
+ for (const piece of String(raw).split("\n")) {
122
+ const body = clampLine(piece, contentWidth);
123
+ const pad = Math.max(0, contentWidth - measureWidth(body));
124
+ lines.push(padLine(`${border(v)} ${body}${" ".repeat(pad)} ${border(v)}`, w, bgFn));
125
+ }
93
126
  }
94
127
  }
95
- return undefined;
128
+ return lines;
96
129
  }
97
130
 
98
131
  function renderPortableFrame(theme, { header, sections = [], state = "pending", borderColor, width }) {
@@ -129,22 +162,7 @@ function renderPortableFrame(theme, { header, sections = [], state = "pending",
129
162
  const contentWidth = Math.max(1, w - 2 - 2);
130
163
  const lines = [];
131
164
  lines.push(paintBar(box.topLeft, box.topRight, header));
132
-
133
- const normalized = sections.length > 0 ? sections : [{ lines: [] }];
134
- for (const section of normalized) {
135
- if (section.label) {
136
- lines.push(paintBar(box.teeRight || "├", box.teeLeft || "┤", section.label));
137
- }
138
- for (const raw of section.lines || []) {
139
- for (const piece of String(raw).split("\n")) {
140
- const body = clampLine(piece, contentWidth);
141
- const pad = Math.max(0, contentWidth - measureWidth(body));
142
- const inner = `${body}${" ".repeat(pad)}`;
143
- lines.push(padLine(`${border(v)} ${inner} ${border(v)}`, w, bgFn));
144
- }
145
- }
146
- }
147
-
165
+ lines.push(...frameBodyLines(sections, contentWidth, box, border, bgFn, w, paintBar));
148
166
  lines.push(paintBar(box.bottomLeft, box.bottomRight, null));
149
167
  return lines;
150
168
  }
@@ -180,4 +198,3 @@ export function novaFramedBlock(theme, build) {
180
198
  export function novaStatusLine(theme, options) {
181
199
  return statusHeader(theme, options);
182
200
  }
183
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -29,6 +29,11 @@
29
29
  "bottleneck.js",
30
30
  "parallel.js",
31
31
  "runtime.js",
32
+ "guest-worker.js",
33
+ "format.js",
34
+ "patch.js",
35
+ "vfs.js",
36
+ "workspace.js",
32
37
  "config.js",
33
38
  "config.default.json",
34
39
  "README.md",
package/parallel.js CHANGED
@@ -1,6 +1,5 @@
1
1
 
2
2
  import { isString } from "./decode.js";
3
-
4
3
  export function isMutatingTool(name, config) {
5
4
  const exact = new Set(config.mutatingTools || []);
6
5
  if (exact.has(name)) return true;
@@ -10,35 +9,35 @@ export function isMutatingTool(name, config) {
10
9
  }
11
10
  return false;
12
11
  }
13
-
12
+ async function runSerial(list) {
13
+ const out = [];
14
+ for (const thunk of list) out.push(await thunk());
15
+ return out;
16
+ }
17
+ function shouldParallelize(mode, anyMutating, count) {
18
+ if (mode === "parallel") return true;
19
+ if (mode !== "auto") return false;
20
+ if (anyMutating) return false;
21
+ return count > 1;
22
+ }
14
23
  export async function runParallelWave(thunks, meta, options = {}) {
15
24
  const list = Array.isArray(thunks) ? thunks : [];
16
- if (list.length === 0) {
17
- return { results: [], mode: "serial", reason: "empty" };
18
- }
19
- const mode = options.mode || "auto";
25
+ if (list.length === 0) return { results: [], mode: "serial", reason: "empty" };
26
+ const { mode = "auto", config = {} } = options;
20
27
  const names = Array.isArray(meta?.names) ? meta.names : [];
21
- const config = options.config || {};
22
28
  const anyMutating = names.some((n) => isString(n) && isMutatingTool(n, config));
23
- const useParallel = mode === "parallel" || (mode === "auto" && !anyMutating && list.length > 1);
24
-
25
- if (!useParallel) {
26
- const out = [];
27
- for (const thunk of list) {
28
- out.push(await thunk());
29
- }
30
- return { results: out, mode: "serial", reason: anyMutating ? "mutating" : "single-or-forced" };
29
+ if (shouldParallelize(mode, anyMutating, list.length)) {
30
+ const results = await Promise.all(list.map((thunk) => thunk()));
31
+ return { results, mode: "parallel", reason: "independent-reads" };
31
32
  }
32
-
33
- const results = await Promise.all(list.map((thunk) => thunk()));
34
- return { results, mode: "parallel", reason: "independent-reads" };
33
+ const results = await runSerial(list);
34
+ if (anyMutating) return { results, mode: "serial", reason: "mutating" };
35
+ return { results, mode: "serial", reason: "single-or-forced" };
35
36
  }
36
-
37
37
  export async function parallel(items) {
38
38
  const list = Array.isArray(items) ? items : [];
39
39
  return Promise.all(list.map((item) => (item instanceof Function ? item() : item)));
40
40
  }
41
-
42
41
  export async function pipeline(items, ...stages) {
43
42
  let current = Array.isArray(items) ? items.slice() : [];
44
43
  for (const stage of stages) {
package/patch.js ADDED
@@ -0,0 +1,106 @@
1
+ import { isString } from "./decode.js";
2
+
3
+ function parseHunkHeader(line) {
4
+ const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
5
+ if (!match) return null;
6
+ return {
7
+ oldStart: parseInt(match[1], 10),
8
+ oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
9
+ newStart: parseInt(match[3], 10),
10
+ newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
11
+ lines: [],
12
+ };
13
+ }
14
+
15
+ function isHunkLine(line) {
16
+ return line.startsWith("+") || line.startsWith("-") || line.startsWith(" ");
17
+ }
18
+
19
+ export function parsePatchHunks(patchText) {
20
+ const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
21
+ const hunks = [];
22
+ let current = null;
23
+
24
+ for (const line of patchLines) {
25
+ const header = parseHunkHeader(line);
26
+ if (header) {
27
+ if (current) hunks.push(current);
28
+ current = header;
29
+ } else if (current && isHunkLine(line)) {
30
+ current.lines.push(line);
31
+ }
32
+ }
33
+ if (current) hunks.push(current);
34
+ if (hunks.length === 0) {
35
+ throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
36
+ }
37
+ return hunks;
38
+ }
39
+
40
+ function findHunkMatch(fileLines, expectedOld, nominal) {
41
+ const matchAt = (idx) => {
42
+ if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
43
+ for (let j = 0; j < expectedOld.length; j++) {
44
+ if (fileLines[idx + j] !== expectedOld[j]) return false;
45
+ }
46
+ return true;
47
+ };
48
+
49
+ if (matchAt(nominal)) return nominal;
50
+ const maxDelta = Math.max(fileLines.length, 100);
51
+ for (let delta = 1; delta <= maxDelta; delta++) {
52
+ if (matchAt(nominal + delta)) return nominal + delta;
53
+ if (matchAt(nominal - delta)) return nominal - delta;
54
+ }
55
+ return -1;
56
+ }
57
+
58
+ function splitHunkLines(hunk) {
59
+ const expectedOld = [];
60
+ const newLines = [];
61
+ for (const hLine of hunk.lines) {
62
+ if (hLine.startsWith("-")) {
63
+ expectedOld.push(hLine.slice(1));
64
+ } else if (hLine.startsWith("+")) {
65
+ newLines.push(hLine.slice(1));
66
+ } else {
67
+ const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
68
+ expectedOld.push(val);
69
+ newLines.push(val);
70
+ }
71
+ }
72
+ return { expectedOld, newLines };
73
+ }
74
+
75
+ export function applyPatchToText(originalText, patchText) {
76
+ if (!isString(patchText) || !patchText.trim()) {
77
+ throw new Error("apply_patch requires non-empty patch");
78
+ }
79
+
80
+ const hunks = parsePatchHunks(patchText);
81
+ let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
82
+ const hasTrailingNewline = originalText.endsWith("\n");
83
+ let offsetShift = 0;
84
+
85
+ for (let h = 0; h < hunks.length; h++) {
86
+ const hunk = hunks[h];
87
+ const { expectedOld, newLines } = splitHunkLines(hunk);
88
+
89
+ if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
90
+ throw new Error(`patch hunk ${h + 1} length does not match its header`);
91
+ }
92
+
93
+ const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
94
+ const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
95
+ if (matchIdx === -1) {
96
+ throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
97
+ }
98
+
99
+ fileLines.splice(matchIdx, expectedOld.length, ...newLines);
100
+ offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
101
+ }
102
+
103
+ let resultText = fileLines.join("\n");
104
+ if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
105
+ return { resultText, hunkCount: hunks.length };
106
+ }