@dex-ai/tools-extension 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @dex-ai/tools
2
+
3
+ Core tool Extensions for [`@dex-ai/sdk`](https://github.com/klxdev/dex-ai-sdk). Each tool is its own Extension — compose the ones you want, skip the ones you don't.
4
+
5
+ | Extension | Tool | What it does | Sandboxed | Destructive |
6
+ |------------------------|-------------|-----------------------------------------------------------|-----------|-------------|
7
+ | `readFileExtension` | `read_file` | Read a file (optional line range). | yes (cwd) | no |
8
+ | `writeFileExtension` | `write_file`| Create or overwrite a file. | yes (cwd) | **yes** |
9
+ | `editFileExtension` | `edit_file` | Atomic multi-edit by exact string match. | yes (cwd) | **yes** |
10
+ | `searchExtension` | `search` | grep regex over contents, or glob over paths. | yes (cwd) | no |
11
+ | `bashExtension` | `bash` | Run `sh -c <command>` with timeout + captured output. | no | **yes** |
12
+
13
+ ## Install + use
14
+
15
+ ```ts
16
+ import { DexAgent } from '@dex-ai/runtime';
17
+ import { openai } from '@dex-ai/openai';
18
+ import {
19
+ readFileExtension, writeFileExtension, editFileExtension,
20
+ searchExtension, bashExtension,
21
+ allFsExtensions,
22
+ } from '@dex-ai/tools';
23
+
24
+ const agent = new DexAgent({
25
+ provider: openai({ modelId: 'gpt-4.1' }),
26
+ extensions: [
27
+ // pick individually...
28
+ readFileExtension({ cwd: process.cwd() }),
29
+ searchExtension({ cwd: process.cwd() }),
30
+
31
+ // ...or bulk register the full fs set:
32
+ ...allFsExtensions({ cwd: process.cwd() }),
33
+
34
+ bashExtension({ cwd: process.cwd() }),
35
+ ],
36
+ });
37
+ ```
38
+
39
+ Subpath imports are also available for bundle-size control:
40
+
41
+ ```ts
42
+ import { bashExtension } from '@dex-ai/tools/bash';
43
+ ```
44
+
45
+ ## Tool contracts
46
+
47
+ ### `read_file`
48
+ ```
49
+ params: { path: string, lineStart?: number, lineEnd?: number }
50
+ output: { type: 'text', value: string }
51
+ errors: path-outside-cwd, file-not-found, not-a-regular-file, too-large (>10MB)
52
+ ```
53
+
54
+ ### `write_file`
55
+ ```
56
+ params: { path: string, content: string }
57
+ output: { type: 'json', value: { bytesWritten: number, created: boolean } }
58
+ behavior: creates parent dirs if missing; overwrites existing files
59
+ ```
60
+
61
+ ### `edit_file`
62
+ ```
63
+ params: {
64
+ path: string,
65
+ edits: Array<{ oldString: string, newString: string, replaceAll?: boolean }>,
66
+ }
67
+ output: { type: 'json', value: { appliedEdits: number } }
68
+ semantics: all-or-nothing. Each oldString must appear exactly once unless
69
+ replaceAll is true. Overlapping ranges fail. File must exist.
70
+ Use write_file to create new files.
71
+ ```
72
+
73
+ ### `search`
74
+ ```
75
+ params: {
76
+ mode: 'grep' | 'find',
77
+ pattern: string, // grep: regex source; find: glob (*, **, ?)
78
+ path?: string, // defaults to cwd
79
+ maxResults?: number, // default 100
80
+ caseInsensitive?: boolean // grep-only
81
+ }
82
+ output: { type: 'json', value: { matches: [...], truncated: boolean } }
83
+ behavior: skips node_modules, .git, dist by default unless the caller scopes
84
+ `path` into them.
85
+ ```
86
+
87
+ ### `bash`
88
+ ```
89
+ params: { command: string, timeoutMs?: number } // default 120_000ms
90
+ output: { type: 'json', value: { stdout, stderr, exitCode, timedOut } }
91
+ behavior: runs via /bin/sh -c in cwd. Timeout kills the process and returns
92
+ timedOut: true rather than throwing.
93
+ ```
94
+
95
+ ## Approval — it's not here
96
+
97
+ This package ships **zero approval logic**. The SDK treats approval as cross-cutting via the `onToolCall` hook; apps install their own approver extension. The typical shape:
98
+
99
+ ```ts
100
+ import type { Extension, ToolCall, ToolResult } from '@dex-ai/sdk';
101
+
102
+ function approvalExtension(opts: {
103
+ gates: (call: ToolCall) => boolean; // returns true if this call needs approval
104
+ ask: (call: ToolCall) => Promise<boolean>; // async prompt; returns true to allow
105
+ }): Extension {
106
+ return {
107
+ name: 'approval',
108
+ async onToolCall(call): Promise<ToolResult | void> {
109
+ if (!opts.gates(call)) return; // no gate — run the tool
110
+ const ok = await opts.ask(call);
111
+ if (ok) return; // approved — pass through
112
+ return { // rejected — model sees this as a tool-result
113
+ toolCallId: call.toolCallId,
114
+ toolName: call.toolName,
115
+ output: { type: 'error-text', value: 'User rejected this tool call.' },
116
+ };
117
+ },
118
+ };
119
+ }
120
+
121
+ // wire it up:
122
+ const agent = new DexAgent({
123
+ provider,
124
+ extensions: [
125
+ bashExtension({ cwd }),
126
+ writeFileExtension({ cwd }),
127
+ approvalExtension({
128
+ gates: (call) => ['bash', 'write_file', 'edit_file'].includes(call.toolName),
129
+ ask: (call) => ui.confirm(`Run ${call.toolName}: ${JSON.stringify(call.input)}?`),
130
+ }),
131
+ ],
132
+ });
133
+ ```
134
+
135
+ Rejected tool calls come back to the model as a normal `tool-result` message containing `error-text`. The model decides what to do next (try a different tool, stop, ask the user).
136
+
137
+ ## Sandbox details
138
+
139
+ The four fs tools resolve every path against `cwd` and reject anything outside. Specifically:
140
+ - `../` traversal is rejected (paths are normalized).
141
+ - Absolute paths outside `cwd` are rejected.
142
+ - Symlinks are followed via `fs.realpath` — a symlink inside `cwd` that points outside is rejected.
143
+
144
+ `bash` is **not** sandboxed at the filesystem level. It runs with `cwd` as its working directory but any approved shell command can read/write anywhere the process has permission to. Approval is the gate — use `onToolCall`.
145
+
146
+ ## Testing
147
+
148
+ ```bash
149
+ bun test
150
+ ```
151
+
152
+ 50 tests across 6 files as of v0.1 — every tool exercised end-to-end through a `DexAgent` + scripted fake Provider.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@dex-ai/tools-extension",
3
+ "version": "0.1.4",
4
+ "description": "Core tool Extensions for @dex-ai/sdk — file read/write/edit, search, bash. Each tool is its own Extension.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ },
11
+ "./read-file": {
12
+ "types": "./src/read-file.ts",
13
+ "default": "./src/read-file.ts"
14
+ },
15
+ "./write-file": {
16
+ "types": "./src/write-file.ts",
17
+ "default": "./src/write-file.ts"
18
+ },
19
+ "./edit-file": {
20
+ "types": "./src/edit-file.ts",
21
+ "default": "./src/edit-file.ts"
22
+ },
23
+ "./search": {
24
+ "types": "./src/search.ts",
25
+ "default": "./src/search.ts"
26
+ },
27
+ "./bash": {
28
+ "types": "./src/bash.ts",
29
+ "default": "./src/bash.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "src"
34
+ ],
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "bun test"
38
+ },
39
+ "dependencies": {
40
+ "@dex-ai/sdk": "^0.1.2"
41
+ },
42
+ "peerDependencies": {
43
+ "zod": "^3.23.0"
44
+ },
45
+ "devDependencies": {
46
+ "zod": "^3.23.8"
47
+ },
48
+ "sideEffects": false,
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "registry": "https://registry.npmjs.org/"
52
+ }
53
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdir, writeFile, symlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tempWorkspace } from './_test-helpers';
5
+ import { resolveInCwd, PathOutsideCwdError } from './_cwd';
6
+
7
+ describe('resolveInCwd', () => {
8
+ let cwd: string;
9
+ let cleanup: () => Promise<void>;
10
+
11
+ beforeEach(async () => {
12
+ ({ cwd, cleanup } = await tempWorkspace());
13
+ });
14
+
15
+ afterEach(() => cleanup());
16
+
17
+ test('relative path resolves under cwd', async () => {
18
+ await writeFile(join(cwd, 'a.txt'), 'hi');
19
+ const r = await resolveInCwd(cwd, 'a.txt');
20
+ expect(r.startsWith(cwd)).toBe(true);
21
+ expect(r.endsWith('a.txt')).toBe(true);
22
+ });
23
+
24
+ test('absolute path under cwd is accepted', async () => {
25
+ await writeFile(join(cwd, 'a.txt'), 'hi');
26
+ const r = await resolveInCwd(cwd, join(cwd, 'a.txt'));
27
+ expect(r.endsWith('a.txt')).toBe(true);
28
+ });
29
+
30
+ test('../ escape is rejected', async () => {
31
+ expect(resolveInCwd(cwd, '../escape.txt')).rejects.toBeInstanceOf(PathOutsideCwdError);
32
+ });
33
+
34
+ test('absolute path outside cwd is rejected', async () => {
35
+ expect(resolveInCwd(cwd, '/etc/passwd')).rejects.toBeInstanceOf(PathOutsideCwdError);
36
+ });
37
+
38
+ test('non-existent file inside cwd is fine (for write)', async () => {
39
+ const r = await resolveInCwd(cwd, 'new/file.txt');
40
+ expect(r.startsWith(cwd)).toBe(true);
41
+ expect(r.endsWith('new/file.txt')).toBe(true);
42
+ });
43
+
44
+ test('symlink inside cwd pointing OUTSIDE is rejected', async () => {
45
+ const outside = await tempWorkspace();
46
+ try {
47
+ await writeFile(join(outside.cwd, 'secret'), 'nope');
48
+ await symlink(join(outside.cwd, 'secret'), join(cwd, 'bad-link'));
49
+ expect(resolveInCwd(cwd, 'bad-link')).rejects.toBeInstanceOf(PathOutsideCwdError);
50
+ } finally {
51
+ await outside.cleanup();
52
+ }
53
+ });
54
+
55
+ test('symlink inside cwd pointing INSIDE is fine', async () => {
56
+ await mkdir(join(cwd, 'sub'));
57
+ await writeFile(join(cwd, 'sub', 'target'), 'ok');
58
+ await symlink(join(cwd, 'sub', 'target'), join(cwd, 'good-link'));
59
+ const r = await resolveInCwd(cwd, 'good-link');
60
+ expect(r.startsWith(cwd)).toBe(true);
61
+ });
62
+
63
+ test('mustExist=true rejects missing paths', async () => {
64
+ expect(resolveInCwd(cwd, 'missing.txt', { mustExist: true })).rejects.toThrow();
65
+ });
66
+ });
package/src/_cwd.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared cwd sandbox helper for fs tools.
3
+ *
4
+ * resolveInCwd takes an absolute or relative input path, resolves it
5
+ * (following `..` segments and symlinks via fs.realpath), and rejects
6
+ * anything that lands outside the sandbox root. Every fs-facing tool
7
+ * calls this before touching the filesystem.
8
+ */
9
+ import { realpath } from "node:fs/promises";
10
+ import { resolve, sep } from "node:path";
11
+
12
+ /**
13
+ * A CwdProvider is either a static path string or a function that returns
14
+ * the current working directory. Tools use this to support dynamic cwd
15
+ * (e.g. changed via an env extension's `cd` tool).
16
+ */
17
+ export type CwdProvider = string | (() => string);
18
+
19
+ /**
20
+ * A RootsProvider returns the list of allowed sandbox root directories.
21
+ * Can be static or dynamic (grows when user adds directories).
22
+ */
23
+ export type RootsProvider =
24
+ | ReadonlyArray<string>
25
+ | (() => ReadonlyArray<string>);
26
+
27
+ /** Resolve a CwdProvider to a concrete path. */
28
+ export function resolveCwd(provider: CwdProvider): string {
29
+ return typeof provider === "function" ? provider() : provider;
30
+ }
31
+
32
+ /** Resolve a RootsProvider to a concrete array. */
33
+ export function resolveRoots(
34
+ provider: RootsProvider | undefined,
35
+ ): ReadonlyArray<string> | undefined {
36
+ if (provider === undefined) return undefined;
37
+ return typeof provider === "function" ? provider() : provider;
38
+ }
39
+
40
+ export class PathOutsideCwdError extends Error {
41
+ override readonly name = "PathOutsideCwdError";
42
+ constructor(
43
+ public readonly input: string,
44
+ public readonly cwd: string,
45
+ public readonly resolved: string,
46
+ ) {
47
+ super(
48
+ `Path outside working directory: ${input} (resolved to ${resolved}; cwd=${cwd})`,
49
+ );
50
+ }
51
+ }
52
+
53
+ export interface ResolveOptions {
54
+ /**
55
+ * If true, the target must already exist (realpath will throw ENOENT otherwise).
56
+ * If false, we resolve as far as possible and check the deepest existing ancestor
57
+ * — this supports write creating new paths inside cwd.
58
+ */
59
+ mustExist?: boolean;
60
+ /**
61
+ * Additional allowed root directories. When provided, a path is valid if it
62
+ * resides within cwd OR within any of these roots. Supports multi-root sandboxes.
63
+ */
64
+ roots?: ReadonlyArray<string> | undefined;
65
+ }
66
+
67
+ async function resolveToRealpath(
68
+ cwdReal: string,
69
+ input: string,
70
+ ): Promise<string> {
71
+ const joined = resolve(cwdReal, input);
72
+
73
+ try {
74
+ return await realpath(joined);
75
+ } catch (err) {
76
+ if ((err as { code?: string }).code !== "ENOENT") throw err;
77
+ }
78
+
79
+ // Path doesn't exist yet. Walk up to find the deepest existing ancestor,
80
+ // realpath that (to resolve symlinks), then reattach the missing suffix.
81
+ const parts = joined.split(sep);
82
+ for (let i = parts.length - 1; i > 0; i--) {
83
+ const candidate = parts.slice(0, i).join(sep) || sep;
84
+ try {
85
+ const real = await realpath(candidate);
86
+ const tail = parts.slice(i).join(sep);
87
+ return tail === "" ? real : resolve(real, tail);
88
+ } catch (err) {
89
+ if ((err as { code?: string }).code !== "ENOENT") throw err;
90
+ }
91
+ }
92
+ // Nothing exists up the chain; return the joined path as-is.
93
+ return joined;
94
+ }
95
+
96
+ /**
97
+ * Resolve `input` against `cwd` and assert the result stays inside the sandbox.
98
+ * Returns the realpath (absolute, symlinks resolved) on success.
99
+ * Throws PathOutsideCwdError on escape.
100
+ *
101
+ * When `opts.roots` is provided, the path is valid if it resides within
102
+ * cwd OR any of the given roots.
103
+ */
104
+ export async function resolveInCwd(
105
+ cwd: string,
106
+ input: string,
107
+ opts: ResolveOptions = {},
108
+ ): Promise<string> {
109
+ // Realpath cwd once so symlinks in cwd itself don't confuse the comparison.
110
+ const cwdReal = await realpath(cwd);
111
+ const target =
112
+ opts.mustExist === true
113
+ ? await realpath(resolve(cwdReal, input))
114
+ : await resolveToRealpath(cwdReal, input);
115
+
116
+ // Check if target is inside cwd
117
+ if (target === cwdReal || target.startsWith(cwdReal + sep)) {
118
+ return target;
119
+ }
120
+
121
+ // Check additional roots if provided
122
+ if (opts.roots && opts.roots.length > 0) {
123
+ for (const root of opts.roots) {
124
+ let rootReal: string;
125
+ try {
126
+ rootReal = await realpath(root);
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (target === rootReal || target.startsWith(rootReal + sep)) {
131
+ return target;
132
+ }
133
+ }
134
+ }
135
+
136
+ throw new PathOutsideCwdError(input, cwdReal, target);
137
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { unifiedDiff } from './_diff';
3
+
4
+ describe('unifiedDiff', () => {
5
+ it('returns empty string for identical content', () => {
6
+ const content = 'line1\nline2\nline3\n';
7
+ expect(unifiedDiff(content, content, 'test.ts')).toBe('');
8
+ });
9
+
10
+ it('shows single line change with context', () => {
11
+ const original = 'line1\nline2\nline3\nline4\nline5\n';
12
+ const modified = 'line1\nline2\nchanged\nline4\nline5\n';
13
+ const diff = unifiedDiff(original, modified, 'test.ts');
14
+
15
+ expect(diff).toContain('--- a/test.ts');
16
+ expect(diff).toContain('+++ b/test.ts');
17
+ expect(diff).toContain('-line3');
18
+ expect(diff).toContain('+changed');
19
+ // Context lines
20
+ expect(diff).toContain(' line2');
21
+ expect(diff).toContain(' line4');
22
+ });
23
+
24
+ it('shows addition', () => {
25
+ const original = 'line1\nline2\n';
26
+ const modified = 'line1\nnew\nline2\n';
27
+ const diff = unifiedDiff(original, modified, 'test.ts');
28
+
29
+ expect(diff).toContain('+new');
30
+ });
31
+
32
+ it('shows deletion', () => {
33
+ const original = 'line1\nline2\nline3\n';
34
+ const modified = 'line1\nline3\n';
35
+ const diff = unifiedDiff(original, modified, 'test.ts');
36
+
37
+ expect(diff).toContain('-line2');
38
+ });
39
+
40
+ it('shows multiple hunks for distant changes', () => {
41
+ const lines = Array.from({ length: 20 }, (_, i) => `line${i + 1}`);
42
+ const original = lines.join('\n') + '\n';
43
+ const modLines = [...lines];
44
+ modLines[2] = 'changed3';
45
+ modLines[17] = 'changed18';
46
+ const modified = modLines.join('\n') + '\n';
47
+ const diff = unifiedDiff(original, modified, 'test.ts');
48
+
49
+ expect(diff).toContain('-line3');
50
+ expect(diff).toContain('+changed3');
51
+ expect(diff).toContain('-line18');
52
+ expect(diff).toContain('+changed18');
53
+ // Should have two @@ hunk headers
54
+ const hunkHeaders = diff.split('\n').filter(l => l.startsWith('@@'));
55
+ expect(hunkHeaders.length).toBe(2);
56
+ });
57
+ });
package/src/_diff.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Minimal unified-diff generator for showing file edits.
3
+ * Produces git-style unified diff output with context lines.
4
+ */
5
+
6
+ const CONTEXT_LINES = 3;
7
+
8
+ interface Hunk {
9
+ oldStart: number;
10
+ oldCount: number;
11
+ newStart: number;
12
+ newCount: number;
13
+ lines: string[];
14
+ }
15
+
16
+ /**
17
+ * Simple LCS-based diff producing edit operations.
18
+ * Returns an array of { type, line } entries.
19
+ */
20
+ function diffLines(
21
+ a: string[],
22
+ b: string[],
23
+ ): Array<{ type: "keep" | "del" | "add"; line: string }> {
24
+ const n = a.length;
25
+ const m = b.length;
26
+
27
+ // Optimize: for very large files, use a simpler approach
28
+ if (n * m > 1_000_000) {
29
+ return simpleDiff(a, b);
30
+ }
31
+
32
+ // LCS table
33
+ const dp: number[][] = Array.from({ length: n + 1 }, () =>
34
+ Array(m + 1).fill(0),
35
+ );
36
+ for (let i = 1; i <= n; i++) {
37
+ for (let j = 1; j <= m; j++) {
38
+ if (a[i - 1] === b[j - 1]) {
39
+ dp[i]![j] = dp[i - 1]![j - 1]! + 1;
40
+ } else {
41
+ dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
42
+ }
43
+ }
44
+ }
45
+
46
+ // Backtrack
47
+ const ops: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
48
+ let i = n,
49
+ j = m;
50
+ while (i > 0 || j > 0) {
51
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
52
+ ops.push({ type: "keep", line: a[i - 1]! });
53
+ i--;
54
+ j--;
55
+ } else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
56
+ ops.push({ type: "add", line: b[j - 1]! });
57
+ j--;
58
+ } else {
59
+ ops.push({ type: "del", line: a[i - 1]! });
60
+ i--;
61
+ }
62
+ }
63
+ ops.reverse();
64
+ return ops;
65
+ }
66
+
67
+ /**
68
+ * Fallback for large files: find changed regions by comparing line-by-line.
69
+ */
70
+ function simpleDiff(
71
+ a: string[],
72
+ b: string[],
73
+ ): Array<{ type: "keep" | "del" | "add"; line: string }> {
74
+ const ops: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
75
+
76
+ // Find common prefix
77
+ let prefix = 0;
78
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) {
79
+ ops.push({ type: "keep", line: a[prefix]! });
80
+ prefix++;
81
+ }
82
+
83
+ // Find common suffix
84
+ let suffixA = a.length - 1;
85
+ let suffixB = b.length - 1;
86
+ const suffixOps: Array<{ type: "keep" | "del" | "add"; line: string }> = [];
87
+ while (suffixA >= prefix && suffixB >= prefix && a[suffixA] === b[suffixB]) {
88
+ suffixOps.push({ type: "keep", line: a[suffixA]! });
89
+ suffixA--;
90
+ suffixB--;
91
+ }
92
+ suffixOps.reverse();
93
+
94
+ // Middle section: all deletions then all additions
95
+ for (let i = prefix; i <= suffixA; i++) {
96
+ ops.push({ type: "del", line: a[i]! });
97
+ }
98
+ for (let i = prefix; i <= suffixB; i++) {
99
+ ops.push({ type: "add", line: b[i]! });
100
+ }
101
+
102
+ ops.push(...suffixOps);
103
+ return ops;
104
+ }
105
+
106
+ /**
107
+ * Group diff ops into hunks with context lines.
108
+ */
109
+ function buildHunks(
110
+ ops: Array<{ type: "keep" | "del" | "add"; line: string }>,
111
+ ): Hunk[] {
112
+ const hunks: Hunk[] = [];
113
+ let currentHunk: Hunk | null = null;
114
+ let oldLine = 0;
115
+ let newLine = 0;
116
+ let trailingContext = 0;
117
+
118
+ for (let idx = 0; idx < ops.length; idx++) {
119
+ const op = ops[idx]!;
120
+
121
+ if (op.type === "keep") {
122
+ oldLine++;
123
+ newLine++;
124
+
125
+ if (currentHunk) {
126
+ trailingContext++;
127
+ currentHunk.lines.push(` ${op.line}`);
128
+ currentHunk.oldCount++;
129
+ currentHunk.newCount++;
130
+
131
+ // If we've accumulated enough trailing context and there's no more changes nearby
132
+ if (trailingContext >= CONTEXT_LINES) {
133
+ // Check if next change is within context distance
134
+ let nextChange = -1;
135
+ for (
136
+ let k = idx + 1;
137
+ k < ops.length && k <= idx + CONTEXT_LINES;
138
+ k++
139
+ ) {
140
+ if (ops[k]!.type !== "keep") {
141
+ nextChange = k;
142
+ break;
143
+ }
144
+ }
145
+ if (nextChange === -1) {
146
+ // Close hunk
147
+ hunks.push(currentHunk);
148
+ currentHunk = null;
149
+ trailingContext = 0;
150
+ }
151
+ }
152
+ }
153
+ } else {
154
+ // Change line — start or extend hunk
155
+ if (!currentHunk) {
156
+ // Start new hunk with leading context
157
+ const contextStart = Math.max(0, idx - CONTEXT_LINES);
158
+ currentHunk = {
159
+ oldStart: oldLine - (idx - contextStart) + 1,
160
+ oldCount: 0,
161
+ newStart: newLine - (idx - contextStart) + 1,
162
+ newCount: 0,
163
+ lines: [],
164
+ };
165
+ // Add leading context
166
+ for (let k = contextStart; k < idx; k++) {
167
+ currentHunk.lines.push(` ${ops[k]!.line}`);
168
+ currentHunk.oldCount++;
169
+ currentHunk.newCount++;
170
+ }
171
+ }
172
+ trailingContext = 0;
173
+
174
+ if (op.type === "del") {
175
+ currentHunk.lines.push(`-${op.line}`);
176
+ currentHunk.oldCount++;
177
+ oldLine++;
178
+ } else {
179
+ currentHunk.lines.push(`+${op.line}`);
180
+ currentHunk.newCount++;
181
+ newLine++;
182
+ }
183
+ }
184
+ }
185
+
186
+ if (currentHunk) {
187
+ hunks.push(currentHunk);
188
+ }
189
+
190
+ return hunks;
191
+ }
192
+
193
+ /**
194
+ * Produce a unified diff string from two file contents.
195
+ */
196
+ export function unifiedDiff(
197
+ original: string,
198
+ modified: string,
199
+ path: string,
200
+ ): string {
201
+ const aLines = original.split("\n");
202
+ const bLines = modified.split("\n");
203
+
204
+ const ops = diffLines(aLines, bLines);
205
+ const hunks = buildHunks(ops);
206
+
207
+ if (hunks.length === 0) return "";
208
+
209
+ const out: string[] = [];
210
+ out.push(`--- a/${path}`);
211
+ out.push(`+++ b/${path}`);
212
+
213
+ for (const hunk of hunks) {
214
+ out.push(
215
+ `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`,
216
+ );
217
+ out.push(...hunk.lines);
218
+ }
219
+
220
+ return out.join("\n");
221
+ }