diff --git a/README.md b/README.md index 5b27f3c..9d1df23 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,6 @@ packages/ per-platform npm packages — binaries injected by CI wash-win32-x64/ fixtures/corpus/ recorded sessions for burn-compare docs/ including compaction-attribution.md -legacy-ts/ original Node implementation, retired but preserved for diff/blame ``` ## Distribution @@ -133,7 +132,7 @@ node scripts/copy-binary.mjs # stage the local binary into the host platform pac Pre-commit safety: `cargo test --release` exercises the parsers, the MCP framing, and a stdio integration test that spawns the binary and lists tools. CI (`.github/workflows/ci.yml`) runs the same plus a layout sanity check on each platform package's `package.json`. -The retired Node implementation lives under `legacy-ts/`, including the original hook scripts and the JS burn SDK stub. Hooks are now Rust subcommands invoked through the launcher: `node bin/wash.mjs hook ` (where kind ∈ `builtin-block`, `tool-redirect`, `edit-batching-nudge`, `post-tool-observe`, `session-start`, `session-stop`). The `/relaywash-savings` slash command calls `wash savings --session …` directly. +Hooks are Rust subcommands invoked through the launcher: `node bin/wash.mjs hook ` (where kind ∈ `builtin-block`, `tool-redirect`, `edit-batching-nudge`, `post-tool-observe`, `session-start`, `session-stop`). The `/relaywash-savings` slash command calls `wash savings --session …` directly. ## Adaptive layer substrate ([wash#13](https://github.com/AgentWorkforce/wash/issues/13)) diff --git a/crates/wash/src/ast/line_regex.rs b/crates/wash/src/ast/line_regex.rs index b2ee847..a84d5f6 100644 --- a/crates/wash/src/ast/line_regex.rs +++ b/crates/wash/src/ast/line_regex.rs @@ -123,9 +123,9 @@ fn symbol_from_header(line: &str) -> Option { } fn strip_inline_comments(s: &str) -> String { + static BLOCK_RE: OnceLock = OnceLock::new(); let no_line = if let Some(i) = s.find("//") { &s[..i] } else { s }; - // Remove block comments on a single line. - let block_re = Regex::new(r"/\*.*?\*/").unwrap(); + let block_re = BLOCK_RE.get_or_init(|| Regex::new(r"/\*.*?\*/").unwrap()); block_re.replace_all(no_line, "").into_owned() } diff --git a/crates/wash/src/mcp/server.rs b/crates/wash/src/mcp/server.rs index 3a079f6..da8aa1b 100644 --- a/crates/wash/src/mcp/server.rs +++ b/crates/wash/src/mcp/server.rs @@ -190,9 +190,10 @@ impl McpServer { } fn format_tool_result(r: &ToolResult) -> Value { - // Tools return a structured object. We emit it as a single text block of JSON so models - // see the data, plus mirror it under `structuredContent` for hosts that read it. - let text = serde_json::to_string_pretty(&r.value).unwrap_or_else(|_| "{}".into()); + // The model reads `content[].text`. Use compact JSON — pretty-printing roughly + // doubles the whitespace tokens for nested results, which defeats the whole point + // of this server. Hosts that prefer a parsed view read `structuredContent`. + let text = serde_json::to_string(&r.value).unwrap_or_else(|_| "{}".into()); json!({ "content": [{"type": "text", "text": text}], "structuredContent": r.value, diff --git a/crates/wash/src/search.rs b/crates/wash/src/search.rs index 12615db..30ad863 100644 --- a/crates/wash/src/search.rs +++ b/crates/wash/src/search.rs @@ -59,9 +59,14 @@ pub fn run(opts: SearchOpts) -> Result> { if searcher.search_path(&matcher, abs, &mut sink).is_err() { continue; } - for snippet in sink.into_snippets(opts.context_lines as u32) { + let snippets = sink.into_snippets(opts.context_lines as u32); + if snippets.is_empty() { + continue; + } + let rel = relativize(&opts.cwd, abs); + for snippet in snippets { hits.push(SearchHit { - path: relativize(&opts.cwd, abs), + path: rel.clone(), line_start: snippet.line_start, line_end: snippet.line_end, snippet: snippet.text, @@ -102,8 +107,8 @@ impl HitSink { fn into_snippets(self, context_lines: u32) -> Vec { let mut snippets = Vec::new(); let mut group: Vec = Vec::new(); - let mut iter = self.lines.keys().copied().collect::>(); - iter.sort_unstable(); + // BTreeMap iterates keys in sorted order — no extra sort needed. + let iter: Vec = self.lines.keys().copied().collect(); let flush = |group: &mut Vec, snippets: &mut Vec, lines: &BTreeMap, match_lines: &HashSet| { if group.is_empty() { diff --git a/crates/wash/src/tools/edit.rs b/crates/wash/src/tools/edit.rs index 95818cb..3cac44d 100644 --- a/crates/wash/src/tools/edit.rs +++ b/crates/wash/src/tools/edit.rs @@ -204,7 +204,7 @@ fn apply_to_file(path: &str, edits: Vec) -> Vec<(usize, EditResult)> { ); } - if let Err(e) = std::fs::write(path, ¤t) { + if let Err(e) = atomic_write(path, ¤t) { let reason = format!("write failed: {e}"); return rollback(path, edits, partial, usize::MAX, Some(reason)); } @@ -288,6 +288,55 @@ fn locate(text: &str, edit: &EditSpec) -> Vec<(usize, usize)> { fuzzy_find_all(text, &edit.old_text) } +/// Write atomically: stage the new contents in a sibling temp file, then rename over the +/// target. A crash mid-write leaves the original file untouched — `std::fs::write` would +/// truncate first and could leave a half-written file behind. +/// +/// Preserves the target's existing permissions across the replace. `rename` swaps the +/// inode for our fresh temp file, which would otherwise drop the original mode bits +/// (e.g., a `0755` script becoming non-executable). On Windows, MoveFileEx assigns the +/// destination directory's default ACL on rename — copying permissions onto the temp +/// file before the rename gives the same effective behavior on both platforms. +fn atomic_write(path: &str, contents: &str) -> std::io::Result<()> { + use std::io::Write; + let target = Path::new(path); + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = dir.join(format!(".{file_name}.wash-{pid}-{nanos}.tmp")); + let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions()); + + let write_result = (|| -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp)?; + if let Some(perms) = &original_perms { + std::fs::set_permissions(&tmp, perms.clone())?; + } + f.write_all(contents.as_bytes())?; + f.sync_all()?; + Ok(()) + })(); + if let Err(e) = write_result { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + + if let Err(e) = std::fs::rename(&tmp, target) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + fn find_all_exact(text: &str, needle: &str) -> Vec<(usize, usize)> { if needle.is_empty() { return Vec::new(); @@ -406,6 +455,28 @@ mod tests { assert_eq!(fs::read_to_string(&path).unwrap(), "a = 1\nb = 2\n"); } + #[cfg(unix)] + #[test] + fn atomic_write_preserves_executable_bit() { + use std::os::unix::fs::PermissionsExt; + let (_dir, path) = tmp_file("#!/bin/sh\necho original\n", ".sh"); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + + let v = call(json!([{ + "path": &path, + "oldText": "echo original", + "newText": "echo edited" + }])) + .unwrap(); + assert_eq!(v["results"][0]["ok"], true); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755, "executable bit must survive atomic rename"); + assert!(fs::read_to_string(&path).unwrap().contains("echo edited")); + } + #[test] fn third_edit_after_failure_marked_rolled_back() { let (_dir, path) = tmp_file("a\nb\nc\n", ".ts"); diff --git a/crates/wash/src/tools/search.rs b/crates/wash/src/tools/search.rs index ef1f988..4056126 100644 --- a/crates/wash/src/tools/search.rs +++ b/crates/wash/src/tools/search.rs @@ -112,13 +112,9 @@ fn run(args: &Value) -> Result { let value = json!({ "results": results, "truncated": truncated, - "_meta": meta, + "_meta": meta.clone(), }); - Ok(ToolResult::new("relaywash__Search", value, Some(meta_for_tool(&replaces, collapsed)))) -} - -fn meta_for_tool(replaces: &[&str], collapsed: u32) -> Meta { - Meta::new(replaces.iter().map(|s| s.to_string()), collapsed) + Ok(ToolResult::new("relaywash__Search", value, Some(meta))) } fn rank_results(mut results: Vec, mode: &str, cwd: &std::path::Path) -> Vec { diff --git a/legacy-ts/build/build.mjs b/legacy-ts/build/build.mjs deleted file mode 100644 index c66b19f..0000000 --- a/legacy-ts/build/build.mjs +++ /dev/null @@ -1,23 +0,0 @@ -// "Build" step: bundle src/ into a single servers/relaywash-server.js by concatenating -// the entry import. We don't have a real bundler dep — node loads the source tree directly. -// This script's job is to (a) ensure the entry file is syntactically valid and (b) stamp -// servers/relaywash-server.js with a thin loader that re-exports src/index.js. That keeps -// .mcp.json's path stable while letting the source live under src/. - -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; -import { writeFileSync } from 'node:fs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, '..'); - -// Sanity: load the entry to surface any syntax errors at build time. -await import(resolve(root, 'src/index.js')); - -const loader = `#!/usr/bin/env node -// relaywash MCP server entry. Source lives under ../src/. -import('../src/index.js'); -`; - -writeFileSync(resolve(root, 'servers/relaywash-server.js'), loader); -console.log('Built servers/relaywash-server.js'); diff --git a/legacy-ts/scripts/builtin-block-hook.js b/legacy-ts/scripts/builtin-block-hook.js deleted file mode 100644 index 298e887..0000000 --- a/legacy-ts/scripts/builtin-block-hook.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -// PreToolUse safety net: block built-in file tools and point the model at the relaywash equivalent. -// Runs even if the active agent's `disallowedTools` doesn't propagate (sub-agents, /agents switches). - -import { readFileSync } from 'node:fs'; - -let payload = {}; -try { - const raw = readFileSync(0, 'utf8'); - if (raw.trim()) payload = JSON.parse(raw); -} catch { - // No stdin or non-JSON; treat as no-op. -} - -const tool = payload.tool_name || payload.toolName || ''; - -const redirects = { - Read: 'relaywash__Read', - Edit: 'relaywash__Edit', - Write: 'relaywash__Edit', - Grep: 'relaywash__Search', - Glob: 'relaywash__Search', - NotebookEdit: 'relaywash__Edit', -}; - -const replacement = redirects[tool]; -if (replacement) { - process.stdout.write( - JSON.stringify({ - decision: 'block', - reason: `relaywash: built-in ${tool} is disabled. Use ${replacement} instead.`, - }) + '\n', - ); - process.exit(0); -} - -process.stdout.write(JSON.stringify({ continue: true }) + '\n'); diff --git a/legacy-ts/scripts/burn-compare.js b/legacy-ts/scripts/burn-compare.js deleted file mode 100644 index 99d76af..0000000 --- a/legacy-ts/scripts/burn-compare.js +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env node -// -// burn-compare: walks the fixture corpus and reports the byte ratio between relaywash-tool -// responses and the vanilla equivalents. -// -// Manifest format (see fixtures/corpus//manifest.json): -// -// { steps: [{ name, tool, args, vanilla?: [{ type, ...op }] }] } -// -// Each step represents ONE relaywash call. The `vanilla` array lists the sequence of -// vanilla Claude Code tool calls that step replaces — this matters because relaywash's -// biggest lever is collapsing 9 vanilla calls into 1 (see issue #1). When `vanilla` is -// omitted the script falls back to the 1:1 mapping used by older fixtures. -// -// Byte accounting: -// replacementBytes = JSON.stringify(replaceResponse).length + PER_CALL_OVERHEAD -// vanillaBytes = sum(JSON-wrapped output of each vanilla op) + N * PER_CALL_OVERHEAD -// -// PER_CALL_OVERHEAD is a coarse model of the JSON-RPC + tool-result framing every call -// pays in real Claude Code sessions (see comment on PER_CALL_OVERHEAD below). - -import { readFileSync, readdirSync, statSync, existsSync, writeFileSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { runSearch } from '../src/tools/search.js'; -import { runRead, _resetReadCache } from '../src/tools/read.js'; - -// Per-call framing model. Tool calls in MCP/Claude Code carry a per-call cost beyond the -// payload bytes: tool name, args echo, result wrapper, JSON-RPC framing, and (importantly) -// re-processing of all earlier tool results in subsequent turns. We model this as a flat -// overhead per call. ~120 bytes is conservative — real overhead is higher because the -// model re-reads earlier results on every subsequent turn, which we don't account for here. -const PER_CALL_OVERHEAD = 120; - -const root = resolve(process.cwd()); -const corpusDir = join(root, 'fixtures', 'corpus'); -const out = { fixtures: [], totals: { replacementBytes: 0, vanillaBytes: 0, replacementCalls: 0, vanillaCalls: 0 } }; - -if (!existsSync(corpusDir)) { - console.error('No fixture corpus at', corpusDir); - process.exit(1); -} - -for (const entry of readdirSync(corpusDir)) { - const dir = join(corpusDir, entry); - if (!statSync(dir).isDirectory()) continue; - const manifestPath = join(dir, 'manifest.json'); - if (!existsSync(manifestPath)) continue; - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - for (const step of manifest.steps) { - _resetReadCache(); - const r = runStep(step, dir); - out.fixtures.push({ fixture: entry, step: step.name, ...r }); - out.totals.replacementBytes += r.replacementBytes; - out.totals.vanillaBytes += r.vanillaBytes; - out.totals.replacementCalls += r.replacementCalls; - out.totals.vanillaCalls += r.vanillaCalls; - } -} - -const ratio = out.totals.vanillaBytes - ? Math.round((out.totals.replacementBytes / out.totals.vanillaBytes) * 1000) / 1000 - : null; - -console.log(`relaywash burn-compare — ${out.fixtures.length} fixtures`); -console.log(`replacement bytes: ${out.totals.replacementBytes} (${out.totals.replacementCalls} calls)`); -console.log(`vanilla bytes: ${out.totals.vanillaBytes} (${out.totals.vanillaCalls} calls)`); -console.log(`call collapse: ${out.totals.vanillaCalls}→${out.totals.replacementCalls} (${callRatio(out.totals)}x fewer)`); -console.log(`ratio: ${ratio} (lower is better)`); -console.log(''); -console.log('per-fixture:'); -for (const f of out.fixtures) { - const r = f.vanillaBytes ? (f.replacementBytes / f.vanillaBytes).toFixed(3) : 'n/a'; - console.log( - ` ${f.fixture}/${f.step}: ${f.replacementBytes}b/${f.replacementCalls}call vs ${f.vanillaBytes}b/${f.vanillaCalls}call (ratio=${r})`, - ); -} - -const jsonOut = process.argv[2]; -if (jsonOut) { - writeFileSync(jsonOut, JSON.stringify({ ...out, ratio }, null, 2)); -} - -function callRatio(t) { - if (!t.replacementCalls) return 'n/a'; - return (t.vanillaCalls / t.replacementCalls).toFixed(1); -} - -function runStep(step, fixtureDir) { - // Replacement side: invoke the relaywash tool, JSON-encode the response, add per-call overhead. - const replacementResponse = runReplacement(step, fixtureDir); - const replacementBytes = JSON.stringify(replacementResponse).length + PER_CALL_OVERHEAD; - const replacementCalls = 1; - - // Vanilla side: each entry in `step.vanilla` is one vanilla tool call. - const vanillaOps = step.vanilla || legacyVanillaFor(step); - let vanillaBytes = 0; - for (const op of vanillaOps) { - vanillaBytes += vanillaBytesFor(op, fixtureDir) + PER_CALL_OVERHEAD; - } - const vanillaCalls = vanillaOps.length; - - return { replacementBytes, vanillaBytes, replacementCalls, vanillaCalls }; -} - -function runReplacement(step, fixtureDir) { - if (step.tool === 'Search') { - return runSearch({ ...step.args, cwd: fixtureDir }); - } - if (step.tool === 'Read') { - return runRead({ ...step.args, path: join(fixtureDir, step.args.path) }); - } - return {}; -} - -function legacyVanillaFor(step) { - // Older manifests (without `vanilla:`) had a 1:1 mapping. Preserve their behavior. - if (step.tool === 'Search') { - const pattern = step.args.content || step.args.symbol; - return pattern ? [{ type: 'grep', pattern }] : []; - } - if (step.tool === 'Read') { - return [{ type: 'read', path: step.args.path }]; - } - return []; -} - -// Vanilla-op runners. Each returns the bytes that vanilla Claude Code would have received -// as the tool result (raw output), JSON-wrapped to mirror what the harness actually sends. - -function vanillaBytesFor(op, fixtureDir) { - switch (op.type) { - case 'read': - return wrapResult(readFile(join(fixtureDir, op.path))).length; - case 'grep': - return wrapResult(runGrep(op.pattern, fixtureDir)).length; - case 'glob': - return wrapResult(runGlob(op.pattern || '**/*', fixtureDir)).length; - default: - return 0; - } -} - -function wrapResult(raw) { - // Mirror what Claude Code's harness would send back as a tool_result content block — - // a single text block, JSON-encoded inside the response envelope. The `content` array + - // type field add a few dozen bytes per call regardless of payload size. - return JSON.stringify({ content: [{ type: 'text', text: raw }] }); -} - -function readFile(path) { - try { - return readFileSync(path, 'utf8'); - } catch { - return ''; - } -} - -function runGrep(pattern, cwd) { - const r = spawnSync( - 'rg', - ['-e', pattern, '--no-messages', '--no-heading', '--with-filename', '--no-require-git', '.'], - { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, - ); - return r.stdout || ''; -} - -function runGlob(pattern, cwd) { - // Vanilla Glob returns a newline-separated list of matched paths. Use rg --files + - // --glob to mimic this without depending on a globbing CLI. - const r = spawnSync('rg', ['--files', '--no-messages', '--no-require-git', '-g', pattern, '.'], { - cwd, - encoding: 'utf8', - maxBuffer: 32 * 1024 * 1024, - }); - return r.stdout || ''; -} diff --git a/legacy-ts/scripts/edit-batching-nudge.js b/legacy-ts/scripts/edit-batching-nudge.js deleted file mode 100644 index c3af4e1..0000000 --- a/legacy-ts/scripts/edit-batching-nudge.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -// PostToolUse on relaywash__Edit: count single-edit calls per session; nudge if >= 3 in 5 turns. -// State is kept in a tiny per-session JSON file under the relayburn home. - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -const ledgerHome = process.env.RELAYBURN_HOME || join(homedir(), '.relayburn'); -const stateDir = join(ledgerHome, 'edit-nudge'); - -let payload = {}; -try { - const raw = readFileSync(0, 'utf8'); - if (raw.trim()) payload = JSON.parse(raw); -} catch { - process.exit(0); -} - -const sessionId = payload.session_id || payload.sessionId || 'unknown'; -const editCount = Array.isArray(payload.tool_input?.edits) ? payload.tool_input.edits.length : 1; - -mkdirSync(stateDir, { recursive: true }); -const file = join(stateDir, `${sessionId}.json`); - -let state = { history: [] }; -if (existsSync(file)) { - try { - state = JSON.parse(readFileSync(file, 'utf8')); - } catch {} -} - -state.history.push({ turn: state.history.length + 1, editCount }); -state.history = state.history.slice(-5); - -writeFileSync(file, JSON.stringify(state)); - -const recentSingles = state.history.filter((h) => h.editCount === 1).length; -if (recentSingles >= 3) { - process.stdout.write( - JSON.stringify({ - continue: true, - systemMessage: - 'relaywash: 3+ single-edit calls in the last 5 turns. relaywash__Edit accepts an `edits[]` array — batch them next time for one round-trip.', - }) + '\n', - ); - process.exit(0); -} - -process.stdout.write(JSON.stringify({ continue: true }) + '\n'); diff --git a/legacy-ts/scripts/generate-large-fixture.mjs b/legacy-ts/scripts/generate-large-fixture.mjs deleted file mode 100644 index 0fc9d35..0000000 --- a/legacy-ts/scripts/generate-large-fixture.mjs +++ /dev/null @@ -1,109 +0,0 @@ -// Generates a synthetic but realistic 2500-line TypeScript module so the burn-compare -// fixture corpus exercises Read's signature-mode elision honestly. -// -// Run: node scripts/generate-large-fixture.mjs - -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const out = resolve(process.argv[2] || 'fixtures/corpus/large-codebase/src/billing-engine.ts'); -const lines = []; - -// Imports -lines.push(`// Auto-generated synthetic billing engine. Used by the relaywash burn-compare fixture corpus.`); -lines.push(`import { Cart, LineItem } from './cart';`); -lines.push(`import { TaxRate, lookupRate } from './tax';`); -lines.push(`import { Customer } from './customer';`); -lines.push(`import { Logger } from './log';`); -lines.push(''); - -// Type declarations -lines.push(`export interface Invoice {`); -lines.push(` id: string;`); -lines.push(` customerId: string;`); -lines.push(` lines: LineItem[];`); -lines.push(` subtotal: number;`); -lines.push(` tax: number;`); -lines.push(` total: number;`); -lines.push(` createdAt: number;`); -lines.push(` status: 'draft' | 'sent' | 'paid' | 'void';`); -lines.push(`}`); -lines.push(''); - -lines.push(`export interface DiscountRule {`); -lines.push(` code: string;`); -lines.push(` percent: number;`); -lines.push(` minSubtotal?: number;`); -lines.push(` expiresAt?: number;`); -lines.push(`}`); -lines.push(''); - -// Big class with mix of small and large methods -lines.push(`export class BillingEngine {`); -lines.push(` private readonly logger = new Logger('BillingEngine');`); -lines.push(` constructor(private readonly taxRate: TaxRate) {}`); -lines.push(''); - -// 5 large methods (40+ lines each — body should be elided in signatures mode) -for (let i = 0; i < 5; i++) { - const name = ['computeTotal', 'applyDiscount', 'splitInvoice', 'mergeInvoices', 'reconcileLines'][i]; - lines.push(` ${name}(input: Invoice, opts?: { strict?: boolean }): Invoice {`); - lines.push(` this.logger.debug('${name} called', { id: input.id });`); - for (let j = 0; j < 35; j++) { - lines.push(` const step${j} = input.lines.reduce((acc, l) => acc + l.qty * l.unitPrice * ${1 + j * 0.001}, 0);`); - } - lines.push(` const subtotal = input.lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);`); - lines.push(` const tax = subtotal * this.taxRate.rate;`); - lines.push(` return { ...input, subtotal, tax, total: subtotal + tax };`); - lines.push(` }`); - lines.push(''); -} - -// 30 small methods (< 20 lines — bodies should be KEPT under the heuristic) -for (let i = 0; i < 30; i++) { - lines.push(` helper${i}(arg: number): number {`); - lines.push(` return arg * ${i + 1} + this.taxRate.rate;`); - lines.push(` }`); - lines.push(''); -} - -// 10 more large methods to push the file over 2000 lines -for (let i = 0; i < 10; i++) { - lines.push(` process${i}(invoice: Invoice, customer: Customer): Invoice {`); - for (let j = 0; j < 80; j++) { - lines.push(` // step ${j}: validate, transform, persist intermediate state`); - lines.push(` if (invoice.lines.length > ${j}) {`); - lines.push(` this.logger.trace('process${i}.step${j}');`); - lines.push(` }`); - } - lines.push(` return invoice;`); - lines.push(` }`); - lines.push(''); -} - -lines.push(`}`); -lines.push(''); - -// Top-level exported helpers — these become individual lineMap entries -const helpers = [ - 'exportInvoicesAsCsv', - 'parseInvoiceFromJson', - 'validateDiscountCode', - 'computeShippingCost', - 'normalizeCustomerName', - 'formatCurrency', - 'roundToCents', - 'serializeForApi', -]; -for (const h of helpers) { - lines.push(`export function ${h}(input: any): string {`); - for (let j = 0; j < 35; j++) { - lines.push(` // ${h} step ${j}: handle edge cases and format output`); - } - lines.push(` return JSON.stringify(input);`); - lines.push(`}`); - lines.push(''); -} - -writeFileSync(out, lines.join('\n')); -console.log(`Wrote ${lines.length} lines → ${out}`); diff --git a/legacy-ts/scripts/relaywash-savings/run.js b/legacy-ts/scripts/relaywash-savings/run.js deleted file mode 100644 index 08a9028..0000000 --- a/legacy-ts/scripts/relaywash-savings/run.js +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -// /relaywash-savings: thin wrapper around relayburn/sdk `summary({ session })`. - -import { summary } from '../../src/burn/sdk.js'; - -const session = process.argv[2] || process.env.CLAUDE_SESSION_ID || 'default'; -const stats = await summary({ session }); - -const lines = []; -lines.push(`relaywash savings — session ${session}`); -lines.push(''); -lines.push(`total tool calls: ${stats.totalCalls}`); -lines.push(`collapsed (built-in equivalents avoided): ${stats.collapsedCalls}`); -lines.push(`replaced built-ins: ${stats.replacedTools.join(', ') || '(none)'}`); -lines.push(''); -lines.push('by tool:'); -for (const [name, info] of Object.entries(stats.byTool).sort()) { - lines.push(` ${name.padEnd(28)} calls=${info.calls} collapsed=${info.collapsedCalls}`); -} -process.stdout.write(lines.join('\n') + '\n'); diff --git a/legacy-ts/scripts/session-start-hook.js b/legacy-ts/scripts/session-start-hook.js deleted file mode 100644 index cc59856..0000000 --- a/legacy-ts/scripts/session-start-hook.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -// SessionStart hook: verify Node >= 20.11 and warm up the relayburn ledger directory. - -import { mkdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -const [major, minor] = process.versions.node.split('.').map(Number); -if (major < 20 || (major === 20 && minor < 11)) { - process.stderr.write( - `relaywash: Node ${process.versions.node} is too old. Need >= 20.11.\n`, - ); - process.exit(2); -} - -const ledgerHome = process.env.RELAYBURN_HOME || join(homedir(), '.relayburn'); -try { - mkdirSync(ledgerHome, { recursive: true }); -} catch (e) { - process.stderr.write(`relaywash: cannot create ledger dir ${ledgerHome}: ${e.message}\n`); - process.exit(2); -} - -process.stdout.write(JSON.stringify({ continue: true }) + '\n'); diff --git a/legacy-ts/scripts/session-stop-ingest-hook.js b/legacy-ts/scripts/session-stop-ingest-hook.js deleted file mode 100644 index f673817..0000000 --- a/legacy-ts/scripts/session-stop-ingest-hook.js +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env node -// Stop hook: ingest the just-ended session into the local relayburn ledger. - -import { readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { ingest } from '../src/burn/sdk.js'; - -let payload = {}; -try { - const raw = readFileSync(0, 'utf8'); - if (raw.trim()) payload = JSON.parse(raw); -} catch {} - -const sessionId = payload.session_id || payload.sessionId || `session-${Date.now()}`; -const transcriptPath = payload.transcript_path || payload.transcriptPath || null; -const ledgerHome = process.env.RELAYBURN_HOME || join(homedir(), '.relayburn'); - -try { - await ingest({ sessionId, transcriptPath, ledgerHome }); -} catch (e) { - // Don't fail the session on ingestion errors. - process.stderr.write(`relaywash: ingest failed: ${e.message}\n`); -} - -process.stdout.write(JSON.stringify({ continue: true }) + '\n'); diff --git a/legacy-ts/scripts/tool-redirect-hook.js b/legacy-ts/scripts/tool-redirect-hook.js deleted file mode 100644 index a12adad..0000000 --- a/legacy-ts/scripts/tool-redirect-hook.js +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env node -// PreToolUse on Bash: warn (do not block) when the model invokes a shell command that has a -// structured relaywash replacement. Covers a focused whitelist — not exhaustive. - -import { readFileSync } from 'node:fs'; - -let payload = {}; -try { - const raw = readFileSync(0, 'utf8'); - if (raw.trim()) payload = JSON.parse(raw); -} catch { - process.stdout.write(JSON.stringify({ continue: true }) + '\n'); - process.exit(0); -} - -const cmd = (payload.tool_input?.command || payload.toolInput?.command || '').trim(); -if (!cmd) { - process.stdout.write(JSON.stringify({ continue: true }) + '\n'); - process.exit(0); -} - -// Pattern → suggested replacement. First match wins. -const PATTERNS = [ - { re: /^(?:cat|bat|head|tail|less|more)\s+\S/, hint: 'relaywash__Read' }, - { re: /^grep\b/, hint: 'relaywash__Search' }, - { re: /^rg\b/, hint: 'relaywash__Search' }, - { re: /^find\s+\S/, hint: 'relaywash__Search' }, - { re: /^git\s+(status|diff|log|show)\b/, hint: 'relaywash__GitState' }, - { re: /^(?:pnpm|npm|yarn)\s+(?:run\s+)?test\b/, hint: 'relaywash__TestRun' }, - { re: /^(?:pytest|jest|go\s+test|cargo\s+test)\b/, hint: 'relaywash__TestRun' }, - { re: /^(?:pnpm|npm|yarn)\s+(?:run\s+)?build\b/, hint: 'relaywash__Build' }, - { re: /^(?:tsc|cargo\s+build|go\s+build|vite\s+build|webpack)\b/, hint: 'relaywash__Build' }, - { re: /^gh\s+pr\s+(view|list|diff)\b/, hint: 'relaywash__GhPR' }, - { re: /^gh\s+api\s+repos\/[^\s]+\/pulls\b/, hint: 'relaywash__GhPR' }, -]; - -for (const { re, hint } of PATTERNS) { - if (re.test(cmd)) { - // Warn-only: emit a system message via JSON, do not block the call. - process.stdout.write( - JSON.stringify({ - continue: true, - systemMessage: `relaywash: \`${cmd.slice(0, 80)}\` has a structured equivalent (${hint}). Consider using it next time for smaller responses.`, - }) + '\n', - ); - process.exit(0); - } -} - -process.stdout.write(JSON.stringify({ continue: true }) + '\n'); diff --git a/legacy-ts/servers/relaywash-server.js b/legacy-ts/servers/relaywash-server.js deleted file mode 100644 index 5aa5882..0000000 --- a/legacy-ts/servers/relaywash-server.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -// relaywash MCP server entry. Source lives under ../src/. -import('../src/index.js'); diff --git a/legacy-ts/src/ast/index.js b/legacy-ts/src/ast/index.js deleted file mode 100644 index 3235666..0000000 --- a/legacy-ts/src/ast/index.js +++ /dev/null @@ -1,214 +0,0 @@ -// Tree-sitter wrapper. The real implementation lazy-loads grammar files via the `web-tree-sitter` -// or `tree-sitter` Node bindings. To keep the bundle dependency-free we ship a *fallback* -// brace/paren balance check that catches the most common syntactic regressions caused by Edit. -// -// When tree-sitter is installed in the user's environment we'd swap this out — see the -// `tryLoadTreeSitter` hook below. - -/** - * Detect language from a file extension. Returns a string identifier or 'unknown'. - */ -export function detectLanguage(path) { - const m = /\.([a-zA-Z0-9]+)$/.exec(path); - if (!m) return 'unknown'; - switch (m[1]) { - case 'ts': - case 'tsx': - return 'typescript'; - case 'js': - case 'jsx': - case 'mjs': - case 'cjs': - return 'javascript'; - case 'py': - return 'python'; - case 'go': - return 'go'; - case 'rs': - return 'rust'; - default: - return 'unknown'; - } -} - -/** - * Returns true if `text` parses cleanly (or close enough) for the given language. - * Uses balance heuristics for now; the public surface stays stable so a real tree-sitter - * backend can be plugged in later. - */ -export function parsesCleanly(text, language) { - if (language === 'python') return pythonIndentSane(text) && pairsBalance(text); - return pairsBalance(text); -} - -function pairsBalance(text) { - const stack = []; - const pairs = { '(': ')', '[': ']', '{': '}' }; - let i = 0; - while (i < text.length) { - const c = text[i]; - // Skip strings and comments — best-effort. - if (c === '"' || c === "'" || c === '`') { - i = skipString(text, i, c); - continue; - } - if (c === '/' && text[i + 1] === '/') { - i = text.indexOf('\n', i); - if (i === -1) break; - continue; - } - if (c === '/' && text[i + 1] === '*') { - const end = text.indexOf('*/', i + 2); - if (end === -1) return false; - i = end + 2; - continue; - } - if (c === '#' && (i === 0 || text[i - 1] === '\n')) { - // Python-style line comment. - i = text.indexOf('\n', i); - if (i === -1) break; - continue; - } - if (c in pairs) { - stack.push(pairs[c]); - } else if (c === ')' || c === ']' || c === '}') { - if (stack.pop() !== c) return false; - } - i++; - } - return stack.length === 0; -} - -function skipString(text, start, quote) { - let i = start + 1; - while (i < text.length) { - const c = text[i]; - if (c === '\\') { - i += 2; - continue; - } - if (c === quote) return i + 1; - if (quote === '`' && c === '$' && text[i + 1] === '{') { - // Walk through template-literal expression. - let depth = 1; - i += 2; - while (i < text.length && depth) { - if (text[i] === '{') depth++; - else if (text[i] === '}') depth--; - i++; - } - continue; - } - i++; - } - return text.length; -} - -function pythonIndentSane(text) { - // Reject obvious tab/space mixing that breaks Python; otherwise pass. - const lines = text.split('\n'); - for (const line of lines) { - if (/^\t+ |^ +\t/.test(line)) return false; - } - return true; -} - -/** - * Extract a signatures-mode view of a file: imports, type/class/interface/function declarations, - * with bodies replaced by `…`. Returns { content, lineMap, languageDetected }. - * - * Heuristic, line-based extraction. Good enough to demonstrate behavior; a tree-sitter pass - * lands later. - */ -export function extractSignatures(text, language) { - const lines = text.split('\n'); - const lineMap = []; - const out = []; - let inBody = 0; // brace depth above 0 = inside a body we elided - - const isHeader = (line) => { - return ( - /^\s*(import|export\s+(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var|enum)\b)/.test( - line, - ) || - /^\s*(?:public|private|protected|static|async)?\s*(?:function|class|interface|type|enum)\b/.test( - line, - ) || - /^\s*(?:from\s+\S+\s+)?import\b/.test(line) || - /^\s*(?:def|class|async\s+def)\b/.test(line) || // python - /^\s*(?:func|type|package|import)\b/.test(line) || // go - /^\s*(?:fn|struct|enum|trait|impl|use|mod|pub\s+(?:fn|struct|enum|trait|mod))\b/.test(line) // rust - ); - }; - - const symbolFromHeader = (line) => { - const patterns = [ - /(?:function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][\w$]*)/, - /(?:def|class)\s+([A-Za-z_][\w]*)/, - /(?:func|type)\s+([A-Za-z_][\w]*)/, - /(?:fn|struct|enum|trait|mod)\s+([A-Za-z_][\w]*)/, - ]; - for (const re of patterns) { - const m = re.exec(line); - if (m) return m[1]; - } - return null; - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (inBody > 0) { - // Track brace depth to know when we exit the body. - for (const c of line) { - if (c === '{') inBody++; - else if (c === '}') inBody--; - } - if (inBody <= 0) { - out.push('}'); - inBody = 0; - } - continue; - } - if (isHeader(line)) { - const sym = symbolFromHeader(line); - if (sym) lineMap.push({ symbol: sym, line: i + 1 }); - // If the header opens a body (ends with `{` after stripping trailing comment/whitespace), - // emit the header line + `…` body marker and skip until the matching close. - const trimmed = line.replace(/\/\/.*$/, '').replace(/\/\*.*\*\//g, '').trimEnd(); - if (trimmed.endsWith('{')) { - out.push(line + ' …'); - inBody = 1; - // Count nested braces opened on this same line (e.g. `class Foo { static bar = { … }`). - let depth = 0; - for (const c of line) { - if (c === '{') depth++; - else if (c === '}') depth--; - } - inBody = depth; - continue; - } - // Python-style header (ends with `:`) - if (/:\s*$/.test(line) && language === 'python') { - out.push(line + ' # …'); - // For python, find next line with same-or-less indentation to close the body. - const baseIndent = (/^\s*/.exec(line) || [''])[0].length; - let j = i + 1; - while (j < lines.length) { - const l = lines[j]; - if (l.trim() === '') { - j++; - continue; - } - const ind = (/^\s*/.exec(l) || [''])[0].length; - if (ind <= baseIndent) break; - j++; - } - i = j - 1; - continue; - } - out.push(line); - } - // Drop everything else. - } - return { content: out.join('\n'), lineMap }; -} diff --git a/legacy-ts/src/burn/meta.js b/legacy-ts/src/burn/meta.js deleted file mode 100644 index 43fcc50..0000000 --- a/legacy-ts/src/burn/meta.js +++ /dev/null @@ -1,10 +0,0 @@ -// Helpers for building the `_meta` annotation that every relaywash tool result carries. -// Burn's annotation reader (AgentWorkforce/burn#219) reads this to attribute savings. - -/** - * @param {string[]} replaces Built-in tool names this call collapses (e.g. ['Glob','Grep','Read']). - * @param {number} collapsedCalls Estimated number of vanilla tool calls this single call replaced. - */ -export function meta(replaces, collapsedCalls) { - return { replaces, collapsedCalls }; -} diff --git a/legacy-ts/src/burn/sdk.js b/legacy-ts/src/burn/sdk.js deleted file mode 100644 index ab27f69..0000000 --- a/legacy-ts/src/burn/sdk.js +++ /dev/null @@ -1,129 +0,0 @@ -// relayburn/sdk stub. -// -// The real `relayburn/sdk` is blocked on AgentWorkforce/burn#218. This local stub provides -// the same surface (ingest, summary, Ledger) so the rest of the relaywash codebase can be -// written against the final API. When the real SDK is published we drop this in favour of -// the npm package without changing any callers. -// -// Storage format: one JSONL file per session under `${ledgerHome}/sessions/.jsonl`. -// Each line is one event: { ts, kind, tool?, tokensIn?, tokensOut?, replaces?, collapsedCalls? }. - -import { mkdirSync, appendFileSync, readFileSync, existsSync, readdirSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -const DEFAULT_HOME = join(homedir(), '.relayburn'); - -function home(opts) { - return opts?.ledgerHome || process.env.RELAYBURN_HOME || DEFAULT_HOME; -} - -function sessionFile(ledgerHome, sessionId) { - return join(ledgerHome, 'sessions', `${sessionId}.jsonl`); -} - -export class Ledger { - constructor(opts = {}) { - this.ledgerHome = home(opts); - mkdirSync(join(this.ledgerHome, 'sessions'), { recursive: true }); - } - - /** Append a tool-use event for `sessionId`. */ - recordToolUse(sessionId, event) { - mkdirSync(join(this.ledgerHome, 'sessions'), { recursive: true }); - const line = JSON.stringify({ ts: Date.now(), kind: 'tool_use', ...event }) + '\n'; - appendFileSync(sessionFile(this.ledgerHome, sessionId), line); - } - - /** Read raw events for one session. */ - readSession(sessionId) { - const f = sessionFile(this.ledgerHome, sessionId); - if (!existsSync(f)) return []; - return readFileSync(f, 'utf8') - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)); - } -} - -/** - * Stop-hook entry: ingest a session into the ledger. With no transcript the ledger only - * has whatever the server appended during the session — that's fine; later we'll parse the - * transcript and reconstruct full attribution here. - */ -export async function ingest({ sessionId, transcriptPath, ledgerHome } = {}) { - const dir = ledgerHome || home(); - mkdirSync(join(dir, 'sessions'), { recursive: true }); - const marker = sessionFile(dir, sessionId || 'unknown'); - appendFileSync( - marker, - JSON.stringify({ ts: Date.now(), kind: 'session_end', transcriptPath: transcriptPath || null }) + - '\n', - ); - return { ok: true, sessionId, ledgerHome: dir }; -} - -/** - * Read-side: summarize a session. - * Returns per-tool counts and a `savings` estimate based on `_meta.collapsedCalls`. - */ -export async function summary({ session, ledgerHome } = {}) { - const dir = ledgerHome || home(); - if (!session) { - // Aggregate across all sessions if no session id given. - const sessionsDir = join(dir, 'sessions'); - if (!existsSync(sessionsDir)) return emptySummary(); - const all = readdirSync(sessionsDir) - .filter((f) => f.endsWith('.jsonl')) - .flatMap((f) => readJsonl(join(sessionsDir, f))); - return aggregate(all); - } - const events = readJsonl(sessionFile(dir, session)); - return aggregate(events); -} - -function readJsonl(path) { - if (!existsSync(path)) return []; - return readFileSync(path, 'utf8') - .split('\n') - .filter(Boolean) - .map((l) => { - try { - return JSON.parse(l); - } catch { - return null; - } - }) - .filter(Boolean); -} - -function emptySummary() { - return { byTool: {}, totalCalls: 0, collapsedCalls: 0, replacedTools: [] }; -} - -function aggregate(events) { - const byTool = {}; - let totalCalls = 0; - let collapsedCalls = 0; - const replacedSet = new Set(); - for (const ev of events) { - if (ev.kind !== 'tool_use') continue; - const tool = ev.tool || 'unknown'; - if (!byTool[tool]) byTool[tool] = { calls: 0, collapsedCalls: 0 }; - byTool[tool].calls++; - totalCalls++; - if (typeof ev.collapsedCalls === 'number') { - byTool[tool].collapsedCalls += ev.collapsedCalls; - collapsedCalls += ev.collapsedCalls; - } - if (Array.isArray(ev.replaces)) { - for (const r of ev.replaces) replacedSet.add(r); - } - } - return { - byTool, - totalCalls, - collapsedCalls, - replacedTools: Array.from(replacedSet).sort(), - }; -} diff --git a/legacy-ts/src/fuzzy/index.js b/legacy-ts/src/fuzzy/index.js deleted file mode 100644 index b8455a8..0000000 --- a/legacy-ts/src/fuzzy/index.js +++ /dev/null @@ -1,93 +0,0 @@ -// Whitespace + Unicode normalization for *matching only*. The user's `newText` is written -// verbatim; we only normalize when locating where to splice. - -const UNICODE_MAP = { - '‘': "'", - '’': "'", - '‚': "'", - '‛': "'", - '“': '"', - '”': '"', - '„': '"', - '‟': '"', - '–': '-', - '—': '-', - '−': '-', - ' ': ' ', // NBSP - ' ': ' ', // narrow NBSP - ' ': ' ', // thin space - '​': '', // zero-width space -}; - -export function normalizeForMatch(s) { - let out = ''; - for (const ch of s) { - out += ch in UNICODE_MAP ? UNICODE_MAP[ch] : ch; - } - // Collapse runs of whitespace (but keep newlines distinct). - out = out.replace(/[ \t]+/g, ' '); - // Trim trailing whitespace on each line. - out = out - .split('\n') - .map((l) => l.replace(/[ \t]+$/, '')) - .join('\n'); - return out; -} - -/** - * Find `needle` inside `haystack`, returning all match ranges [start, end) on the original - * haystack indexes. We normalize both sides to make the search whitespace/Unicode-tolerant, - * but we maintain a back-map from normalized indexes to original haystack indexes so the - * splice happens against the original text. - */ -export function fuzzyFindAll(haystack, needle) { - const { normalized, mapBack } = normalizeWithMap(haystack); - const normNeedle = normalizeForMatch(needle); - if (!normNeedle) return []; - const matches = []; - let from = 0; - while (true) { - const idx = normalized.indexOf(normNeedle, from); - if (idx === -1) break; - const start = mapBack[idx]; - const endNormIdx = idx + normNeedle.length; - const end = endNormIdx >= mapBack.length ? haystack.length : mapBack[endNormIdx]; - matches.push([start, end]); - from = idx + Math.max(1, normNeedle.length); - } - return matches; -} - -function normalizeWithMap(s) { - let normalized = ''; - /** mapBack[i] = original index of the character that produced normalized[i]. */ - const mapBack = []; - let lastWasSpace = false; - for (let i = 0; i < s.length; i++) { - let ch = s[i]; - if (ch in UNICODE_MAP) ch = UNICODE_MAP[ch]; - if (ch === '') continue; // zero-width drop - if (ch === ' ' || ch === '\t') { - if (lastWasSpace) continue; - normalized += ' '; - mapBack.push(i); - lastWasSpace = true; - continue; - } - if (ch === '\n') { - // Trim trailing spaces on the line we just finished. - while (normalized.endsWith(' ')) { - normalized = normalized.slice(0, -1); - mapBack.pop(); - } - normalized += '\n'; - mapBack.push(i); - lastWasSpace = false; - continue; - } - normalized += ch; - mapBack.push(i); - lastWasSpace = false; - } - return { normalized, mapBack }; -} diff --git a/legacy-ts/src/index.js b/legacy-ts/src/index.js deleted file mode 100644 index a6f7238..0000000 --- a/legacy-ts/src/index.js +++ /dev/null @@ -1,49 +0,0 @@ -// relaywash MCP server entry. Registers all tools and starts the stdio server. - -import { McpServer } from './mcp/server.js'; -import { Ledger } from './burn/sdk.js'; -import { recordCall } from './measure.js'; - -import { pingTool } from './tools/ping.js'; -import { searchTool } from './tools/search.js'; -import { editTool } from './tools/edit.js'; -import { readTool, noteSearchedSymbol } from './tools/read.js'; -import { gitStateTool } from './tools/git-state.js'; -import { testRunTool } from './tools/test-run.js'; -import { buildTool } from './tools/build.js'; -import { ghPrTool } from './tools/gh-pr.js'; - -const ledger = new Ledger({}); - -// Tools list, in the order we expose them to Claude Code. -const tools = [pingTool, searchTool, editTool, readTool, gitStateTool, testRunTool, buildTool, ghPrTool]; - -// Wrap each handler to (a) emit a tool_use event into the burn ledger and (b) optionally -// record a shadow comparison for `--measure` mode. -const wrapped = tools.map((t) => ({ - ...t, - handler: async (args, ctx) => { - // Side-effect: keep the Read tool's session-state aware of the latest Search query. - if (t.name === 'relaywash__Search' && args && (args.symbol || args.content)) { - noteSearchedSymbol(args.symbol || args.content); - } - const result = await t.handler(args, ctx); - try { - const replaces = result?._meta?.replaces || []; - const collapsedCalls = result?._meta?.collapsedCalls || 0; - ledger.recordToolUse(ctx.sessionId || 'default', { - tool: t.name, - replaces, - collapsedCalls, - }); - recordCall(t.name, args, result); - } catch {} - return result; - }, -})); - -const server = new McpServer({ name: 'relaywash', version: '0.1.0', tools: wrapped }); -server.run().catch((err) => { - process.stderr.write(`relaywash: fatal: ${err?.stack || err}\n`); - process.exit(1); -}); diff --git a/legacy-ts/src/mcp/server.js b/legacy-ts/src/mcp/server.js deleted file mode 100644 index 5143d09..0000000 --- a/legacy-ts/src/mcp/server.js +++ /dev/null @@ -1,165 +0,0 @@ -// Minimal MCP server over stdio. Implements the subset of the protocol that Claude Code -// exercises: `initialize`, `tools/list`, `tools/call`, plus standard JSON-RPC plumbing. -// -// We don't depend on @modelcontextprotocol/sdk to keep the bundle tiny. The wire format is -// LSP-style: each JSON-RPC message is framed by `Content-Length` / blank line / JSON body. - -import { stdin, stdout, stderr } from 'node:process'; - -const PROTOCOL_VERSION = '2024-11-05'; - -/** - * @typedef {{ - * name: string; - * description: string; - * inputSchema: object; - * handler: (args: any, ctx: { sessionId?: string }) => Promise | any; - * }} Tool - */ - -export class McpServer { - /** - * @param {{ name: string; version: string; tools: Tool[] }} opts - */ - constructor(opts) { - this.name = opts.name; - this.version = opts.version; - /** @type {Map} */ - this.tools = new Map(); - for (const t of opts.tools) this.tools.set(t.name, t); - /** Optional hook the harness may set; kept here to thread through to handlers. */ - this.sessionId = process.env.CLAUDE_SESSION_ID || undefined; - } - - registerTool(tool) { - this.tools.set(tool.name, tool); - } - - /** Start reading from stdin. Resolves on EOF. */ - async run() { - let buffer = Buffer.alloc(0); - - return new Promise((resolve) => { - stdin.on('data', (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - // Try to extract framed messages. - while (true) { - const headerEnd = buffer.indexOf('\r\n\r\n'); - if (headerEnd === -1) break; - const header = buffer.slice(0, headerEnd).toString('utf8'); - const m = /Content-Length:\s*(\d+)/i.exec(header); - if (!m) { - // Malformed — drop everything up to header end and continue. - buffer = buffer.slice(headerEnd + 4); - continue; - } - const len = Number(m[1]); - const start = headerEnd + 4; - if (buffer.length < start + len) break; - const body = buffer.slice(start, start + len).toString('utf8'); - buffer = buffer.slice(start + len); - this._handleRaw(body).catch((err) => { - stderr.write(`relaywash: handler error: ${err?.stack || err}\n`); - }); - } - }); - stdin.on('end', () => resolve(undefined)); - stdin.on('close', () => resolve(undefined)); - }); - } - - async _handleRaw(body) { - let msg; - try { - msg = JSON.parse(body); - } catch { - return; - } - if (Array.isArray(msg)) { - for (const m of msg) await this._handleOne(m); - return; - } - await this._handleOne(msg); - } - - async _handleOne(msg) { - if (typeof msg !== 'object' || !msg) return; - if (msg.method) { - // Request or notification - const id = msg.id; - try { - const result = await this._dispatch(msg.method, msg.params || {}); - if (id !== undefined && id !== null) { - this._send({ jsonrpc: '2.0', id, result }); - } - } catch (err) { - if (id !== undefined && id !== null) { - this._send({ - jsonrpc: '2.0', - id, - error: { code: -32000, message: err?.message || String(err) }, - }); - } - } - } - // Responses to server-initiated requests are ignored (we don't initiate). - } - - async _dispatch(method, params) { - switch (method) { - case 'initialize': - return { - protocolVersion: PROTOCOL_VERSION, - serverInfo: { name: this.name, version: this.version }, - capabilities: { tools: {} }, - }; - case 'initialized': - case 'notifications/initialized': - return undefined; - case 'tools/list': - return { - tools: Array.from(this.tools.values()).map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - })), - }; - case 'tools/call': { - const tool = this.tools.get(params.name); - if (!tool) throw new Error(`Unknown tool: ${params.name}`); - const args = params.arguments || {}; - const ctx = { sessionId: params._sessionId || this.sessionId }; - const result = await tool.handler(args, ctx); - // MCP `tools/call` shape: { content: [{ type: 'text', text }], isError?, structuredContent? } - return formatToolResult(result); - } - case 'ping': - return {}; - case 'shutdown': - case 'exit': - process.exit(0); - default: - throw new Error(`Method not implemented: ${method}`); - } - } - - _send(payload) { - const body = JSON.stringify(payload); - const buf = Buffer.from(body, 'utf8'); - stdout.write(`Content-Length: ${buf.length}\r\n\r\n`); - stdout.write(buf); - } -} - -function formatToolResult(result) { - // Tools return their own structured object. We emit it as a single text block of JSON - // so models see the data, plus mirror it under structuredContent for hosts that read it. - if (result && result.__rawText) { - return { content: [{ type: 'text', text: result.__rawText }] }; - } - const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2); - return { - content: [{ type: 'text', text }], - structuredContent: typeof result === 'object' ? result : undefined, - }; -} diff --git a/legacy-ts/src/measure.js b/legacy-ts/src/measure.js deleted file mode 100644 index f98307e..0000000 --- a/legacy-ts/src/measure.js +++ /dev/null @@ -1,73 +0,0 @@ -// `--measure` mode (Phase 6). -// -// When enabled (RELAYWASH_MEASURE=1), every replacement-tool call is paired with a "what would -// the built-in have returned" shadow read for offline comparison. Both responses are written to -// a JSONL log under `${RELAYBURN_HOME}/measure/`. Off by default; opt-in for benchmarking PRs. - -import { spawnSync } from 'node:child_process'; -import { mkdirSync, appendFileSync, readFileSync, existsSync, statSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; - -export const measureEnabled = () => process.env.RELAYWASH_MEASURE === '1'; - -const home = () => process.env.RELAYBURN_HOME || join(homedir(), '.relayburn'); -const measureDir = () => join(home(), 'measure'); - -export function recordCall(toolName, args, structured) { - if (!measureEnabled()) return; - try { - mkdirSync(measureDir(), { recursive: true }); - const replacementSize = JSON.stringify(structured ?? {}).length; - const shadow = shadowFor(toolName, args); - const shadowSize = shadow ? shadow.length : 0; - const ratio = shadowSize ? Math.round((replacementSize / shadowSize) * 1000) / 1000 : null; - const line = JSON.stringify({ - ts: Date.now(), - tool: toolName, - replacementBytes: replacementSize, - shadowBytes: shadowSize, - ratio, - }); - appendFileSync(join(measureDir(), 'compare.jsonl'), line + '\n'); - } catch { - // Measurement must never fail the call. - } -} - -function shadowFor(toolName, args) { - // For each replacement tool, run the closest vanilla equivalent and capture its raw output. - switch (toolName) { - case 'relaywash__Read': - return shadowRead(args); - case 'relaywash__Search': - return shadowSearch(args); - case 'relaywash__GitState': - return shadowGit(args); - default: - return null; - } -} - -function shadowRead(args) { - const path = args?.path; - if (!path || !existsSync(path)) return null; - const s = statSync(path); - if (!s.isFile()) return null; - return readFileSync(path, 'utf8'); -} - -function shadowSearch(args) { - const re = args?.content || (args?.symbol ? `\\b${args.symbol}\\b` : null); - if (!re) return null; - const r = spawnSync('rg', ['-e', re, '--no-messages'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); - return r.stdout || ''; -} - -function shadowGit(args) { - const op = args?.op; - if (!op) return null; - const cwd = args?.cwd || process.cwd(); - const r = spawnSync('git', [op], { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); - return r.stdout || ''; -} diff --git a/legacy-ts/src/tools/build.js b/legacy-ts/src/tools/build.js deleted file mode 100644 index 8072af5..0000000 --- a/legacy-ts/src/tools/build.js +++ /dev/null @@ -1,151 +0,0 @@ -// relaywash__Build — structured build output: one line on success; parsed errors on failure. - -import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { meta } from '../burn/meta.js'; - -export const buildTool = { - name: 'relaywash__Build', - description: - 'Run the project build and return a tiny structured response. Successful builds return one line; failing tsc/cargo/go builds return parsed `errors[]`; other builders return an `errorTail`.', - inputSchema: { - type: 'object', - properties: { - builder: { - type: 'string', - enum: ['auto', 'pnpm', 'npm', 'yarn', 'tsc', 'cargo', 'go', 'vite', 'webpack'], - default: 'auto', - }, - target: { type: 'string' }, - errorTailLines: { type: 'integer', default: 50 }, - cwd: { type: 'string' }, - }, - additionalProperties: false, - }, - handler(args) { - return runBuild(args || {}); - }, -}; - -const LOG_DIR = join(tmpdir(), 'relaywash-logs'); - -export function runBuild(args) { - const cwd = args.cwd || process.cwd(); - const builder = args.builder === 'auto' || !args.builder ? detectBuilder(cwd) : args.builder; - const cmd = buildCommand(builder, args); - if (!cmd) { - return { - builder, - success: false, - duration: 0, - errorTail: `no command for builder: ${builder}`, - fullLogPath: null, - _meta: meta(['Bash:build'], 1), - }; - } - const t0 = Date.now(); - const r = spawnSync(cmd[0], cmd.slice(1), { - cwd, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - env: process.env, - }); - const duration = Date.now() - t0; - const raw = (r.stdout || '') + '\n' + (r.stderr || ''); - mkdirSync(LOG_DIR, { recursive: true }); - const fullLogPath = join(LOG_DIR, `build-${Date.now()}.log`); - writeFileSync(fullLogPath, raw); - - const success = r.status === 0; - if (success) { - return { builder, success: true, duration, fullLogPath, _meta: meta(['Bash:build'], 1) }; - } - const errors = parseErrors(builder, raw); - if (errors.length) { - return { builder, success: false, duration, errors, fullLogPath, _meta: meta(['Bash:build'], 1) }; - } - const tailLines = args.errorTailLines ?? 50; - const errorTail = raw.split('\n').slice(-tailLines).join('\n'); - return { builder, success: false, duration, errorTail, fullLogPath, _meta: meta(['Bash:build'], 1) }; -} - -function detectBuilder(cwd) { - const has = (p) => existsSync(join(cwd, p)); - if (has('Cargo.toml')) return 'cargo'; - if (has('go.mod')) return 'go'; - if (has('tsconfig.json') && !has('package.json')) return 'tsc'; - if (has('pnpm-lock.yaml')) return 'pnpm'; - if (has('yarn.lock')) return 'yarn'; - if (has('package-lock.json') || has('package.json')) return 'npm'; - return 'tsc'; -} - -function buildCommand(builder, args) { - const target = args.target; - switch (builder) { - case 'pnpm': - return ['pnpm', 'build', ...(target ? [target] : [])]; - case 'npm': - return ['npm', 'run', 'build']; - case 'yarn': - return ['yarn', 'build']; - case 'tsc': - return ['npx', 'tsc', ...(target ? ['-p', target] : [])]; - case 'cargo': - return ['cargo', 'build']; - case 'go': - return ['go', 'build', target || './...']; - case 'vite': - return ['npx', 'vite', 'build']; - case 'webpack': - return ['npx', 'webpack', 'build']; - default: - return null; - } -} - -function parseErrors(builder, raw) { - if (builder === 'tsc' || builder === 'pnpm' || builder === 'npm' || builder === 'yarn') { - return parseTscErrors(raw); - } - if (builder === 'cargo') return parseCargoErrors(raw); - if (builder === 'go') return parseGoErrors(raw); - return []; -} - -function parseTscErrors(raw) { - const out = []; - const re = /^(.+?)\((\d+),(\d+)\):\s*error\s+TS\d+:\s*(.+)$/gm; - let m; - while ((m = re.exec(raw))) { - out.push({ file: m[1], line: Number(m[2]), col: Number(m[3]), message: m[4] }); - } - // Also match the "file:line:col - error" style. - const re2 = /^(.+?):(\d+):(\d+)\s*-\s*error\s+TS\d+:\s*(.+)$/gm; - while ((m = re2.exec(raw))) { - out.push({ file: m[1], line: Number(m[2]), col: Number(m[3]), message: m[4] }); - } - return out; -} - -function parseCargoErrors(raw) { - const out = []; - const re = /^error(?:\[E\d+\])?:\s*(.+?)\n\s+-->\s*(.+?):(\d+):(\d+)/gm; - let m; - while ((m = re.exec(raw))) { - out.push({ file: m[2], line: Number(m[3]), col: Number(m[4]), message: m[1] }); - } - return out; -} - -function parseGoErrors(raw) { - const out = []; - const re = /^(.+?\.go):(\d+):(\d+):\s*(.+)$/gm; - let m; - while ((m = re.exec(raw))) { - out.push({ file: m[1], line: Number(m[2]), col: Number(m[3]), message: m[4] }); - } - return out; -} diff --git a/legacy-ts/src/tools/edit.js b/legacy-ts/src/tools/edit.js deleted file mode 100644 index 400ad5e..0000000 --- a/legacy-ts/src/tools/edit.js +++ /dev/null @@ -1,142 +0,0 @@ -// relaywash__Edit — batched multi-file edits with fuzzy matching and tree-sitter post-check. - -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { fuzzyFindAll, normalizeForMatch } from '../fuzzy/index.js'; -import { detectLanguage, parsesCleanly } from '../ast/index.js'; -import { meta } from '../burn/meta.js'; - -export const editTool = { - name: 'relaywash__Edit', - description: - 'Batched multi-file edit with fuzzy matching and post-edit syntax check. Pass an array of edits and they apply atomically per-file. Whitespace and visually-equivalent Unicode differences in `oldText` are tolerated for matching only.', - inputSchema: { - type: 'object', - properties: { - edits: { - type: 'array', - minItems: 1, - items: { - type: 'object', - properties: { - path: { type: 'string' }, - oldText: { type: 'string' }, - newText: { type: 'string' }, - fuzzy: { type: 'boolean', default: true }, - }, - required: ['path', 'oldText', 'newText'], - additionalProperties: false, - }, - }, - }, - required: ['edits'], - additionalProperties: false, - }, - handler(args) { - return runEdit(args || {}); - }, -}; - -export function runEdit({ edits }) { - /** @type {Map>} */ - const grouped = new Map(); - for (const e of edits) { - if (!grouped.has(e.path)) grouped.set(e.path, []); - grouped.get(e.path).push({ oldText: e.oldText, newText: e.newText, fuzzy: e.fuzzy !== false }); - } - - const results = []; - for (const [path, perFileEdits] of grouped) { - results.push(...applyToFile(path, perFileEdits)); - } - return { - results, - _meta: meta(['Edit'], edits.length), - }; -} - -function applyToFile(path, edits) { - if (!existsSync(path)) { - return edits.map(() => ({ path, ok: false, reason: 'file does not exist' })); - } - const original = readFileSync(path, 'utf8'); - const language = detectLanguage(path); - const cleanBefore = language === 'unknown' ? true : parsesCleanly(original, language); - - let current = original; - /** @type {Array<{ ok: boolean; reason?: string }>} */ - const perEditResults = []; - - for (const edit of edits) { - const matches = locate(current, edit); - if (matches.length === 0) { - perEditResults.push({ ok: false, reason: 'oldText not found' }); - // Atomic: stop applying further edits on this file. - return rollback(path, edits, perEditResults, original); - } - if (matches.length > 1) { - perEditResults.push({ - ok: false, - reason: `ambiguous match (${matches.length} occurrences) — disambiguate by including more context`, - }); - return rollback(path, edits, perEditResults, original); - } - const [start, end] = matches[0]; - current = current.slice(0, start) + edit.newText + current.slice(end); - perEditResults.push({ ok: true }); - } - - // Post-edit syntax check. - if (cleanBefore && language !== 'unknown' && !parsesCleanly(current, language)) { - return rollback(path, edits, perEditResults, original, 'post-edit syntax check failed'); - } - - writeFileSync(path, current); - return perEditResults.map((r) => ({ path, ...r })); -} - -function locate(text, edit) { - if (edit.fuzzy === false) { - const out = []; - let i = 0; - while (true) { - const idx = text.indexOf(edit.oldText, i); - if (idx === -1) break; - out.push([idx, idx + edit.oldText.length]); - i = idx + Math.max(1, edit.oldText.length); - } - return out; - } - // Try exact first; fall back to fuzzy. - const exact = []; - let i = 0; - while (true) { - const idx = text.indexOf(edit.oldText, i); - if (idx === -1) break; - exact.push([idx, idx + edit.oldText.length]); - i = idx + Math.max(1, edit.oldText.length); - } - if (exact.length > 0) return exact; - return fuzzyFindAll(text, edit.oldText); -} - -function rollback(path, edits, partialResults, _original, reason) { - // Mark all results — those past the failure get a "rolled back" reason. - const results = []; - for (let i = 0; i < edits.length; i++) { - if (i < partialResults.length) { - const r = partialResults[i]; - results.push({ path, ...r }); - } else { - results.push({ path, ok: false, reason: 'rolled back' }); - } - } - if (reason) { - // Prepend a note explaining why. - const idx = results.findIndex((r) => r.ok); - if (idx !== -1) results[idx] = { ...results[idx], ok: false, reason }; - } - return results; -} - -// Re-exported for tests -export { normalizeForMatch }; diff --git a/legacy-ts/src/tools/gh-pr.js b/legacy-ts/src/tools/gh-pr.js deleted file mode 100644 index 22c18ea..0000000 --- a/legacy-ts/src/tools/gh-pr.js +++ /dev/null @@ -1,156 +0,0 @@ -// relaywash__GhPR — structured PR access via the `gh` CLI. - -import { spawnSync } from 'node:child_process'; -import { meta } from '../burn/meta.js'; - -export const ghPrTool = { - name: 'relaywash__GhPR', - description: - 'Structured PR access (replaces gh pr view/list/diff and gh api repos/.../pulls). Returns a small subset of fields by default; use `fields` to expand. Bodies and diff hunks are truncated.', - inputSchema: { - type: 'object', - properties: { - op: { type: 'string', enum: ['view', 'list', 'diff', 'comments'] }, - number: { type: 'integer' }, - repo: { type: 'string' }, - fields: { type: 'array', items: { type: 'string' } }, - maxComments: { type: 'integer', default: 20 }, - maxDiffLines: { type: 'integer', default: 200 }, - cwd: { type: 'string' }, - }, - required: ['op'], - additionalProperties: false, - }, - handler(args) { - return runGhPr(args || {}); - }, -}; - -const VIEW_DEFAULT_FIELDS = ['number', 'title', 'state', 'author', 'headRefName', 'baseRefName', 'mergeable', 'isDraft']; -const LIST_FIELDS = ['number', 'title', 'state', 'author', 'updatedAt']; - -export function runGhPr(args) { - const cwd = args.cwd || process.cwd(); - const op = args.op; - const replaces = - op === 'view' ? ['Bash:gh-pr-view'] : - op === 'list' ? ['Bash:gh-pr-list'] : - op === 'diff' ? ['Bash:gh-pr-diff'] : - op === 'comments' ? ['Bash:gh-api-pr-comments'] : - []; - - if (op === 'view') return { ...ghView({ cwd, args }), _meta: meta(replaces, 1) }; - if (op === 'list') return { ...ghList({ cwd, args }), _meta: meta(replaces, 1) }; - if (op === 'diff') return { ...ghDiff({ cwd, args }), _meta: meta(replaces, 1) }; - if (op === 'comments') return { ...ghComments({ cwd, args }), _meta: meta(replaces, 1) }; - throw new Error(`unknown op: ${op}`); -} - -function gh(cwd, args) { - const r = spawnSync('gh', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); - if (r.status !== 0) { - throw new Error(`gh ${args.join(' ')} failed: ${r.stderr || r.stdout || 'no output'}`); - } - return r.stdout; -} - -function ghView({ cwd, args }) { - if (!args.number) throw new Error('GhPR view requires `number`'); - const fields = args.fields && args.fields.length ? args.fields : VIEW_DEFAULT_FIELDS; - const cmd = ['pr', 'view', String(args.number), '--json', fields.join(',')]; - if (args.repo) cmd.push('--repo', args.repo); - const json = JSON.parse(gh(cwd, cmd)); - // Normalise author to a string login. - if (json.author && typeof json.author === 'object') json.author = json.author.login; - // If `body` requested, truncate hard. - if (typeof json.body === 'string' && json.body.length > 1500) { - json.body = json.body.slice(0, 1500) + '\n... (truncated)'; - } - return json; -} - -function ghList({ cwd, args }) { - const cmd = ['pr', 'list', '--json', LIST_FIELDS.join(','), '--limit', '30']; - if (args.repo) cmd.push('--repo', args.repo); - const out = JSON.parse(gh(cwd, cmd)); - return { - pulls: out.map((p) => ({ - number: p.number, - title: p.title, - state: p.state, - author: p.author?.login || p.author, - updatedAt: p.updatedAt, - })), - }; -} - -function ghDiff({ cwd, args }) { - if (!args.number) throw new Error('GhPR diff requires `number`'); - const cmd = ['pr', 'diff', String(args.number)]; - if (args.repo) cmd.push('--repo', args.repo); - const raw = gh(cwd, cmd); - const files = parseDiffPerFile(raw, args.maxDiffLines ?? 200); - return { number: args.number, files, total: files.length }; -} - -function parseDiffPerFile(raw, maxLines) { - const blocks = raw.split(/^diff --git /gm).slice(1); - return blocks.map((b) => { - const lines = b.split('\n'); - const m = /a\/(.+?) b\/(.+)/.exec(lines[0]); - const path = m ? m[2] : lines[0]; - let added = 0; - let removed = 0; - const hunkLines = []; - let inHunk = false; - for (const l of lines.slice(1)) { - if (l.startsWith('@@')) { - inHunk = true; - hunkLines.push(l); - continue; - } - if (!inHunk) continue; - hunkLines.push(l); - if (l.startsWith('+') && !l.startsWith('+++')) added++; - else if (l.startsWith('-') && !l.startsWith('---')) removed++; - } - let body = hunkLines.join('\n'); - let truncated = false; - if (hunkLines.length > maxLines) { - const head = hunkLines.slice(0, Math.floor(maxLines / 2)); - const tail = hunkLines.slice(-Math.floor(maxLines / 2)); - body = `${head.join('\n')}\n... (${hunkLines.length - maxLines} lines truncated) ...\n${tail.join('\n')}`; - truncated = true; - } - return { path, added, removed, hunks: body, truncated }; - }); -} - -function ghComments({ cwd, args }) { - if (!args.number) throw new Error('GhPR comments requires `number`'); - // Use `gh api` to pull review comments + issue comments. Minimal field set. - const repoSeg = args.repo ? args.repo : ''; - const baseUrl = repoSeg ? `repos/${repoSeg}/pulls/${args.number}` : `repos/{owner}/{repo}/pulls/${args.number}`; - const reviewRaw = gh(cwd, ['api', `${baseUrl}/comments`]); - const issueRaw = gh(cwd, ['api', `repos/${repoSeg || '{owner}/{repo}'}/issues/${args.number}/comments`]); - const review = JSON.parse(reviewRaw); - const issues = JSON.parse(issueRaw); - const max = args.maxComments ?? 20; - const trim = (s) => (s && s.length > 500 ? s.slice(0, 500) + '\n... (truncated)' : s); - const comments = [ - ...issues.map((c) => ({ - author: c.user?.login, - body: trim(c.body || ''), - createdAt: c.created_at, - })), - ...review.map((c) => ({ - author: c.user?.login, - body: trim(c.body || ''), - createdAt: c.created_at, - path: c.path, - line: c.line || c.original_line, - })), - ]; - comments.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || '')); - return { number: args.number, comments: comments.slice(0, max), total: comments.length }; -} diff --git a/legacy-ts/src/tools/git-state.js b/legacy-ts/src/tools/git-state.js deleted file mode 100644 index 43597f4..0000000 --- a/legacy-ts/src/tools/git-state.js +++ /dev/null @@ -1,188 +0,0 @@ -// relaywash__GitState — structured git status/diff/log/show. - -import { spawnSync } from 'node:child_process'; -import { meta } from '../burn/meta.js'; - -export const gitStateTool = { - name: 'relaywash__GitState', - description: - 'Structured git status/diff/log/show. Returns file lists + summary stats; per-file diffs are truncated. Use this instead of raw `git status`/`git diff`/`git log`/`git show` Bash calls.', - inputSchema: { - type: 'object', - properties: { - op: { type: 'string', enum: ['status', 'diff', 'log', 'show'] }, - paths: { type: 'array', items: { type: 'string' } }, - revision: { type: 'string' }, - base: { type: 'string' }, - maxFiles: { type: 'integer', default: 50 }, - maxLines: { type: 'integer', default: 200, description: 'Max diff lines per file.' }, - withBody: { type: 'boolean', default: false, description: '`log` only — include commit body.' }, - cwd: { type: 'string' }, - }, - required: ['op'], - additionalProperties: false, - }, - handler(args) { - return runGitState(args || {}); - }, -}; - -export function runGitState(args) { - const op = args.op; - const cwd = args.cwd || process.cwd(); - const maxFiles = args.maxFiles ?? 50; - const maxLines = args.maxLines ?? 200; - const replaces = [`Bash:git-${op}`]; - - if (op === 'status') return { ...gitStatus({ cwd, paths: args.paths }), _meta: meta(replaces, 1) }; - if (op === 'log') - return { - ...gitLog({ cwd, paths: args.paths, revision: args.revision, withBody: args.withBody, maxFiles }), - _meta: meta(replaces, 1), - }; - if (op === 'diff' || op === 'show') - return { - ...gitDiff({ cwd, op, paths: args.paths, revision: args.revision, base: args.base, maxFiles, maxLines }), - _meta: meta(replaces, 1), - }; - throw new Error(`unknown op: ${op}`); -} - -function git(cwd, args) { - const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); - if (r.status !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${r.stderr || r.stdout || 'no output'}`); - } - return r.stdout; -} - -function gitStatus({ cwd, paths }) { - const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); - let ahead = 0; - let behind = 0; - try { - const upstream = git(cwd, ['rev-list', '--left-right', '--count', '@{u}...HEAD']).trim(); - const [b, a] = upstream.split(/\s+/).map(Number); - behind = b || 0; - ahead = a || 0; - } catch { - // No upstream — leave at 0. - } - const cmd = ['status', '--porcelain=v1']; - if (paths && paths.length) cmd.push('--', ...paths); - const raw = git(cwd, cmd); - const files = raw - .split('\n') - .filter(Boolean) - .map((line) => { - const code = line.slice(0, 2); - const path = line.slice(3); - return { path, change: codeToChange(code) }; - }); - return { branch, ahead, behind, files }; -} - -function codeToChange(code) { - const c = code.replace(' ', ''); - switch (c) { - case 'M': - case 'MM': - return 'modified'; - case 'A': - return 'added'; - case 'D': - return 'deleted'; - case 'R': - return 'renamed'; - case '??': - return 'untracked'; - default: - return code.trim(); - } -} - -function gitLog({ cwd, paths, revision, withBody, maxFiles }) { - // Use `--pretty=format:%H%x1f%an%x1f%ad%x1f%s%x1f%b%x1e` so we can parse robustly. - const sep1 = '\x1f'; - const sep2 = '\x1e'; - const args = [ - 'log', - `--pretty=format:%H${sep1}%an${sep1}%ad${sep1}%s${sep1}%b${sep2}`, - '--date=iso-strict', - `-n`, - String(maxFiles), - ]; - if (revision) args.push(revision); - if (paths && paths.length) args.push('--', ...paths); - const raw = git(cwd, args); - const commits = []; - for (const block of raw.split(sep2)) { - const trimmed = block.trim(); - if (!trimmed) continue; - const [sha, author, date, subject, body] = trimmed.split(sep1); - const c = { sha: sha?.slice(0, 12), author, date, subject }; - if (withBody && body) c.body = body.trim(); - commits.push(c); - } - return { commits }; -} - -function gitDiff({ cwd, op, paths, revision, base, maxFiles, maxLines }) { - const baseArgs = op === 'show' ? ['show', '--stat=200', revision || 'HEAD'] : ['diff']; - if (op === 'diff') { - if (base && revision) baseArgs.push(`${base}..${revision}`); - else if (revision) baseArgs.push(revision); - } - const stat = git(cwd, [...baseArgs, '--stat=200', ...(paths ? ['--', ...paths] : [])]); - // Per-file diffs (truncated): - const diffArgs = op === 'show' ? ['show', revision || 'HEAD', '--no-color'] : ['diff', '--no-color']; - if (op === 'diff') { - if (base && revision) diffArgs.push(`${base}..${revision}`); - else if (revision) diffArgs.push(revision); - } - if (paths && paths.length) diffArgs.push('--', ...paths); - const raw = git(cwd, diffArgs); - const perFile = parsePerFileDiffs(raw, maxLines); - const limited = perFile.slice(0, maxFiles); - return { - summary: stat.trim().split('\n').slice(-2).join('\n'), // "N files changed, …" tail - files: limited, - truncated: perFile.length > maxFiles, - }; -} - -function parsePerFileDiffs(raw, maxLines) { - const files = []; - const blocks = raw.split(/^diff --git /gm).slice(1); - for (const b of blocks) { - const lines = b.split('\n'); - const header = lines[0]; - const m = /a\/(.+?) b\/(.+)/.exec(header); - const path = m ? m[2] : header; - let added = 0; - let removed = 0; - const hunkLines = []; - let inHunk = false; - for (const l of lines.slice(1)) { - if (l.startsWith('@@')) { - inHunk = true; - hunkLines.push(l); - continue; - } - if (!inHunk) continue; - hunkLines.push(l); - if (l.startsWith('+') && !l.startsWith('+++')) added++; - else if (l.startsWith('-') && !l.startsWith('---')) removed++; - } - let body = hunkLines.join('\n'); - let truncated = false; - if (hunkLines.length > maxLines) { - const head = hunkLines.slice(0, Math.floor(maxLines / 2)); - const tail = hunkLines.slice(-Math.floor(maxLines / 2)); - body = `${head.join('\n')}\n... (${hunkLines.length - maxLines} lines truncated) ...\n${tail.join('\n')}`; - truncated = true; - } - files.push({ path, added, removed, hunks: body, truncated }); - } - return files; -} diff --git a/legacy-ts/src/tools/ping.js b/legacy-ts/src/tools/ping.js deleted file mode 100644 index 298a20f..0000000 --- a/legacy-ts/src/tools/ping.js +++ /dev/null @@ -1,15 +0,0 @@ -import { meta } from '../burn/meta.js'; - -export const pingTool = { - name: 'relaywash__Ping', - description: - 'Health check / annotation pipeline probe. Returns "pong" plus a sample _meta field.', - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - handler() { - return { result: 'pong', _meta: meta([], 0) }; - }, -}; diff --git a/legacy-ts/src/tools/read.js b/legacy-ts/src/tools/read.js deleted file mode 100644 index 64e896f..0000000 --- a/legacy-ts/src/tools/read.js +++ /dev/null @@ -1,187 +0,0 @@ -// relaywash__Read — AST-aware read with signatures mode, mtime cache, and heuristics -// to suppress signatures where they backfire (small files / small functions / recently-searched -// symbols). - -import { readFileSync, statSync } from 'node:fs'; -import { detectLanguage, extractSignatures } from '../ast/index.js'; -import { meta } from '../burn/meta.js'; - -const SMALL_FILE_LINES = 200; -const SMALL_FUNCTION_LINES = 20; - -/** - * Per-session mtime cache. Outer key = sessionId, inner key = absolute path. - * Scoped by session so concurrent or sequential MCP sessions can't leak "unchanged" - * suppression into each other. - * @type {Map>} - */ -const sessionMtimeCaches = new Map(); - -function cacheFor(sessionId) { - const key = sessionId || 'default'; - let m = sessionMtimeCaches.get(key); - if (!m) { - m = new Map(); - sessionMtimeCaches.set(key, m); - } - return m; -} - -/** Most-recent search query per session (set by the Search tool). */ -let lastSearchSymbol = null; - -export function noteSearchedSymbol(sym) { - lastSearchSymbol = sym || null; -} - -export function _resetReadCache() { - sessionMtimeCaches.clear(); - lastSearchSymbol = null; -} - -export const readTool = { - name: 'relaywash__Read', - description: - 'AST-aware read. Default mode "signatures" returns imports + declarations + signatures (bodies elided) plus a `lineMap` so you can issue precise `mode: "range"` follow-ups. Small files come back fully. Repeated reads of an unchanged file in the same session return empty content.', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string' }, - mode: { type: 'string', enum: ['signatures', 'range', 'full'] }, - range: { - type: 'array', - items: { type: 'integer' }, - minItems: 2, - maxItems: 2, - description: '1-based inclusive [start, end] line range.', - }, - }, - required: ['path'], - additionalProperties: false, - }, - handler(args, ctx) { - return runRead(args || {}, ctx || {}); - }, -}; - -export function runRead({ path, mode, range }, ctx = {}) { - const language = detectLanguage(path); - const stat = statSync(path); - const cache = cacheFor(ctx.sessionId); - const cached = cache.get(path); - const unchanged = cached && cached.mtime === stat.mtimeMs; - - if (unchanged && !range) { - return { - content: '', - truncated: false, - languageDetected: language, - _meta: meta(['Read'], 1), - }; - } - - const text = readFileSync(path, 'utf8'); - cache.set(path, { mtime: stat.mtimeMs }); - - if (mode === 'range' || range) { - if (!range || range.length !== 2) { - throw new Error('mode: "range" requires `range: [start, end]`'); - } - const [start, end] = range; - const lines = text.split('\n'); - const slice = lines.slice(Math.max(0, start - 1), Math.min(lines.length, end)).join('\n'); - return { - content: slice, - truncated: false, - languageDetected: language, - _meta: meta(['Read'], 1), - }; - } - - if (mode === 'full' || language === 'unknown') { - return { - content: text, - truncated: false, - languageDetected: language, - _meta: meta(['Read'], 1), - }; - } - - // mode === 'signatures' (default for known languages). - const lines = text.split('\n'); - if (lines.length <= SMALL_FILE_LINES) { - return { - content: text, - truncated: false, - languageDetected: language, - _meta: meta(['Read'], 1), - }; - } - - const { content, lineMap } = extractSignatures(text, language); - // Heuristic: if a symbol matches the most-recent Search query OR a function in the file - // is small (< SMALL_FUNCTION_LINES), include its body in the output. - const augmented = augmentWithSmallBodies(text, content, lineMap, lastSearchSymbol); - - return { - content: augmented, - truncated: true, - languageDetected: language, - lineMap, - _meta: meta(['Read'], 1), - }; -} - -function augmentWithSmallBodies(fullText, sigContent, lineMap, searchedSymbol) { - // For each entry in lineMap, look up its starting line in fullText and figure out the body - // span. If the body is < SMALL_FUNCTION_LINES OR the symbol matches `searchedSymbol`, splice - // the full body back into sigContent, replacing the `… body` placeholder for that header. - if (!lineMap || !lineMap.length) return sigContent; - const fullLines = fullText.split('\n'); - const sigLines = sigContent.split('\n'); - const sigLineIdxByText = new Map(); - sigLines.forEach((l, i) => sigLineIdxByText.set(l, i)); - - for (const entry of lineMap) { - const headerIdx = entry.line - 1; - if (headerIdx < 0 || headerIdx >= fullLines.length) continue; - const bodyEnd = findBodyEnd(fullLines, headerIdx); - const bodyLen = bodyEnd - headerIdx; - const matchesSearched = - searchedSymbol && entry.symbol && entry.symbol.toLowerCase() === searchedSymbol.toLowerCase(); - if (!matchesSearched && bodyLen > SMALL_FUNCTION_LINES) continue; - // Locate header in sig content and replace the elided body marker with the full body. - const headerLine = fullLines[headerIdx]; - const sigIdx = sigLines.findIndex((l) => l.startsWith(headerLine)); - if (sigIdx === -1) continue; - // Replace next 1 line ('}' or '… body line') with full body slice. - const fullBody = fullLines.slice(headerIdx, bodyEnd + 1); - sigLines.splice(sigIdx, 2, ...fullBody); - } - return sigLines.join('\n'); -} - -function findBodyEnd(lines, headerIdx) { - const headerLine = lines[headerIdx]; - if (headerLine.replace(/\/\/.*$/, '').trimEnd().endsWith('{')) { - let depth = 0; - for (let i = headerIdx; i < lines.length; i++) { - for (const c of lines[i]) { - if (c === '{') depth++; - else if (c === '}') depth--; - } - if (depth <= 0 && i > headerIdx) return i; - } - return lines.length - 1; - } - if (/:\s*$/.test(headerLine)) { - const baseIndent = (/^\s*/.exec(headerLine) || [''])[0].length; - for (let i = headerIdx + 1; i < lines.length; i++) { - if (lines[i].trim() === '') continue; - const ind = (/^\s*/.exec(lines[i]) || [''])[0].length; - if (ind <= baseIndent) return i - 1; - } - return lines.length - 1; - } - return headerIdx; -} diff --git a/legacy-ts/src/tools/search.js b/legacy-ts/src/tools/search.js deleted file mode 100644 index e2856ae..0000000 --- a/legacy-ts/src/tools/search.js +++ /dev/null @@ -1,355 +0,0 @@ -// relaywash__Search — collapses Glob + Grep + Read into one ranked-snippet response. -// -// Backend: prefer `rg` (ripgrep) when present, otherwise a JS scanner. Both honour `.gitignore` -// transitively (rg natively; the JS fallback consults a small built-in matcher). - -import { spawnSync } from 'node:child_process'; -import { readFileSync, statSync, readdirSync, existsSync } from 'node:fs'; -import { join, relative, resolve, dirname } from 'node:path'; -import { meta } from '../burn/meta.js'; - -export const searchTool = { - name: 'relaywash__Search', - description: - 'Combined glob + grep + read. Returns ranked snippets across matched files. Use this instead of chaining Glob → Grep → Read. Always returns snippets only, never full file contents.', - inputSchema: { - type: 'object', - properties: { - paths: { - type: 'array', - items: { type: 'string' }, - description: 'Glob patterns. Default: ["**/*"] minus .gitignore.', - }, - content: { type: 'string', description: 'Regex to match in file contents.' }, - symbol: { - type: 'string', - description: 'Identifier to find (word-boundary search). Use this OR `content`.', - }, - maxResults: { type: 'integer', minimum: 1, default: 50 }, - contextLines: { type: 'integer', minimum: 0, default: 2 }, - rank: { type: 'string', enum: ['matches', 'mtime', 'path-depth'], default: 'matches' }, - cwd: { type: 'string', description: 'Search root. Defaults to process.cwd().' }, - }, - additionalProperties: false, - }, - handler(args) { - return runSearch(args || {}); - }, -}; - -export function runSearch(args) { - const cwd = args.cwd || process.cwd(); - const maxResults = args.maxResults ?? 50; - const contextLines = args.contextLines ?? 2; - const rank = args.rank || 'matches'; - const paths = (args.paths && args.paths.length ? args.paths : ['**/*']).slice(); - - let pattern = args.content; - let isSymbolSearch = false; - if (!pattern && args.symbol) { - // Word-boundary identifier search. - pattern = `\\b${escapeRegex(args.symbol)}\\b`; - isSymbolSearch = true; - } - - const fileMatches = pattern - ? scanContent({ cwd, pattern, paths, contextLines }) - : scanGlobOnly({ cwd, paths }); - - const ranked = rankResults(fileMatches, rank, cwd); - const truncated = ranked.length > maxResults; - const results = ranked.slice(0, maxResults); - - // _meta.replaces depends on whether we returned snippets (Read collapsed in too) or just paths. - const replaces = pattern - ? results.some((r) => r.snippet) - ? ['Glob', 'Grep', 'Read'] - : ['Glob', 'Grep'] - : ['Glob']; - - return { - results, - truncated, - _meta: meta(replaces, Math.max(1, results.length * (replaces.length))), - }; -} - -function escapeRegex(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function scanContent({ cwd, pattern, paths, contextLines }) { - const rg = which('rg'); - if (rg) return scanWithRipgrep({ cwd, pattern, paths, contextLines }); - return scanWithJs({ cwd, pattern, paths, contextLines }); -} - -function which(bin) { - const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { - encoding: 'utf8', - }); - return r.status === 0 ? r.stdout.trim().split('\n')[0] : null; -} - -function scanWithRipgrep({ cwd, pattern, paths, contextLines }) { - const args = [ - '--json', - '--no-messages', - '--no-require-git', // honor .gitignore even when there's no .git directory. - '-C', - String(contextLines), - '-e', - pattern, - ]; - for (const p of paths) { - if (p === '**/*' || p === '**') continue; - args.push('-g', p); - } - // Explicit search root — rg won't search cwd reliably when invoked without a TTY. - args.push('.'); - const res = spawnSync('rg', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); - if (res.status !== 0 && res.status !== 1) { - // 1 = no matches; >=2 = error. Fall back to JS scanner on error. - return scanWithJs({ cwd, pattern, paths, contextLines }); - } - return parseRipgrepJson(res.stdout, contextLines); -} - -function parseRipgrepJson(stdout, contextLines) { - /** @type {Map; matchLines: Set }>} */ - const byFile = new Map(); - for (const raw of stdout.split('\n')) { - if (!raw) continue; - let evt; - try { - evt = JSON.parse(raw); - } catch { - continue; - } - if (evt.type !== 'match' && evt.type !== 'context') continue; - const path = evt.data?.path?.text; - if (!path) continue; - if (!byFile.has(path)) byFile.set(path, { lines: new Map(), matchLines: new Set() }); - const entry = byFile.get(path); - const lineNumber = evt.data.line_number; - const text = (evt.data.lines?.text || '').replace(/\n$/, ''); - entry.lines.set(lineNumber, text); - if (evt.type === 'match') entry.matchLines.add(lineNumber); - } - - const results = []; - for (const [path, { lines, matchLines }] of byFile) { - // Group consecutive (or near-consecutive within contextLines) lines into snippets. - const sortedNums = [...lines.keys()].sort((a, b) => a - b); - let group = []; - const flush = () => { - if (!group.length) return; - const start = group[0]; - const end = group[group.length - 1]; - const snippet = group - .map((n) => `${n.toString().padStart(4, ' ')} ${lines.get(n)}`) - .join('\n'); - const matchCount = group.filter((n) => matchLines.has(n)).length; - if (matchCount > 0) { - results.push({ path, lineStart: start, lineEnd: end, snippet, matchCount }); - } - }; - for (const n of sortedNums) { - if (!group.length || n - group[group.length - 1] <= contextLines + 1) { - group.push(n); - } else { - flush(); - group = [n]; - } - } - flush(); - } - return results; -} - -function scanWithJs({ cwd, pattern, paths, contextLines }) { - const re = new RegExp(pattern, 'g'); - const files = listFiles(cwd, paths); - const out = []; - for (const file of files) { - let text; - try { - text = readFileSync(file, 'utf8'); - } catch { - continue; - } - if (!re.test(text)) { - re.lastIndex = 0; - continue; - } - re.lastIndex = 0; - const lines = text.split('\n'); - /** @type {Set} */ - const matchLines = new Set(); - for (let i = 0; i < lines.length; i++) { - const local = new RegExp(pattern, 'g'); - if (local.test(lines[i])) matchLines.add(i + 1); - } - if (!matchLines.size) continue; - const sorted = [...matchLines].sort((a, b) => a - b); - let group = null; - const flushGroup = () => { - if (!group) return; - const start = Math.max(1, group.first - contextLines); - const end = Math.min(lines.length, group.last + contextLines); - const snippetLines = []; - for (let n = start; n <= end; n++) { - snippetLines.push(`${String(n).padStart(4, ' ')} ${lines[n - 1]}`); - } - out.push({ - path: relative(cwd, file), - lineStart: start, - lineEnd: end, - snippet: snippetLines.join('\n'), - matchCount: group.count, - }); - group = null; - }; - for (const n of sorted) { - if (group && n - group.last <= contextLines * 2 + 1) { - group.last = n; - group.count++; - } else { - flushGroup(); - group = { first: n, last: n, count: 1 }; - } - } - flushGroup(); - } - return out; -} - -function scanGlobOnly({ cwd, paths }) { - const files = listFiles(cwd, paths); - return files.map((f) => ({ - path: relative(cwd, f), - lineStart: 0, - lineEnd: 0, - snippet: '', - matchCount: 0, - })); -} - -// ---- Path/glob walking with .gitignore support (minimal) ---- - -function listFiles(cwd, patterns) { - const ignore = loadGitignore(cwd); - /** @type {string[]} */ - const out = []; - walk(cwd, cwd, ignore, out); - if (patterns.length === 1 && (patterns[0] === '**/*' || patterns[0] === '**')) return out; - const regexes = patterns.map(globToRegex); - return out.filter((abs) => { - const rel = relative(cwd, abs).split(/[\\/]/).join('/'); - return regexes.some((re) => re.test(rel)); - }); -} - -function walk(root, dir, ignore, out) { - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - if (e.name === '.git' || e.name === 'node_modules') continue; - const abs = join(dir, e.name); - const rel = relative(root, abs).split(/[\\/]/).join('/'); - if (ignore(rel, e.isDirectory())) continue; - if (e.isDirectory()) walk(root, abs, ignore, out); - else if (e.isFile()) out.push(abs); - } -} - -function loadGitignore(cwd) { - const patterns = []; - let cur = cwd; - // Walk up looking for .gitignore files (just current dir for simplicity). - const f = join(cur, '.gitignore'); - if (existsSync(f)) { - for (const line of readFileSync(f, 'utf8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - patterns.push(trimmed); - } - } - const compiled = patterns.map((p) => ({ - negate: p.startsWith('!'), - re: globToRegex(p.replace(/^!/, '').replace(/\/$/, '')), - dirOnly: p.endsWith('/'), - })); - return (rel, isDir) => { - let ignored = false; - for (const { negate, re, dirOnly } of compiled) { - if (dirOnly && !isDir) continue; - if (re.test(rel)) ignored = !negate; - } - return ignored; - }; -} - -export function globToRegex(glob) { - // Convert a gitignore/glob pattern to a regex matching forward-slash relative paths. - let g = glob; - if (g.startsWith('/')) g = g.slice(1); - // Escape regex metacharacters except for glob ones. - let re = ''; - let i = 0; - while (i < g.length) { - const c = g[i]; - if (c === '*') { - if (g[i + 1] === '*') { - // ** — match anything including / - re += '.*'; - i += 2; - if (g[i] === '/') i++; - continue; - } - re += '[^/]*'; - i++; - continue; - } - if (c === '?') { - re += '[^/]'; - i++; - continue; - } - if ('.+^${}()|[]\\'.includes(c)) { - re += '\\' + c; - i++; - continue; - } - re += c; - i++; - } - return new RegExp('^(?:.*/)?' + re + '(?:/.*)?$'); -} - -function rankResults(results, mode, cwd) { - if (mode === 'matches') { - return [...results].sort((a, b) => b.matchCount - a.matchCount || a.path.localeCompare(b.path)); - } - if (mode === 'mtime') { - const withMtime = results.map((r) => { - let mtime = 0; - try { - // r.path is relative to the search cwd; resolve against cwd, not process.cwd(). - mtime = statSync(resolve(cwd || process.cwd(), r.path)).mtimeMs; - } catch {} - return { r, mtime }; - }); - return withMtime.sort((a, b) => b.mtime - a.mtime).map((x) => x.r); - } - if (mode === 'path-depth') { - return [...results].sort( - (a, b) => a.path.split('/').length - b.path.split('/').length || a.path.localeCompare(b.path), - ); - } - return results; -} diff --git a/legacy-ts/src/tools/test-run.js b/legacy-ts/src/tools/test-run.js deleted file mode 100644 index f31dd90..0000000 --- a/legacy-ts/src/tools/test-run.js +++ /dev/null @@ -1,244 +0,0 @@ -// relaywash__TestRun — structured runner output: counts + failed test summaries. -// Streams raw output to a temp file; returns only the structured summary. - -import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { meta } from '../burn/meta.js'; - -export const testRunTool = { - name: 'relaywash__TestRun', - description: - 'Run tests and return structured counts + failure summaries. Use `failuresOnly` (default true) to elide passing-test noise. Use `getFailureLog: ` to fetch the log slice for a single failure from a previous run.', - inputSchema: { - type: 'object', - properties: { - runner: { - type: 'string', - enum: ['auto', 'pnpm', 'npm', 'yarn', 'jest', 'pytest', 'go', 'cargo', 'node'], - default: 'auto', - }, - pattern: { type: 'string', description: 'Test name filter passed to the runner.' }, - paths: { type: 'array', items: { type: 'string' } }, - failuresOnly: { type: 'boolean', default: true }, - maxFailures: { type: 'integer', default: 10 }, - getFailureLog: { - type: 'string', - description: 'Fetch the log slice for one named failure (from a previous run).', - }, - cwd: { type: 'string' }, - }, - additionalProperties: false, - }, - handler(args) { - return runTestRun(args || {}); - }, -}; - -const LOG_DIR = join(tmpdir(), 'relaywash-logs'); - -export function runTestRun(args) { - const cwd = args.cwd || process.cwd(); - if (args.getFailureLog) return fetchFailureSlice(args.getFailureLog, cwd); - - const runner = args.runner === 'auto' || !args.runner ? detectRunner(cwd) : args.runner; - const cmd = buildCommand(runner, args); - if (!cmd) { - return { - runner, - passed: 0, - failed: 0, - skipped: 0, - duration: 0, - failures: [], - fullLogPath: null, - error: `no command for runner: ${runner}`, - _meta: meta(['Bash:test'], 1), - }; - } - const t0 = Date.now(); - const r = spawnSync(cmd[0], cmd.slice(1), { - cwd, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - env: process.env, - }); - const duration = Date.now() - t0; - const raw = (r.stdout || '') + (r.stderr || ''); - mkdirSync(LOG_DIR, { recursive: true }); - const fullLogPath = join(LOG_DIR, `testrun-${Date.now()}.log`); - writeFileSync(fullLogPath, raw); - - const parsed = parseRunnerOutput(runner, raw); - const failures = parsed.failures.slice(0, args.maxFailures ?? 10); - - return { - runner, - passed: parsed.passed, - failed: parsed.failed, - skipped: parsed.skipped, - duration, - failures: args.failuresOnly === false ? parsed.failures : failures, - fullLogPath, - _meta: meta(['Bash:test'], 1), - }; -} - -function detectRunner(cwd) { - const has = (p) => existsSync(join(cwd, p)); - if (has('Cargo.toml')) return 'cargo'; - if (has('go.mod')) return 'go'; - if (has('pytest.ini') || has('pyproject.toml')) return 'pytest'; - if (has('jest.config.js') || has('jest.config.ts') || has('jest.config.cjs')) return 'jest'; - if (has('pnpm-lock.yaml')) return 'pnpm'; - if (has('yarn.lock')) return 'yarn'; - if (has('package-lock.json')) return 'npm'; - if (has('package.json')) return 'npm'; - return 'node'; -} - -function buildCommand(runner, args) { - const pattern = args.pattern; - const paths = args.paths || []; - switch (runner) { - case 'pnpm': - return ['pnpm', 'test', ...(pattern ? ['--', '-t', pattern] : []), ...paths]; - case 'npm': - return ['npm', 'test', '--', ...(pattern ? ['-t', pattern] : []), ...paths]; - case 'yarn': - return ['yarn', 'test', ...(pattern ? ['-t', pattern] : []), ...paths]; - case 'jest': - return ['npx', 'jest', ...(pattern ? ['-t', pattern] : []), ...paths]; - case 'pytest': - return ['pytest', ...(pattern ? ['-k', pattern] : []), ...paths]; - case 'go': - return ['go', 'test', './...', ...(pattern ? ['-run', pattern] : [])]; - case 'cargo': - return ['cargo', 'test', ...(pattern ? [pattern] : [])]; - case 'node': - return ['node', '--test', ...(paths.length ? paths : ['test/'])]; - default: - return null; - } -} - -function parseRunnerOutput(runner, raw) { - if (runner === 'pytest') return parsePytest(raw); - if (runner === 'go') return parseGoTest(raw); - if (runner === 'cargo') return parseCargoTest(raw); - if (runner === 'node') return parseNodeTest(raw); - return parseJest(raw); // jest / pnpm / npm / yarn typically render via jest or vitest -} - -function parsePytest(raw) { - const m = /=+\s*(\d+)\s+failed,?\s*(?:(\d+)\s+passed)?(?:.*?(\d+)\s+skipped)?/i.exec(raw) || - /=+\s*(\d+)\s+passed(?:.*?(\d+)\s+skipped)?/i.exec(raw); - let passed = 0; - let failed = 0; - let skipped = 0; - const m2 = /(\d+)\s+passed/.exec(raw); - const m3 = /(\d+)\s+failed/.exec(raw); - const m4 = /(\d+)\s+skipped/.exec(raw); - if (m2) passed = Number(m2[1]); - if (m3) failed = Number(m3[1]); - if (m4) skipped = Number(m4[1]); - const failures = []; - const failBlock = /FAILED\s+(\S+)::(\S+)/g; - let fm; - while ((fm = failBlock.exec(raw))) { - failures.push({ name: fm[2], file: fm[1], message: '' }); - } - return { passed, failed, skipped, failures }; -} - -function parseGoTest(raw) { - let passed = 0; - let failed = 0; - const skipped = 0; - const failures = []; - for (const line of raw.split('\n')) { - if (/^---\s*PASS:\s/.test(line)) passed++; - else if (/^---\s*FAIL:\s/.test(line)) { - failed++; - const m = /^---\s*FAIL:\s+(\S+)/.exec(line); - if (m) failures.push({ name: m[1], file: '', message: '' }); - } - } - return { passed, failed, skipped, failures }; -} - -function parseCargoTest(raw) { - const m = /test result: (?:ok|FAILED)\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored/i.exec( - raw, - ); - const passed = m ? Number(m[1]) : 0; - const failed = m ? Number(m[2]) : 0; - const skipped = m ? Number(m[3]) : 0; - const failures = []; - const fm = /failures:\n\n((?:\s+.+\n)+)/.exec(raw); - if (fm) { - for (const line of fm[1].split('\n')) { - const t = line.trim(); - if (t) failures.push({ name: t, file: '', message: '' }); - } - } - return { passed, failed, skipped, failures }; -} - -function parseNodeTest(raw) { - let passed = 0; - let failed = 0; - let skipped = 0; - const failures = []; - const m = /# pass\s+(\d+)/.exec(raw); - const f = /# fail\s+(\d+)/.exec(raw); - const s = /# skipped\s+(\d+)/.exec(raw); - if (m) passed = Number(m[1]); - if (f) failed = Number(f[1]); - if (s) skipped = Number(s[1]); - // Parse "not ok N - name" lines. - const re = /^not ok \d+ - (.+)$/gm; - let fm; - while ((fm = re.exec(raw))) { - failures.push({ name: fm[1].trim(), file: '', message: '' }); - } - return { passed, failed, skipped, failures }; -} - -function parseJest(raw) { - let passed = 0; - let failed = 0; - let skipped = 0; - const m = /Tests?:\s*(?:(\d+)\s+failed,\s*)?(?:(\d+)\s+skipped,\s*)?(\d+)\s+passed/.exec(raw); - if (m) { - failed = m[1] ? Number(m[1]) : 0; - skipped = m[2] ? Number(m[2]) : 0; - passed = Number(m[3] || 0); - } - const failures = []; - const re = /●\s+(.+?)\n\n([\s\S]*?)(?=\n●|\nTest Suites:|\nTests:|$)/g; - let fm; - while ((fm = re.exec(raw))) { - failures.push({ name: fm[1].trim(), file: '', message: fm[2].slice(0, 1000) }); - } - return { passed, failed, skipped, failures }; -} - -function fetchFailureSlice(name, _cwd) { - // Find the most recent log file and return the slice around the named failure. - if (!existsSync(LOG_DIR)) return { found: false }; - const fs = readFileSync(join(LOG_DIR, latestLog())).toString(); - const idx = fs.indexOf(name); - if (idx === -1) return { found: false }; - const start = Math.max(0, idx - 500); - const end = Math.min(fs.length, idx + 2000); - return { found: true, slice: fs.slice(start, end), _meta: meta(['Bash:test'], 1) }; -} - -function latestLog() { - // Return the alphabetically last log filename — timestamps sort lexicographically. - const files = readdirSync(LOG_DIR).filter((f) => f.endsWith('.log')); - files.sort(); - return files[files.length - 1]; -} diff --git a/legacy-ts/test/build-tool.test.js b/legacy-ts/test/build-tool.test.js deleted file mode 100644 index bf34fb8..0000000 --- a/legacy-ts/test/build-tool.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { runBuild } from '../src/tools/build.js'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -test('Build: returns a structured response with success boolean', () => { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-build-')); - // Set up a minimal package.json with a passing build script. - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ name: 'fixture', scripts: { build: 'echo built' } }), - ); - const r = runBuild({ builder: 'npm', cwd: dir }); - assert.equal(typeof r.success, 'boolean'); - assert.equal(r._meta.replaces[0], 'Bash:build'); -}); - -test('Build: failing tsc-style output produces parsed errors', () => { - // Synthetic test of the parser, not the runner. - const { parseErrors: _internal } = { parseErrors: undefined }; - // Use the exported runBuild path by faking a tsc command that prints an error and exits 1. - const dir = mkdtempSync(join(tmpdir(), 'relaywash-build-fail-')); - // A package.json with a build script that mimics tsc-style error output and exits non-zero. - writeFileSync( - join(dir, 'package.json'), - JSON.stringify({ - name: 'fail', - scripts: { build: 'node -e "console.log(\\"src/foo.ts(10,5): error TS2304: Cannot find name x.\\"); process.exit(1)"' }, - }), - ); - const r = runBuild({ builder: 'npm', cwd: dir }); - assert.equal(r.success, false); - // Either errors[] or errorTail is present. - assert(r.errors || r.errorTail); -}); diff --git a/legacy-ts/test/burn-sdk.test.js b/legacy-ts/test/burn-sdk.test.js deleted file mode 100644 index 4cdd300..0000000 --- a/legacy-ts/test/burn-sdk.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Ledger, ingest, summary } from '../src/burn/sdk.js'; - -test('Ledger: round-trips tool_use events', async () => { - const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-')); - const led = new Ledger({ ledgerHome }); - led.recordToolUse('s1', { tool: 'relaywash__Search', replaces: ['Glob', 'Grep'], collapsedCalls: 6 }); - led.recordToolUse('s1', { tool: 'relaywash__Read', replaces: ['Read'], collapsedCalls: 1 }); - await ingest({ sessionId: 's1', ledgerHome }); - const s = await summary({ session: 's1', ledgerHome }); - assert.equal(s.totalCalls, 2); - assert.equal(s.collapsedCalls, 7); - assert.deepEqual(s.replacedTools.sort(), ['Glob', 'Grep', 'Read']); -}); - -test('summary: empty session returns zeroes', async () => { - const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-empty-')); - const s = await summary({ session: 'absent', ledgerHome }); - assert.equal(s.totalCalls, 0); - assert.equal(s.collapsedCalls, 0); -}); diff --git a/legacy-ts/test/edit.test.js b/legacy-ts/test/edit.test.js deleted file mode 100644 index 352f62b..0000000 --- a/legacy-ts/test/edit.test.js +++ /dev/null @@ -1,79 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { runEdit } from '../src/tools/edit.js'; -import { fuzzyFindAll, normalizeForMatch } from '../src/fuzzy/index.js'; - -function tmpFile(content, ext = '.ts') { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-edit-')); - const path = join(dir, `f${ext}`); - writeFileSync(path, content); - return path; -} - -test('Edit: applies a single edit and writes verbatim newText', () => { - const path = tmpFile('export const x = 1;\n'); - const r = runEdit({ - edits: [{ path, oldText: 'const x = 1', newText: 'const x = 42' }], - }); - assert.equal(r.results[0].ok, true); - assert.equal(readFileSync(path, 'utf8'), 'export const x = 42;\n'); -}); - -test('Edit: batched edits across multiple files', () => { - const a = tmpFile('a = 1'); - const b = tmpFile('b = 2'); - const r = runEdit({ - edits: [ - { path: a, oldText: 'a = 1', newText: 'a = 11' }, - { path: b, oldText: 'b = 2', newText: 'b = 22' }, - ], - }); - assert.equal(r.results.filter((x) => x.ok).length, 2); - assert.equal(readFileSync(a, 'utf8'), 'a = 11'); - assert.equal(readFileSync(b, 'utf8'), 'b = 22'); -}); - -test('Edit: fuzzy match tolerates whitespace diffs', () => { - // File uses tabs; old text uses spaces — most common cause of edit-retry loops in vanilla Edit. - const path = tmpFile('export function foo(x) {\n\treturn x\n}\n'); - const r = runEdit({ - edits: [ - { path, oldText: 'export function foo(x) {\n return x\n}', newText: 'export function foo(x) {\n return x + 1\n}' }, - ], - }); - assert.equal(r.results[0].ok, true, JSON.stringify(r.results[0])); - assert.equal(readFileSync(path, 'utf8').includes('return x + 1'), true); -}); - -test('Edit: ambiguous match is rejected', () => { - const path = tmpFile('foo();\nfoo();\n'); - const r = runEdit({ - edits: [{ path, oldText: 'foo();', newText: 'bar();' }], - }); - assert.equal(r.results[0].ok, false); - assert.match(r.results[0].reason, /ambiguous/); -}); - -test('Edit: rolls back when post-edit syntax check fails', () => { - const before = 'export function foo() { return 1 }\n'; - const path = tmpFile(before); - // Remove the closing brace — would break syntax. - const r = runEdit({ - edits: [{ path, oldText: '{ return 1 }', newText: '{ return 1' }], - }); - assert.equal(r.results[0].ok, false); - assert.equal(readFileSync(path, 'utf8'), before); -}); - -test('fuzzyFindAll: handles smart quotes', () => { - const haystack = 'const x = "hello"'; - const matches = fuzzyFindAll(haystack, 'const x = “hello”'); - assert.equal(matches.length, 1); -}); - -test('normalizeForMatch: collapses runs of whitespace', () => { - assert.equal(normalizeForMatch('a \t b'), 'a b'); -}); diff --git a/legacy-ts/test/git-state.test.js b/legacy-ts/test/git-state.test.js deleted file mode 100644 index 7f511d4..0000000 --- a/legacy-ts/test/git-state.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { runGitState } from '../src/tools/git-state.js'; - -function newRepo() { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-git-')); - const run = (args) => spawnSync('git', args, { cwd: dir }); - run(['init', '-b', 'main']); - run(['config', 'user.email', 'test@example.com']); - run(['config', 'user.name', 'tester']); - run(['config', 'commit.gpgsign', 'false']); - run(['config', 'tag.gpgsign', 'false']); - writeFileSync(join(dir, 'README.md'), '# hi\n'); - run(['add', '.']); - spawnSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', 'init'], { - cwd: dir, - env: { ...process.env, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'commit.gpgsign', GIT_CONFIG_VALUE_0: 'false' }, - }); - return dir; -} - -test('GitState: status returns branch and file list', () => { - const dir = newRepo(); - writeFileSync(join(dir, 'new.txt'), 'hello'); - const r = runGitState({ op: 'status', cwd: dir }); - assert.equal(r.branch, 'main'); - assert(Array.isArray(r.files)); - assert.equal(r._meta.replaces[0], 'Bash:git-status'); -}); - -test('GitState: log returns structured commits', () => { - const dir = newRepo(); - const r = runGitState({ op: 'log', cwd: dir }); - assert(r.commits.length >= 1); - assert(r.commits[0].sha); - assert.equal(r.commits[0].subject, 'init'); -}); diff --git a/legacy-ts/test/read.test.js b/legacy-ts/test/read.test.js deleted file mode 100644 index 3a9da68..0000000 --- a/legacy-ts/test/read.test.js +++ /dev/null @@ -1,83 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { runRead, _resetReadCache, noteSearchedSymbol } from '../src/tools/read.js'; - -function bigTsFile() { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-read-')); - const path = join(dir, 'big.ts'); - const lines = ['import { x } from "y";', '']; - // 50 functions, each with a 30-line body so the < 20 lines heuristic doesn't include bodies. - for (let i = 0; i < 50; i++) { - lines.push(`export function fn${i}(arg: number): number {`); - for (let j = 0; j < 30; j++) lines.push(` // body line ${j}`); - lines.push(' return arg + 1;'); - lines.push('}'); - lines.push(''); - } - writeFileSync(path, lines.join('\n')); - return path; -} - -test('Read: small file returns full content (heuristic)', () => { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-read-small-')); - const path = join(dir, 'small.ts'); - writeFileSync(path, 'export const x = 1\n'); - _resetReadCache(); - const r = runRead({ path, mode: 'signatures' }); - assert.equal(r.content.includes('export const x = 1'), true); -}); - -test('Read: large file in signatures mode returns reduced content + lineMap', () => { - _resetReadCache(); - const path = bigTsFile(); - const r = runRead({ path, mode: 'signatures' }); - assert.equal(r.languageDetected, 'typescript'); - assert.equal(r.truncated, true); - assert(r.lineMap && r.lineMap.length >= 5); - assert(r.content.length > 0); - // Reduced enough — body comments should mostly be elided. - assert(r.content.split('\n').length < 600); -}); - -test('Read: repeat-read of unchanged file returns empty content', () => { - _resetReadCache(); - const path = bigTsFile(); - runRead({ path, mode: 'signatures' }); - const r = runRead({ path, mode: 'signatures' }); - assert.equal(r.content, ''); -}); - -test('Read: range mode returns the requested slice', () => { - _resetReadCache(); - const path = bigTsFile(); - const r = runRead({ path, mode: 'range', range: [1, 3] }); - const lines = r.content.split('\n'); - assert.equal(lines.length, 3); - assert.equal(lines[0], 'import { x } from "y";'); -}); - -test('Read: mtime cache is scoped per session', () => { - _resetReadCache(); - const path = bigTsFile(); - // Session A reads — populates A's cache. - runRead({ path, mode: 'signatures' }, { sessionId: 'A' }); - // Session B reads the same file for the first time — must NOT get the empty-content - // optimization, because B never read it. - const r = runRead({ path, mode: 'signatures' }, { sessionId: 'B' }); - assert(r.content.length > 0, 'session B should get full response on first read'); - // Session A re-reading still gets the empty-content optimization. - const rA = runRead({ path, mode: 'signatures' }, { sessionId: 'A' }); - assert.equal(rA.content, ''); -}); - -test('Read: searched symbol heuristic keeps body', () => { - _resetReadCache(); - noteSearchedSymbol('fn5'); - const path = bigTsFile(); - const r = runRead({ path, mode: 'signatures' }); - // The searched symbol should have its body preserved. - assert(r.content.includes('fn5')); -}); diff --git a/legacy-ts/test/search.test.js b/legacy-ts/test/search.test.js deleted file mode 100644 index 61c4ab2..0000000 --- a/legacy-ts/test/search.test.js +++ /dev/null @@ -1,53 +0,0 @@ -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { runSearch, globToRegex } from '../src/tools/search.js'; - -function fixtureRepo() { - const dir = mkdtempSync(join(tmpdir(), 'relaywash-search-')); - mkdirSync(join(dir, 'src')); - writeFileSync(join(dir, 'src/a.ts'), 'export function foo() { return 1 }\n// foo lives here\n'); - writeFileSync(join(dir, 'src/b.ts'), 'import { foo } from "./a"\nfoo()\n'); - writeFileSync(join(dir, 'src/c.txt'), 'no match here\n'); - writeFileSync(join(dir, '.gitignore'), 'ignored.txt\n'); - writeFileSync(join(dir, 'ignored.txt'), 'foo should not appear here\n'); - return dir; -} - -test('Search: symbol mode returns ranked results with snippets', () => { - const dir = fixtureRepo(); - const r = runSearch({ symbol: 'foo', cwd: dir }); - assert.equal(r._meta.replaces.includes('Glob'), true); - assert.equal(r._meta.replaces.includes('Grep'), true); - assert(r.results.length >= 2); - for (const res of r.results) assert(res.snippet.length > 0); - // .gitignore should hide ignored.txt - assert.equal( - r.results.some((res) => res.path.includes('ignored.txt')), - false, - ); -}); - -test('Search: maxResults truncates', () => { - const dir = fixtureRepo(); - const r = runSearch({ symbol: 'foo', cwd: dir, maxResults: 1 }); - assert.equal(r.results.length, 1); - assert.equal(r.truncated, true); -}); - -test('Search: ranking modes produce different orderings', () => { - const dir = fixtureRepo(); - const byMatches = runSearch({ symbol: 'foo', cwd: dir, rank: 'matches' }); - const byPathDepth = runSearch({ symbol: 'foo', cwd: dir, rank: 'path-depth' }); - // At minimum, both succeed and return the same set, possibly different order. - assert.equal(byMatches.results.length, byPathDepth.results.length); -}); - -test('globToRegex: matches double-star', () => { - const re = globToRegex('**/*.ts'); - assert.equal(re.test('src/a.ts'), true); - assert.equal(re.test('a.ts'), true); - assert.equal(re.test('src/a.js'), false); -});