tarsk 0.3.25 → 0.3.28

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/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { Hono as Hono10 } from "hono";
7
7
  import { cors } from "hono/cors";
8
8
  import { serve } from "@hono/node-server";
9
9
  import { serveStatic } from "@hono/node-server/serve-static";
10
- import path from "path";
10
+ import path3 from "path";
11
11
  import open2 from "open";
12
12
  import { fileURLToPath as fileURLToPath2 } from "url";
13
13
 
@@ -1838,24 +1838,1404 @@ class MetadataManager {
1838
1838
  } else {
1839
1839
  state.enabledModels[provider] = modelIds;
1840
1840
  }
1841
- await this.saveState(state);
1842
- }
1843
- async setOnboardingCompleted(completed) {
1844
- const state = await this.loadState();
1845
- state.onboardingCompleted = completed;
1846
- await this.saveState(state);
1847
- }
1848
- async getOnboardingCompleted() {
1849
- const state = await this.loadState();
1850
- return state.onboardingCompleted;
1851
- }
1841
+ await this.saveState(state);
1842
+ }
1843
+ async setOnboardingCompleted(completed) {
1844
+ const state = await this.loadState();
1845
+ state.onboardingCompleted = completed;
1846
+ await this.saveState(state);
1847
+ }
1848
+ async getOnboardingCompleted() {
1849
+ const state = await this.loadState();
1850
+ return state.onboardingCompleted;
1851
+ }
1852
+ }
1853
+
1854
+ // src/managers/pi-executor.ts
1855
+ import { Agent } from "@mariozechner/pi-agent-core";
1856
+ import {
1857
+ getModel
1858
+ } from "@mariozechner/pi-ai";
1859
+ import { resolve } from "path";
1860
+
1861
+ // src/tools/bash.ts
1862
+ import { randomBytes as randomBytes2 } from "node:crypto";
1863
+ import { createWriteStream, existsSync as existsSync3 } from "node:fs";
1864
+ import { tmpdir } from "node:os";
1865
+ import { join as join6 } from "node:path";
1866
+ import { Type } from "@sinclair/typebox";
1867
+ import { spawn as spawn5 } from "child_process";
1868
+
1869
+ // src/tools/shell.ts
1870
+ import { existsSync as existsSync2 } from "node:fs";
1871
+ import { spawn as spawn4, spawnSync as spawnSync3 } from "node:child_process";
1872
+ var cachedShellConfig = null;
1873
+ function findBashOnPath() {
1874
+ if (process.platform === "win32") {
1875
+ try {
1876
+ const result = spawnSync3("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
1877
+ if (result.status === 0 && result.stdout) {
1878
+ const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
1879
+ if (firstMatch && existsSync2(firstMatch)) {
1880
+ return firstMatch;
1881
+ }
1882
+ }
1883
+ } catch {}
1884
+ return null;
1885
+ }
1886
+ try {
1887
+ const result = spawnSync3("which", ["bash"], { encoding: "utf-8", timeout: 5000 });
1888
+ if (result.status === 0 && result.stdout) {
1889
+ const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
1890
+ if (firstMatch)
1891
+ return firstMatch;
1892
+ }
1893
+ } catch {}
1894
+ return null;
1895
+ }
1896
+ function getShellConfig() {
1897
+ if (cachedShellConfig) {
1898
+ return cachedShellConfig;
1899
+ }
1900
+ if (process.platform === "win32") {
1901
+ const paths = [];
1902
+ if (process.env.ProgramFiles) {
1903
+ paths.push(`${process.env.ProgramFiles}\\Git\\bin\\bash.exe`);
1904
+ }
1905
+ if (process.env["ProgramFiles(x86)"]) {
1906
+ paths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\bin\\bash.exe`);
1907
+ }
1908
+ for (const p of paths) {
1909
+ if (existsSync2(p)) {
1910
+ cachedShellConfig = { shell: p, args: ["-c"] };
1911
+ return cachedShellConfig;
1912
+ }
1913
+ }
1914
+ const bashOnPath2 = findBashOnPath();
1915
+ if (bashOnPath2) {
1916
+ cachedShellConfig = { shell: bashOnPath2, args: ["-c"] };
1917
+ return cachedShellConfig;
1918
+ }
1919
+ throw new Error("No bash shell found. Install Git for Windows or add bash to PATH.");
1920
+ }
1921
+ if (existsSync2("/bin/bash")) {
1922
+ cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
1923
+ return cachedShellConfig;
1924
+ }
1925
+ const bashOnPath = findBashOnPath();
1926
+ if (bashOnPath) {
1927
+ cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
1928
+ return cachedShellConfig;
1929
+ }
1930
+ cachedShellConfig = { shell: "sh", args: ["-c"] };
1931
+ return cachedShellConfig;
1932
+ }
1933
+ function getShellEnv() {
1934
+ return { ...process.env };
1935
+ }
1936
+ function killProcessTree(pid) {
1937
+ if (process.platform === "win32") {
1938
+ try {
1939
+ spawn4("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true });
1940
+ } catch {}
1941
+ } else {
1942
+ try {
1943
+ process.kill(-pid, "SIGKILL");
1944
+ } catch {
1945
+ try {
1946
+ process.kill(pid, "SIGKILL");
1947
+ } catch {}
1948
+ }
1949
+ }
1950
+ }
1951
+
1952
+ // src/tools/truncate.ts
1953
+ var DEFAULT_MAX_LINES = 2000;
1954
+ var DEFAULT_MAX_BYTES = 50 * 1024;
1955
+ var GREP_MAX_LINE_LENGTH = 500;
1956
+ function formatSize(bytes) {
1957
+ if (bytes < 1024) {
1958
+ return `${bytes}B`;
1959
+ } else if (bytes < 1024 * 1024) {
1960
+ return `${(bytes / 1024).toFixed(1)}KB`;
1961
+ } else {
1962
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1963
+ }
1964
+ }
1965
+ function truncateHead(content, options = {}) {
1966
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
1967
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
1968
+ const totalBytes = Buffer.byteLength(content, "utf-8");
1969
+ const lines = content.split(`
1970
+ `);
1971
+ const totalLines = lines.length;
1972
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
1973
+ return {
1974
+ content,
1975
+ truncated: false,
1976
+ truncatedBy: null,
1977
+ totalLines,
1978
+ totalBytes,
1979
+ outputLines: totalLines,
1980
+ outputBytes: totalBytes,
1981
+ lastLinePartial: false,
1982
+ firstLineExceedsLimit: false,
1983
+ maxLines,
1984
+ maxBytes
1985
+ };
1986
+ }
1987
+ const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
1988
+ if (firstLineBytes > maxBytes) {
1989
+ return {
1990
+ content: "",
1991
+ truncated: true,
1992
+ truncatedBy: "bytes",
1993
+ totalLines,
1994
+ totalBytes,
1995
+ outputLines: 0,
1996
+ outputBytes: 0,
1997
+ lastLinePartial: false,
1998
+ firstLineExceedsLimit: true,
1999
+ maxLines,
2000
+ maxBytes
2001
+ };
2002
+ }
2003
+ const outputLinesArr = [];
2004
+ let outputBytesCount = 0;
2005
+ let truncatedBy = "lines";
2006
+ for (let i = 0;i < lines.length && i < maxLines; i++) {
2007
+ const line = lines[i];
2008
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
2009
+ if (outputBytesCount + lineBytes > maxBytes) {
2010
+ truncatedBy = "bytes";
2011
+ break;
2012
+ }
2013
+ outputLinesArr.push(line);
2014
+ outputBytesCount += lineBytes;
2015
+ }
2016
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
2017
+ truncatedBy = "lines";
2018
+ }
2019
+ const outputContent = outputLinesArr.join(`
2020
+ `);
2021
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
2022
+ return {
2023
+ content: outputContent,
2024
+ truncated: true,
2025
+ truncatedBy,
2026
+ totalLines,
2027
+ totalBytes,
2028
+ outputLines: outputLinesArr.length,
2029
+ outputBytes: finalOutputBytes,
2030
+ lastLinePartial: false,
2031
+ firstLineExceedsLimit: false,
2032
+ maxLines,
2033
+ maxBytes
2034
+ };
2035
+ }
2036
+ function truncateTail(content, options = {}) {
2037
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
2038
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
2039
+ const totalBytes = Buffer.byteLength(content, "utf-8");
2040
+ const lines = content.split(`
2041
+ `);
2042
+ const totalLines = lines.length;
2043
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
2044
+ return {
2045
+ content,
2046
+ truncated: false,
2047
+ truncatedBy: null,
2048
+ totalLines,
2049
+ totalBytes,
2050
+ outputLines: totalLines,
2051
+ outputBytes: totalBytes,
2052
+ lastLinePartial: false,
2053
+ firstLineExceedsLimit: false,
2054
+ maxLines,
2055
+ maxBytes
2056
+ };
2057
+ }
2058
+ const outputLinesArr = [];
2059
+ let outputBytesCount = 0;
2060
+ let truncatedBy = "lines";
2061
+ let lastLinePartial = false;
2062
+ for (let i = lines.length - 1;i >= 0 && outputLinesArr.length < maxLines; i--) {
2063
+ const line = lines[i];
2064
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
2065
+ if (outputBytesCount + lineBytes > maxBytes) {
2066
+ truncatedBy = "bytes";
2067
+ if (outputLinesArr.length === 0) {
2068
+ const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
2069
+ outputLinesArr.unshift(truncatedLine);
2070
+ outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
2071
+ lastLinePartial = true;
2072
+ }
2073
+ break;
2074
+ }
2075
+ outputLinesArr.unshift(line);
2076
+ outputBytesCount += lineBytes;
2077
+ }
2078
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
2079
+ truncatedBy = "lines";
2080
+ }
2081
+ const outputContent = outputLinesArr.join(`
2082
+ `);
2083
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
2084
+ return {
2085
+ content: outputContent,
2086
+ truncated: true,
2087
+ truncatedBy,
2088
+ totalLines,
2089
+ totalBytes,
2090
+ outputLines: outputLinesArr.length,
2091
+ outputBytes: finalOutputBytes,
2092
+ lastLinePartial,
2093
+ firstLineExceedsLimit: false,
2094
+ maxLines,
2095
+ maxBytes
2096
+ };
2097
+ }
2098
+ function truncateStringToBytesFromEnd(str, maxBytes) {
2099
+ const buf = Buffer.from(str, "utf-8");
2100
+ if (buf.length <= maxBytes) {
2101
+ return str;
2102
+ }
2103
+ let start = buf.length - maxBytes;
2104
+ while (start < buf.length && (buf[start] & 192) === 128) {
2105
+ start++;
2106
+ }
2107
+ return buf.slice(start).toString("utf-8");
2108
+ }
2109
+ function truncateLine(line, maxChars = GREP_MAX_LINE_LENGTH) {
2110
+ if (line.length <= maxChars) {
2111
+ return { text: line, wasTruncated: false };
2112
+ }
2113
+ return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
2114
+ }
2115
+
2116
+ // src/tools/bash.ts
2117
+ function getTempFilePath() {
2118
+ return join6(tmpdir(), `tarsk-bash-${randomBytes2(8).toString("hex")}.log`);
2119
+ }
2120
+ var bashSchema = Type.Object({
2121
+ command: Type.String({ description: "Bash command to execute" }),
2122
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional)" }))
2123
+ });
2124
+ var defaultBashOperations = {
2125
+ exec: (command, cwd, { onData, signal, timeout, env }) => {
2126
+ return new Promise((resolve, reject) => {
2127
+ const { shell, args } = getShellConfig();
2128
+ if (!existsSync3(cwd)) {
2129
+ reject(new Error(`Working directory does not exist: ${cwd}`));
2130
+ return;
2131
+ }
2132
+ const child = spawn5(shell, [...args, command], {
2133
+ cwd,
2134
+ detached: true,
2135
+ env: env ?? getShellEnv(),
2136
+ stdio: ["ignore", "pipe", "pipe"]
2137
+ });
2138
+ let timedOut = false;
2139
+ let timeoutHandle;
2140
+ if (timeout !== undefined && timeout > 0) {
2141
+ timeoutHandle = setTimeout(() => {
2142
+ timedOut = true;
2143
+ if (child.pid)
2144
+ killProcessTree(child.pid);
2145
+ }, timeout * 1000);
2146
+ }
2147
+ if (child.stdout)
2148
+ child.stdout.on("data", onData);
2149
+ if (child.stderr)
2150
+ child.stderr.on("data", onData);
2151
+ child.on("error", (err) => {
2152
+ if (timeoutHandle)
2153
+ clearTimeout(timeoutHandle);
2154
+ if (signal)
2155
+ signal.removeEventListener("abort", onAbort);
2156
+ reject(err);
2157
+ });
2158
+ const onAbort = () => {
2159
+ if (child.pid)
2160
+ killProcessTree(child.pid);
2161
+ };
2162
+ if (signal) {
2163
+ if (signal.aborted)
2164
+ onAbort();
2165
+ else
2166
+ signal.addEventListener("abort", onAbort, { once: true });
2167
+ }
2168
+ child.on("close", (code) => {
2169
+ if (timeoutHandle)
2170
+ clearTimeout(timeoutHandle);
2171
+ if (signal)
2172
+ signal.removeEventListener("abort", onAbort);
2173
+ if (signal?.aborted) {
2174
+ reject(new Error("aborted"));
2175
+ return;
2176
+ }
2177
+ if (timedOut) {
2178
+ reject(new Error(`timeout:${timeout}`));
2179
+ return;
2180
+ }
2181
+ resolve({ exitCode: code });
2182
+ });
2183
+ });
2184
+ }
2185
+ };
2186
+ function createBashTool(cwd, options) {
2187
+ const ops = options?.operations ?? defaultBashOperations;
2188
+ const commandPrefix = options?.commandPrefix;
2189
+ return {
2190
+ name: "bash",
2191
+ label: "bash",
2192
+ description: `Execute a bash command in the current working directory. Output truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB. Optionally provide a timeout in seconds.`,
2193
+ parameters: bashSchema,
2194
+ execute: async (_toolCallId, { command, timeout }, signal, onUpdate) => {
2195
+ const resolvedCommand = commandPrefix ? `${commandPrefix}
2196
+ ${command}` : command;
2197
+ return new Promise((resolve, reject) => {
2198
+ let tempFilePath;
2199
+ let tempFileStream;
2200
+ let totalBytes = 0;
2201
+ const chunks = [];
2202
+ let chunksBytes = 0;
2203
+ const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
2204
+ const handleData = (data) => {
2205
+ totalBytes += data.length;
2206
+ if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
2207
+ tempFilePath = getTempFilePath();
2208
+ tempFileStream = createWriteStream(tempFilePath);
2209
+ for (const chunk of chunks)
2210
+ tempFileStream.write(chunk);
2211
+ }
2212
+ if (tempFileStream)
2213
+ tempFileStream.write(data);
2214
+ chunks.push(data);
2215
+ chunksBytes += data.length;
2216
+ while (chunksBytes > maxChunksBytes && chunks.length > 1) {
2217
+ const removed = chunks.shift();
2218
+ chunksBytes -= removed.length;
2219
+ }
2220
+ if (onUpdate) {
2221
+ const fullText = Buffer.concat(chunks).toString("utf-8");
2222
+ const truncation = truncateTail(fullText);
2223
+ onUpdate({
2224
+ content: [{ type: "text", text: truncation.content || "" }],
2225
+ details: {
2226
+ truncation: truncation.truncated ? truncation : undefined,
2227
+ fullOutputPath: tempFilePath
2228
+ }
2229
+ });
2230
+ }
2231
+ };
2232
+ ops.exec(resolvedCommand, cwd, { onData: handleData, signal, timeout, env: getShellEnv() }).then(({ exitCode }) => {
2233
+ if (tempFileStream)
2234
+ tempFileStream.end();
2235
+ const fullOutput = Buffer.concat(chunks).toString("utf-8");
2236
+ const truncation = truncateTail(fullOutput);
2237
+ let outputText = truncation.content || "(no output)";
2238
+ let details;
2239
+ if (truncation.truncated) {
2240
+ details = { truncation, fullOutputPath: tempFilePath };
2241
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
2242
+ const endLine = truncation.totalLines;
2243
+ if (truncation.lastLinePartial) {
2244
+ outputText += `
2245
+
2246
+ [Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}. Full output: ${tempFilePath}]`;
2247
+ } else if (truncation.truncatedBy === "lines") {
2248
+ outputText += `
2249
+
2250
+ [Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
2251
+ } else {
2252
+ outputText += `
2253
+
2254
+ [Showing lines ${startLine}-${endLine} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
2255
+ }
2256
+ }
2257
+ if (exitCode !== 0 && exitCode !== null) {
2258
+ outputText += `
2259
+
2260
+ Command exited with code ${exitCode}`;
2261
+ reject(new Error(outputText));
2262
+ } else {
2263
+ resolve({ content: [{ type: "text", text: outputText }], details });
2264
+ }
2265
+ }).catch((err) => {
2266
+ if (tempFileStream)
2267
+ tempFileStream.end();
2268
+ const output = Buffer.concat(chunks).toString("utf-8");
2269
+ if (err.message === "aborted") {
2270
+ reject(new Error(output ? `${output}
2271
+
2272
+ Command aborted` : "Command aborted"));
2273
+ } else if (err.message.startsWith("timeout:")) {
2274
+ const secs = err.message.split(":")[1];
2275
+ reject(new Error(output ? `${output}
2276
+
2277
+ Command timed out after ${secs} seconds` : `Command timed out after ${secs} seconds`));
2278
+ } else {
2279
+ reject(err);
2280
+ }
2281
+ });
2282
+ });
2283
+ }
2284
+ };
2285
+ }
2286
+ var bashTool = createBashTool(process.cwd());
2287
+
2288
+ // src/tools/edit.ts
2289
+ import { Type as Type2 } from "@sinclair/typebox";
2290
+ import { constants as constants2 } from "fs";
2291
+ import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
2292
+
2293
+ // src/tools/edit-diff.ts
2294
+ import * as Diff from "diff";
2295
+ function detectLineEnding(content) {
2296
+ const crlfIdx = content.indexOf(`\r
2297
+ `);
2298
+ const lfIdx = content.indexOf(`
2299
+ `);
2300
+ if (lfIdx === -1)
2301
+ return `
2302
+ `;
2303
+ if (crlfIdx === -1)
2304
+ return `
2305
+ `;
2306
+ return crlfIdx < lfIdx ? `\r
2307
+ ` : `
2308
+ `;
2309
+ }
2310
+ function normalizeToLF(text) {
2311
+ return text.replace(/\r\n/g, `
2312
+ `).replace(/\r/g, `
2313
+ `);
2314
+ }
2315
+ function restoreLineEndings(text, ending) {
2316
+ return ending === `\r
2317
+ ` ? text.replace(/\n/g, `\r
2318
+ `) : text;
2319
+ }
2320
+ function normalizeForFuzzyMatch(text) {
2321
+ return text.split(`
2322
+ `).map((line) => line.trimEnd()).join(`
2323
+ `).replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"').replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-").replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
2324
+ }
2325
+ function fuzzyFindText(content, oldText) {
2326
+ const exactIndex = content.indexOf(oldText);
2327
+ if (exactIndex !== -1) {
2328
+ return {
2329
+ found: true,
2330
+ index: exactIndex,
2331
+ matchLength: oldText.length,
2332
+ usedFuzzyMatch: false,
2333
+ contentForReplacement: content
2334
+ };
2335
+ }
2336
+ const fuzzyContent = normalizeForFuzzyMatch(content);
2337
+ const fuzzyOldText = normalizeForFuzzyMatch(oldText);
2338
+ const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
2339
+ if (fuzzyIndex === -1) {
2340
+ return {
2341
+ found: false,
2342
+ index: -1,
2343
+ matchLength: 0,
2344
+ usedFuzzyMatch: false,
2345
+ contentForReplacement: content
2346
+ };
2347
+ }
2348
+ return {
2349
+ found: true,
2350
+ index: fuzzyIndex,
2351
+ matchLength: fuzzyOldText.length,
2352
+ usedFuzzyMatch: true,
2353
+ contentForReplacement: fuzzyContent
2354
+ };
2355
+ }
2356
+ function stripBom(content) {
2357
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
2358
+ }
2359
+ function generateDiffString(oldContent, newContent, contextLines = 4) {
2360
+ const parts = Diff.diffLines(oldContent, newContent);
2361
+ const output = [];
2362
+ const oldLines = oldContent.split(`
2363
+ `);
2364
+ const newLines = newContent.split(`
2365
+ `);
2366
+ const maxLineNum = Math.max(oldLines.length, newLines.length);
2367
+ const lineNumWidth = String(maxLineNum).length;
2368
+ let oldLineNum = 1;
2369
+ let newLineNum = 1;
2370
+ let lastWasChange = false;
2371
+ let firstChangedLine;
2372
+ for (let i = 0;i < parts.length; i++) {
2373
+ const part = parts[i];
2374
+ const raw = part.value.split(`
2375
+ `);
2376
+ if (raw[raw.length - 1] === "") {
2377
+ raw.pop();
2378
+ }
2379
+ if (part.added || part.removed) {
2380
+ if (firstChangedLine === undefined) {
2381
+ firstChangedLine = newLineNum;
2382
+ }
2383
+ for (const line of raw) {
2384
+ if (part.added) {
2385
+ output.push(`+${String(newLineNum).padStart(lineNumWidth, " ")} ${line}`);
2386
+ newLineNum++;
2387
+ } else {
2388
+ output.push(`-${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
2389
+ oldLineNum++;
2390
+ }
2391
+ }
2392
+ lastWasChange = true;
2393
+ } else {
2394
+ const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
2395
+ if (lastWasChange || nextPartIsChange) {
2396
+ let linesToShow = raw;
2397
+ let skipStart = 0;
2398
+ let skipEnd = 0;
2399
+ if (!lastWasChange) {
2400
+ skipStart = Math.max(0, raw.length - contextLines);
2401
+ linesToShow = raw.slice(skipStart);
2402
+ }
2403
+ if (!nextPartIsChange && linesToShow.length > contextLines) {
2404
+ skipEnd = linesToShow.length - contextLines;
2405
+ linesToShow = linesToShow.slice(0, contextLines);
2406
+ }
2407
+ if (skipStart > 0) {
2408
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
2409
+ oldLineNum += skipStart;
2410
+ newLineNum += skipStart;
2411
+ }
2412
+ for (const line of linesToShow) {
2413
+ output.push(` ${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
2414
+ oldLineNum++;
2415
+ newLineNum++;
2416
+ }
2417
+ if (skipEnd > 0) {
2418
+ output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
2419
+ oldLineNum += skipEnd;
2420
+ newLineNum += skipEnd;
2421
+ }
2422
+ } else {
2423
+ oldLineNum += raw.length;
2424
+ newLineNum += raw.length;
2425
+ }
2426
+ lastWasChange = false;
2427
+ }
2428
+ }
2429
+ return { diff: output.join(`
2430
+ `), firstChangedLine };
2431
+ }
2432
+
2433
+ // src/tools/path-utils.ts
2434
+ import { accessSync, constants } from "node:fs";
2435
+ import * as os from "node:os";
2436
+ import { isAbsolute, resolve as resolvePath } from "node:path";
2437
+ var UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
2438
+ var NARROW_NO_BREAK_SPACE = " ";
2439
+ function normalizeUnicodeSpaces(str) {
2440
+ return str.replace(UNICODE_SPACES, " ");
2441
+ }
2442
+ function tryMacOSScreenshotPath(filePath) {
2443
+ return filePath.replace(/ (AM|PM)\./g, `${NARROW_NO_BREAK_SPACE}$1.`);
2444
+ }
2445
+ function tryNFDVariant(filePath) {
2446
+ return filePath.normalize("NFD");
2447
+ }
2448
+ function tryCurlyQuoteVariant(filePath) {
2449
+ return filePath.replace(/'/g, "’");
2450
+ }
2451
+ function fileExists(filePath) {
2452
+ try {
2453
+ accessSync(filePath, constants.F_OK);
2454
+ return true;
2455
+ } catch {
2456
+ return false;
2457
+ }
2458
+ }
2459
+ function normalizeAtPrefix(filePath) {
2460
+ return filePath.startsWith("@") ? filePath.slice(1) : filePath;
2461
+ }
2462
+ function expandPath(filePath) {
2463
+ const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
2464
+ if (normalized === "~") {
2465
+ return os.homedir();
2466
+ }
2467
+ if (normalized.startsWith("~/")) {
2468
+ return os.homedir() + normalized.slice(1);
2469
+ }
2470
+ return normalized;
2471
+ }
2472
+ function resolveToCwd(filePath, cwd) {
2473
+ const expanded = expandPath(filePath);
2474
+ if (isAbsolute(expanded)) {
2475
+ return expanded;
2476
+ }
2477
+ return resolvePath(cwd, expanded);
2478
+ }
2479
+ function resolveReadPath(filePath, cwd) {
2480
+ const resolved = resolveToCwd(filePath, cwd);
2481
+ if (fileExists(resolved)) {
2482
+ return resolved;
2483
+ }
2484
+ const amPmVariant = tryMacOSScreenshotPath(resolved);
2485
+ if (amPmVariant !== resolved && fileExists(amPmVariant)) {
2486
+ return amPmVariant;
2487
+ }
2488
+ const nfdVariant = tryNFDVariant(resolved);
2489
+ if (nfdVariant !== resolved && fileExists(nfdVariant)) {
2490
+ return nfdVariant;
2491
+ }
2492
+ const curlyVariant = tryCurlyQuoteVariant(resolved);
2493
+ if (curlyVariant !== resolved && fileExists(curlyVariant)) {
2494
+ return curlyVariant;
2495
+ }
2496
+ const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
2497
+ if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) {
2498
+ return nfdCurlyVariant;
2499
+ }
2500
+ return resolved;
2501
+ }
2502
+
2503
+ // src/tools/edit.ts
2504
+ var editSchema = Type2.Object({
2505
+ path: Type2.String({ description: "Path to the file to edit (relative or absolute)" }),
2506
+ oldText: Type2.String({ description: "Exact text to find and replace (must match exactly)" }),
2507
+ newText: Type2.String({ description: "New text to replace the old text with" })
2508
+ });
2509
+ var defaultEditOperations = {
2510
+ readFile: (path) => fsReadFile(path),
2511
+ writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
2512
+ access: (path) => fsAccess(path, constants2.R_OK | constants2.W_OK)
2513
+ };
2514
+ function createEditTool(cwd, options) {
2515
+ const ops = options?.operations ?? defaultEditOperations;
2516
+ return {
2517
+ name: "edit",
2518
+ label: "edit",
2519
+ description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
2520
+ parameters: editSchema,
2521
+ execute: async (_toolCallId, { path, oldText, newText }, signal) => {
2522
+ const absolutePath = resolveToCwd(path, cwd);
2523
+ return new Promise((resolve, reject) => {
2524
+ if (signal?.aborted) {
2525
+ reject(new Error("Operation aborted"));
2526
+ return;
2527
+ }
2528
+ let aborted = false;
2529
+ const onAbort = () => {
2530
+ aborted = true;
2531
+ reject(new Error("Operation aborted"));
2532
+ };
2533
+ if (signal)
2534
+ signal.addEventListener("abort", onAbort, { once: true });
2535
+ (async () => {
2536
+ try {
2537
+ try {
2538
+ await ops.access(absolutePath);
2539
+ } catch {
2540
+ if (signal)
2541
+ signal.removeEventListener("abort", onAbort);
2542
+ reject(new Error(`File not found: ${path}`));
2543
+ return;
2544
+ }
2545
+ if (aborted)
2546
+ return;
2547
+ const buffer = await ops.readFile(absolutePath);
2548
+ const rawContent = buffer.toString("utf-8");
2549
+ if (aborted)
2550
+ return;
2551
+ const { bom, text: content } = stripBom(rawContent);
2552
+ const originalEnding = detectLineEnding(content);
2553
+ const normalizedContent = normalizeToLF(content);
2554
+ const normalizedOldText = normalizeToLF(oldText);
2555
+ const normalizedNewText = normalizeToLF(newText);
2556
+ const matchResult = fuzzyFindText(normalizedContent, normalizedOldText);
2557
+ if (!matchResult.found) {
2558
+ if (signal)
2559
+ signal.removeEventListener("abort", onAbort);
2560
+ reject(new Error(`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`));
2561
+ return;
2562
+ }
2563
+ const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
2564
+ const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText);
2565
+ const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
2566
+ if (occurrences > 1) {
2567
+ if (signal)
2568
+ signal.removeEventListener("abort", onAbort);
2569
+ reject(new Error(`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`));
2570
+ return;
2571
+ }
2572
+ if (aborted)
2573
+ return;
2574
+ const baseContent = matchResult.contentForReplacement;
2575
+ const newContent = baseContent.substring(0, matchResult.index) + normalizedNewText + baseContent.substring(matchResult.index + matchResult.matchLength);
2576
+ if (baseContent === newContent) {
2577
+ if (signal)
2578
+ signal.removeEventListener("abort", onAbort);
2579
+ reject(new Error(`No changes made to ${path}. The replacement produced identical content.`));
2580
+ return;
2581
+ }
2582
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
2583
+ await ops.writeFile(absolutePath, finalContent);
2584
+ if (aborted)
2585
+ return;
2586
+ if (signal)
2587
+ signal.removeEventListener("abort", onAbort);
2588
+ const diffResult = generateDiffString(baseContent, newContent);
2589
+ resolve({
2590
+ content: [{ type: "text", text: `Successfully replaced text in ${path}.` }],
2591
+ details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }
2592
+ });
2593
+ } catch (error) {
2594
+ if (signal)
2595
+ signal.removeEventListener("abort", onAbort);
2596
+ if (!aborted)
2597
+ reject(error);
2598
+ }
2599
+ })();
2600
+ });
2601
+ }
2602
+ };
2603
+ }
2604
+ var editTool = createEditTool(process.cwd());
2605
+
2606
+ // src/tools/find.ts
2607
+ import { Type as Type3 } from "@sinclair/typebox";
2608
+ import { existsSync as existsSync4 } from "fs";
2609
+ import path from "path";
2610
+ import { globSync } from "glob";
2611
+ var findSchema = Type3.Object({
2612
+ pattern: Type3.String({
2613
+ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
2614
+ }),
2615
+ path: Type3.Optional(Type3.String({ description: "Directory to search in (default: current directory)" })),
2616
+ limit: Type3.Optional(Type3.Number({ description: "Maximum number of results (default: 1000)" }))
2617
+ });
2618
+ var DEFAULT_LIMIT = 1000;
2619
+ var defaultFindOperations = {
2620
+ exists: existsSync4,
2621
+ glob: async (pattern, searchCwd, { ignore, limit }) => {
2622
+ const results = [];
2623
+ try {
2624
+ const found = globSync(pattern, {
2625
+ cwd: searchCwd,
2626
+ dot: true,
2627
+ ignore: ["**/node_modules/**", "**/.git/**", ...ignore],
2628
+ mark: false
2629
+ });
2630
+ for (let i = 0;i < Math.min(found.length, limit); i++) {
2631
+ results.push(path.join(searchCwd, found[i]));
2632
+ }
2633
+ } catch {}
2634
+ return results;
2635
+ }
2636
+ };
2637
+ function createFindTool(cwd, options) {
2638
+ const customOps = options?.operations;
2639
+ return {
2640
+ name: "find",
2641
+ label: "find",
2642
+ description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Output truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB.`,
2643
+ parameters: findSchema,
2644
+ execute: async (_toolCallId, { pattern, path: searchDir, limit }, signal) => {
2645
+ return new Promise((resolve, reject) => {
2646
+ if (signal?.aborted) {
2647
+ reject(new Error("Operation aborted"));
2648
+ return;
2649
+ }
2650
+ const onAbort = () => reject(new Error("Operation aborted"));
2651
+ signal?.addEventListener("abort", onAbort, { once: true });
2652
+ (async () => {
2653
+ try {
2654
+ const searchPath = resolveToCwd(searchDir || ".", cwd);
2655
+ const effectiveLimit = limit ?? DEFAULT_LIMIT;
2656
+ const ops = customOps ?? defaultFindOperations;
2657
+ if (!await ops.exists(searchPath)) {
2658
+ reject(new Error(`Path not found: ${searchPath}`));
2659
+ return;
2660
+ }
2661
+ const results = await ops.glob(pattern, searchPath, {
2662
+ ignore: ["**/node_modules/**", "**/.git/**"],
2663
+ limit: effectiveLimit
2664
+ });
2665
+ signal?.removeEventListener("abort", onAbort);
2666
+ if (results.length === 0) {
2667
+ resolve({
2668
+ content: [{ type: "text", text: "No files found matching pattern" }],
2669
+ details: undefined
2670
+ });
2671
+ return;
2672
+ }
2673
+ const relativized = results.map((p) => {
2674
+ if (p.startsWith(searchPath + path.sep) || p.startsWith(searchPath + "/")) {
2675
+ return p.slice(searchPath.length + 1).replace(/\\/g, "/");
2676
+ }
2677
+ return path.relative(searchPath, p).replace(/\\/g, "/");
2678
+ });
2679
+ const resultLimitReached = relativized.length >= effectiveLimit;
2680
+ const rawOutput = relativized.join(`
2681
+ `);
2682
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
2683
+ let resultOutput = truncation.content;
2684
+ const details = {};
2685
+ const notices = [];
2686
+ if (resultLimitReached) {
2687
+ notices.push(`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`);
2688
+ details.resultLimitReached = effectiveLimit;
2689
+ }
2690
+ if (truncation.truncated) {
2691
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
2692
+ details.truncation = truncation;
2693
+ }
2694
+ if (notices.length > 0)
2695
+ resultOutput += `
2696
+
2697
+ [${notices.join(". ")}]`;
2698
+ resolve({
2699
+ content: [{ type: "text", text: resultOutput }],
2700
+ details: Object.keys(details).length > 0 ? details : undefined
2701
+ });
2702
+ } catch (e) {
2703
+ signal?.removeEventListener("abort", onAbort);
2704
+ reject(e);
2705
+ }
2706
+ })();
2707
+ });
2708
+ }
2709
+ };
2710
+ }
2711
+ var findTool = createFindTool(process.cwd());
2712
+
2713
+ // src/tools/grep.ts
2714
+ import { createInterface as createInterface2 } from "node:readline";
2715
+ import { Type as Type4 } from "@sinclair/typebox";
2716
+ import { spawn as spawn6 } from "child_process";
2717
+ import { readFileSync as readFileSync2, statSync } from "fs";
2718
+ import path2 from "path";
2719
+
2720
+ // src/tools/resolve-bin.ts
2721
+ import { spawnSync as spawnSync4 } from "node:child_process";
2722
+ function resolveBin(name) {
2723
+ return new Promise((resolve) => {
2724
+ const cmd = process.platform === "win32" ? "where" : "which";
2725
+ const args = process.platform === "win32" ? [name + ".exe", name] : [name];
2726
+ try {
2727
+ const result = spawnSync4(cmd, args, { encoding: "utf-8", timeout: 5000 });
2728
+ if (result.status === 0 && result.stdout) {
2729
+ const first = result.stdout.trim().split(/\r?\n/)[0]?.trim();
2730
+ if (first) {
2731
+ resolve(first);
2732
+ return;
2733
+ }
2734
+ }
2735
+ } catch {}
2736
+ resolve(null);
2737
+ });
2738
+ }
2739
+
2740
+ // src/tools/grep.ts
2741
+ var grepSchema = Type4.Object({
2742
+ pattern: Type4.String({ description: "Search pattern (regex or literal string)" }),
2743
+ path: Type4.Optional(Type4.String({ description: "Directory or file to search (default: current directory)" })),
2744
+ glob: Type4.Optional(Type4.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })),
2745
+ ignoreCase: Type4.Optional(Type4.Boolean({ description: "Case-insensitive search (default: false)" })),
2746
+ literal: Type4.Optional(Type4.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" })),
2747
+ context: Type4.Optional(Type4.Number({ description: "Number of lines before/after each match (default: 0)" })),
2748
+ limit: Type4.Optional(Type4.Number({ description: "Maximum number of matches to return (default: 100)" }))
2749
+ });
2750
+ var DEFAULT_LIMIT2 = 100;
2751
+ var defaultGrepOperations = {
2752
+ isDirectory: (p) => statSync(p).isDirectory(),
2753
+ readFile: (p) => readFileSync2(p, "utf-8")
2754
+ };
2755
+ function createGrepTool(cwd, options) {
2756
+ const customOps = options?.operations;
2757
+ return {
2758
+ name: "grep",
2759
+ label: "grep",
2760
+ description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output truncated to ${DEFAULT_LIMIT2} matches or ${DEFAULT_MAX_BYTES / 1024}KB. Long lines truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
2761
+ parameters: grepSchema,
2762
+ execute: async (_toolCallId, { pattern, path: searchDir, glob, ignoreCase, literal, context, limit }, signal) => {
2763
+ return new Promise((resolve, reject) => {
2764
+ if (signal?.aborted) {
2765
+ reject(new Error("Operation aborted"));
2766
+ return;
2767
+ }
2768
+ let settled = false;
2769
+ const settle = (fn) => {
2770
+ if (!settled) {
2771
+ settled = true;
2772
+ fn();
2773
+ }
2774
+ };
2775
+ (async () => {
2776
+ try {
2777
+ const rgPath = await resolveBin("rg");
2778
+ if (!rgPath) {
2779
+ settle(() => reject(new Error("ripgrep (rg) is not available. Install rg or add it to PATH.")));
2780
+ return;
2781
+ }
2782
+ const searchPath = resolveToCwd(searchDir || ".", cwd);
2783
+ const ops = customOps ?? defaultGrepOperations;
2784
+ let isDirectory;
2785
+ try {
2786
+ isDirectory = await ops.isDirectory(searchPath);
2787
+ } catch {
2788
+ settle(() => reject(new Error(`Path not found: ${searchPath}`)));
2789
+ return;
2790
+ }
2791
+ const contextValue = context && context > 0 ? context : 0;
2792
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT2);
2793
+ const formatPath = (filePath) => {
2794
+ if (isDirectory) {
2795
+ const relative2 = path2.relative(searchPath, filePath);
2796
+ if (relative2 && !relative2.startsWith("..")) {
2797
+ return relative2.replace(/\\/g, "/");
2798
+ }
2799
+ }
2800
+ return path2.basename(filePath);
2801
+ };
2802
+ const fileCache = new Map;
2803
+ const getFileLines = async (filePath) => {
2804
+ let lines = fileCache.get(filePath);
2805
+ if (!lines) {
2806
+ try {
2807
+ const content = await ops.readFile(filePath);
2808
+ lines = content.replace(/\r\n/g, `
2809
+ `).replace(/\r/g, `
2810
+ `).split(`
2811
+ `);
2812
+ } catch {
2813
+ lines = [];
2814
+ }
2815
+ fileCache.set(filePath, lines);
2816
+ }
2817
+ return lines;
2818
+ };
2819
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
2820
+ if (ignoreCase)
2821
+ args.push("--ignore-case");
2822
+ if (literal)
2823
+ args.push("--fixed-strings");
2824
+ if (glob)
2825
+ args.push("--glob", glob);
2826
+ args.push(pattern, searchPath);
2827
+ const child = spawn6(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
2828
+ const rl = createInterface2({ input: child.stdout });
2829
+ let stderr = "";
2830
+ let matchCount = 0;
2831
+ let matchLimitReached = false;
2832
+ let linesTruncated = false;
2833
+ let aborted = false;
2834
+ let killedDueToLimit = false;
2835
+ const outputLines = [];
2836
+ const cleanup = () => {
2837
+ rl.close();
2838
+ signal?.removeEventListener("abort", onAbort);
2839
+ };
2840
+ const stopChild = (dueToLimit = false) => {
2841
+ if (!child.killed) {
2842
+ killedDueToLimit = dueToLimit;
2843
+ child.kill();
2844
+ }
2845
+ };
2846
+ const onAbort = () => {
2847
+ aborted = true;
2848
+ stopChild();
2849
+ };
2850
+ signal?.addEventListener("abort", onAbort, { once: true });
2851
+ child.stderr?.on("data", (chunk) => {
2852
+ stderr += chunk.toString();
2853
+ });
2854
+ const formatBlock = async (filePath, lineNumber) => {
2855
+ const relativePath = formatPath(filePath);
2856
+ const lines = await getFileLines(filePath);
2857
+ if (!lines.length) {
2858
+ return [`${relativePath}:${lineNumber}: (unable to read file)`];
2859
+ }
2860
+ const block = [];
2861
+ const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber;
2862
+ const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber;
2863
+ for (let current = start;current <= end; current++) {
2864
+ const lineText = lines[current - 1] ?? "";
2865
+ const sanitized = lineText.replace(/\r/g, "");
2866
+ const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
2867
+ if (wasTruncated)
2868
+ linesTruncated = true;
2869
+ const isMatchLine = current === lineNumber;
2870
+ if (isMatchLine) {
2871
+ block.push(`${relativePath}:${current}: ${truncatedText}`);
2872
+ } else {
2873
+ block.push(`${relativePath}-${current}- ${truncatedText}`);
2874
+ }
2875
+ }
2876
+ return block;
2877
+ };
2878
+ const matches = [];
2879
+ rl.on("line", (line) => {
2880
+ if (!line.trim() || matchCount >= effectiveLimit)
2881
+ return;
2882
+ try {
2883
+ const event = JSON.parse(line);
2884
+ if (event.type === "match") {
2885
+ matchCount++;
2886
+ const filePath = event.data?.path?.text;
2887
+ const lineNumber = event.data?.line_number;
2888
+ if (filePath != null && typeof lineNumber === "number") {
2889
+ matches.push({ filePath, lineNumber });
2890
+ }
2891
+ if (matchCount >= effectiveLimit) {
2892
+ matchLimitReached = true;
2893
+ stopChild(true);
2894
+ }
2895
+ }
2896
+ } catch {}
2897
+ });
2898
+ child.on("error", (error) => {
2899
+ cleanup();
2900
+ settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
2901
+ });
2902
+ child.on("close", async (code) => {
2903
+ cleanup();
2904
+ if (aborted) {
2905
+ settle(() => reject(new Error("Operation aborted")));
2906
+ return;
2907
+ }
2908
+ if (!killedDueToLimit && code !== 0 && code !== 1) {
2909
+ settle(() => reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`)));
2910
+ return;
2911
+ }
2912
+ if (matchCount === 0) {
2913
+ settle(() => resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }));
2914
+ return;
2915
+ }
2916
+ for (const match of matches) {
2917
+ const block = await formatBlock(match.filePath, match.lineNumber);
2918
+ outputLines.push(...block);
2919
+ }
2920
+ const rawOutput = outputLines.join(`
2921
+ `);
2922
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
2923
+ let output = truncation.content;
2924
+ const details = {};
2925
+ const notices = [];
2926
+ if (matchLimitReached) {
2927
+ notices.push(`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`);
2928
+ details.matchLimitReached = effectiveLimit;
2929
+ }
2930
+ if (truncation.truncated) {
2931
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
2932
+ details.truncation = truncation;
2933
+ }
2934
+ if (linesTruncated) {
2935
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`);
2936
+ details.linesTruncated = true;
2937
+ }
2938
+ if (notices.length > 0)
2939
+ output += `
2940
+
2941
+ [${notices.join(". ")}]`;
2942
+ settle(() => resolve({
2943
+ content: [{ type: "text", text: output }],
2944
+ details: Object.keys(details).length > 0 ? details : undefined
2945
+ }));
2946
+ });
2947
+ } catch (err) {
2948
+ settle(() => reject(err));
2949
+ }
2950
+ })();
2951
+ });
2952
+ }
2953
+ };
2954
+ }
2955
+ var grepTool = createGrepTool(process.cwd());
2956
+
2957
+ // src/tools/ls.ts
2958
+ import { Type as Type5 } from "@sinclair/typebox";
2959
+ import { existsSync as existsSync5, readdirSync, statSync as statSync2 } from "fs";
2960
+ import nodePath from "path";
2961
+ var lsSchema = Type5.Object({
2962
+ path: Type5.Optional(Type5.String({ description: "Directory to list (default: current directory)" })),
2963
+ limit: Type5.Optional(Type5.Number({ description: "Maximum number of entries to return (default: 500)" }))
2964
+ });
2965
+ var DEFAULT_LIMIT3 = 500;
2966
+ var defaultLsOperations = {
2967
+ exists: existsSync5,
2968
+ stat: statSync2,
2969
+ readdir: readdirSync
2970
+ };
2971
+ function createLsTool(cwd, options) {
2972
+ const ops = options?.operations ?? defaultLsOperations;
2973
+ return {
2974
+ name: "ls",
2975
+ label: "ls",
2976
+ description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Output truncated to ${DEFAULT_LIMIT3} entries or ${DEFAULT_MAX_BYTES / 1024}KB.`,
2977
+ parameters: lsSchema,
2978
+ execute: async (_toolCallId, { path: dirPathArg, limit }, signal) => {
2979
+ return new Promise((resolve, reject) => {
2980
+ if (signal?.aborted) {
2981
+ reject(new Error("Operation aborted"));
2982
+ return;
2983
+ }
2984
+ const onAbort = () => reject(new Error("Operation aborted"));
2985
+ signal?.addEventListener("abort", onAbort, { once: true });
2986
+ (async () => {
2987
+ try {
2988
+ const dirPath = resolveToCwd(dirPathArg || ".", cwd);
2989
+ const effectiveLimit = limit ?? DEFAULT_LIMIT3;
2990
+ if (!await ops.exists(dirPath)) {
2991
+ reject(new Error(`Path not found: ${dirPath}`));
2992
+ return;
2993
+ }
2994
+ const stat = await ops.stat(dirPath);
2995
+ if (!stat.isDirectory()) {
2996
+ reject(new Error(`Not a directory: ${dirPath}`));
2997
+ return;
2998
+ }
2999
+ let entries;
3000
+ try {
3001
+ entries = await ops.readdir(dirPath);
3002
+ } catch (e) {
3003
+ reject(new Error(`Cannot read directory: ${e instanceof Error ? e.message : String(e)}`));
3004
+ return;
3005
+ }
3006
+ entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
3007
+ const results = [];
3008
+ let entryLimitReached = false;
3009
+ for (const entry of entries) {
3010
+ if (results.length >= effectiveLimit) {
3011
+ entryLimitReached = true;
3012
+ break;
3013
+ }
3014
+ const fullPath = nodePath.join(dirPath, entry);
3015
+ try {
3016
+ const entryStat = await ops.stat(fullPath);
3017
+ results.push(entry + (entryStat.isDirectory() ? "/" : ""));
3018
+ } catch {
3019
+ continue;
3020
+ }
3021
+ }
3022
+ signal?.removeEventListener("abort", onAbort);
3023
+ if (results.length === 0) {
3024
+ resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
3025
+ return;
3026
+ }
3027
+ const rawOutput = results.join(`
3028
+ `);
3029
+ const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
3030
+ let output = truncation.content;
3031
+ const details = {};
3032
+ const notices = [];
3033
+ if (entryLimitReached) {
3034
+ notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
3035
+ details.entryLimitReached = effectiveLimit;
3036
+ }
3037
+ if (truncation.truncated) {
3038
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
3039
+ details.truncation = truncation;
3040
+ }
3041
+ if (notices.length > 0)
3042
+ output += `
3043
+
3044
+ [${notices.join(". ")}]`;
3045
+ resolve({
3046
+ content: [{ type: "text", text: output }],
3047
+ details: Object.keys(details).length > 0 ? details : undefined
3048
+ });
3049
+ } catch (e) {
3050
+ signal?.removeEventListener("abort", onAbort);
3051
+ reject(e);
3052
+ }
3053
+ })();
3054
+ });
3055
+ }
3056
+ };
3057
+ }
3058
+ var lsTool = createLsTool(process.cwd());
3059
+
3060
+ // src/tools/read.ts
3061
+ import { Type as Type6 } from "@sinclair/typebox";
3062
+ import { constants as constants3 } from "fs";
3063
+ import { access as fsAccess2, readFile as fsReadFile2 } from "fs/promises";
3064
+ var readSchema = Type6.Object({
3065
+ path: Type6.String({ description: "Path to the file to read (relative or absolute)" }),
3066
+ offset: Type6.Optional(Type6.Number({ description: "Line number to start reading from (1-indexed)" })),
3067
+ limit: Type6.Optional(Type6.Number({ description: "Maximum number of lines to read" }))
3068
+ });
3069
+ var defaultReadOperations = {
3070
+ readFile: (path3) => fsReadFile2(path3),
3071
+ access: (path3) => fsAccess2(path3, constants3.R_OK)
3072
+ };
3073
+ function createReadTool(cwd, options) {
3074
+ const ops = options?.operations ?? defaultReadOperations;
3075
+ return {
3076
+ name: "read",
3077
+ label: "read",
3078
+ description: `Read the contents of a text file. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
3079
+ parameters: readSchema,
3080
+ execute: async (_toolCallId, { path: path3, offset, limit }, signal) => {
3081
+ const absolutePath = resolveReadPath(path3, cwd);
3082
+ return new Promise((resolve, reject) => {
3083
+ if (signal?.aborted) {
3084
+ reject(new Error("Operation aborted"));
3085
+ return;
3086
+ }
3087
+ let aborted = false;
3088
+ const onAbort = () => {
3089
+ aborted = true;
3090
+ reject(new Error("Operation aborted"));
3091
+ };
3092
+ if (signal) {
3093
+ signal.addEventListener("abort", onAbort, { once: true });
3094
+ }
3095
+ (async () => {
3096
+ try {
3097
+ await ops.access(absolutePath);
3098
+ if (aborted)
3099
+ return;
3100
+ const buffer = await ops.readFile(absolutePath);
3101
+ const textContent = buffer.toString("utf-8");
3102
+ const allLines = textContent.split(`
3103
+ `);
3104
+ const totalFileLines = allLines.length;
3105
+ const startLine = offset ? Math.max(0, offset - 1) : 0;
3106
+ const startLineDisplay = startLine + 1;
3107
+ if (startLine >= allLines.length) {
3108
+ throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
3109
+ }
3110
+ let selectedContent;
3111
+ let userLimitedLines;
3112
+ if (limit !== undefined) {
3113
+ const endLine = Math.min(startLine + limit, allLines.length);
3114
+ selectedContent = allLines.slice(startLine, endLine).join(`
3115
+ `);
3116
+ userLimitedLines = endLine - startLine;
3117
+ } else {
3118
+ selectedContent = allLines.slice(startLine).join(`
3119
+ `);
3120
+ }
3121
+ const truncation = truncateHead(selectedContent);
3122
+ let outputText;
3123
+ let details;
3124
+ if (truncation.firstLineExceedsLimit) {
3125
+ outputText = `[Line ${startLineDisplay} is ${formatSize(Buffer.byteLength(allLines[startLine], "utf-8"))}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path3} | head -c ${DEFAULT_MAX_BYTES}]`;
3126
+ details = { truncation };
3127
+ } else if (truncation.truncated) {
3128
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
3129
+ const nextOffset = endLineDisplay + 1;
3130
+ outputText = truncation.content;
3131
+ if (truncation.truncatedBy === "lines") {
3132
+ outputText += `
3133
+
3134
+ [Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
3135
+ } else {
3136
+ outputText += `
3137
+
3138
+ [Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
3139
+ }
3140
+ details = { truncation };
3141
+ } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
3142
+ const remaining = allLines.length - (startLine + userLimitedLines);
3143
+ const nextOffset = startLine + userLimitedLines + 1;
3144
+ outputText = truncation.content;
3145
+ outputText += `
3146
+
3147
+ [${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
3148
+ } else {
3149
+ outputText = truncation.content;
3150
+ }
3151
+ if (aborted)
3152
+ return;
3153
+ if (signal)
3154
+ signal.removeEventListener("abort", onAbort);
3155
+ resolve({ content: [{ type: "text", text: outputText }], details });
3156
+ } catch (error) {
3157
+ if (signal)
3158
+ signal.removeEventListener("abort", onAbort);
3159
+ if (!aborted)
3160
+ reject(error);
3161
+ }
3162
+ })();
3163
+ });
3164
+ }
3165
+ };
1852
3166
  }
3167
+ var readTool = createReadTool(process.cwd());
1853
3168
 
1854
- // src/managers/neovate-executor.ts
1855
- import {
1856
- createSession
1857
- } from "@neovate/code";
1858
- import { resolve } from "path";
3169
+ // src/tools/write.ts
3170
+ import { Type as Type7 } from "@sinclair/typebox";
3171
+ import { mkdir as fsMkdir, writeFile as fsWriteFile2 } from "fs/promises";
3172
+ import { dirname as dirname4 } from "path";
3173
+ var writeSchema = Type7.Object({
3174
+ path: Type7.String({ description: "Path to the file to write (relative or absolute)" }),
3175
+ content: Type7.String({ description: "Content to write to the file" })
3176
+ });
3177
+ var defaultWriteOperations = {
3178
+ writeFile: (path3, content) => fsWriteFile2(path3, content, "utf-8"),
3179
+ mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {})
3180
+ };
3181
+ function createWriteTool(cwd, options) {
3182
+ const ops = options?.operations ?? defaultWriteOperations;
3183
+ return {
3184
+ name: "write",
3185
+ label: "write",
3186
+ description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
3187
+ parameters: writeSchema,
3188
+ execute: async (_toolCallId, { path: path3, content }, signal) => {
3189
+ const absolutePath = resolveToCwd(path3, cwd);
3190
+ const dir = dirname4(absolutePath);
3191
+ return new Promise((resolve, reject) => {
3192
+ if (signal?.aborted) {
3193
+ reject(new Error("Operation aborted"));
3194
+ return;
3195
+ }
3196
+ let aborted = false;
3197
+ const onAbort = () => {
3198
+ aborted = true;
3199
+ reject(new Error("Operation aborted"));
3200
+ };
3201
+ if (signal)
3202
+ signal.addEventListener("abort", onAbort, { once: true });
3203
+ (async () => {
3204
+ try {
3205
+ await ops.mkdir(dir);
3206
+ if (aborted)
3207
+ return;
3208
+ await ops.writeFile(absolutePath, content);
3209
+ if (aborted)
3210
+ return;
3211
+ if (signal)
3212
+ signal.removeEventListener("abort", onAbort);
3213
+ resolve({
3214
+ content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path3}` }],
3215
+ details: undefined
3216
+ });
3217
+ } catch (error) {
3218
+ if (signal)
3219
+ signal.removeEventListener("abort", onAbort);
3220
+ if (!aborted)
3221
+ reject(error);
3222
+ }
3223
+ })();
3224
+ });
3225
+ }
3226
+ };
3227
+ }
3228
+ var writeTool = createWriteTool(process.cwd());
3229
+
3230
+ // src/tools/index.ts
3231
+ function createCodingTools(cwd, options) {
3232
+ return [
3233
+ createReadTool(cwd, options?.read),
3234
+ createBashTool(cwd, options?.bash),
3235
+ createEditTool(cwd),
3236
+ createWriteTool(cwd)
3237
+ ];
3238
+ }
1859
3239
 
1860
3240
  // src/provider.ts
1861
3241
  var PROVIDERS = [
@@ -2043,33 +3423,79 @@ var PROVIDERS = [
2043
3423
  }
2044
3424
  ];
2045
3425
 
2046
- // src/managers/neovate-executor.ts
3426
+ // src/managers/pi-executor.ts
3427
+ var PROVIDER_NAME_TO_PI = {
3428
+ anthropic: "anthropic",
3429
+ openai: "openai",
3430
+ google: "google",
3431
+ groq: "groq",
3432
+ cerebras: "cerebras",
3433
+ xai: "xai",
3434
+ openrouter: "openrouter",
3435
+ "github copilot": "github-copilot",
3436
+ minimax: "minimax",
3437
+ "kimi coding plan": "kimi-coding",
3438
+ "hugging face": "huggingface",
3439
+ codex: "openai-codex",
3440
+ mistral: "mistral"
3441
+ };
3442
+ function determineApiType(apiUrl) {
3443
+ if (apiUrl.includes("/anthropic/"))
3444
+ return "anthropic-messages";
3445
+ if (apiUrl.includes("api.anthropic.com"))
3446
+ return "anthropic-messages";
3447
+ return "openai-completions";
3448
+ }
3449
+ function resolveModel(providerName, modelId, providerConfig) {
3450
+ const piProvider = PROVIDER_NAME_TO_PI[providerName.toLowerCase()];
3451
+ if (piProvider) {
3452
+ try {
3453
+ return getModel(piProvider, modelId);
3454
+ } catch {}
3455
+ }
3456
+ const baseUrl = providerConfig.api;
3457
+ if (!baseUrl) {
3458
+ throw new Error(`No API URL configured for provider: ${providerName}`);
3459
+ }
3460
+ const apiType = determineApiType(baseUrl);
3461
+ return {
3462
+ id: modelId,
3463
+ name: modelId,
3464
+ api: apiType,
3465
+ provider: providerName.toLowerCase(),
3466
+ baseUrl,
3467
+ reasoning: false,
3468
+ input: ["text"],
3469
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3470
+ contextWindow: 128000,
3471
+ maxTokens: 16384
3472
+ };
3473
+ }
3474
+ function extractTextFromAssistantMessage(msg) {
3475
+ return msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
3476
+ }
2047
3477
  var getErrorCode = (error) => {
2048
3478
  if (error && typeof error === "object" && "code" in error) {
2049
3479
  const code = error.code;
2050
- if (typeof code === "string") {
3480
+ if (typeof code === "string")
2051
3481
  return code;
2052
- }
2053
3482
  }
2054
3483
  return;
2055
3484
  };
2056
3485
 
2057
- class NeovateExecutorImpl {
3486
+ class PiExecutorImpl {
2058
3487
  metadataManager;
2059
3488
  constructor(metadataManager) {
2060
3489
  this.metadataManager = metadataManager;
2061
3490
  }
2062
3491
  async* execute(userPrompt, context) {
2063
- let session = null;
2064
3492
  try {
2065
3493
  const providerName = context.provider;
2066
3494
  if (!providerName) {
2067
- console.error(`[ai] No provider specified in context`);
2068
3495
  throw new Error("No provider specified in execution context");
2069
3496
  }
2070
3497
  let model = context.model?.trim();
2071
3498
  if (!model) {
2072
- console.warn("[ai] No model provided in context, using default");
2073
3499
  throw new Error("No model specified in execution context");
2074
3500
  }
2075
3501
  model = model.replace(`${providerName.toLowerCase()}/`, "");
@@ -2095,59 +3521,126 @@ class NeovateExecutorImpl {
2095
3521
  throw new Error(`No API key found for provider: ${providerName}. ` + `Set ${providerConfig.keyName || "an API key"} in environment variables or configuration.`);
2096
3522
  }
2097
3523
  console.log(`[ai] API key source for ${providerName}: ${apiKeySource}`);
2098
- const api = providerConfig.api;
2099
- if (!api) {
2100
- throw new Error(`No API URL configured for provider: ${providerName}`);
2101
- }
2102
- const providers = {
2103
- tarsk: {
2104
- api,
2105
- options: { apiKey },
2106
- models: { [model]: model }
2107
- }
2108
- };
2109
- const sessionConfig = {
2110
- model: `tarsk/${model}`,
2111
- cwd,
2112
- productName: "Tarsk.io",
2113
- providers
2114
- };
2115
- console.log("[ai] Creating session:", JSON.stringify(sessionConfig));
2116
- console.log("[ai] Creating session with model:", model);
2117
- console.log("[ai] Session config has model:", !!sessionConfig.model);
2118
- session = await createSession(sessionConfig);
2119
- if (!session) {
2120
- throw new Error("Failed to create session: createSession returned falsy value");
2121
- }
2122
- console.log("[ai] Session created:", session.sessionId);
2123
- console.log("[ai] Sending prompt to session...");
2124
- await session.send(userPrompt);
2125
- console.log("[ai] Prompt sent, waiting for responses...");
2126
- let lastMessageContent = null;
2127
- for await (const msg of session.receive()) {
2128
- const contentLength = typeof msg?.content === "string" ? msg.content.length : undefined;
2129
- console.log("[ai] Received msg:", { type: msg?.type, contentLength });
2130
- console.log("[ai] Msg:", msg);
2131
- if (msg?.type === "message") {
2132
- const messageContent = typeof msg.text === "string" ? msg.text : typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
2133
- lastMessageContent = messageContent;
2134
- yield {
3524
+ const resolvedModel = resolveModel(providerName, model, providerConfig);
3525
+ console.log("[ai] Resolved model:", {
3526
+ id: resolvedModel.id,
3527
+ api: resolvedModel.api,
3528
+ provider: resolvedModel.provider
3529
+ });
3530
+ const tools = createCodingTools(cwd);
3531
+ const agent = new Agent({
3532
+ initialState: {
3533
+ systemPrompt: "You are a helpful coding assistant. You have access to read, bash, edit, and write tools. Use them to explore and modify the codebase as needed.",
3534
+ model: resolvedModel,
3535
+ tools
3536
+ },
3537
+ getApiKey: async () => apiKey
3538
+ });
3539
+ const queue = [];
3540
+ let resolver = null;
3541
+ let done = false;
3542
+ let finalContent = "";
3543
+ let errorOccurred = false;
3544
+ const unsubscribe = agent.subscribe((event) => {
3545
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
3546
+ const delta = event.assistantMessageEvent.delta;
3547
+ finalContent += delta;
3548
+ queue.push({
2135
3549
  type: "message",
2136
- role: msg.role || "assistant",
2137
- content: messageContent
2138
- };
2139
- } else if (msg?.type === "result") {
2140
- const resultContent = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
2141
- if (resultContent !== lastMessageContent) {
2142
- yield {
2143
- type: "result",
2144
- content: resultContent
2145
- };
3550
+ role: "assistant",
3551
+ content: delta
3552
+ });
3553
+ } else if (event.type === "tool_execution_start") {
3554
+ queue.push({
3555
+ type: "message",
3556
+ role: "tool",
3557
+ content: JSON.stringify([
3558
+ {
3559
+ type: "tool_use",
3560
+ name: event.toolName,
3561
+ id: event.toolCallId,
3562
+ input: event.args
3563
+ }
3564
+ ])
3565
+ });
3566
+ } else if (event.type === "tool_execution_end") {
3567
+ const resultContent = event.result?.content?.filter((b) => b.type === "text").map((b) => b.text || "").join("") || JSON.stringify(event.result);
3568
+ queue.push({
3569
+ type: "message",
3570
+ role: "tool",
3571
+ content: JSON.stringify([
3572
+ {
3573
+ type: "tool-result",
3574
+ toolCallId: event.toolCallId,
3575
+ toolName: event.toolName,
3576
+ content: resultContent,
3577
+ isError: event.isError
3578
+ }
3579
+ ])
3580
+ });
3581
+ } else if (event.type === "agent_end") {
3582
+ const messages = event.messages;
3583
+ const lastAssistant = [...messages].reverse().find((m) => ("role" in m) && m.role === "assistant");
3584
+ if (lastAssistant) {
3585
+ const extracted = extractTextFromAssistantMessage(lastAssistant);
3586
+ if (extracted && extracted !== finalContent) {
3587
+ finalContent = extracted;
3588
+ }
3589
+ }
3590
+ }
3591
+ if (resolver) {
3592
+ resolver();
3593
+ resolver = null;
3594
+ }
3595
+ });
3596
+ const promptDone = agent.prompt(userPrompt).then(() => {
3597
+ done = true;
3598
+ if (resolver) {
3599
+ resolver();
3600
+ resolver = null;
3601
+ }
3602
+ }).catch((err) => {
3603
+ if (!errorOccurred) {
3604
+ errorOccurred = true;
3605
+ const errMessage = err instanceof Error ? err.message : String(err);
3606
+ queue.push({
3607
+ type: "error",
3608
+ content: errMessage,
3609
+ error: {
3610
+ code: getErrorCode(err) || "EXECUTION_ERROR",
3611
+ message: errMessage,
3612
+ details: {
3613
+ stack: err instanceof Error ? err.stack : undefined
3614
+ }
3615
+ }
3616
+ });
3617
+ }
3618
+ done = true;
3619
+ if (resolver) {
3620
+ resolver();
3621
+ resolver = null;
3622
+ }
3623
+ });
3624
+ try {
3625
+ while (!done || queue.length > 0) {
3626
+ if (queue.length > 0) {
3627
+ yield queue.shift();
2146
3628
  } else {
2147
- console.log("[ai] Skipping duplicate result event (same as message)");
3629
+ await new Promise((r) => {
3630
+ resolver = r;
3631
+ });
2148
3632
  }
2149
- break;
2150
3633
  }
3634
+ await promptDone;
3635
+ if (!errorOccurred && finalContent) {
3636
+ yield {
3637
+ type: "result",
3638
+ content: finalContent
3639
+ };
3640
+ }
3641
+ } finally {
3642
+ unsubscribe();
3643
+ agent.abort();
2151
3644
  }
2152
3645
  } catch (error) {
2153
3646
  console.error("[ai] Error during execution:", error);
@@ -2178,22 +3671,13 @@ class NeovateExecutorImpl {
2178
3671
  details: { stack: errStack, originalError: errDetails }
2179
3672
  }
2180
3673
  };
2181
- } finally {
2182
- if (session) {
2183
- try {
2184
- await session.close?.();
2185
- console.log("[ai] Session closed");
2186
- } catch (closeError) {
2187
- console.error("[ai] Error closing session:", closeError);
2188
- }
2189
- }
2190
3674
  }
2191
3675
  }
2192
3676
  }
2193
3677
 
2194
3678
  // src/managers/conversation-manager.ts
2195
3679
  import { promises as fs2 } from "fs";
2196
- import { join as join6, dirname as dirname4 } from "path";
3680
+ import { join as join7, dirname as dirname5 } from "path";
2197
3681
  import { randomUUID as randomUUID3 } from "crypto";
2198
3682
  var CONVERSATION_FILE = "conversation-history.json";
2199
3683
 
@@ -2267,13 +3751,13 @@ class ConversationManagerImpl {
2267
3751
  }
2268
3752
  async saveConversationHistory(threadPath, history) {
2269
3753
  const filePath = this.getConversationFilePath(threadPath);
2270
- await fs2.mkdir(dirname4(filePath), { recursive: true });
3754
+ await fs2.mkdir(dirname5(filePath), { recursive: true });
2271
3755
  await fs2.writeFile(filePath, JSON.stringify(history, null, 2), "utf-8");
2272
3756
  }
2273
3757
  getConversationFilePath(threadPath) {
2274
3758
  const threadId = threadPath.split(/[\\/]/).pop() || "unknown";
2275
- const conversationsDir = join6(this.metadataDir, "conversations");
2276
- return join6(conversationsDir, threadId, CONVERSATION_FILE);
3759
+ const conversationsDir = join7(this.metadataDir, "conversations");
3760
+ return join7(conversationsDir, threadId, CONVERSATION_FILE);
2277
3761
  }
2278
3762
  }
2279
3763
 
@@ -2961,7 +4445,7 @@ function delay(ms) {
2961
4445
  }
2962
4446
 
2963
4447
  // src/routes/chat.ts
2964
- function createChatRoutes(threadManager, neovateExecutor, conversationManager, processingStateManager) {
4448
+ function createChatRoutes(threadManager, agentExecutor, conversationManager, processingStateManager) {
2965
4449
  const router = new Hono3;
2966
4450
  router.post("/", async (c) => {
2967
4451
  try {
@@ -3061,7 +4545,7 @@ User: ${content}` : content;
3061
4545
  const capturedEvents = [];
3062
4546
  let fullContent = "";
3063
4547
  try {
3064
- for await (const event of neovateExecutor.execute(promptWithContext, context)) {
4548
+ for await (const event of agentExecutor.execute(promptWithContext, context)) {
3065
4549
  capturedEvents.push(event);
3066
4550
  if (event.type === "message" && event.content) {
3067
4551
  fullContent += event.content;
@@ -3278,9 +4762,9 @@ async function getAIHubMixCredits(apiKey) {
3278
4762
 
3279
4763
  // src/utils/env-manager.ts
3280
4764
  import { promises as fs3 } from "fs";
3281
- import { join as join7 } from "path";
4765
+ import { join as join8 } from "path";
3282
4766
  async function updateEnvFile(keyNames) {
3283
- const envPath = join7(process.cwd(), ".env");
4767
+ const envPath = join8(process.cwd(), ".env");
3284
4768
  let content = "";
3285
4769
  try {
3286
4770
  content = await fs3.readFile(envPath, "utf-8");
@@ -3311,7 +4795,7 @@ async function updateEnvFile(keyNames) {
3311
4795
  `, "utf-8");
3312
4796
  }
3313
4797
  async function readEnvFile() {
3314
- const envPath = join7(process.cwd(), ".env");
4798
+ const envPath = join8(process.cwd(), ".env");
3315
4799
  const envMap = {};
3316
4800
  try {
3317
4801
  const content = await fs3.readFile(envPath, "utf-8");
@@ -4097,9 +5581,106 @@ function createModelRoutes(metadataManager) {
4097
5581
 
4098
5582
  // src/routes/git.ts
4099
5583
  import { Hono as Hono6 } from "hono";
4100
- import { spawn as spawn4 } from "child_process";
4101
- import { resolve as resolve2 } from "path";
4102
- import { createSession as createSession2 } from "@neovate/code";
5584
+ import { spawn as spawn7 } from "child_process";
5585
+ import { existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync3 } from "fs";
5586
+ import { join as join10 } from "path";
5587
+ import { isAbsolute as isAbsolute2, normalize, resolve as resolve2 } from "path";
5588
+
5589
+ // src/paths.ts
5590
+ import { join as join9 } from "path";
5591
+ import { homedir as homedir2 } from "os";
5592
+ var APP_SUPPORT_DIR = join9(homedir2(), "Library", "Application Support", "Tarsk");
5593
+ var DATA_DIR = join9(APP_SUPPORT_DIR, "data");
5594
+ function getDataDir() {
5595
+ return DATA_DIR;
5596
+ }
5597
+
5598
+ // src/routes/git.ts
5599
+ import {
5600
+ completeSimple,
5601
+ getModel as getModel2
5602
+ } from "@mariozechner/pi-ai";
5603
+ var PROVIDER_NAME_TO_PI2 = {
5604
+ anthropic: "anthropic",
5605
+ openai: "openai",
5606
+ google: "google",
5607
+ groq: "groq",
5608
+ cerebras: "cerebras",
5609
+ xai: "xai",
5610
+ openrouter: "openrouter",
5611
+ "github copilot": "github-copilot",
5612
+ minimax: "minimax",
5613
+ "kimi coding plan": "kimi-coding",
5614
+ "hugging face": "huggingface",
5615
+ codex: "openai-codex",
5616
+ mistral: "mistral"
5617
+ };
5618
+ function resolveModelForGit(providerName, modelId, apiUrl) {
5619
+ const piProvider = PROVIDER_NAME_TO_PI2[providerName.toLowerCase()];
5620
+ if (piProvider) {
5621
+ try {
5622
+ return getModel2(piProvider, modelId);
5623
+ } catch {}
5624
+ }
5625
+ if (!apiUrl)
5626
+ throw new Error(`No API URL configured for provider: ${providerName}`);
5627
+ const apiType = apiUrl.includes("/anthropic/") || apiUrl.includes("api.anthropic.com") ? "anthropic-messages" : "openai-completions";
5628
+ return {
5629
+ id: modelId,
5630
+ name: modelId,
5631
+ api: apiType,
5632
+ provider: providerName.toLowerCase(),
5633
+ baseUrl: apiUrl,
5634
+ reasoning: false,
5635
+ input: ["text"],
5636
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5637
+ contextWindow: 128000,
5638
+ maxTokens: 16384
5639
+ };
5640
+ }
5641
+ function resolveProviderAndKey(metadataProviderKeys, preferredProvider) {
5642
+ if (preferredProvider) {
5643
+ const prov = PROVIDERS.find((p) => p.name.toLowerCase() === preferredProvider.toLowerCase());
5644
+ if (prov) {
5645
+ const envKey = prov.keyName ? process.env[prov.keyName] : undefined;
5646
+ const key = envKey || metadataProviderKeys[prov.name];
5647
+ if (key)
5648
+ return { provider: prov, apiKey: key };
5649
+ }
5650
+ }
5651
+ const providerPriority = ["Anthropic", "OpenAI", "OpenRouter", "Groq", "DeepSeek"];
5652
+ for (const name of providerPriority) {
5653
+ const prov = PROVIDERS.find((p) => p.name === name);
5654
+ if (prov?.keyName) {
5655
+ const envKey = process.env[prov.keyName];
5656
+ if (envKey)
5657
+ return { provider: prov, apiKey: envKey };
5658
+ }
5659
+ }
5660
+ for (const name of providerPriority) {
5661
+ const prov = PROVIDERS.find((p) => p.name === name);
5662
+ if (prov && metadataProviderKeys[prov.name]) {
5663
+ return { provider: prov, apiKey: metadataProviderKeys[prov.name] };
5664
+ }
5665
+ }
5666
+ for (const prov of PROVIDERS) {
5667
+ if (prov.api && prov.keyName) {
5668
+ const envKey = process.env[prov.keyName];
5669
+ if (envKey)
5670
+ return { provider: prov, apiKey: envKey };
5671
+ if (metadataProviderKeys[prov.name])
5672
+ return { provider: prov, apiKey: metadataProviderKeys[prov.name] };
5673
+ }
5674
+ }
5675
+ return null;
5676
+ }
5677
+ var DEFAULT_MODEL_MAP = {
5678
+ Anthropic: "claude-3-5-sonnet-20241022",
5679
+ OpenAI: "gpt-4o-mini",
5680
+ OpenRouter: "anthropic/claude-3.5-sonnet",
5681
+ Groq: "llama-3.3-70b-versatile",
5682
+ DeepSeek: "deepseek-chat"
5683
+ };
4103
5684
  async function generateCommitMessageWithAI(diff, metadataManager, model, provider) {
4104
5685
  const truncatedDiff = diff.length > 3000 ? diff.substring(0, 3000) + `
4105
5686
  ...(truncated)` : diff;
@@ -4114,126 +5695,61 @@ Git diff:
4114
5695
  ${truncatedDiff}
4115
5696
 
4116
5697
  Generate only the commit message, nothing else:`;
4117
- let session = null;
4118
5698
  try {
4119
5699
  const providerKeys = await metadataManager.getProviderKeys();
4120
- let selectedProvider = null;
4121
- let apiKey = null;
4122
- let selectedModel = model;
4123
- if (provider) {
4124
- selectedProvider = PROVIDERS.find((p) => p.name.toLowerCase() === provider.toLowerCase()) || null;
4125
- if (selectedProvider) {
4126
- if (selectedProvider.keyName) {
4127
- apiKey = process.env[selectedProvider.keyName] || null;
4128
- }
4129
- if (!apiKey) {
4130
- apiKey = providerKeys[selectedProvider.name] || null;
4131
- }
4132
- }
4133
- }
4134
- if (!selectedProvider || !apiKey) {
4135
- const providerPriority = ["Anthropic", "OpenAI", "OpenRouter", "Groq", "DeepSeek"];
4136
- for (const providerName of providerPriority) {
4137
- const prov = PROVIDERS.find((p) => p.name === providerName);
4138
- if (prov?.keyName) {
4139
- const envKey = process.env[prov.keyName];
4140
- if (envKey) {
4141
- selectedProvider = prov;
4142
- apiKey = envKey;
4143
- break;
4144
- }
4145
- }
4146
- }
4147
- if (!selectedProvider || !apiKey) {
4148
- for (const providerName of providerPriority) {
4149
- const prov = PROVIDERS.find((p) => p.name === providerName);
4150
- if (prov && providerKeys[prov.name]) {
4151
- selectedProvider = prov;
4152
- apiKey = providerKeys[prov.name];
4153
- break;
4154
- }
4155
- }
4156
- }
4157
- if (!selectedProvider || !apiKey) {
4158
- for (const prov of PROVIDERS) {
4159
- if (prov.api && prov.keyName) {
4160
- const envKey = process.env[prov.keyName];
4161
- if (envKey) {
4162
- selectedProvider = prov;
4163
- apiKey = envKey;
4164
- break;
4165
- }
4166
- if (providerKeys[prov.name]) {
4167
- selectedProvider = prov;
4168
- apiKey = providerKeys[prov.name];
4169
- break;
4170
- }
4171
- }
4172
- }
4173
- }
4174
- if (!selectedModel) {
4175
- const modelMap = {
4176
- Anthropic: "claude-3-5-sonnet-20241022",
4177
- OpenAI: "gpt-4o-mini",
4178
- OpenRouter: "anthropic/claude-3.5-sonnet",
4179
- Groq: "llama-3.3-70b-versatile",
4180
- DeepSeek: "deepseek-chat"
4181
- };
4182
- selectedModel = modelMap[selectedProvider?.name || ""] || "default";
4183
- }
4184
- }
4185
- if (!selectedProvider || !apiKey || !selectedProvider.api) {
5700
+ const resolved = resolveProviderAndKey(providerKeys, provider);
5701
+ if (!resolved || !resolved.provider.api) {
4186
5702
  throw new Error("No AI provider configured. Please configure an API key in settings.");
4187
5703
  }
4188
- if (selectedModel && selectedProvider) {
4189
- selectedModel = selectedModel.replace(`${selectedProvider.name.toLowerCase()}/`, "");
4190
- }
4191
- const finalModel = selectedModel || "default";
4192
- const providers = {
4193
- tarsk: {
4194
- api: selectedProvider.api,
4195
- options: { apiKey },
4196
- models: { [finalModel]: finalModel }
4197
- }
4198
- };
4199
- const sessionConfig = {
4200
- model: `tarsk/${finalModel}`,
4201
- cwd: process.cwd(),
4202
- productName: "Tarsk.io",
4203
- providers
4204
- };
4205
- session = await createSession2(sessionConfig);
4206
- await session.send(prompt);
4207
- let commitMessage = "";
4208
- for await (const msg of session.receive()) {
4209
- if (msg?.type === "message" || msg?.type === "result") {
4210
- const content = typeof msg.text === "string" ? msg.text : typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
4211
- commitMessage = content.trim();
4212
- if (msg?.type === "result")
4213
- break;
4214
- }
5704
+ let selectedModel = model;
5705
+ if (selectedModel) {
5706
+ selectedModel = selectedModel.replace(`${resolved.provider.name.toLowerCase()}/`, "");
5707
+ } else {
5708
+ selectedModel = DEFAULT_MODEL_MAP[resolved.provider.name] || "default";
4215
5709
  }
5710
+ const resolvedModel = resolveModelForGit(resolved.provider.name, selectedModel, resolved.provider.api);
5711
+ const response = await completeSimple(resolvedModel, {
5712
+ messages: [{ role: "user", content: prompt }]
5713
+ }, { apiKey: resolved.apiKey });
5714
+ let commitMessage = response.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
4216
5715
  commitMessage = commitMessage.replace(/^["']|["']$/g, "").trim();
4217
5716
  return commitMessage || "Update files";
4218
5717
  } catch (error) {
4219
5718
  console.error("Failed to generate AI commit message:", error);
4220
5719
  return "Update files";
4221
- } finally {
4222
- if (session) {
4223
- try {
4224
- await session.close?.();
4225
- } catch (closeError) {
4226
- console.error("Error closing session:", closeError);
4227
- }
4228
- }
4229
5720
  }
4230
5721
  }
5722
+ function resolveThreadPath(repoPath) {
5723
+ const base = isAbsolute2(repoPath) ? repoPath : resolve2(getDataDir(), repoPath);
5724
+ return normalize(base);
5725
+ }
5726
+ function getGitRoot(cwd) {
5727
+ return new Promise((resolveRoot, reject) => {
5728
+ const proc = spawn7("git", ["rev-parse", "--show-toplevel"], { cwd });
5729
+ let out = "";
5730
+ let err = "";
5731
+ proc.stdout.on("data", (d) => {
5732
+ out += d.toString();
5733
+ });
5734
+ proc.stderr.on("data", (d) => {
5735
+ err += d.toString();
5736
+ });
5737
+ proc.on("close", (code) => {
5738
+ if (code === 0) {
5739
+ resolveRoot(out.trim());
5740
+ } else {
5741
+ reject(new Error(err.trim() || "Not a git repository"));
5742
+ }
5743
+ });
5744
+ proc.on("error", reject);
5745
+ });
5746
+ }
4231
5747
  function createGitRoutes(metadataManager) {
4232
5748
  const router = new Hono6;
4233
5749
  router.get("/username", async (c) => {
4234
5750
  try {
4235
5751
  const name = await new Promise((resolve3, reject) => {
4236
- const proc = spawn4("git", ["config", "user.name"]);
5752
+ const proc = spawn7("git", ["config", "user.name"]);
4237
5753
  let out = "";
4238
5754
  let err = "";
4239
5755
  proc.stdout.on("data", (d) => {
@@ -4267,9 +5783,23 @@ function createGitRoutes(metadataManager) {
4267
5783
  if (!repoPath) {
4268
5784
  return c.json({ error: "Thread path not found" }, 404);
4269
5785
  }
4270
- const absolutePath = resolve2(repoPath);
5786
+ const absolutePath = resolveThreadPath(repoPath);
5787
+ if (!existsSync6(absolutePath)) {
5788
+ return c.json({
5789
+ error: `Thread repo path does not exist: ${absolutePath}. Check that the project folder is present.`
5790
+ }, 400);
5791
+ }
5792
+ let gitRoot;
5793
+ try {
5794
+ gitRoot = await getGitRoot(absolutePath);
5795
+ } catch (e) {
5796
+ const msg = e instanceof Error ? e.message : String(e);
5797
+ return c.json({
5798
+ error: `Path is not a git repository: ${absolutePath}. ${msg}`
5799
+ }, 400);
5800
+ }
4271
5801
  const { hasChanges, changedFilesCount } = await new Promise((resolve3) => {
4272
- const proc = spawn4("git", ["status", "--porcelain"], { cwd: absolutePath });
5802
+ const proc = spawn7("git", ["status", "--porcelain"], { cwd: gitRoot });
4273
5803
  let out = "";
4274
5804
  proc.stdout.on("data", (d) => {
4275
5805
  out += d.toString();
@@ -4285,7 +5815,7 @@ function createGitRoutes(metadataManager) {
4285
5815
  proc.on("error", () => resolve3({ hasChanges: false, changedFilesCount: 0 }));
4286
5816
  });
4287
5817
  const currentBranch = await new Promise((resolve3) => {
4288
- const proc = spawn4("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: absolutePath });
5818
+ const proc = spawn7("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: gitRoot });
4289
5819
  let out = "";
4290
5820
  proc.stdout.on("data", (d) => {
4291
5821
  out += d.toString();
@@ -4296,7 +5826,7 @@ function createGitRoutes(metadataManager) {
4296
5826
  proc.on("error", () => resolve3(""));
4297
5827
  });
4298
5828
  const hasUnpushedCommits = await new Promise((resolve3) => {
4299
- const proc = spawn4("git", ["log", `origin/${currentBranch}..HEAD`, "--oneline"], { cwd: absolutePath });
5829
+ const proc = spawn7("git", ["log", `origin/${currentBranch}..HEAD`, "--oneline"], { cwd: gitRoot });
4300
5830
  let out = "";
4301
5831
  proc.stdout.on("data", (d) => {
4302
5832
  out += d.toString();
@@ -4327,9 +5857,15 @@ function createGitRoutes(metadataManager) {
4327
5857
  if (!repoPath) {
4328
5858
  return c.json({ error: "Thread path not found" }, 404);
4329
5859
  }
4330
- const absolutePath = resolve2(repoPath);
5860
+ const absolutePath = resolveThreadPath(repoPath);
5861
+ let gitRoot;
5862
+ try {
5863
+ gitRoot = await getGitRoot(absolutePath);
5864
+ } catch {
5865
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
5866
+ }
4331
5867
  const statusOutput = await new Promise((resolve3) => {
4332
- const proc = spawn4("git", ["status", "--porcelain"], { cwd: absolutePath });
5868
+ const proc = spawn7("git", ["status", "--porcelain"], { cwd: gitRoot });
4333
5869
  let out = "";
4334
5870
  proc.stdout.on("data", (d) => {
4335
5871
  out += d.toString();
@@ -4370,7 +5906,7 @@ function createGitRoutes(metadataManager) {
4370
5906
  const hunks = [];
4371
5907
  if (file.status === "deleted") {
4372
5908
  const content = await new Promise((resolve3) => {
4373
- const proc = spawn4("git", ["show", `HEAD:${file.path}`], { cwd: absolutePath });
5909
+ const proc = spawn7("git", ["show", `HEAD:${file.path}`], { cwd: gitRoot });
4374
5910
  let out = "";
4375
5911
  proc.stdout.on("data", (d) => {
4376
5912
  out += d.toString();
@@ -4382,13 +5918,13 @@ function createGitRoutes(metadataManager) {
4382
5918
  } else if (file.status === "added" && file.path.startsWith("(new)")) {
4383
5919
  const fs4 = await import("fs");
4384
5920
  try {
4385
- newContent = fs4.readFileSync(`${absolutePath}/${file.path.replace("(new) ", "")}`, "utf-8");
5921
+ newContent = fs4.readFileSync(resolve2(gitRoot, file.path.replace("(new) ", "")), "utf-8");
4386
5922
  } catch {
4387
5923
  newContent = "";
4388
5924
  }
4389
5925
  } else {
4390
5926
  const diffOutput = await new Promise((resolve3) => {
4391
- const proc = spawn4("git", file.status === "added" ? ["diff", "--cached", "--", file.path] : ["diff", "HEAD", "--", file.path], { cwd: absolutePath });
5927
+ const proc = spawn7("git", file.status === "added" ? ["diff", "--cached", "--", file.path] : ["diff", "HEAD", "--", file.path], { cwd: gitRoot });
4392
5928
  let out = "";
4393
5929
  proc.stdout.on("data", (d) => {
4394
5930
  out += d.toString();
@@ -4402,7 +5938,7 @@ function createGitRoutes(metadataManager) {
4402
5938
  let oldLineNum = 0;
4403
5939
  let newLineNum = 0;
4404
5940
  const oldContentOutput = await new Promise((resolve3) => {
4405
- const proc = spawn4("git", ["show", `HEAD:${file.path}`], { cwd: absolutePath });
5941
+ const proc = spawn7("git", ["show", `HEAD:${file.path}`], { cwd: gitRoot });
4406
5942
  let out = "";
4407
5943
  proc.stdout.on("data", (d) => {
4408
5944
  out += d.toString();
@@ -4413,7 +5949,7 @@ function createGitRoutes(metadataManager) {
4413
5949
  oldContent = oldContentOutput;
4414
5950
  const fs4 = await import("fs");
4415
5951
  try {
4416
- newContent = fs4.readFileSync(`${absolutePath}/${file.path}`, "utf-8");
5952
+ newContent = fs4.readFileSync(resolve2(gitRoot, file.path), "utf-8");
4417
5953
  } catch {
4418
5954
  newContent = "";
4419
5955
  }
@@ -4479,17 +6015,58 @@ function createGitRoutes(metadataManager) {
4479
6015
  const threadId = c.req.param("threadId");
4480
6016
  const body = await c.req.json().catch(() => ({}));
4481
6017
  const { model, provider } = body;
6018
+ process.stdout.write(`[generate-commit-message] threadId=${threadId}
6019
+ `);
4482
6020
  const thread = await metadataManager.loadThreads().then((threads) => threads.find((t) => t.id === threadId));
4483
6021
  if (!thread) {
6022
+ process.stdout.write(`[generate-commit-message] thread not found
6023
+ `);
4484
6024
  return c.json({ error: "Thread not found" }, 404);
4485
6025
  }
4486
6026
  const repoPath = thread.path;
4487
6027
  if (!repoPath) {
6028
+ process.stdout.write(`[generate-commit-message] thread path missing
6029
+ `);
4488
6030
  return c.json({ error: "Thread path not found" }, 404);
4489
6031
  }
4490
- const absolutePath = resolve2(repoPath);
4491
- const diff = await new Promise((resolve3, reject) => {
4492
- const proc = spawn4("git", ["diff", "--cached"], { cwd: absolutePath });
6032
+ const absolutePath = resolveThreadPath(repoPath);
6033
+ process.stdout.write(`[generate-commit-message] resolved path: ${absolutePath}
6034
+ `);
6035
+ if (!existsSync6(absolutePath)) {
6036
+ process.stdout.write(`[generate-commit-message] path does not exist: ${absolutePath}
6037
+ `);
6038
+ return c.json({
6039
+ error: `Thread repo path does not exist: ${absolutePath}. Check that the project folder is present.`
6040
+ }, 400);
6041
+ }
6042
+ let gitRoot;
6043
+ try {
6044
+ gitRoot = await getGitRoot(absolutePath);
6045
+ process.stdout.write(`[generate-commit-message] git root: ${gitRoot}
6046
+ `);
6047
+ } catch (e) {
6048
+ const msg = e instanceof Error ? e.message : String(e);
6049
+ process.stdout.write(`[generate-commit-message] not a git repo: ${msg}
6050
+ `);
6051
+ return c.json({
6052
+ error: `Path is not a git repository: ${absolutePath}. ${msg}`
6053
+ }, 400);
6054
+ }
6055
+ let diff = await new Promise((resolveDiff, reject) => {
6056
+ const runUnstagedDiff = () => {
6057
+ process.stdout.write(`[generate-commit-message] using unstaged diff (git diff)
6058
+ `);
6059
+ const proc2 = spawn7("git", ["diff"], { cwd: gitRoot });
6060
+ let out2 = "";
6061
+ proc2.stdout.on("data", (d) => {
6062
+ out2 += d.toString();
6063
+ });
6064
+ proc2.on("close", (code) => {
6065
+ resolveDiff(code === 0 ? out2 : "");
6066
+ });
6067
+ proc2.on("error", reject);
6068
+ };
6069
+ const proc = spawn7("git", ["diff", "--cached"], { cwd: gitRoot });
4493
6070
  let out = "";
4494
6071
  let err = "";
4495
6072
  proc.stdout.on("data", (d) => {
@@ -4501,26 +6078,91 @@ function createGitRoutes(metadataManager) {
4501
6078
  proc.on("close", (code) => {
4502
6079
  if (code === 0) {
4503
6080
  if (!out.trim()) {
4504
- const proc2 = spawn4("git", ["diff"], { cwd: absolutePath });
4505
- let out2 = "";
4506
- proc2.stdout.on("data", (d) => {
4507
- out2 += d.toString();
4508
- });
4509
- proc2.on("close", () => resolve3(out2));
4510
- proc2.on("error", reject);
6081
+ process.stdout.write(`[generate-commit-message] no staged changes, checking unstaged
6082
+ `);
6083
+ runUnstagedDiff();
4511
6084
  } else {
4512
- resolve3(out);
6085
+ process.stdout.write(`[generate-commit-message] using staged diff (git diff --cached)
6086
+ `);
6087
+ resolveDiff(out);
4513
6088
  }
4514
6089
  } else {
4515
- reject(new Error(err || "Failed to get git diff"));
6090
+ const noHead = /HEAD|revision|unknown revision/i.test(err);
6091
+ if (noHead) {
6092
+ process.stdout.write(`[generate-commit-message] no HEAD (new repo), using working tree diff
6093
+ `);
6094
+ runUnstagedDiff();
6095
+ } else {
6096
+ reject(new Error(err || "Failed to get git diff"));
6097
+ }
4516
6098
  }
4517
6099
  });
4518
6100
  proc.on("error", reject);
4519
6101
  });
4520
6102
  if (!diff.trim()) {
6103
+ const statusOut = await new Promise((resolveStatus) => {
6104
+ const proc = spawn7("git", ["status", "--porcelain"], { cwd: gitRoot });
6105
+ let out = "";
6106
+ proc.stdout.on("data", (d) => {
6107
+ out += d.toString();
6108
+ });
6109
+ proc.on("close", () => resolveStatus(out));
6110
+ proc.on("error", () => resolveStatus(""));
6111
+ });
6112
+ const lines = statusOut.trim().split(`
6113
+ `).filter((l) => l.length > 0);
6114
+ const untrackedPaths = [];
6115
+ for (const line of lines) {
6116
+ const code = line.slice(0, 2);
6117
+ const pathPart = line.slice(3).trim();
6118
+ if (code === "??") {
6119
+ untrackedPaths.push(pathPart);
6120
+ }
6121
+ }
6122
+ if (untrackedPaths.length > 0) {
6123
+ process.stdout.write(`[generate-commit-message] building diff for ${untrackedPaths.length} untracked file(s)
6124
+ `);
6125
+ const parts = [];
6126
+ const maxFileSize = 1e5;
6127
+ for (const relPath of untrackedPaths) {
6128
+ const fullPath = join10(gitRoot, relPath);
6129
+ if (!existsSync6(fullPath))
6130
+ continue;
6131
+ try {
6132
+ if (statSync3(fullPath).isDirectory())
6133
+ continue;
6134
+ const content = readFileSync3(fullPath, "utf-8");
6135
+ const safeContent = content.length > maxFileSize ? content.slice(0, maxFileSize) + `
6136
+ ...(truncated)` : content;
6137
+ const linesForDiff = safeContent.split(/\r?\n/).map((l) => `+${l}`).join(`
6138
+ `);
6139
+ parts.push(`diff --git a/${relPath} b/${relPath}
6140
+ new file mode 100644
6141
+ --- /dev/null
6142
+ +++ b/${relPath}
6143
+ ${linesForDiff}`);
6144
+ } catch {
6145
+ parts.push(`diff --git a/${relPath} b/${relPath}
6146
+ new file mode 100644
6147
+ (Binary or unreadable file: ${relPath})`);
6148
+ }
6149
+ }
6150
+ if (parts.length > 0) {
6151
+ diff = parts.join(`
6152
+ `);
6153
+ }
6154
+ }
6155
+ }
6156
+ if (!diff.trim()) {
6157
+ process.stdout.write(`[generate-commit-message] no changes to generate message for
6158
+ `);
4521
6159
  return c.json({ error: "No changes to generate commit message for" }, 400);
4522
6160
  }
6161
+ process.stdout.write(`[generate-commit-message] diff length=${diff.length} chars, generating message with AI
6162
+ `);
4523
6163
  const commitMessage = await generateCommitMessageWithAI(diff, metadataManager, model, provider);
6164
+ process.stdout.write(`[generate-commit-message] generated: ${commitMessage.replace(/\n/g, " ")}
6165
+ `);
4524
6166
  return c.json({ message: commitMessage });
4525
6167
  } catch (error) {
4526
6168
  const message = error instanceof Error ? error.message : "Failed to generate commit message";
@@ -4543,9 +6185,15 @@ function createGitRoutes(metadataManager) {
4543
6185
  if (!repoPath) {
4544
6186
  return c.json({ error: "Thread path not found" }, 404);
4545
6187
  }
4546
- const absolutePath = resolve2(repoPath);
6188
+ const absolutePath = resolveThreadPath(repoPath);
6189
+ let gitRoot;
6190
+ try {
6191
+ gitRoot = await getGitRoot(absolutePath);
6192
+ } catch {
6193
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
6194
+ }
4547
6195
  await new Promise((resolve3, reject) => {
4548
- const proc = spawn4("git", ["add", "-A"], { cwd: absolutePath });
6196
+ const proc = spawn7("git", ["add", "-A"], { cwd: gitRoot });
4549
6197
  proc.on("close", (code) => {
4550
6198
  if (code === 0)
4551
6199
  resolve3();
@@ -4555,7 +6203,7 @@ function createGitRoutes(metadataManager) {
4555
6203
  proc.on("error", reject);
4556
6204
  });
4557
6205
  await new Promise((resolve3, reject) => {
4558
- const proc = spawn4("git", ["commit", "-m", message], { cwd: absolutePath });
6206
+ const proc = spawn7("git", ["commit", "-m", message], { cwd: gitRoot });
4559
6207
  proc.on("close", (code) => {
4560
6208
  if (code === 0)
4561
6209
  resolve3();
@@ -4581,9 +6229,15 @@ function createGitRoutes(metadataManager) {
4581
6229
  if (!repoPath) {
4582
6230
  return c.json({ error: "Thread path not found" }, 404);
4583
6231
  }
4584
- const absolutePath = resolve2(repoPath);
6232
+ const absolutePath = resolveThreadPath(repoPath);
6233
+ let gitRoot;
6234
+ try {
6235
+ gitRoot = await getGitRoot(absolutePath);
6236
+ } catch {
6237
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
6238
+ }
4585
6239
  const currentBranch = await new Promise((resolve3, reject) => {
4586
- const proc = spawn4("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: absolutePath });
6240
+ const proc = spawn7("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: gitRoot });
4587
6241
  let out = "";
4588
6242
  proc.stdout.on("data", (d) => {
4589
6243
  out += d.toString();
@@ -4592,7 +6246,7 @@ function createGitRoutes(metadataManager) {
4592
6246
  proc.on("error", reject);
4593
6247
  });
4594
6248
  await new Promise((resolve3, reject) => {
4595
- const proc = spawn4("git", ["push", "-u", "origin", currentBranch], { cwd: absolutePath });
6249
+ const proc = spawn7("git", ["push", "-u", "origin", currentBranch], { cwd: gitRoot });
4596
6250
  let err = "";
4597
6251
  proc.stderr.on("data", (d) => {
4598
6252
  err += d.toString();
@@ -4624,9 +6278,24 @@ function createGitRoutes(metadataManager) {
4624
6278
  if (!repoPath) {
4625
6279
  return c.json({ error: "Thread path not found" }, 404);
4626
6280
  }
4627
- const absolutePath = resolve2(repoPath);
4628
- const diff = await new Promise((resolve3, reject) => {
4629
- const proc = spawn4("git", ["diff", "HEAD"], { cwd: absolutePath });
6281
+ const absolutePath = resolveThreadPath(repoPath);
6282
+ let gitRoot;
6283
+ try {
6284
+ gitRoot = await getGitRoot(absolutePath);
6285
+ } catch {
6286
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
6287
+ }
6288
+ const diff = await new Promise((resolveDiff, reject) => {
6289
+ const runPlainDiff = () => {
6290
+ const proc2 = spawn7("git", ["diff"], { cwd: gitRoot });
6291
+ let out2 = "";
6292
+ proc2.stdout.on("data", (d) => {
6293
+ out2 += d.toString();
6294
+ });
6295
+ proc2.on("close", () => resolveDiff(out2));
6296
+ proc2.on("error", reject);
6297
+ };
6298
+ const proc = spawn7("git", ["diff", "HEAD"], { cwd: gitRoot });
4630
6299
  let out = "";
4631
6300
  let err = "";
4632
6301
  proc.stdout.on("data", (d) => {
@@ -4637,9 +6306,13 @@ function createGitRoutes(metadataManager) {
4637
6306
  });
4638
6307
  proc.on("close", (code) => {
4639
6308
  if (code === 0) {
4640
- resolve3(out);
6309
+ resolveDiff(out);
4641
6310
  } else {
4642
- reject(new Error(err || "Failed to get git diff"));
6311
+ const noHead = /HEAD|revision|unknown revision/i.test(err);
6312
+ if (noHead)
6313
+ runPlainDiff();
6314
+ else
6315
+ reject(new Error(err || "Failed to get git diff"));
4643
6316
  }
4644
6317
  });
4645
6318
  proc.on("error", reject);
@@ -4661,119 +6334,27 @@ ${truncatedDiff}
4661
6334
  Generate the response in this exact format:
4662
6335
  TITLE: <title here>
4663
6336
  DESCRIPTION: <description here>`;
4664
- let session = null;
4665
- try {
4666
- const providerKeys = await metadataManager.getProviderKeys();
4667
- let selectedProvider = null;
4668
- let apiKey = null;
4669
- let selectedModel = model;
4670
- if (provider) {
4671
- selectedProvider = PROVIDERS.find((p) => p.name.toLowerCase() === provider.toLowerCase()) || null;
4672
- if (selectedProvider) {
4673
- if (selectedProvider.keyName) {
4674
- apiKey = process.env[selectedProvider.keyName] || null;
4675
- }
4676
- if (!apiKey) {
4677
- apiKey = providerKeys[selectedProvider.name] || null;
4678
- }
4679
- }
4680
- }
4681
- if (!selectedProvider || !apiKey) {
4682
- const providerPriority = ["Anthropic", "OpenAI", "OpenRouter", "Groq", "DeepSeek"];
4683
- for (const providerName of providerPriority) {
4684
- const prov = PROVIDERS.find((p) => p.name === providerName);
4685
- if (prov?.keyName) {
4686
- const envKey = process.env[prov.keyName];
4687
- if (envKey) {
4688
- selectedProvider = prov;
4689
- apiKey = envKey;
4690
- break;
4691
- }
4692
- }
4693
- }
4694
- if (!selectedProvider || !apiKey) {
4695
- for (const providerName of providerPriority) {
4696
- const prov = PROVIDERS.find((p) => p.name === providerName);
4697
- if (prov && providerKeys[prov.name]) {
4698
- selectedProvider = prov;
4699
- apiKey = providerKeys[prov.name];
4700
- break;
4701
- }
4702
- }
4703
- }
4704
- if (!selectedProvider || !apiKey) {
4705
- for (const prov of PROVIDERS) {
4706
- if (prov.api && prov.keyName) {
4707
- const envKey = process.env[prov.keyName];
4708
- if (envKey) {
4709
- selectedProvider = prov;
4710
- apiKey = envKey;
4711
- break;
4712
- }
4713
- if (providerKeys[prov.name]) {
4714
- selectedProvider = prov;
4715
- apiKey = providerKeys[prov.name];
4716
- break;
4717
- }
4718
- }
4719
- }
4720
- }
4721
- if (!selectedModel) {
4722
- const modelMap = {
4723
- Anthropic: "claude-3-5-sonnet-20241022",
4724
- OpenAI: "gpt-4o-mini",
4725
- OpenRouter: "anthropic/claude-3.5-sonnet",
4726
- Groq: "llama-3.3-70b-versatile",
4727
- DeepSeek: "deepseek-chat"
4728
- };
4729
- selectedModel = modelMap[selectedProvider?.name || ""] || "default";
4730
- }
4731
- }
4732
- if (!selectedProvider || !apiKey || !selectedProvider.api) {
4733
- throw new Error("No AI provider configured. Please configure an API key in settings.");
4734
- }
4735
- if (selectedModel && selectedProvider) {
4736
- selectedModel = selectedModel.replace(`${selectedProvider.name.toLowerCase()}/`, "");
4737
- }
4738
- const finalModel = selectedModel || "default";
4739
- const providers = {
4740
- tarsk: {
4741
- api: selectedProvider.api,
4742
- options: { apiKey },
4743
- models: { [finalModel]: finalModel }
4744
- }
4745
- };
4746
- const sessionConfig = {
4747
- model: `tarsk/${finalModel}`,
4748
- cwd: process.cwd(),
4749
- productName: "Tarsk.io",
4750
- providers
4751
- };
4752
- session = await createSession2(sessionConfig);
4753
- await session.send(prompt);
4754
- let prInfo = "";
4755
- for await (const msg of session.receive()) {
4756
- if (msg?.type === "message" || msg?.type === "result") {
4757
- const content = typeof msg.text === "string" ? msg.text : typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
4758
- prInfo = content.trim();
4759
- if (msg?.type === "result")
4760
- break;
4761
- }
4762
- }
4763
- const titleMatch = prInfo.match(/TITLE:\s*(.+?)(?:\nDESCRIPTION:|$)/s);
4764
- const descriptionMatch = prInfo.match(/DESCRIPTION:\s*(.+?)$/s);
4765
- const title = titleMatch ? titleMatch[1].trim() : "Update";
4766
- const description = descriptionMatch ? descriptionMatch[1].trim() : "";
4767
- return c.json({ title, description });
4768
- } finally {
4769
- if (session) {
4770
- try {
4771
- await session.close?.();
4772
- } catch (closeError) {
4773
- console.error("Error closing session:", closeError);
4774
- }
4775
- }
4776
- }
6337
+ const providerKeys = await metadataManager.getProviderKeys();
6338
+ const resolved = resolveProviderAndKey(providerKeys, provider);
6339
+ if (!resolved || !resolved.provider.api) {
6340
+ throw new Error("No AI provider configured. Please configure an API key in settings.");
6341
+ }
6342
+ let selectedModel = model;
6343
+ if (selectedModel) {
6344
+ selectedModel = selectedModel.replace(`${resolved.provider.name.toLowerCase()}/`, "");
6345
+ } else {
6346
+ selectedModel = DEFAULT_MODEL_MAP[resolved.provider.name] || "default";
6347
+ }
6348
+ const resolvedModel = resolveModelForGit(resolved.provider.name, selectedModel, resolved.provider.api);
6349
+ const response = await completeSimple(resolvedModel, {
6350
+ messages: [{ role: "user", content: prompt }]
6351
+ }, { apiKey: resolved.apiKey });
6352
+ const prInfo = response.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
6353
+ const titleMatch = prInfo.match(/TITLE:\s*(.+?)(?:\nDESCRIPTION:|$)/s);
6354
+ const descriptionMatch = prInfo.match(/DESCRIPTION:\s*(.+?)$/s);
6355
+ const title = titleMatch ? titleMatch[1].trim() : "Update";
6356
+ const description = descriptionMatch ? descriptionMatch[1].trim() : "";
6357
+ return c.json({ title, description });
4777
6358
  } catch (error) {
4778
6359
  const message = error instanceof Error ? error.message : "Failed to generate PR info";
4779
6360
  return c.json({ error: message }, 500);
@@ -4791,9 +6372,15 @@ DESCRIPTION: <description here>`;
4791
6372
  if (!repoPath) {
4792
6373
  return c.json({ error: "Thread path not found" }, 404);
4793
6374
  }
4794
- const absolutePath = resolve2(repoPath);
6375
+ const absolutePath = resolveThreadPath(repoPath);
6376
+ let gitRoot;
6377
+ try {
6378
+ gitRoot = await getGitRoot(absolutePath);
6379
+ } catch {
6380
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
6381
+ }
4795
6382
  const commits = await new Promise((resolve3, reject) => {
4796
- const proc = spawn4("git", ["log", "--oneline", "-n", limit.toString(), "--format=%H|%s|%an|%ai"], { cwd: absolutePath });
6383
+ const proc = spawn7("git", ["log", "--oneline", "-n", limit.toString(), "--format=%H|%s|%an|%ai"], { cwd: gitRoot });
4797
6384
  let out = "";
4798
6385
  let err = "";
4799
6386
  proc.stdout.on("data", (d) => {
@@ -4841,9 +6428,15 @@ DESCRIPTION: <description here>`;
4841
6428
  if (!repoPath) {
4842
6429
  return c.json({ error: "Thread path not found" }, 404);
4843
6430
  }
4844
- const absolutePath = resolve2(repoPath);
6431
+ const absolutePath = resolveThreadPath(repoPath);
6432
+ let gitRoot;
6433
+ try {
6434
+ gitRoot = await getGitRoot(absolutePath);
6435
+ } catch {
6436
+ return c.json({ error: `Path is not a git repository: ${absolutePath}` }, 400);
6437
+ }
4845
6438
  const currentBranch = await new Promise((resolve3, reject) => {
4846
- const proc = spawn4("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: absolutePath });
6439
+ const proc = spawn7("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: gitRoot });
4847
6440
  let out = "";
4848
6441
  proc.stdout.on("data", (d) => {
4849
6442
  out += d.toString();
@@ -4853,7 +6446,7 @@ DESCRIPTION: <description here>`;
4853
6446
  });
4854
6447
  const prUrl = await new Promise((resolve3, reject) => {
4855
6448
  const args = ["pr", "create", "--title", title || currentBranch, "--body", description || ""];
4856
- const proc = spawn4("gh", args, { cwd: absolutePath });
6449
+ const proc = spawn7("gh", args, { cwd: gitRoot });
4857
6450
  let out = "";
4858
6451
  let err = "";
4859
6452
  proc.stdout.on("data", (d) => {
@@ -4974,7 +6567,7 @@ function createRunRoutes(projectManager) {
4974
6567
 
4975
6568
  // src/routes/onboarding.ts
4976
6569
  import { Hono as Hono8 } from "hono";
4977
- import { spawn as spawn5 } from "child_process";
6570
+ import { spawn as spawn8 } from "child_process";
4978
6571
  function createOnboardingRoutes(metadataManager) {
4979
6572
  const router = new Hono8;
4980
6573
  router.get("/status", async (c) => {
@@ -4989,7 +6582,7 @@ function createOnboardingRoutes(metadataManager) {
4989
6582
  router.get("/git-check", async (c) => {
4990
6583
  try {
4991
6584
  const gitInstalled = await new Promise((resolve3) => {
4992
- const proc = spawn5("git", ["--version"]);
6585
+ const proc = spawn8("git", ["--version"]);
4993
6586
  let _err = "";
4994
6587
  proc.stderr.on("data", (d) => {
4995
6588
  _err += d.toString();
@@ -5066,18 +6659,9 @@ function createScaffoldRoutes(projectManager) {
5066
6659
  return router;
5067
6660
  }
5068
6661
 
5069
- // src/paths.ts
5070
- import { join as join8 } from "path";
5071
- import { homedir } from "os";
5072
- var APP_SUPPORT_DIR = join8(homedir(), "Library", "Application Support", "Tarsk");
5073
- var DATA_DIR = join8(APP_SUPPORT_DIR, "data");
5074
- function getDataDir() {
5075
- return DATA_DIR;
5076
- }
5077
-
5078
6662
  // src/index.ts
5079
6663
  var __filename2 = fileURLToPath2(import.meta.url);
5080
- var __dirname3 = path.dirname(__filename2);
6664
+ var __dirname3 = path3.dirname(__filename2);
5081
6665
  var args = process.argv.slice(2);
5082
6666
  var isDebug = args.includes("--debug");
5083
6667
  var shouldOpenBrowser = args.includes("--open");
@@ -5093,7 +6677,7 @@ var processingStateManager = new ProcessingStateManagerImpl;
5093
6677
  var projectManager = new ProjectManagerImpl(dataDir, metadataManager, gitManager, processingStateManager);
5094
6678
  var threadManager = new ThreadManagerImpl(metadataManager, gitManager, processingStateManager);
5095
6679
  var conversationManager = new ConversationManagerImpl(dataDir);
5096
- var neovateExecutor = new NeovateExecutorImpl(metadataManager);
6680
+ var agentExecutor = new PiExecutorImpl(metadataManager);
5097
6681
  await metadataManager.initialize();
5098
6682
  app.get("/health", (c) => {
5099
6683
  return c.json({
@@ -5115,14 +6699,14 @@ app.get("/api/threads/processing", (c) => {
5115
6699
  app.route("/api/projects", createProjectRoutes(projectManager, threadManager));
5116
6700
  app.route("/api/projects", createRunRoutes(projectManager));
5117
6701
  app.route("/api/threads", createThreadRoutes(threadManager, gitManager, conversationManager));
5118
- app.route("/api/chat", createChatRoutes(threadManager, neovateExecutor, conversationManager, processingStateManager));
6702
+ app.route("/api/chat", createChatRoutes(threadManager, agentExecutor, conversationManager, processingStateManager));
5119
6703
  app.route("/api/providers", createProviderRoutes(metadataManager));
5120
6704
  app.route("/api/models", createModelRoutes(metadataManager));
5121
6705
  app.route("/api/git", createGitRoutes(metadataManager));
5122
6706
  app.route("/api/onboarding", createOnboardingRoutes(metadataManager));
5123
6707
  app.route("/api/scaffold", createScaffoldRoutes(projectManager));
5124
- var publicDir = path.join(__dirname3, "public");
5125
- var staticRoot = path.relative(process.cwd(), publicDir);
6708
+ var publicDir = path3.join(__dirname3, "public");
6709
+ var staticRoot = path3.relative(process.cwd(), publicDir);
5126
6710
  app.use("/*", async (c, next) => {
5127
6711
  if (c.req.path.startsWith("/api/")) {
5128
6712
  return next();
@@ -5134,7 +6718,7 @@ app.get("*", async (c, next) => {
5134
6718
  return next();
5135
6719
  }
5136
6720
  return serveStatic({
5137
- path: path.relative(process.cwd(), path.join(publicDir, "index.html"))
6721
+ path: path3.relative(process.cwd(), path3.join(publicDir, "index.html"))
5138
6722
  })(c, next);
5139
6723
  });
5140
6724
  app.all("*", (c) => {