pi-supernova 0.0.7 → 0.0.11

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,60 @@ try {
30
30
  function result(text, details) {
31
31
  return { content: [{ type: "text", text }], details };
32
32
  }
33
+
34
+ const PROGRESS_FRAME_MS = 40;
35
+
36
+ /**
37
+ * Live trace updates for the card. The first update is immediate (seeds the result slot);
38
+ * later ones are coalesced to one host re-render per frame so a tight loop of nova.calls
39
+ * is not throttled by the TUI. A throwing host callback must never break the run.
40
+ */
41
+ function progressEmitter(onUpdate) {
42
+ if (!isFunction(onUpdate)) return Object.assign(() => {}, { flush() {} });
43
+ let pending = null;
44
+ let timer = null;
45
+ const send = (trace) => {
46
+ try {
47
+ onUpdate({ content: [{ type: "text", text: "" }], details: { trace, running: true } });
48
+ } catch {}
49
+ };
50
+ const flush = () => {
51
+ timer = null;
52
+ if (pending === null) return;
53
+ const trace = pending;
54
+ pending = null;
55
+ send(trace);
56
+ };
57
+ const emit = (trace) => {
58
+ if (timer === null && pending === null) {
59
+ send(trace);
60
+ timer = setTimeout(flush, PROGRESS_FRAME_MS);
61
+ return;
62
+ }
63
+ pending = trace;
64
+ if (timer === null) timer = setTimeout(flush, PROGRESS_FRAME_MS);
65
+ };
66
+ emit.flush = () => {
67
+ if (timer !== null) clearTimeout(timer);
68
+ pending = null;
69
+ timer = null;
70
+ };
71
+ return emit;
72
+ }
73
+
74
+ function logsBlock(outcome, tail = "") {
75
+ return outcome.logs?.length ? `\n--- logs\n${outcome.logs.join("\n")}${tail}` : "";
76
+ }
77
+
78
+ function errorText(outcome) {
79
+ return `error ${outcome.wallMs}ms: ${outcome.error}${logsBlock(outcome)}`;
80
+ }
81
+
82
+ function successText(outcome) {
83
+ const truncated = outcome.returnTruncated ? " [return truncated]" : "";
84
+ const hint = outcome.undefinedReturn ? " (no return statement — add \`return\` to get a value)" : "";
85
+ return `ok ${outcome.wallMs}ms${truncated}${logsBlock(outcome, "\n--- result")}\n${outcome.resultText}${hint}`;
86
+ }
33
87
  function unwrapStructuredResult(response, operation) {
34
88
  if (response?.ok === false) {
35
89
  throw new Error(response.value || response.error || `${operation} failed`);
@@ -43,21 +97,17 @@ function unwrapStructuredResult(response, operation) {
43
97
  }
44
98
  }
45
99
 
46
- const TOOL_DESCRIPTION = `Execute JavaScript that orchestrates host tools in one shot (Code Mode).
47
-
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)
100
+ 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).
57
101
 
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.`;
102
+ Globals (async):
103
+ read(path|paths, offset?, limit?) → text | text[]
104
+ write(path, text) · edit(path, oldText, newText) · patch(path, unifiedDiff)
105
+ bash(cmd, {cwd?, timeoutMs?}) → output, throws on non-zero exit · exec(cmd, argv?) quotes argv
106
+ evidence(query, {k?}) → {spans: [{path, lines, name, text}]} top-K spans that answer a question — use before read
107
+ snap(query, root?) → {path, line, signature, context} · surface(path) → {items: [{name, kind, line}]}
108
+ nova.call(name, args) → {ok, value} for any host tool · nova.callMany([{name, args}]) parallel when read-only
109
+ nova.search(query) → [{name, description}] · nova.describe(name) → parameters · nova.has(name) sync
110
+ parallel(thunks) · pipeline(items, ...stages)`;
61
111
 
62
112
  export default function piSupernova(pi) {
63
113
  const config = loadConfig();
@@ -101,16 +151,18 @@ export default function piSupernova(pi) {
101
151
  async callMany(calls) {
102
152
  return bridge.callMany(calls);
103
153
  },
104
- async speculate(fn) {
154
+ speculateBegin() {
105
155
  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
- }
156
+ },
157
+ async speculateCommit() {
158
+ await bridge.commitSpeculation();
159
+ },
160
+ speculateRollback() {
161
+ bridge.rollbackSpeculation();
162
+ },
163
+ names() {
164
+ const cat = catalog.length ? catalog : refreshCatalog();
165
+ return [...new Set([...cat.map((t) => t.name), ...bridge.executors.keys(), ...Object.keys(bridge.natives)])];
114
166
  },
115
167
  async surface(filePath) {
116
168
  return unwrapStructuredResult(await bridge.call("surface", { path: filePath }), "surface");
@@ -118,9 +170,6 @@ export default function piSupernova(pi) {
118
170
  async snap(query, targetPath) {
119
171
  return unwrapStructuredResult(await bridge.call("snap", { query, path: targetPath }), "snap");
120
172
  },
121
- has(name) {
122
- return bridge.hasExecutor(name) || catalog.some((t) => t.name === name);
123
- },
124
173
  };
125
174
  }
126
175
 
@@ -128,23 +177,13 @@ export default function piSupernova(pi) {
128
177
  name: "supernova",
129
178
  label: "Supernova",
130
179
  description: TOOL_DESCRIPTION,
131
- promptSnippet: "Compose multiple host tools in one JavaScript program via supernova",
180
+ promptSnippet: "Compose host tools in one JavaScript program",
132
181
  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.",
182
+ "Use supernova for multi-step tool work: loops, filtering, parallel reads, read→edit chains. To understand code, call evidence(question) and read only the returned spans; read whole files only to edit them. Return a compact shaped value; keep raw tool output inside the program.",
136
183
  ],
137
184
  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
- ),
185
+ code: Type.String({ description: "JavaScript program: async body or arrow function." }),
186
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, description: "Hard timeout in ms." })),
148
187
  }),
149
188
  // One self-owned result frame is shared by Pi and OMP; renderCall stays empty
150
189
  // so separate call/result slots cannot duplicate the lifecycle card.
@@ -163,74 +202,26 @@ export default function piSupernova(pi) {
163
202
  bridge.resetCallBudget();
164
203
  refreshCatalog();
165
204
  bridge.beginSpeculation();
205
+ const emitProgress = progressEmitter(onUpdate);
206
+ bridge.setCallListener((_record, allTrace) => emitProgress(allTrace));
207
+ emitProgress([]);
166
208
 
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
209
  let outcome;
194
210
  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
- };
211
+ outcome = await runProgram(params, runController.signal, abortRun);
209
212
  } finally {
210
213
  bridge.setCallListener(null);
214
+ emitProgress.flush();
211
215
  signal?.removeEventListener("abort", abortRun);
212
216
  }
213
217
 
214
218
  const trace = bridge.getTrace();
215
219
  if (!outcome.ok) {
216
220
  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
- });
221
+ return result(errorText(outcome), { ok: false, error: outcome.error, wallMs: outcome.wallMs, logs: outcome.logs, trace });
226
222
  }
227
-
228
223
  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, {
224
+ return result(successText(outcome), {
234
225
  ok: true,
235
226
  wallMs: outcome.wallMs,
236
227
  returnTruncated: outcome.returnTruncated,
@@ -242,9 +233,28 @@ export default function piSupernova(pi) {
242
233
  },
243
234
  });
244
235
 
236
+ async function runProgram(params, signal, onTimeout) {
237
+ const runStartedAt = performance.now();
238
+ const runConfig = {
239
+ ...config,
240
+ timeoutMs: Number.isInteger(params?.timeoutMs) ? params.timeoutMs : config.timeoutMs,
241
+ };
242
+ try {
243
+ return await runGuestProgram({ code: String(params?.code || ""), nova: makeNovaApi(), config: runConfig, signal, onTimeout });
244
+ } catch (error) {
245
+ return {
246
+ ok: false,
247
+ error: error instanceof Error ? error.message : String(error),
248
+ logs: [],
249
+ wallMs: Math.round(performance.now() - runStartedAt),
250
+ };
251
+ }
252
+ }
253
+
245
254
  pi.on("session_start", (_event, ctx) => {
246
255
  if (ctx && isString(ctx.cwd) && ctx.cwd) cwd = ctx.cwd;
247
256
  refreshCatalog();
257
+ warmGuestWorker(config).catch(() => {});
248
258
  });
249
259
 
250
260
  pi.registerCommand("supernova", {
@@ -257,7 +267,7 @@ export default function piSupernova(pi) {
257
267
  `pi-supernova catalog: ${catalog.length} tools`,
258
268
  `captured executors: ${captured.length ? captured.join(", ") : "(none yet — load this package early)"}`,
259
269
  `native adapters: ${natives.join(", ")}`,
260
- `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxBridgeCalls=${config.maxBridgeCalls}`,
270
+ `timeoutMs=${config.timeoutMs} maxCallResultChars=${config.maxCallResultChars} maxReturnChars=${config.maxReturnChars} maxBridgeCalls=${config.maxBridgeCalls} maxHeapMb=${config.maxHeapMb}`,
261
271
  ];
262
272
  ctx.ui.notify(lines.join("\n"), "info");
263
273
  },
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.11",
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,13 @@
29
29
  "bottleneck.js",
30
30
  "parallel.js",
31
31
  "runtime.js",
32
+ "guest-worker.js",
33
+ "repo-index.js",
34
+ "evidence.js",
35
+ "format.js",
36
+ "patch.js",
37
+ "vfs.js",
38
+ "workspace.js",
32
39
  "config.js",
33
40
  "config.default.json",
34
41
  "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
+ }