feat(scripts): added LLM rewriter for prompt scratchpad style

- Peels prose lines, preserving fragile tokens and code blocks byte-exact.
- Rewrites via OpenRouter in id-keyed batches, falling back on dropped tokens.
- Converted the system prompt to the terse implementation-scratchpad voice.
This commit is contained in:
can1357
2026-06-04 14:26:56 +02:00
parent 10be5ddb54
commit e9cb3e7ead
3 changed files with 837 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
You rewrite engineering-prose fragments into a terse implementation-scratchpad voice. You receive one JSON object of fragments and return one JSON object of rewritten fragments. Apply the style below to every fragment.
# Style
Drop articles, subjects, and "to." Strip "a," "the," "I," and the infinitive "to" wherever the meaning survives. "Need render SVG background," not "I need to render the SVG background." "Color wheel hidden," not "The color wheel is hidden."
Open clauses with a small fixed set of verbs. Most sentences start with Need, Need maybe, Need perhaps, Could, We'll, or Let's. Use Need for unresolved to-dos and We'll / Let's for decisions you've just committed to. The shift from "Need maybe set palette width…" to "We'll set .wb-pen-rack…" is how a choice gets locked in.
Hedge constantly with bare particles. maybe, perhaps, could, might, ~, okay. Scatter them mid-clause: "width 420 maybe ok," "height 220 maybe." The hedging is the texture; don't smooth it out.
End deliberations with one-word verdicts as full sentences. Fine. Good. Nice. complicated. Fine. These close a thread. Pattern: float an idea, note a problem, then dismiss it — "transform scale? complicated. Fine."
Self-interrogate, then resolve in the same breath. Pose a fragment-question and immediately answer or wave it off. "For smaller viewport, width 420 maybe ok. In screenshot browser 1200." / "We have width 420; max-width calc. But control positions fixed…"
Pivot on a bare "But." Mid-thought reversals get a lone "But" with no setup. "Could use scale? Not needed? … But to get proper arc positions, flex plus transform works okay."
Chain micro-clauses with semicolons; inline raw numbers and units. Don't narrate measurements — drop them in. "width constant 280; visible width 420," "absolute left 82 top 24, height 112."
Collapse cause and effect. Conditionals get telegraphed: "If color hidden, width row stays." "For eraser state, no swatches means width row maybe still at y 157."
Interleave code fragments without framing. Drop in snippets mid-reasoning with no "here's the code" preamble; resume prose right after.
Stay in present tense, neutral affect. No feelings, no "let me think," no recap of what you just did. Pure forward motion through the problem.
# Hard constraints
- Preserve every technical token EXACTLY as written, with the same characters, casing, and number of occurrences: backtick code spans (`` `like this` ``), XML/HTML tags (`<like-this>`), template expressions (`{{like.this}}`), URLs, file paths, flags, command names, API names, numbers, and units. Never reword, reorder, split, or drop the contents of a code span, tag, or `{{…}}` expression. If compressing a clause would delete one of these tokens, keep the token — the rewrite is rejected when any token goes missing.
- Keep the RFC-2119 keywords UPPERCASE wherever they appear, even after dropping the subject: MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, RECOMMENDED, MAY, OPTIONAL, NEVER, AVOID. "You MUST load context" → "MUST load context", never "must load context". "You NEVER yield" → "NEVER yield", never "never yield".
- Capitalize the scratchpad opener verbs at the start of a clause: Need, Could, We'll, Let's, Check, Risk, Fix, Run, Fine, Good, Decision.
- Rewrite only. Do not translate, annotate, summarize, or explain. No commentary.
- One fragment in maps to exactly one fragment out. Never merge two fragments, never split one, never reorder.
# Protocol
Input is a single JSON object: `{"items":[{"id":<int>,"text":"<fragment>"}, ...]}`.
Respond with ONLY a JSON object of the same shape and the same `id` values, in any order: `{"items":[{"id":<int>,"text":"<rewritten fragment>"}, ...]}`.
No markdown fences, no prose before or after the JSON. The number of returned items MUST equal the number of input items, with identical `id`s. Each `text` value MUST be valid JSON — escape every embedded double-quote and newline.
+220
View File
@@ -0,0 +1,220 @@
import { hookFetch } from "@oh-my-pi/pi-utils";
import { describe, expect, it } from "bun:test";
import {
isVerbatimLine,
makeOpenRouterRewriter,
peel,
planRewrite,
preservesTokens,
type RewriteItem,
rewriteAll,
} from "./rewrite-system-prompt";
describe("peel", () => {
it("keeps prefix + core + suffix === line for every shape", () => {
const lines = [
"You are the engineer.",
"- You NEVER yield.",
" - debugging across code,",
'{{#has tools "read"}}- file reads → `{{toolRefs.read}}`{{/has}}',
"1. first numbered item",
];
for (const line of lines) {
const { prefix, core, suffix } = peel(line);
expect(prefix + core + suffix).toBe(line);
}
});
it("peels a list marker into the prefix", () => {
expect(peel("- You NEVER yield.")).toEqual({
prefix: "- ",
core: "You NEVER yield.",
suffix: "",
});
});
it("peels wrapping handlebars block tokens into prefix/suffix, leaving the sentence as core", () => {
expect(peel('{{#has tools "read"}}- file reads here{{/has}}')).toEqual({
prefix: '{{#has tools "read"}}- ',
core: "file reads here",
suffix: "{{/has}}",
});
});
});
describe("isVerbatimLine", () => {
it("preserves structural lines", () => {
for (const line of [
"",
" ",
"<stakes>",
"</system-conventions>",
"<tool id={{name}}>",
"{{#if skills.length}}",
"{{/each}}",
"{{content}}",
"- {{name}}: {{description}}",
"# URLs",
"## LSP",
"===================================",
"- `mcp://<uri>`: MCP resource",
]) {
expect(isVerbatimLine(line)).toBe(true);
}
});
it("treats real prose as rewritable, including prose wrapped in handlebars", () => {
for (const line of [
"You MUST optimize for correctness first.",
"- You NEVER yield incomplete work.",
'{{#has tools "read"}}- file/dir reads → `{{toolRefs.read}}`, not `cat`/`ls`{{/has}}',
"- `omp://`: Harness documentation; AVOID reading unless asked.",
]) {
expect(isVerbatimLine(line)).toBe(false);
}
});
});
describe("preservesTokens", () => {
it("passes only when every fragile token survives with its multiplicity", () => {
const tokens = ["`{{toolRefs.read}}`", "`{{toolRefs.read}}`", "`cat`"];
expect(
preservesTokens("use `{{toolRefs.read}}` not `cat`, `{{toolRefs.read}}` lists", tokens),
).toBe(true);
// only one occurrence of the duplicated token -> fails the count check
expect(preservesTokens("use `{{toolRefs.read}}` not `cat`", tokens)).toBe(false);
// token reworded away -> fails
expect(preservesTokens("use read not `cat`", tokens)).toBe(false);
});
it("is vacuously true when there are no tokens", () => {
expect(preservesTokens("anything goes", [])).toBe(true);
});
});
describe("planRewrite", () => {
it("collects only prose lines, with peeled prefix and extracted tokens", () => {
const src = [
"<stakes>",
"You MUST do the thing.",
"- Use `{{toolRefs.read}}` to read.",
"# Header",
"{{#if x}}",
"</stakes>",
"",
].join("\n");
const plan = planRewrite(src);
expect(plan.prose.map((p) => p.lineIndex)).toEqual([1, 2]);
expect(plan.prose[1]).toMatchObject({
prefix: "- ",
core: "Use `{{toolRefs.read}}` to read.",
tokens: ["`{{toolRefs.read}}`"],
});
});
});
describe("rewriteAll", () => {
const src = [
"<stakes>",
"You MUST do the thing.",
"- Use `{{toolRefs.read}}` to read.",
"</stakes>",
].join("\n");
it("rewrites prose, reassembles by line, and leaves structural lines byte-exact", async () => {
const fake = async (items: RewriteItem[]) =>
new Map(items.map((i) => [i.id, `NEW:${i.text}`]));
const { content, stats } = await rewriteAll(src, fake, {
chunkSize: 3,
concurrency: 2,
limit: 0,
});
const lines = content.split("\n");
expect(lines[0]).toBe("<stakes>"); // structural, untouched
expect(lines[3]).toBe("</stakes>");
expect(lines[1]).toBe("NEW:You MUST do the thing.");
expect(lines[2]).toBe("- NEW:Use `{{toolRefs.read}}` to read."); // prefix + token preserved
expect(stats).toMatchObject({ proseLines: 2, changed: 2, fallback: 0 });
});
it("falls back to the original line when the chunk omits an id", async () => {
const empty = async () => new Map<number, string>();
const { content, stats } = await rewriteAll(src, empty, {
chunkSize: 3,
concurrency: 2,
limit: 0,
});
expect(content).toBe(src); // nothing changed
expect(stats).toMatchObject({ changed: 0, fallback: 2 });
});
});
describe("makeOpenRouterRewriter", () => {
const reply = (items: { id: number; text: string }[]) =>
new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items }) } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
it("re-requests only the lines whose rewrite dropped a fragile token", async () => {
const requestedIds: number[][] = [];
using _hook = hookFetch((_input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
messages: { content: string }[];
};
const sent = JSON.parse(body.messages[1].content) as {
items: { id: number; text: string }[];
};
requestedIds.push(sent.items.map((i) => i.id));
const firstAttempt = requestedIds.length === 1;
return reply(
sent.items.map(({ id }) =>
id === 1
? { id, text: firstAttempt ? "Use X to read." : "Read via `X`." } // first drops the token
: { id, text: "PLAIN." },
),
);
});
const rewrite = makeOpenRouterRewriter({
apiKey: "k",
model: "m",
baseUrl: "https://example.invalid/v1",
temperature: 0,
retries: 2,
system: "s",
});
const result = await rewrite([
{ id: 1, text: "Use `X` to read.", tokens: ["`X`"] },
{ id: 2, text: "Plain prose here.", tokens: [] },
]);
expect(result.get(1)).toBe("Read via `X`."); // accepted only after the token survived
expect(result.get(2)).toBe("PLAIN.");
expect(requestedIds.length).toBe(2);
expect(requestedIds[1]).toEqual([1]); // retry carried only the still-failing line
});
it("omits an id that never passes validation so the caller keeps the original", async () => {
using _hook = hookFetch((_input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
messages: { content: string }[];
};
const sent = JSON.parse(body.messages[1].content) as {
items: { id: number; text: string }[];
};
return reply(sent.items.map(({ id }) => ({ id, text: "no token here" })));
});
const rewrite = makeOpenRouterRewriter({
apiKey: "k",
model: "m",
baseUrl: "https://example.invalid/v1",
temperature: 0,
retries: 1,
system: "s",
});
const result = await rewrite([{ id: 7, text: "keep `T`", tokens: ["`T`"] }]);
expect(result.has(7)).toBe(false);
});
});
+578
View File
@@ -0,0 +1,578 @@
#!/usr/bin/env bun
/**
* Rewrite the natural-language prose of a prompt file into the terse
* implementation-scratchpad voice (see `rewrite-system-prompt.style.md`),
* leaving every structural token byte-exact.
*
* The file is processed line by line. Lines that are blank, Markdown headings,
* XML tags, or Handlebars directives are preserved verbatim. Prose lines have
* their list/indent prefix and any wrapping `{{#…}}`/`{{/…}}` block tokens
* peeled off; the remaining sentence keeps its inline code/tags/URLs/template
* expressions visible and is sent to an LLM (Sonnet 4.5 via OpenRouter by
* default) in small id-keyed JSON batches that run in parallel. Each response
* is validated — every fragile token must survive — and any line whose rewrite
* drops a token falls back to its original text.
*
* Usage:
* OPENROUTER_API_KEY=… bun scripts/rewrite-system-prompt.ts # rewrite system prompt in place
* OPENROUTER_API_KEY=… bun scripts/rewrite-system-prompt.ts --all # rewrite every prompt + rule in place
* OPENROUTER_API_KEY=… bun scripts/rewrite-system-prompt.ts -i a -o b # write to a different file
* bun scripts/rewrite-system-prompt.ts --dry-run # plan only, no network
*
* Output is in place by default (the input is overwritten); pass -o/--output to
* redirect a single-file run. YAML frontmatter and fenced code blocks are kept
* byte-exact and never sent to the model.
*
* Flags:
* -i, --input <path> source file (default: the coding-agent system prompt)
* -o, --output <path> destination for a single-file run (default: in place)
* --all rewrite every bundled prompt and rule (always in place)
* --model <id> OpenRouter model id (default: anthropic/claude-sonnet-4.5)
* --base-url <url> OpenRouter-compatible base (default: https://openrouter.ai/api/v1)
* --chunk <n> prose lines per request (default: 3)
* --concurrency <n> parallel requests in flight (default: 6)
* --retries <n> network/parse retries per chunk (default: 2)
* --temperature <n> sampling temperature (default: 0.4)
* --limit <n> rewrite only the first N prose lines per file (0 = all)
* --dry-run classify + chunk, print a plan, make no network calls
*/
import { parseArgs } from "node:util";
import * as path from "node:path";
import STYLE_GUIDE from "./rewrite-system-prompt.style.md" with { type: "text" };
const DEFAULT_INPUT = "packages/coding-agent/src/prompts/system/system-prompt.md";
const DEFAULT_MODEL = "anthropic/claude-sonnet-4.5";
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
/** Prompt + rule markdown globs for `--all`, repo-root anchored. */
const PROMPT_GLOBS = [
"packages/coding-agent/src/prompts/**/*.md",
"packages/coding-agent/src/commit/prompts/*.md",
"packages/coding-agent/src/commit/agentic/prompts/*.md",
"packages/coding-agent/src/autoresearch/*.md",
"packages/coding-agent/src/discovery/builtin-rules/*.md",
"packages/agent/src/compaction/prompts/*.md",
"packages/ai/src/prompts/*.md",
"packages/typescript-edit-benchmark/src/prompts/*.md",
"packages/hashline/src/prompt.md",
];
/** Matches one inline token that must survive a rewrite untouched. */
const FRAGILE_RE = /\{\{[^}]*\}\}|<[^>]*>|`[^`]*`|[A-Za-z][\w+.-]*:\/\/\S+/g;
export interface ProseEntry {
/** Index into the file's line array. */
lineIndex: number;
/** Leading whitespace + wrapping block tokens + list marker. */
prefix: string;
/** Trailing wrapping block tokens. */
suffix: string;
/** The rewritable sentence core (fragile tokens left visible for the model). */
core: string;
/** Fragile tokens (code spans, tags, URLs, template exprs) that must survive. */
tokens: string[];
}
export interface RewritePlan {
lines: string[];
prose: ProseEntry[];
}
export interface RewriteItem {
id: number;
text: string;
/** Fragile tokens the rewrite MUST preserve; validated before acceptance. */
tokens: readonly string[];
}
/**
* Rewrite a batch of fragments. Returns a map of `id` -> validated rewritten
* text; an id absent from the map could not be rewritten (caller keeps original).
*/
export type RewriteChunk = (items: RewriteItem[]) => Promise<Map<number, string>>;
/**
* Split a line into a verbatim `prefix`, a rewritable `core`, and a verbatim
* `suffix`. Invariant: `prefix + core + suffix === line`.
*
* `prefix` absorbs leading whitespace, any leading `{{…}}` block tokens, and a
* single list/ordered marker. `suffix` absorbs trailing `{{…}}` tokens.
*/
export function peel(line: string): { prefix: string; core: string; suffix: string } {
let s = line;
let suffix = "";
for (;;) {
const m = s.match(/(\s*\{\{[^}]*\}\}\s*)$/);
if (!m) break;
suffix = m[1] + suffix;
s = s.slice(0, s.length - m[1].length);
}
let prefix = "";
for (;;) {
const m = s.match(/^(\s*\{\{[^}]*\}\}\s*)/);
if (!m) break;
prefix += m[1];
s = s.slice(m[1].length);
}
const marker = s.match(/^(\s*(?:[-*+]\s+|\d+\.\s+))/);
if (marker) {
prefix += marker[1];
s = s.slice(marker[1].length);
}
return { prefix, core: s, suffix };
}
/**
* Decide whether a line is structural (kept verbatim) rather than prose.
*
* Verbatim when: blank, a Markdown heading or horizontal rule, or — after
* stripping Handlebars expressions, XML tags, code spans, and URLs — it carries
* fewer than three alphabetic words and no sentence-ending punctuation. That
* leaves XML tags, Handlebars directives, template-data list items, and short
* `token: label` definition rows untouched while still catching wrapped prose
* such as `{{#has tools "read"}}- file/dir reads …{{/has}}`.
*/
export function isVerbatimLine(line: string): boolean {
const t = line.trim();
if (t === "") return true;
if (/^#{1,6}\s/.test(t)) return true; // markdown heading
if (/^[-*=_]{3,}\s*$/.test(t)) return true; // rule / heading underline
const residue = t
.replace(/\{\{[^}]*\}\}/g, " ")
.replace(/<[^>]*>/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/[A-Za-z][\w+.-]*:\/\/\S+/g, " ");
const words = residue.match(/[A-Za-z]{2,}/g) ?? [];
if (words.length === 0) return true;
const hasSentencePunct = /[.;?!]/.test(residue);
if (words.length < 3 && !hasSentencePunct) return true;
return false;
}
/**
* Verify a rewritten core still carries every fragile token from the original,
* counting multiplicity. A dropped or reworded token (`{{…}}`, code span, tag,
* URL) fails the check so the caller can fall back to the original line.
*/
export function preservesTokens(rewritten: string, tokens: readonly string[]): boolean {
const want = new Map<string, number>();
for (const tok of tokens) want.set(tok, (want.get(tok) ?? 0) + 1);
for (const [tok, n] of want) {
if (rewritten.split(tok).length - 1 < n) return false;
}
return true;
}
/**
* Mark every line that belongs to a block-level structure the rewriter must
* keep byte-exact and never send to the model: a leading YAML frontmatter block
* (`---` … `---` at the very top) and fenced code blocks (``` or ~~~). The
* per-line {@link isVerbatimLine} classifier only sees one line at a time, so a
* frontmatter `description:` field or a `// comment` inside an example would
* otherwise read as prose and get rewritten.
*/
export function blockSkipMask(lines: readonly string[]): boolean[] {
const skip = new Array<boolean>(lines.length).fill(false);
let start = 0;
// Leading YAML frontmatter: `---` on the first line, closed by the next `---`.
if (lines.length > 0 && lines[0].trim() === "---") {
let close = -1;
for (let j = 1; j < lines.length; j++) {
if (lines[j].trim() === "---") {
close = j;
break;
}
}
if (close >= 0) {
for (let k = 0; k <= close; k++) skip[k] = true;
start = close + 1;
}
}
// Fenced code blocks; the closing fence must repeat the opening fence char.
let fenceChar: string | null = null;
for (let i = start; i < lines.length; i++) {
const m = lines[i].trim().match(/^(```+|~~~+)/);
if (fenceChar === null) {
if (m) {
fenceChar = m[1][0];
skip[i] = true;
}
} else {
skip[i] = true;
if (m && m[1][0] === fenceChar) fenceChar = null;
}
}
return skip;
}
/** Classify every line; build the rewrite plan. */
export function planRewrite(content: string): RewritePlan {
const lines = content.split("\n");
const skip = blockSkipMask(lines);
const prose: ProseEntry[] = [];
for (let i = 0; i < lines.length; i++) {
if (skip[i]) continue; // frontmatter / fenced code block
const line = lines[i];
if (isVerbatimLine(line)) continue;
const { prefix, core, suffix } = peel(line);
if (core.trim() === "") continue; // nothing rewritable after peeling
const tokens = Array.from(core.matchAll(FRAGILE_RE), (m) => m[0]);
prose.push({ lineIndex: i, prefix, suffix, core, tokens });
}
return { lines, prose };
}
/** Split a list into fixed-size groups, preserving order. */
export function chunk<T>(items: readonly T[], size: number): T[][] {
const groups: T[][] = [];
const step = Math.max(1, size);
for (let i = 0; i < items.length; i += step) groups.push(items.slice(i, i + step));
return groups;
}
/** Run `fn` over `items` with at most `limit` concurrent calls; preserve order. */
async function mapPool<T, R>(
items: readonly T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
const width = Math.max(1, Math.min(limit, items.length));
const workers = Array.from({ length: width }, async () => {
for (;;) {
const i = next++;
if (i >= items.length) break;
results[i] = await fn(items[i], i);
}
});
await Promise.all(workers);
return results;
}
export interface RewriteStats {
totalLines: number;
proseLines: number;
attempted: number;
changed: number;
fallback: number;
}
export interface RewriteOptions {
chunkSize: number;
concurrency: number;
limit: number;
onProgress?: (done: number, total: number) => void;
}
/**
* Apply `rewriteChunk` across the plan's prose lines and reassemble the file.
*
* `rewriteChunk` returns replacement text keyed by line index. A missing id, or
* a rewrite that drops a fragile token, falls back to the original line — so a
* flaky batch degrades to "unchanged," never to corruption.
*/
export async function rewriteAll(
content: string,
rewriteChunk: RewriteChunk,
opts: RewriteOptions,
): Promise<{ content: string; stats: RewriteStats }> {
const plan = planRewrite(content);
const lines = [...plan.lines];
const toRewrite = opts.limit > 0 ? plan.prose.slice(0, opts.limit) : plan.prose;
const groups = chunk(toRewrite, opts.chunkSize);
let done = 0;
const resolved = await mapPool(groups, opts.concurrency, async (group) => {
const items: RewriteItem[] = group.map((e) => ({
id: e.lineIndex,
text: e.core,
tokens: e.tokens,
}));
let map: Map<number, string>;
try {
map = await rewriteChunk(items);
} catch {
map = new Map();
}
const out = group.map((entry) => {
const candidate = map.get(entry.lineIndex);
return { entry, text: candidate ?? entry.core, ok: candidate != null };
});
done += group.length;
opts.onProgress?.(done, toRewrite.length);
return out;
});
let changed = 0;
let fallback = 0;
for (const group of resolved) {
for (const { entry, text, ok } of group) {
const rebuilt = entry.prefix + text + entry.suffix;
lines[entry.lineIndex] = rebuilt;
if (!ok) fallback++;
else if (rebuilt !== plan.lines[entry.lineIndex]) changed++;
}
}
return {
content: lines.join("\n"),
stats: {
totalLines: plan.lines.length,
proseLines: plan.prose.length,
attempted: toRewrite.length,
changed,
fallback,
},
};
}
/** Tolerantly parse the model's `{"items":[…]}` reply (strips fences, locates the object). */
export function parseItemsResponse(text: string): { id: number; text: string }[] {
let s = text.trim();
const fence = s.match(/^```[a-zA-Z]*\s*([\s\S]*?)\s*```$/);
if (fence) s = fence[1].trim();
let parsed: unknown;
try {
parsed = JSON.parse(s);
} catch {
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start < 0 || end <= start) throw new Error("response is not JSON");
parsed = JSON.parse(s.slice(start, end + 1));
}
const items = (parsed as { items?: unknown })?.items;
if (!Array.isArray(items)) throw new Error("response missing items[]");
const out: { id: number; text: string }[] = [];
for (const it of items) {
const id = (it as { id?: unknown })?.id;
const value = (it as { text?: unknown })?.text;
if (typeof id === "number" && typeof value === "string") out.push({ id, text: value });
}
return out;
}
interface OpenRouterOptions {
apiKey: string;
model: string;
baseUrl: string;
temperature: number;
retries: number;
system: string;
}
/** OpenAI-style JSON-schema response format; forces a valid `{items:[{id,text}]}` reply. */
const REWRITE_RESPONSE_FORMAT = {
type: "json_schema",
json_schema: {
name: "rewrites",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
items: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
id: { type: "integer" },
text: { type: "string" },
},
required: ["id", "text"],
},
},
},
required: ["items"],
},
},
} as const;
/** Build a {@link RewriteChunk} backed by an OpenRouter chat-completions endpoint. */
export function makeOpenRouterRewriter(opts: OpenRouterOptions): RewriteChunk {
return async (items) => {
const result = new Map<number, string>();
let pending = items;
let lastErr: unknown;
for (let attempt = 0; attempt <= opts.retries && pending.length > 0; attempt++) {
try {
const body = JSON.stringify({
model: opts.model,
temperature: opts.temperature,
response_format: REWRITE_RESPONSE_FORMAT,
messages: [
{ role: "system", content: opts.system },
{
role: "user",
content: JSON.stringify({ items: pending.map((p) => ({ id: p.id, text: p.text })) }),
},
],
});
const res = await fetch(`${opts.baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${opts.apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://omp.sh/",
"X-Title": "Oh-My-Pi",
},
body,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: unknown } }[];
};
const content = data.choices?.[0]?.message?.content;
if (typeof content !== "string") throw new Error("no message content");
const got = new Map<number, string>();
for (const it of parseItemsResponse(content)) got.set(it.id, it.text);
// Keep only rewrites that preserve every fragile token; re-request the rest.
const stillPending: RewriteItem[] = [];
for (const p of pending) {
const text = got.get(p.id);
if (text != null && preservesTokens(text, p.tokens)) result.set(p.id, text);
else stillPending.push(p);
}
pending = stillPending;
} catch (err) {
lastErr = err;
}
if (pending.length > 0 && attempt < opts.retries) await Bun.sleep(400 * (attempt + 1));
}
if (pending.length > 0) {
console.error(
` ${pending.length} line(s) [${pending.map((p) => p.id).join(",")}] kept original: ${String(lastErr ?? "rewrite dropped a token")}`,
);
}
return result;
};
}
interface CliOptions {
input: string;
output: string;
model: string;
baseUrl: string;
chunkSize: number;
concurrency: number;
retries: number;
temperature: number;
limit: number;
dryRun: boolean;
all: boolean;
}
function parseCli(argv: string[]): CliOptions {
const { values } = parseArgs({
args: argv,
options: {
input: { type: "string", short: "i" },
output: { type: "string", short: "o" },
all: { type: "boolean", default: false },
model: { type: "string", default: DEFAULT_MODEL },
"base-url": { type: "string", default: DEFAULT_BASE_URL },
chunk: { type: "string", default: "3" },
concurrency: { type: "string", default: "6" },
retries: { type: "string", default: "2" },
temperature: { type: "string", default: "0.4" },
limit: { type: "string", default: "0" },
"dry-run": { type: "boolean", default: false },
},
allowPositionals: false,
});
const input = values.input ?? DEFAULT_INPUT;
const output = values.output ?? input;
const num = (name: string, raw: string | undefined, fallback: number): number => {
const n = Number(raw);
if (!Number.isFinite(n)) throw new Error(`--${name} must be a number, got ${raw}`);
return raw === undefined ? fallback : n;
};
return {
input,
output,
model: values.model ?? DEFAULT_MODEL,
baseUrl: values["base-url"] ?? DEFAULT_BASE_URL,
chunkSize: num("chunk", values.chunk, 3),
concurrency: num("concurrency", values.concurrency, 6),
retries: num("retries", values.retries, 2),
temperature: num("temperature", values.temperature, 0.4),
limit: num("limit", values.limit, 0),
dryRun: values["dry-run"] ?? false,
all: values.all ?? false,
};
}
/** Resolve every bundled prompt and rule markdown file, repo-root anchored and sorted. */
async function collectPromptFiles(): Promise<string[]> {
const root = path.resolve(import.meta.dir, "..");
const seen = new Set<string>();
for (const pattern of PROMPT_GLOBS) {
for await (const rel of new Bun.Glob(pattern).scan({ cwd: root, onlyFiles: true })) {
if (rel.endsWith(".rewritten.md")) continue; // skip stale artifacts from older runs
seen.add(path.join(root, rel));
}
}
return [...seen].sort();
}
async function main(): Promise<void> {
const opts = parseCli(process.argv.slice(2));
const files = opts.all ? await collectPromptFiles() : [opts.input];
if (files.length === 0) {
console.error("No prompt files matched.");
return;
}
if (opts.dryRun) {
for (const file of files) {
const content = await Bun.file(file).text();
const plan = planRewrite(content);
const groups = chunk(opts.limit > 0 ? plan.prose.slice(0, opts.limit) : plan.prose, opts.chunkSize);
console.error(
`${file}: ${plan.lines.length} lines, ${plan.prose.length} prose, ${plan.lines.length - plan.prose.length} verbatim, ${groups.length} chunk(s).`,
);
}
console.error("Dry run: no network calls, nothing written.");
return;
}
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
console.error("Error: OPENROUTER_API_KEY is not set.");
process.exit(1);
}
const rewriteChunk = makeOpenRouterRewriter({
apiKey,
model: opts.model,
baseUrl: opts.baseUrl,
temperature: opts.temperature,
retries: opts.retries,
system: STYLE_GUIDE,
});
for (const file of files) {
const output = opts.all ? file : opts.output;
const content = await Bun.file(file).text();
const { content: rewritten, stats } = await rewriteAll(content, rewriteChunk, {
chunkSize: opts.chunkSize,
concurrency: opts.concurrency,
limit: opts.limit,
onProgress: (done, total) => {
if (done === total || done % 10 === 0) console.error(` ${file}: ${done}/${total} prose lines`);
},
});
await Bun.write(output, rewritten);
console.error(
`Wrote ${output}: ${stats.changed} changed, ${stats.fallback} fallback, ${stats.proseLines} prose / ${stats.totalLines} lines (model ${opts.model}).`,
);
}
}
if (import.meta.main) {
await main();
}