From 38e48b963224fd512493b2806280a2d6f3cacf9e Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 10 Feb 2026 19:26:49 +0100 Subject: [PATCH] feat(coding-agent): added streaming functions and improved hashline format with 2-char hex hashes - Added streaming functions `streamHashLinesFromUtf8` and `streamHashLinesFromLines` for processing large files with configurable chunking. - Added `HashlineStreamOptions` interface to control streaming behavior with `startLine`, `maxChunkLines`, and `maxChunkBytes` options. - Changed hashline format from 4-character to 2-character hex hashes for more compact output. - Updated `computeLineHash` function to normalize whitespace and removed line number from hash seed for consistency. - Improved CLI argument parsing with explicit handling of `--help`, `--version`, and subcommand detection. - Removed `@types/diff` dev dependency as it is no longer needed. --- packages/coding-agent/CHANGELOG.md | 18 ++ packages/coding-agent/package.json | 1 - packages/coding-agent/src/cli.ts | 39 ++- packages/coding-agent/src/patch/hashline.ts | 232 +++++++++++++++++- packages/coding-agent/src/patch/index.ts | 2 + .../coding-agent/test/core/hashline.test.ts | 55 +++++ packages/utils/src/cli.ts | 84 +++++-- 7 files changed, 378 insertions(+), 53 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 54ea4c666..288cc55fc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +### Added + +- Exported `streamHashLinesFromUtf8` and `streamHashLinesFromLines` functions for streaming hashline-formatted output with configurable chunking +- Added `HashlineStreamOptions` interface to control streaming behavior (startLine, maxChunkLines, maxChunkBytes) +- Added `streamHashLinesFromUtf8` function to incrementally format content with hash lines from a UTF-8 byte stream +- Added `streamHashLinesFromLines` function to incrementally format content with hash lines from an iterable of lines + +### Changed + +- Updated hashline format to use 2-character hex hashes instead of 4-character hashes for more compact line references +- Modified `computeLineHash` to normalize whitespace in line content and removed line number from hash seed for consistency +- Improved CLI argument parsing to explicitly handle `--help`, `--version`, and subcommand detection instead of prefix-based routing + +### Removed + +- Removed `@types/diff` dev dependency +- Removed AggregateError unwrapping from console.warn in CLI initialization + ## [11.10.1] - 2026-02-10 ### Changed diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 987193583..18d00ab33 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -102,7 +102,6 @@ "zod": "^4.3.6" }, "devDependencies": { - "@types/diff": "^7.0.2", "@types/ms": "^2.1.0", "@types/bun": "^1.3.8", "ms": "^2.1.3", diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 7cbf4fc5a..fea8044d7 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -6,20 +6,6 @@ import { type CommandEntry, run } from "@oh-my-pi/pi-utils/cli"; import { APP_NAME, VERSION } from "./config"; -// Unwrap AggregateError in console.warn to surface real messages -const originalWarn = console.warn; -console.warn = (...args: unknown[]) => { - for (const arg of args) { - if (arg instanceof AggregateError) { - for (const err of arg.errors) { - originalWarn(err instanceof Error ? (err.stack ?? err.message) : String(err)); - } - return; - } - } - originalWarn(...args); -}; - process.title = APP_NAME; const commands: CommandEntry[] = [ @@ -46,14 +32,27 @@ async function showHelp(config: import("@oh-my-pi/pi-utils/cli").CliConfig): Pro } } +/** + * Determine whether argv[0] is a known subcommand name. + * If not, the entire argv is treated as args to the default "launch" command. + */ +function isSubcommand(first: string | undefined): boolean { + if (!first || first.startsWith("-") || first.startsWith("@")) return false; + return commands.some(e => e.name === first || e.aliases?.includes(first)); +} + /** Run the CLI with the given argv (no `process.argv` prefix). */ export function runCli(argv: string[]): Promise { - const runArgv = argv.length === 0 || argv[0]?.startsWith("-") ? ["launch", ...argv] : argv; + // --help and --version are handled by run() directly, don't rewrite those. + // Everything else that isn't a known subcommand routes to "launch". + const first = argv[0]; + const runArgv = + first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help" + ? argv + : isSubcommand(first) + ? argv + : ["launch", ...argv]; return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp }); } -runCli(process.argv.slice(2)).catch((error: unknown) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - process.stderr.write(`${message}\n`); - process.exit(1); -}); +await runCli(process.argv.slice(2)); diff --git a/packages/coding-agent/src/patch/hashline.ts b/packages/coding-agent/src/patch/hashline.ts index f49b607a3..adbeb913d 100644 --- a/packages/coding-agent/src/patch/hashline.ts +++ b/packages/coding-agent/src/patch/hashline.ts @@ -1,15 +1,15 @@ /** * Hashline edit mode — a line-addressable edit format using content hashes. * - * Each line in a file is identified by its 1-indexed line number and a 4-character - * hex hash derived from the line content and the line number (xxHash64 with the - * line number as seed, truncated to 4 hex chars). + * Each line in a file is identified by its 1-indexed line number and a short + * hex hash derived from the normalized line content (xxHash64, truncated to 2 + * hex chars). * The combined `LINE:HASH` reference acts as both an address and a staleness check: * if the file has changed since the caller last read it, hash mismatches are caught * before any mutation occurs. * * Displayed format: `LINENUM:HASH| CONTENT` - * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3f2"`) + * Reference format: `"LINENUM:HASH"` (e.g. `"5:a3"`) */ import type { HashlineEdit, HashMismatch, SrcSpec } from "./types"; @@ -305,11 +305,11 @@ const HASH_MASK = BigInt((1 << (HASH_LEN * 4)) - 1); const HEX_DICT = Array.from({ length: Number(HASH_MASK) + 1 }, (_, i) => i.toString(16).padStart(HASH_LEN, "0")); /** - * Compute the 4-character hex hash of a single line. + * Compute a short hex hash of a single line. * - * Uses xxHash64 truncated to the first 4 hex characters. - * The line number is included as a seed so the same content on different lines - * produces different hashes. + * Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN} + * hex characters. The `idx` parameter is accepted for compatibility with older + * call sites, but is not currently mixed into the hash. * The line input should not include a trailing newline. */ export function computeLineHash(idx: number, line: string): string { @@ -333,7 +333,7 @@ export function computeLineHash(idx: number, line: string): string { * @example * ``` * formatHashLines("function hi() {\n return;\n}") - * // "1:a3f2| function hi() {\n2:b1c0| return;\n3:de45| }" + * // "1:HH| function hi() {\n2:HH| return;\n3:HH| }" * ``` */ export function formatHashLines(content: string, startLine = 1): string { @@ -347,6 +347,218 @@ export function formatHashLines(content: string, startLine = 1): string { .join("\n"); } +// ═══════════════════════════════════════════════════════════════════════════ +// Hashline streaming formatter +// ═══════════════════════════════════════════════════════════════════════════ + +export interface HashlineStreamOptions { + /** First line number to use when formatting (1-indexed). */ + startLine?: number; + /** Maximum formatted lines per yielded chunk (default: 200). */ + maxChunkLines?: number; + /** Maximum UTF-8 bytes per yielded chunk (default: 64 KiB). */ + maxChunkBytes?: number; +} + +function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + "getReader" in value && + typeof (value as { getReader?: unknown }).getReader === "function" + ); +} + +async function* bytesFromReadableStream(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) yield value; + } + } finally { + reader.releaseLock(); + } +} + +/** + * Stream hashline-formatted output from a UTF-8 byte source. + * + * This is intended for large files where callers want incremental output + * (e.g. while reading from a file handle) rather than allocating a single + * large string. + */ +export async function* streamHashLinesFromUtf8( + source: ReadableStream | AsyncIterable, + options: HashlineStreamOptions = {}, +): AsyncGenerator { + const startLine = options.startLine ?? 1; + const maxChunkLines = options.maxChunkLines ?? 200; + const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024; + const decoder = new TextDecoder("utf-8"); + const chunks = isReadableStream(source) ? bytesFromReadableStream(source) : source; + let lineNum = startLine; + let pending = ""; + let sawAnyText = false; + let endedWithNewline = false; + let outLines: string[] = []; + let outBytes = 0; + + const flush = (): string | undefined => { + if (outLines.length === 0) return undefined; + const chunk = outLines.join("\n"); + outLines = []; + outBytes = 0; + return chunk; + }; + + const pushLine = (line: string): string[] => { + const formatted = `${lineNum}:${computeLineHash(lineNum, line)}| ${line}`; + lineNum++; + + const chunksToYield: string[] = []; + const sepBytes = outLines.length === 0 ? 0 : 1; // "\n" + const lineBytes = Buffer.byteLength(formatted, "utf-8"); + + if ( + outLines.length > 0 && + (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes) + ) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + outLines.push(formatted); + outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; + + if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + return chunksToYield; + }; + + const consumeText = (text: string): string[] => { + if (text.length === 0) return []; + sawAnyText = true; + pending += text; + const chunksToYield: string[] = []; + while (true) { + const idx = pending.indexOf("\n"); + if (idx === -1) break; + const line = pending.slice(0, idx); + pending = pending.slice(idx + 1); + endedWithNewline = true; + chunksToYield.push(...pushLine(line)); + } + if (pending.length > 0) endedWithNewline = false; + return chunksToYield; + }; + for await (const chunk of chunks) { + for (const out of consumeText(decoder.decode(chunk, { stream: true }))) { + yield out; + } + } + + for (const out of consumeText(decoder.decode())) { + yield out; + } + if (!sawAnyText) { + // Mirror `"".split("\n")` behavior: one empty line. + for (const out of pushLine("")) { + yield out; + } + } else if (pending.length > 0 || endedWithNewline) { + // Emit the final line (may be empty if the file ended with a newline). + for (const out of pushLine(pending)) { + yield out; + } + } + + const last = flush(); + if (last) yield last; +} + +/** + * Stream hashline-formatted output from an (async) iterable of lines. + * + * Each yielded chunk is a `\n`-joined string of one or more formatted lines. + */ +export async function* streamHashLinesFromLines( + lines: Iterable | AsyncIterable, + options: HashlineStreamOptions = {}, +): AsyncGenerator { + const startLine = options.startLine ?? 1; + const maxChunkLines = options.maxChunkLines ?? 200; + const maxChunkBytes = options.maxChunkBytes ?? 64 * 1024; + + let lineNum = startLine; + let outLines: string[] = []; + let outBytes = 0; + let sawAnyLine = false; + const flush = (): string | undefined => { + if (outLines.length === 0) return undefined; + const chunk = outLines.join("\n"); + outLines = []; + outBytes = 0; + return chunk; + }; + + const pushLine = (line: string): string[] => { + sawAnyLine = true; + const formatted = `${lineNum}:${computeLineHash(lineNum, line)}| ${line}`; + lineNum++; + + const chunksToYield: string[] = []; + const sepBytes = outLines.length === 0 ? 0 : 1; + const lineBytes = Buffer.byteLength(formatted, "utf-8"); + + if ( + outLines.length > 0 && + (outLines.length >= maxChunkLines || outBytes + sepBytes + lineBytes > maxChunkBytes) + ) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + outLines.push(formatted); + outBytes += (outLines.length === 1 ? 0 : 1) + lineBytes; + + if (outLines.length >= maxChunkLines || outBytes >= maxChunkBytes) { + const flushed = flush(); + if (flushed) chunksToYield.push(flushed); + } + + return chunksToYield; + }; + + const asyncIterator = (lines as AsyncIterable)[Symbol.asyncIterator]; + if (typeof asyncIterator === "function") { + for await (const line of lines as AsyncIterable) { + for (const out of pushLine(line)) { + yield out; + } + } + } else { + for (const line of lines as Iterable) { + for (const out of pushLine(line)) { + yield out; + } + } + } + if (!sawAnyLine) { + // Mirror `"".split("\n")` behavior: one empty line. + for (const out of pushLine("")) { + yield out; + } + } + + const last = flush(); + if (last) yield last; +} + /** * Parse a line reference string like `"5:abcd"` into structured form. * @@ -360,7 +572,7 @@ export function parseLineRef(ref: string): { line: number; hash: string } { const prefixMatch = strictMatch ? null : cleaned.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})`)); const match = strictMatch ?? prefixMatch; if (!match) { - throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:a3f2").`); + throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:aa").`); } const line = Number.parseInt(match[1], 10); if (line < 1) { diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 7a7dfc726..98ececb55 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -55,6 +55,8 @@ export { formatHashLines, HashlineMismatchError, parseLineRef, + streamHashLinesFromLines, + streamHashLinesFromUtf8, validateLineRef, } from "./hashline"; // Normalization diff --git a/packages/coding-agent/test/core/hashline.test.ts b/packages/coding-agent/test/core/hashline.test.ts index 200ed46a7..787d74363 100644 --- a/packages/coding-agent/test/core/hashline.test.ts +++ b/packages/coding-agent/test/core/hashline.test.ts @@ -6,6 +6,8 @@ import { type HashlineEdit, HashlineMismatchError, parseLineRef, + streamHashLinesFromLines, + streamHashLinesFromUtf8, validateLineRef, } from "@oh-my-pi/pi-coding-agent/patch"; @@ -87,6 +89,59 @@ describe("formatHashLines", () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════ +// streamHashLinesFromUtf8 / streamHashLinesFromLines +// ═══════════════════════════════════════════════════════════════════════════ + +describe("streamHashLinesFrom*", () => { + async function collectText(gen: AsyncIterable): Promise { + const parts: string[] = []; + for await (const part of gen) { + parts.push(part); + } + return parts.join("\n"); + } + + async function* utf8Chunks(text: string, chunkSize: number): AsyncGenerator { + const bytes = new TextEncoder().encode(text); + for (let i = 0; i < bytes.length; i += chunkSize) { + yield bytes.slice(i, i + chunkSize); + } + } + + test("streamHashLinesFromUtf8 matches formatHashLines", async () => { + const content = "foo\nbar\nbaz"; + const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); + expect(streamed).toBe(formatHashLines(content)); + }); + + test("streamHashLinesFromUtf8 handles empty content", async () => { + const content = ""; + const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 })); + expect(streamed).toBe(formatHashLines(content)); + }); + + test("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => { + const content = "foo\nbar\n"; + const lines = ["foo", "bar", ""]; // match `content.split("\\n")` + const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 })); + expect(streamed).toBe(formatHashLines(content)); + }); + + test("chunking respects maxChunkLines", async () => { + const content = "a\nb\nc"; + const parts: string[] = []; + for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), { + maxChunkLines: 1, + maxChunkBytes: 1024, + })) { + parts.push(part); + } + expect(parts).toHaveLength(3); + expect(parts.join("\n")).toBe(formatHashLines(content)); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // parseLineRef // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/utils/src/cli.ts b/packages/utils/src/cli.ts index a9c6a68d9..4cbd41ead 100644 --- a/packages/utils/src/cli.ts +++ b/packages/utils/src/cli.ts @@ -24,6 +24,7 @@ export interface FlagDescriptor(opts?: T): FlagDescriptor<"string"> & T { @@ -57,6 +61,7 @@ export const Flags = { return { kind: "integer" as const, ...opts } as FlagDescriptor<"integer"> & T; }, }; + export const Args = { string(opts?: T): ArgDescriptor & T { return { kind: "string" as const, ...opts } as ArgDescriptor & T; @@ -147,7 +152,7 @@ export abstract class Command { const Cmd = _Cmd as CommandCtor; const flagDefs = (Cmd.flags ?? {}) as Record; const argDefs = (Cmd.args ?? {}) as Record; - const strict = Cmd.strict ?? true; + const strict = Cmd.strict !== false; // Build node:util parseArgs options from flag descriptors const options: Record< @@ -166,14 +171,16 @@ export abstract class Command { options[name] = opt; } + // strict=false when command declares args (positionals must pass through) + // or when the command itself opts out const { values: rawValues, positionals } = nodeParseArgs({ args: this.argv, options, allowPositionals: true, - strict: strict && Object.keys(argDefs).length === 0, + strict, }); - // Convert raw values to proper types + // Convert raw values to proper types and validate const flags: Record = {}; for (const [name, desc] of Object.entries(flagDefs)) { const raw = rawValues[name]; @@ -181,28 +188,56 @@ export abstract class Command { if (raw === undefined || typeof raw === "boolean") { flags[name] = desc.default ?? undefined; } else { - flags[name] = Number.parseInt(raw as string, 10); + const n = Number.parseInt(raw as string, 10); + if (Number.isNaN(n)) { + throw new Error(`Expected integer for --${name}, got "${raw}"`); + } + flags[name] = n; } } else if (desc.kind === "boolean") { flags[name] = raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined; } else { // string - flags[name] = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined); + const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined); + // Validate options constraint + if (val !== undefined && desc.options && !Array.isArray(val)) { + if (!desc.options.includes(val as string)) { + throw new Error(`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`); + } + } + flags[name] = val; + } + // Validate required + if (desc.required && flags[name] === undefined) { + throw new Error(`Missing required flag: --${name}`); } } - // Map positionals to named args in declaration order + // Map positionals to named args in declaration order and validate const args: Record = {}; let posIdx = 0; for (const [argName, desc] of Object.entries(argDefs)) { if (desc.multiple) { - args[argName] = positionals.slice(posIdx); + const val = positionals.slice(posIdx); + args[argName] = val.length > 0 ? val : undefined; posIdx = positionals.length; } else { - args[argName] = positionals[posIdx]; + const val = positionals[posIdx]; + args[argName] = val; posIdx++; } + // Validate required + if (desc.required && args[argName] === undefined) { + throw new Error(`Missing required argument: ${argName}`); + } + // Validate options constraint + const argVal = args[argName]; + if (argVal !== undefined && desc.options && typeof argVal === "string") { + if (!desc.options.includes(argVal)) { + throw new Error(`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`); + } + } } return { flags, args, argv: positionals } as never; @@ -213,7 +248,7 @@ export abstract class Command { // Help rendering // --------------------------------------------------------------------------- -/** Render full root help: header, index command details, subcommand list. */ +/** Render full root help: header, default command details, subcommand list. */ export function renderRootHelp(config: CliConfig): void { const { bin, version, commands } = config; const lines: string[] = []; @@ -221,14 +256,15 @@ export function renderRootHelp(config: CliConfig): void { lines.push("USAGE"); lines.push(` $ ${bin} [COMMAND]\n`); - // Show the index command's flags/args/examples inline - const indexCmd = commands.get("index"); - if (indexCmd) { - renderCommandBody(lines, indexCmd); + // Show the default command's flags/args/examples inline. + // The default command is the one marked hidden (it's the implicit entry point). + const defaultCmd = [...commands.values()].find(C => C.hidden); + if (defaultCmd) { + renderCommandBody(lines, defaultCmd); } // List visible subcommands - const visible = [...commands.entries()].filter(([name, C]) => !C.hidden && name !== "index"); + const visible = [...commands.entries()].filter(([, C]) => !C.hidden); if (visible.length > 0) { lines.push("COMMANDS"); const maxLen = Math.max(...visible.map(([n]) => n.length)); @@ -322,6 +358,11 @@ export interface RunOptions { help?: (config: CliConfig) => Promise | void; } +/** Find a command entry by exact name or alias. */ +function findEntry(commands: CommandEntry[], id: string): CommandEntry | undefined { + return commands.find(e => e.name === id) ?? commands.find(e => e.aliases?.includes(id)); +} + /** * Main entry point — replaces `run()` from @oclif/core. * @@ -331,11 +372,11 @@ export interface RunOptions { export async function run(opts: RunOptions): Promise { const { bin, version, argv } = opts; - const commandId = argv[0] ?? "index"; + const commandId = argv[0] ?? ""; const commandArgv = argv.slice(1); // Top-level help - if (commandId === "--help" || commandId === "-h" || commandId === "help") { + if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") { const config = await loadAllCommands(opts); if (opts.help) { await opts.help(config); @@ -354,9 +395,11 @@ export async function run(opts: RunOptions): Promise { // Per-command help if (commandArgv.includes("--help") || commandArgv.includes("-h")) { const config = await loadAllCommands(opts); - const Cmd = config.commands.get(commandId); + // Resolve aliases for help too + const entry = findEntry(opts.commands, commandId); + const Cmd = entry ? config.commands.get(entry.name) : undefined; if (Cmd) { - renderCommandHelp(bin, commandId, Cmd); + renderCommandHelp(bin, entry!.name, Cmd); } else { process.stderr.write(`Unknown command: ${commandId}\n`); } @@ -364,10 +407,7 @@ export async function run(opts: RunOptions): Promise { } // Find command by name or alias - let entry = opts.commands.find(e => e.name === commandId); - if (!entry) { - entry = opts.commands.find(e => e.aliases?.includes(commandId)); - } + const entry = findEntry(opts.commands, commandId); if (!entry) { process.stderr.write(`Error: command ${commandId} not found\n`);